From e9b1d29d8bac50abc5997b552457086f3a66462e Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 6 Oct 2021 11:42:52 -0400 Subject: [PATCH] UX: Revamp quick search (#14499) Co-authored-by: Robin Ward Co-authored-by: Alan Guo Xiang Tan --- .../javascripts/discourse/app/lib/search.js | 15 +- .../discourse/app/services/search.js | 8 +- .../app/widgets/search-menu-controls.js | 61 +-- .../app/widgets/search-menu-results.js | 357 ++++++++++------ .../discourse/app/widgets/search-menu.js | 237 +++++------ .../discourse/tests/acceptance/group-test.js | 11 +- .../discourse/tests/acceptance/search-test.js | 287 +++++++++---- .../stylesheets/common/base/search-menu.scss | 402 ++++++++---------- config/locales/client.en.yml | 16 +- lib/search.rb | 15 +- lib/svg_sprite/svg_sprite.rb | 1 + spec/components/search_spec.rb | 29 ++ 12 files changed, 815 insertions(+), 624 deletions(-) diff --git a/app/assets/javascripts/discourse/app/lib/search.js b/app/assets/javascripts/discourse/app/lib/search.js index eb2e829c95c..5df67d4f302 100644 --- a/app/assets/javascripts/discourse/app/lib/search.js +++ b/app/assets/javascripts/discourse/app/lib/search.js @@ -117,11 +117,8 @@ function translateGroupedSearchResults(results, opts) { const name = pair[1]; if (results[name].length > 0) { const componentName = - opts.searchContext && - opts.searchContext.type === "topic" && - type === "topic" - ? "post" - : type; + opts.showPosts && type === "topic" ? "post" : type; + const result = { results: results[name], componentName: `search-result-${componentName}`, @@ -157,12 +154,8 @@ export function searchForTerm(term, opts) { data.restrict_to_archetype = opts.restrictToArchetype; } - if (opts.searchContext) { - data.search_context = { - type: opts.searchContext.type, - id: opts.searchContext.id, - name: opts.searchContext.name, - }; + if (term.includes("topic:")) { + opts.showPosts = true; } let ajaxPromise = ajax("/search/query", { data }); diff --git a/app/assets/javascripts/discourse/app/services/search.js b/app/assets/javascripts/discourse/app/services/search.js index 87a77b7d36d..7546dd1ded5 100644 --- a/app/assets/javascripts/discourse/app/services/search.js +++ b/app/assets/javascripts/discourse/app/services/search.js @@ -1,17 +1,11 @@ import EmberObject, { get } from "@ember/object"; -import discourseComputed, { observes } from "discourse-common/utils/decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default EmberObject.extend({ searchContextEnabled: false, // checkbox to scope search searchContext: null, - term: null, highlightTerm: null, - @observes("term") - _sethighlightTerm() { - this.set("highlightTerm", this.term); - }, - @discourseComputed("searchContext") contextType: { get(searchContext) { diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu-controls.js b/app/assets/javascripts/discourse/app/widgets/search-menu-controls.js index b01dd15d8a0..f6aed4e6778 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu-controls.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu-controls.js @@ -1,34 +1,21 @@ import I18n from "I18n"; import { createWidget } from "discourse/widgets/widget"; -import { get } from "@ember/object"; -import { h } from "virtual-dom"; -import { searchContextDescription } from "discourse/lib/search"; createWidget("search-term", { tagName: "input", buildId: () => "search-term", buildKey: () => "search-term", - defaultState() { - return { afterAutocomplete: false }; - }, - buildAttributes(attrs) { return { type: "text", value: attrs.value || "", - autocomplete: "discourse", - placeholder: attrs.contextEnabled ? "" : I18n.t("search.title"), + autocomplete: "off", + placeholder: I18n.t("search.title"), "aria-label": I18n.t("search.title"), }; }, - keyUp(e) { - if (e.key === "Enter" && !this.state.afterAutocomplete) { - return this.sendWidgetAction("fullSearch"); - } - }, - input(e) { const val = this.attrs.value; @@ -41,47 +28,9 @@ createWidget("search-term", { }, }); +// TODO: No longer used, remove in December 2021 createWidget("search-context", { - tagName: "div.search-context", - - html(attrs) { - const service = this.register.lookup("search-service:main"); - const ctx = service.get("searchContext"); - - const result = []; - if (ctx) { - const description = searchContextDescription( - get(ctx, "type"), - get(ctx, "user.username") || - get(ctx, "category.name") || - get(ctx, "tag.id") - ); - result.push( - h("label", [ - h("input", { type: "checkbox", checked: attrs.contextEnabled }), - " ", - description, - ]) - ); - } - - if (!attrs.contextEnabled) { - result.push( - this.attach("link", { - href: attrs.url, - label: "show_help", - className: "show-help", - }) - ); - } - - return result; - }, - - click() { - const val = $(".search-context input").is(":checked"); - if (val !== this.attrs.contextEnabled) { - this.sendWidgetAction("searchContextChanged", val); - } + html() { + return false; }, }); diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js index ca01d7e9ce8..cf435b74ec3 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js @@ -1,4 +1,5 @@ import { escapeExpression, formatUsername } from "discourse/lib/utilities"; +import { deepMerge } from "discourse-common/lib/object"; import I18n from "I18n"; import RawHtml from "discourse/widgets/raw-html"; import { avatarImg } from "discourse/widgets/post"; @@ -10,6 +11,10 @@ import { h } from "virtual-dom"; import highlightSearch from "discourse/lib/highlight-search"; import { iconNode } from "discourse-common/lib/icon-library"; import renderTag from "discourse/lib/render-tag"; +import { + MODIFIER_REGEXP, + TOPIC_REPLACE_REGEXP, +} from "discourse/widgets/search-menu"; const suggestionShortcuts = [ "in:title", @@ -24,6 +29,29 @@ const suggestionShortcuts = [ "order:latest_topic", ]; +const QUICK_TIPS = [ + { + label: "#", + description: I18n.t("search.tips.category_tag"), + }, + { + label: "@", + description: I18n.t("search.tips.author"), + }, + { + label: "in:", + description: I18n.t("search.tips.in"), + }, + { + label: "status:", + description: I18n.t("search.tips.status"), + }, + { + label: I18n.t("search.tips.full_search_key", { modifier: "Ctrl" }), + description: I18n.t("search.tips.full_search"), + }, +]; + export function addSearchSuggestion(value) { if (suggestionShortcuts.indexOf(value) === -1) { suggestionShortcuts.push(value); @@ -33,7 +61,7 @@ export function addSearchSuggestion(value) { class Highlighted extends RawHtml { constructor(html, term) { super({ html: `${html}` }); - this.term = term; + this.term = term.replace(TOPIC_REPLACE_REGEXP, ""); } decorate($html) { @@ -63,7 +91,6 @@ function createSearchResult({ type, linkField, builder }) { className: "search-link", searchResultId, searchResultType: type, - searchContextEnabled: attrs.searchContextEnabled, searchLogId: attrs.searchLogId, }) ); @@ -95,7 +122,10 @@ createSearchResult({ linkField: "url", builder(t) { const tag = escapeExpression(t.id); - return new RawHtml({ html: renderTag(tag, { tagName: "span" }) }); + return [ + iconNode("tag"), + new RawHtml({ html: renderTag(tag, { tagName: "span" }) }), + ]; }, }); @@ -227,84 +257,82 @@ createWidget("search-menu-results", { tagName: "div.results", html(attrs) { - if (attrs.suggestionKeyword) { + const { term, suggestionKeyword, results, searchTopics } = attrs; + + if (suggestionKeyword) { return this.attach("search-menu-assistant", { - fullTerm: attrs.term, - suggestionKeyword: attrs.suggestionKeyword, + term, + suggestionKeyword, results: attrs.suggestionResults || [], }); } - if (attrs.invalidTerm) { + if (searchTopics && attrs.invalidTerm) { return h("div.no-results", I18n.t("search.too_short")); } - if (attrs.noResults) { + if (searchTopics && attrs.noResults) { return h("div.no-results", I18n.t("search.no_results")); } - const results = attrs.results; + if (!term) { + return this.attach("search-menu-initial-options", { + term, + }); + } + const resultTypes = results.resultTypes || []; const mainResultsContent = []; const usersAndGroups = []; const categoriesAndTags = []; - const usersAndGroupsMore = []; - const categoriesAndTagsMore = []; const buildMoreNode = (result) => { - const more = []; - const moreArgs = { - className: "filter", + className: "filter search-link", contents: () => [I18n.t("more"), "..."], }; if (result.moreUrl) { - more.push( - this.attach("link", $.extend(moreArgs, { href: result.moreUrl })) + return this.attach( + "link", + deepMerge(moreArgs, { + href: result.moreUrl, + }) ); } else if (result.more) { - more.push( - this.attach( - "link", - $.extend(moreArgs, { - action: "moreOfType", - actionParam: result.type, - className: "filter filter-type", - }) - ) + return this.attach( + "link", + deepMerge(moreArgs, { + action: "moreOfType", + actionParam: result.type, + }) ); } - - if (more.length) { - return more; - } }; const assignContainer = (result, node) => { - if (["topic"].includes(result.type)) { - mainResultsContent.push(node); - } + if (searchTopics) { + if (["topic"].includes(result.type)) { + mainResultsContent.push(node); + } + } else { + if (["user", "group"].includes(result.type)) { + usersAndGroups.push(node); + } - if (["user", "group"].includes(result.type)) { - usersAndGroups.push(node); - usersAndGroupsMore.push(buildMoreNode(result)); - } - - if (["category", "tag"].includes(result.type)) { - categoriesAndTags.push(node); - categoriesAndTagsMore.push(buildMoreNode(result)); + if (["category", "tag"].includes(result.type)) { + categoriesAndTags.push(node); + } } }; resultTypes.forEach((rt) => { const resultNodeContents = [ this.attach(rt.componentName, { - searchContextEnabled: attrs.searchContextEnabled, searchLogId: attrs.results.grouped_search_result.search_log_id, results: rt.results, - term: attrs.term, + term, }), ]; @@ -320,31 +348,19 @@ createWidget("search-menu-results", { const content = []; - if (mainResultsContent.length) { - content.push(h("div.main-results", mainResultsContent)); - } - - if (usersAndGroups.length || categoriesAndTags.length) { - const secondaryResultsContents = []; - - secondaryResultsContents.push(usersAndGroups); - secondaryResultsContents.push(usersAndGroupsMore); - - if (usersAndGroups.length && categoriesAndTags.length) { - secondaryResultsContents.push(h("div.separator")); + if (!searchTopics) { + content.push(this.attach("search-menu-initial-options", { term })); + } else { + if (mainResultsContent.length) { + content.push(mainResultsContent); + } else { + return h("div.no-results", I18n.t("search.no_results")); } - - secondaryResultsContents.push(categoriesAndTags); - secondaryResultsContents.push(categoriesAndTagsMore); - - const secondaryResults = h( - "div.secondary-results", - secondaryResultsContents - ); - - content.push(secondaryResults); } + content.push(categoriesAndTags); + content.push(usersAndGroups); + return content; }, }); @@ -369,8 +385,8 @@ createWidget("search-menu-assistant", { } const content = []; - const { fullTerm, suggestionKeyword } = attrs; - let prefix = fullTerm.split(suggestionKeyword)[0].trim() || ""; + const { suggestionKeyword, term } = attrs; + let prefix = term?.split(suggestionKeyword)[0].trim() || ""; if (prefix.length) { prefix = `${prefix} `; @@ -388,7 +404,8 @@ createWidget("search-menu-assistant", { this.attach("search-menu-assistant-item", { prefix, category: item.model, - slug: `${prefix}${fullSlug} `, + slug: `${prefix}${fullSlug}`, + withInLabel: attrs.withInLabel, }) ); } else { @@ -396,7 +413,8 @@ createWidget("search-menu-assistant", { this.attach("search-menu-assistant-item", { prefix, tag: item.name, - slug: `${prefix}#${item.name} `, + slug: `${prefix}#${item.name}`, + withInLabel: attrs.withInLabel, }) ); } @@ -408,17 +426,17 @@ createWidget("search-menu-assistant", { this.attach("search-menu-assistant-item", { prefix, user, - slug: `${prefix}@${user.username} `, + slug: `${prefix}@${user.username}`, }) ); }); break; default: suggestionShortcuts.forEach((item) => { - if (item.includes(suggestionKeyword)) { + if (item.includes(suggestionKeyword) || !suggestionKeyword) { content.push( this.attach("search-menu-assistant-item", { - slug: `${prefix}${item} `, + slug: `${prefix}${item}`, }) ); } @@ -430,42 +448,146 @@ createWidget("search-menu-assistant", { }, }); +createWidget("search-menu-initial-options", { + tagName: "ul.search-menu-initial-options", + + html(attrs) { + if (attrs.term?.match(MODIFIER_REGEXP)) { + return this.defaultRow(attrs.term); + } + + const service = this.register.lookup("search-service:main"); + const ctx = service.get("searchContext"); + + const content = []; + if (attrs.term) { + if (ctx) { + const term = attrs.term ? `${attrs.term} ` : ""; + + switch (ctx.type) { + case "topic": + content.push( + this.attach("search-menu-assistant-item", { + slug: `${term}topic:${ctx.id}`, + label: [ + h("span", term), + h("span.label-suffix", I18n.t("search.in_this_topic")), + ], + }) + ); + break; + + case "private_messages": + content.push( + this.attach("search-menu-assistant-item", { + slug: `${term}in:personal`, + }) + ); + break; + + case "category": + const fullSlug = ctx.category.parentCategory + ? `#${ctx.category.parentCategory.slug}:${ctx.category.slug}` + : `#${ctx.category.slug}`; + + content.push( + this.attach("search-menu-assistant", { + term: `${term}${fullSlug}`, + suggestionKeyword: "#", + results: [{ model: ctx.category }], + withInLabel: true, + }) + ); + + break; + case "tag": + content.push( + this.attach("search-menu-assistant", { + term: `${term}#${ctx.name}`, + suggestionKeyword: "#", + results: [{ name: ctx.name }], + withInLabel: true, + }) + ); + break; + case "user": + content.push( + this.attach("search-menu-assistant-item", { + slug: `${term}@${ctx.user.username}`, + label: [ + h("span", term), + h( + "span.label-suffix", + I18n.t("search.in_posts_by", { + username: ctx.user.username, + }) + ), + ], + }) + ); + break; + } + } + + const rowOptions = { withLabel: true }; + content.push(this.defaultRow(attrs.term, rowOptions)); + return content; + } + + if (content.length === 0) { + content.push(this.attach("random-quick-tip")); + } + + return content; + }, + + defaultRow(term, opts = { withLabel: false }) { + return this.attach("search-menu-assistant-item", { + slug: term, + label: [ + h("span", `${term} `), + h( + "span.label-suffix", + opts.withLabel ? I18n.t("search.in_topics_posts") : null + ), + ], + }); + }, +}); + createWidget("search-menu-assistant-item", { tagName: "li.search-menu-assistant-item", html(attrs) { const prefix = attrs.prefix?.trim(); + const attributes = {}; + attributes.href = "#"; + + let content = [iconNode("search")]; + + if (prefix) { + content.push(h("span.search-item-prefix", `${prefix} `)); + } + + if (attrs.withInLabel) { + content.push(h("span.label-suffix", `${I18n.t("search.in")} `)); + } + if (attrs.category) { - return h( - "a.widget-link.search-link", - { - attributes: { - href: attrs.category.url, - }, - }, - [ - h("span.search-item-prefix", prefix), - this.attach("category-link", { - category: attrs.category, - allowUncategorized: true, - recursive: true, - }), - ] + attributes.href = attrs.category.url; + + content.push( + this.attach("category-link", { + category: attrs.category, + allowUncategorized: true, + recursive: true, + }) ); } else if (attrs.tag) { - return h( - "a.widget-link.search-link", - { - attributes: { - href: getURL(`/tag/${attrs.tag}`), - }, - }, - [ - h("span.search-item-prefix", prefix), - iconNode("tag"), - h("span.search-item-tag", attrs.tag), - ] - ); + attributes.href = getURL(`/tag/${attrs.tag}`); + + content.push(iconNode("tag")); + content.push(h("span.search-item-tag", attrs.tag)); } else if (attrs.user) { const userResult = [ avatarImg("small", { @@ -474,30 +596,11 @@ createWidget("search-menu-assistant-item", { }), h("span.username", formatUsername(attrs.user.username)), ]; - - return h( - "a.widget-link.search-link", - { - attributes: { - href: "#", - }, - }, - [ - h("span.search-item-prefix", prefix), - h("span.search-item-user", userResult), - ] - ); + content.push(h("span.search-item-user", userResult)); } else { - return h( - "a.widget-link.search-link", - { - attributes: { - href: "#", - }, - }, - h("span.search-item-slug", attrs.slug) - ); + content.push(h("span.search-item-slug", attrs.label || attrs.slug)); } + return h("a.widget-link.search-link", { attributes }, content); }, click(e) { @@ -509,3 +612,15 @@ createWidget("search-menu-assistant-item", { return false; }, }); + +createWidget("random-quick-tip", { + tagName: "li.search-random-quick-tip", + + html() { + const item = QUICK_TIPS[Math.floor(Math.random() * QUICK_TIPS.length)]; + return [ + h("span.tip-label", item.label), + h("span.tip-description", item.description), + ]; + }, +}); diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu.js b/app/assets/javascripts/discourse/app/widgets/search-menu.js index 6e7799d4599..d612f40da26 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu.js @@ -2,9 +2,11 @@ import { isValidSearchTerm, searchForTerm } from "discourse/lib/search"; import DiscourseURL from "discourse/lib/url"; import { createWidget } from "discourse/widgets/widget"; import discourseDebounce from "discourse-common/lib/debounce"; -import { get } from "@ember/object"; import getURL from "discourse-common/lib/get-url"; import { h } from "virtual-dom"; +import I18n from "I18n"; +import { iconNode } from "discourse-common/lib/icon-library"; +import { isiPad } from "discourse/lib/utilities"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { Promise } from "rsvp"; import { search as searchCategoryTag } from "discourse/lib/category-tag-search"; @@ -14,6 +16,9 @@ import { CANCELLED_STATUS } from "discourse/lib/autocomplete"; 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 TOPIC_REPLACE_REGEXP = /\stopic:\d+/i; +export const MODIFIER_REGEXP = /.*(\#|\@|:).*$/gi; +export const DEFAULT_TYPE_FILTER = "exclude_topics"; const searchData = {}; @@ -22,10 +27,8 @@ export function initSearchData() { searchData.results = {}; searchData.noResults = false; searchData.term = undefined; - searchData.typeFilter = null; + searchData.typeFilter = DEFAULT_TYPE_FILTER; searchData.invalidTerm = false; - searchData.topicId = null; - searchData.afterAutocomplete = false; searchData.suggestionResults = []; } @@ -46,8 +49,7 @@ const SearchHelper = { perform(widget) { this.cancel(); - const { term, typeFilter, contextEnabled } = searchData; - const searchContext = contextEnabled ? widget.searchContext() : null; + const { term, typeFilter } = searchData; const fullSearchUrl = widget.fullSearchUrl(); const matchSuggestions = this.matchesSuggestions(); @@ -105,7 +107,14 @@ const SearchHelper = { searchData.suggestionKeyword = false; - if (!isValidSearchTerm(term, widget.siteSettings)) { + if (!term) { + searchData.noResults = false; + searchData.results = []; + searchData.loading = false; + searchData.invalidTerm = false; + + widget.scheduleRerender(); + } else if (!isValidSearchTerm(term, widget.siteSettings)) { searchData.noResults = true; searchData.results = []; searchData.loading = false; @@ -114,9 +123,9 @@ const SearchHelper = { widget.scheduleRerender(); } else { searchData.invalidTerm = false; + this._activeSearch = searchForTerm(term, { typeFilter, - searchContext, fullSearchUrl, }); this._activeSearch @@ -124,49 +133,50 @@ const SearchHelper = { // we ensure the current search term is the one used // when starting the query if (results && term === searchData.term) { + if (term.includes("topic:")) { + widget.appEvents.trigger("post-stream:refresh", { force: true }); + } + searchData.noResults = results.resultTypes.length === 0; searchData.results = results; - - if (searchContext && searchContext.type === "topic") { - widget.appEvents.trigger("post-stream:refresh", { force: true }); - searchData.topicId = searchContext.id; - } else { - searchData.topicId = null; - } } }) .catch(popupAjaxError) .finally(() => { searchData.loading = false; - searchData.afterAutocomplete = false; widget.scheduleRerender(); }); } }, matchesSuggestions() { - if (searchData.term === undefined) { + if (searchData.term === undefined || this.includesTopics()) { return false; } - const categoriesMatch = searchData.term.match(CATEGORY_SLUG_REGEXP); + const term = searchData.term.trim(); + const categoriesMatch = term.match(CATEGORY_SLUG_REGEXP); if (categoriesMatch) { return { type: "category", categoriesMatch }; } - const usernamesMatch = searchData.term.match(USERNAME_REGEXP); + const usernamesMatch = term.match(USERNAME_REGEXP); if (usernamesMatch) { return { type: "username", usernamesMatch }; } - const suggestionsMatch = searchData.term.match(SUGGESTIONS_REGEXP); + const suggestionsMatch = term.match(SUGGESTIONS_REGEXP); if (suggestionsMatch) { return suggestionsMatch; } return false; }, + + includesTopics() { + return searchData.typeFilter !== DEFAULT_TYPE_FILTER; + }, }; export default createWidget("search-menu", { @@ -174,11 +184,6 @@ export default createWidget("search-menu", { searchData, fullSearchUrl(opts) { - const contextEnabled = searchData.contextEnabled; - - const ctx = contextEnabled ? this.searchContext() : null; - const type = ctx ? get(ctx, "type") : null; - let url = "/search"; const params = []; @@ -187,24 +192,6 @@ export default createWidget("search-menu", { query += `q=${encodeURIComponent(searchData.term)}`; - if (contextEnabled && ctx) { - if (type === "private_messages") { - if ( - this.currentUser && - ctx.id.toString().toLowerCase() === - this.currentUser.get("username_lower") - ) { - query += " in:personal"; - } else { - query += encodeURIComponent( - ` personal_messages:${ctx.id.toString().toLowerCase()}` - ); - } - } else { - query += encodeURIComponent(" " + type + ":" + ctx.id); - } - } - if (query) { params.push(query); } @@ -222,37 +209,47 @@ export default createWidget("search-menu", { }, panelContents() { - const { contextEnabled, afterAutocomplete } = searchData; - - let searchInput = [ - this.attach( - "search-term", - { value: searchData.term, contextEnabled }, - { state: { afterAutocomplete } } - ), - ]; - if (searchData.term && searchData.loading) { + let searchInput = [this.attach("search-term", { value: searchData.term })]; + if (searchData.loading) { searchInput.push(h("div.searching", h("div.spinner"))); + } else { + const clearButton = this.attach("link", { + attributes: { + title: I18n.t("search.clear_search"), + }, + action: "clearSearch", + className: "clear-search", + contents: () => iconNode("times"), + }); + + const advancedSearchButton = this.attach("link", { + href: this.fullSearchUrl({ expanded: true }), + contents: () => iconNode("sliders-h"), + className: "show-advanced-search", + title: I18n.t("search.open_advanced"), + }); + + if (searchData.term) { + searchInput.push( + h("div.searching", [clearButton, advancedSearchButton]) + ); + } else { + searchInput.push(h("div.searching", advancedSearchButton)); + } } - const results = [ - h("div.search-input", searchInput), - this.attach("search-context", { - contextEnabled, - url: this.fullSearchUrl({ expanded: true }), - }), - ]; + const results = [h("div.search-input", searchInput)]; - if (searchData.term && !searchData.loading) { + if (!searchData.loading) { results.push( this.attach("search-menu-results", { term: searchData.term, noResults: searchData.noResults, results: searchData.results, invalidTerm: searchData.invalidTerm, - searchContextEnabled: searchData.contextEnabled, suggestionKeyword: searchData.suggestionKeyword, suggestionResults: searchData.suggestionResults, + searchTopics: SearchHelper.includesTopics(), }) ); } @@ -260,6 +257,14 @@ export default createWidget("search-menu", { return results; }, + clearSearch() { + searchData.term = ""; + const searchInput = document.getElementById("search-term"); + searchInput.value = ""; + searchInput.focus(); + this.triggerSearch(); + }, + searchService() { if (!this._searchService) { this._searchService = this.register.lookup("search-service:main"); @@ -267,29 +272,7 @@ export default createWidget("search-menu", { return this._searchService; }, - searchContext() { - if (!this._searchContext) { - this._searchContext = this.searchService().get("searchContext"); - } - return this._searchContext; - }, - - html(attrs) { - const searchContext = this.searchContext(); - - const shouldTriggerSearch = - searchData.contextEnabled !== attrs.contextEnabled || - (searchContext && - searchContext.type === "topic" && - searchData.topicId !== null && - searchData.topicId !== searchContext.id); - - if (shouldTriggerSearch && searchData.term) { - this.triggerSearch(); - } - - searchData.contextEnabled = attrs.contextEnabled; - + html() { return this.attach("menu-panel", { maxWidth: 500, contents: () => this.panelContents(), @@ -312,18 +295,21 @@ export default createWidget("search-menu", { } if (e.which === 65 /* a */) { - let focused = $("header .results .search-link:focus"); - if (focused.length === 1) { - if ($("#reply-control.open").length === 1) { + if (document.activeElement?.classList.contains("search-link")) { + if (document.querySelector("#reply-control.open")) { // add a link and focus composer - this.appEvents.trigger("composer:insert-text", focused[0].href, { - ensureSpace: true, - }); + this.appEvents.trigger( + "composer:insert-text", + document.activeElement.getAttribute("href"), + { + ensureSpace: true, + } + ); this.appEvents.trigger("header:keyboard-trigger", { type: "search" }); e.preventDefault(); - $("#reply-control.open textarea").focus(); + document.querySelector("#reply-control.open textarea").focus(); return false; } } @@ -332,20 +318,28 @@ export default createWidget("search-menu", { const up = e.which === 38; const down = e.which === 40; if (up || down) { - let focused = $(".search-menu *:focus")[0]; + let focused = document.activeElement.closest(".search-menu") + ? document.activeElement + : null; if (!focused) { return; } - let links = $(".search-menu .results a"); - let results = $(".search-menu .results .search-link"); + 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.each((idx, item) => { - if ($(item).hasClass("search-link")) { + links.forEach((item) => { + if (item.classList.contains("search-link")) { prevResult = item; } @@ -357,30 +351,46 @@ export default createWidget("search-menu", { let index = -1; if (result) { - index = results.index(result); + index = Array.prototype.indexOf.call(results, result); } if (index === -1 && down) { - $(".search-menu .search-link:first").focus(); + document.querySelector(".search-menu .results .search-link").focus(); } else if (index === 0 && up) { - $(".search-menu input:first").focus(); + document.querySelector(".search-menu input#search-term").focus(); } else if (index > -1) { index += down ? 1 : -1; if (index >= 0 && index < results.length) { - $(results[index]).focus(); + results[index].focus(); } } e.preventDefault(); return false; } + + const searchInput = document.querySelector("#search-term"); + if (e.which === 13 && e.target === searchInput) { + // same combination as key-enter-escape mixin + if (e.ctrlKey || e.metaKey || (isiPad() && e.altKey)) { + this.fullSearch(); + } else { + searchData.typeFilter = null; + this.triggerSearch(); + } + } }, triggerSearch() { searchData.noResults = false; - this.searchService().set("highlightTerm", searchData.term); - searchData.loading = true; - discourseDebounce(SearchHelper, SearchHelper.perform, this, 400); + if (searchData.term.includes("topic:")) { + const highlightTerm = searchData.term.replace(TOPIC_REPLACE_REGEXP, ""); + this.searchService().set("highlightTerm", highlightTerm); + } + searchData.loading = SearchHelper.includesTopics() ? true : false; + + const delay = SearchHelper.includesTopics() ? 400 : 200; + discourseDebounce(SearchHelper, SearchHelper.perform, this, delay); }, moreOfType(type) { @@ -388,30 +398,17 @@ export default createWidget("search-menu", { this.triggerSearch(); }, - searchContextChanged(enabled) { - // This indicates the checkbox has been clicked, NOT that the context has changed. - searchData.typeFilter = null; - this.sendWidgetAction("searchMenuContextChanged", enabled); - searchData.contextEnabled = enabled; - this.triggerSearch(); - }, - - searchTermChanged(term) { - searchData.typeFilter = null; + searchTermChanged(term, opts = {}) { + searchData.typeFilter = opts.searchTopics ? null : DEFAULT_TYPE_FILTER; searchData.term = term; this.triggerSearch(); }, triggerAutocomplete(term) { - searchData.afterAutocomplete = true; - this.searchTermChanged(term); + this.searchTermChanged(term, { searchTopics: true }); }, fullSearch() { - if (!isValidSearchTerm(searchData.term, this.siteSettings)) { - return; - } - searchData.results = []; searchData.loading = false; SearchHelper.cancel(); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-test.js index 7c4c3ebb603..2a4d38edf49 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-test.js @@ -245,9 +245,14 @@ acceptance("Group - Authenticated", function (needs) { ); await click("#search-button"); - assert.ok( - exists(".search-context input:checked"), - "scope to message checkbox is checked" + await fillIn("#search-term", "smth"); + + assert.equal( + query( + ".search-menu .results .search-menu-assistant-item:first-child" + ).innerText.trim(), + "smth in:personal", + "contextual search is available as first option" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-test.js index 19b333b3551..47ec79c9458 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/search-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/search-test.js @@ -3,22 +3,28 @@ import { count, exists, query, - queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, 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/widgets/search-menu"; acceptance("Search - Anonymous", function (needs) { - let calledEmpty = false; - needs.pretender((server, helper) => { server.get("/search/query", (request) => { - if (!request.queryParams["search_context[type]"]) { - calledEmpty = true; + 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"], + tags: searchFixtures["search/query"]["tags"], + groups: searchFixtures["search/query"]["groups"], + grouped_search_result: + searchFixtures["search/query"]["grouped_search_result"], + }); } - return helper.response(searchFixtures["search/query"]); }); }); @@ -28,24 +34,61 @@ acceptance("Search - Anonymous", function (needs) { await click("#search-button"); - assert.ok(exists("#search-term"), "it shows the search bar"); - assert.ok(!exists(".search-menu .results ul li"), "no results by default"); + 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"); - await triggerKeyEvent("#search-term", "keyup", 16); - assert.ok(exists(".search-menu .results ul li"), "it shows results"); + + assert.ok( + !exists(".search-menu .results ul li.search-random-quick-tip"), + "quick tip no longer shown" + ); + + assert.equal( + query( + ".search-menu .results ul.search-menu-initial-options li:first-child" + ).innerText.trim(), + `dev ${I18n.t("search.in_topics_posts")}`, + "shows topic search as first dropdown item" + ); + + 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-menu", "keydown", 40); + 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-help"); + await click(".show-advanced-search"); assert.equal( - queryAll(".full-page-search").val(), + query(".full-page-search").value, "dev", - "it shows the search term" + "it goes to full search page and preserves the search term" ); + assert.ok( exists(".search-advanced-options"), "advanced search is expanded" @@ -68,85 +111,75 @@ acceptance("Search - Anonymous", function (needs) { assert.ok(!exists(".search-menu")); }); - test("search for a tag", async function (assert) { - await visit("/"); + test("search scope", async function (assert) { + const firstResult = + ".search-menu .results .search-menu-assistant-item:first-child"; - await click("#search-button"); - - await fillIn("#search-term", "evil"); - await triggerKeyEvent("#search-term", "keyup", 16); - assert.ok(exists(".search-menu .results ul li"), "it shows results"); - }); - - test("search scope checkbox", async function (assert) { await visit("/tag/important"); await click("#search-button"); - assert.ok( - exists(".search-context input:checked"), - "scope to tag checkbox is checked" + await fillIn("#search-term", "smth"); + + assert.equal( + query(firstResult).textContent.trim(), + `smth ${I18n.t("search.in")} test`, + "tag-scoped search is first available option" ); - await click("#search-button"); await visit("/c/bug"); await click("#search-button"); - assert.ok( - exists(".search-context input:checked"), - "scope to category checkbox is checked" + + assert.equal( + query(firstResult).textContent.trim(), + `smth ${I18n.t("search.in")} bug`, + "category-scoped search is first available option" ); - await click("#search-button"); await visit("/t/internationalization-localization/280"); await click("#search-button"); - assert.not( - exists(".search-context input:checked"), - "scope to topic checkbox is not checked" + + assert.equal( + query(firstResult).textContent.trim(), + `smth ${I18n.t("search.in_this_topic")}`, + "topic-scoped search is first available option" ); - await click("#search-button"); await visit("/u/eviltrout"); await click("#search-button"); - assert.ok( - exists(".search-context input:checked"), - "scope to user checkbox is checked" + + assert.equal( + query(firstResult).textContent.trim(), + `smth ${I18n.t("search.in_posts_by", { + username: "eviltrout", + })}`, + "user-scoped search is first available option" ); }); - test("Search with context", async function (assert) { + test("search scope for topics", async function (assert) { await visit("/t/internationalization-localization/280/1"); await click("#search-button"); await fillIn("#search-term", "a proper"); - await click(".search-context input[type='checkbox']"); - await triggerKeyEvent("#search-term", "keyup", 16); + await focus("input#search-term"); + await triggerKeyEvent(".search-menu", "keydown", 40); - assert.ok(exists(".search-menu .results ul li"), "it shows results"); - - const highlighted = []; - - queryAll("#post_7 span.highlighted").map((_, span) => { - highlighted.push(span.innerText); - }); - - assert.deepEqual( - highlighted, - ["a proper"], - "it should highlight the post with the search terms correctly" + await click(document.activeElement); + assert.ok( + exists(".search-menu .search-result-post ul li"), + "clicking first option formats results as posts" ); - calledEmpty = false; - await visit("/"); - await click("#search-button"); + assert.equal( + query("#post_7 span.highlighted").textContent.trim(), + "a proper", + "highlights the post correctly" + ); - assert.ok(!exists(".search-context input[type='checkbox']")); - assert.ok(calledEmpty, "it triggers a new search"); - - await visit("/t/internationalization-localization/280/1"); - await click("#search-button"); - - assert.ok(!$(".search-context input[type=checkbox]").is(":checked")); + await click(".clear-search"); + assert.equal(query("#search-term").value, "", "clear button works"); }); - test("Right filters are shown to anonymous users", async function (assert) { + test("Right filters are shown in full page search", async function (assert) { const inSelector = selectKit(".select-kit#in"); await visit("/search?expanded=true"); @@ -205,7 +238,7 @@ acceptance("Search - Authenticated", function (needs) { }); }); - test("Right filters are shown to logged-in users", async function (assert) { + test("Right filters are shown in full page search", async function (assert) { const inSelector = selectKit(".select-kit#in"); await visit("/search?expanded=true"); @@ -230,16 +263,96 @@ acceptance("Search - Authenticated", function (needs) { test("Works with empty result sets", async function (assert) { await visit("/t/internationalization-localization/280"); - await click(".search-dropdown"); - await click(".search-context input[type=checkbox]"); + await click("#search-button"); await fillIn("#search-term", "plans"); - await triggerKeyEvent("#search-term", "keyup", 32); - assert.notEqual(count(".item"), 0); + await focus("input#search-term"); + await triggerKeyEvent(".search-menu", "keydown", 40); + await click(document.activeElement); + + assert.notEqual(count(".search-menu .results .item"), 0); await fillIn("#search-term", "plans empty"); - await triggerKeyEvent("#search-term", "keyup", 32); - assert.equal(count(".item"), 0); - assert.equal(count(".no-results"), 1); + await triggerKeyEvent("#search-term", "keydown", 13); + + assert.equal(count(".search-menu .results .item"), 0); + assert.equal(count(".search-menu .results .no-results"), 1); + }); + + test("search dropdown keyboard navigation", async function (assert) { + const keyEnter = 13; + const keyArrowDown = 40; + const keyArrowUp = 38; + const keyEsc = 27; + const keyA = 65; + 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", "keydown", keyEnter); + assert.ok( + exists(query(`${container} .search-result-topic`)), + "has topic results" + ); + + await triggerKeyEvent("#search-term", "keydown", keyArrowDown); + + assert.equal( + document.activeElement.getAttribute("href"), + query(`${container} li:first-child a`).getAttribute("href"), + "arrow down selects first element" + ); + + await triggerKeyEvent("#search-term", "keydown", keyArrowDown); + + assert.equal( + document.activeElement.getAttribute("href"), + query(`${container} li:nth-child(2) a`).getAttribute("href"), + "arrow down selects next element" + ); + + await triggerKeyEvent("#search-term", "keydown", keyArrowDown); + await triggerKeyEvent("#search-term", "keydown", keyArrowDown); + await triggerKeyEvent("#search-term", "keydown", keyArrowDown); + await triggerKeyEvent("#search-term", "keydown", keyArrowDown); + + assert.equal( + document.activeElement.getAttribute("href"), + "/search?q=dev", + "arrow down sets focus to more results link" + ); + + await triggerKeyEvent(".search-menu", "keydown", keyEsc); + assert.ok(!exists(".search-menu:visible"), "Esc removes search dropdown"); + + await click("#search-button"); + await triggerKeyEvent(".search-menu", "keydown", keyArrowDown); + await triggerKeyEvent(".search-menu", "keydown", keyArrowUp); + + assert.equal( + document.activeElement.tagName.toLowerCase(), + "input", + "arrow up sets focus to search term input" + ); + + await triggerKeyEvent(".search-menu", "keydown", keyEsc); + await click("#create-topic"); + await click("#search-button"); + await triggerKeyEvent(".search-menu", "keydown", keyArrowDown); + + const firstLink = query(`${container} li:nth-child(1) a`).getAttribute( + "href" + ); + await triggerKeyEvent(".search-menu", "keydown", keyA); + + assert.equal( + query("#reply-control textarea").value, + firstLink, + "hitting A when focused on a search result copies link to composer" + ); }); }); @@ -249,19 +362,17 @@ acceptance("Search - with tagging enabled", function (needs) { test("displays tags", async function (assert) { await visit("/"); - await click("#search-button"); - await fillIn("#search-term", "dev"); - await triggerKeyEvent("#search-term", "keyup", 16); + await triggerKeyEvent("#search-term", "keydown", 13); - const tags = queryAll( - ".search-menu .results ul li:nth-of-type(1) .discourse-tags" - ) - .text() - .trim(); - - assert.equal(tags, "dev slow"); + assert.equal( + 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) { @@ -276,7 +387,7 @@ acceptance("Search - with tagging enabled", function (needs) { ".search-menu .results ul.search-menu-assistant .search-link"; assert.ok(exists(query(firstItem))); - const firstTag = query(`${firstItem} .search-item-tag`).innerText.trim(); + const firstTag = query(`${firstItem} .search-item-tag`).textContent.trim(); assert.equal(firstTag, "monkey"); }); }); @@ -325,10 +436,10 @@ acceptance("Search - assistant", function (needs) { const firstResultSlug = query( `${firstCategory} .category-name` - ).innerText.trim(); + ).textContent.trim(); await click(firstCategory); - assert.equal(query("#search-term").value, `#${firstResultSlug} `); + assert.equal(query("#search-term").value, `#${firstResultSlug}`); await fillIn("#search-term", "sam #"); await triggerKeyEvent("#search-term", "keyup", 51); @@ -338,11 +449,11 @@ acceptance("Search - assistant", function (needs) { query( ".search-menu .results ul.search-menu-assistant .search-item-prefix" ).innerText, - "sam" + "sam " ); await click(firstCategory); - assert.equal(query("#search-term").value, `sam #${firstResultSlug} `); + assert.equal(query("#search-term").value, `sam #${firstResultSlug}`); }); test("shows in: shortcuts", async function (assert) { @@ -379,6 +490,6 @@ acceptance("Search - assistant", function (needs) { assert.equal(firstUsername, "TeaMoe"); await click(query(firstUser)); - assert.equal(query("#search-term").value, `@${firstUsername} `); + assert.equal(query("#search-term").value, `@${firstUsername}`); }); }); diff --git a/app/assets/stylesheets/common/base/search-menu.scss b/app/assets/stylesheets/common/base/search-menu.scss index 2c19163e164..b9c77595a47 100644 --- a/app/assets/stylesheets/common/base/search-menu.scss +++ b/app/assets/stylesheets/common/base/search-menu.scss @@ -1,22 +1,27 @@ +@mixin user-item-flex { + display: flex; + flex-direction: column; + line-height: $line-height-medium; + color: var(--primary-high-or-secondary-low); +} + +@mixin separator { + border-top: 1px solid var(--primary-low); + margin-top: 0.5em; + padding-top: 0.5em; +} + +$search-pad-vertical: 0.25em; +$search-pad-horizontal: 0.5em; + .search-menu { - --search-padding: 0.5em; .menu-panel .panel-body-contents { overflow-y: auto; } .search-input { position: relative; - padding: var(--search-padding); - } - - .search-context { - label { - padding: 0.25em var(--search-padding); - } - .show-help { - margin-left: auto; - line-height: var(--line-height-medium); - } + padding: $search-pad-vertical 0.1em; } .heading { @@ -27,28 +32,15 @@ } input[type="text"] { - box-sizing: border-box; width: 100%; - min-height: 32px; margin-bottom: 0; } - .search-context { - display: flex; - align-items: center; - - label { - margin-bottom: 0; - } - } - - .search-context + .results { - margin-top: 1em; - } - .results { display: flex; - flex-direction: row; + flex-direction: column; + padding-top: $search-pad-vertical; + padding-bottom: $search-pad-vertical; .list { min-width: 100px; @@ -79,11 +71,16 @@ .second-line { display: flex; flex-wrap: wrap; - align-items: center; + align-items: baseline; - .discourse-tags { - .discourse-tag { - margin-right: 0.25em; + .badge-wrapper { + margin-right: 0.5em; + } + .discourse-tags .discourse-tag { + margin-right: 0.25em; + + .d-icon { + margin-right: 0; } } } @@ -95,215 +92,186 @@ } } - .main-results { + .search-result-category { + .widget-link { + margin-bottom: 0; + } + } + + .search-result-group .group-result, + .search-result-user .user-result { display: flex; - flex: 1 1 auto; - .topic-statuses { - color: var(--primary-medium); - } + align-items: center; + font-size: var(--font-down-1); } - - .main-results + .secondary-results { - border-left: 1px solid var(--primary-low); - margin-left: 1em; - padding-left: 1em; - max-width: 33%; - } - - .secondary-results { - display: flex; - flex-direction: column; - flex: 1 1 auto; - - .separator { - margin: 1em 0.25em; - height: 1px; - background: var(--primary-low); + .search-result-group .group-result { + .d-icon, + .avatar-flair { + width: 20px; + height: 20px; } - .search-result-tag { - .discourse-tag { - font-size: $font-down-1; - } - } - - .search-result-category { - .widget-link { - margin-bottom: 0; - } - } - - .search-result-group { - .search-link { - color: var(--primary-high); - - &:hover { - color: var(--primary); - } - } - - .group-result { - display: flex; - align-items: center; - - .d-icon, - .avatar-flair { - min-width: 25px; - margin-right: 0.5em; - - .d-icon { - margin-right: 0; - } - } - - .avatar-flair-image { - background-repeat: no-repeat; - background-size: 100% 100%; - min-height: 25px; - } - - .group-names { - display: flex; - flex-direction: column; - overflow: auto; - line-height: $line-height-medium; - - &:hover { - .name, - .slug { - color: var(--primary-high); - } - } - - .name, - .slug { - @include ellipsis; - } - - .name { - font-weight: 700; - } - - .slug { - font-size: $font-down-1; - color: var(--primary-high); - } - } - } - } - - .search-result-category, - .search-result-user, - .search-result-group, - .search-result-tag { - .list { - display: block; - - .item { - .widget-link.search-link { - flex: 1; - font-size: $font-0; - padding: 0.25em; - } - } - } - } - - .search-result-user { - .user-result { - display: flex; - flex-direction: row; - align-items: center; - - .avatar { - margin-right: 0.5em; - display: block; - min-width: 25px; - } - - .user-titles { - display: flex; - flex-direction: column; - overflow: auto; - line-height: $line-height-medium; - - .username, - .name { - @include ellipsis; - } - - .username { - color: var(--primary-high-or-secondary-low); - font-size: $font-down-1; - } - - .custom-field { - color: var(--primary-high-or-secondary-low); - font-size: $font-down-2; - } - - .name { - color: var(--primary-high-or-secondary-low); - font-size: $font-0; - font-weight: 700; - } - } - } - } - } - - .search-menu-assistant { - min-width: 100%; - margin-top: -1em; - - .search-menu-assistant-item { - > span { - vertical-align: baseline; - display: inline-block; - } - } - - .search-item-user .avatar, - .search-item-prefix { + .avatar-flair { margin-right: 0.5em; + border-radius: 50%; + &.avatar-flair-image { + background-repeat: no-repeat; + background-size: 100% 100%; + } + .d-icon { + margin-right: 0; + } } - .search-item-tag { - color: var(--primary-high); - font-size: var(--font-down-1); - } + .group-names { + @include user-item-flex; + .name, + .slug { + @include ellipsis; + } - .d-icon-tag { - // match category badge styling - // tag/category suggestions can be displayed simultaneously - font-size: var(--font-down-2); + .name { + font-weight: 700; + } + } + } + + .search-result-user .user-result { + .user-titles { + @include user-item-flex; + + .username, + .name { + @include ellipsis; + } + + .name { + font-weight: 700; + } + + .username, + .name, + .custom-field { + color: var(--primary-high-or-secondary-low); + } + + .custom-field { + font-size: var(--font-down-2); + } + } + } + + .search-result-category, + .search-result-tag { + + .search-result-user, + + .search-result-group { + @include separator; + } + } + + .search-result-user .user-result img.avatar, + .search-item-user img.avatar { + width: 20px; + height: 20px; + margin-right: 5px; + } + + .label-suffix { + color: var(--primary-medium); + } + + .badge-wrapper { + font-size: var(--font-0); + margin-left: 2px; + } + + .search-menu-initial-options { + + .search-result-tag, + + .search-result-category, + + .search-result-user, + + .search-result-group { + @include separator; + } + } + + .search-menu-initial-options, + .search-result-tag, + .search-menu-assistant { + .search-link { + .d-icon { + margin-right: 5px; + vertical-align: middle; + } + .d-icon-tag { + font-size: var(--font-down-2); + } + + .d-icon-search { + font-size: var(--font-down-1); + } + } + } + + .search-random-quick-tip { + padding: $search-pad-vertical $search-pad-horizontal; + padding-bottom: 0; + font-size: var(--font-down-2); + color: var(--primary-medium); + .tip-label { + background-color: rgba(var(--tertiary-rgb), 0.1); margin-right: 4px; + padding: 2px 4px; + display: inline-block; } } } .searching { position: absolute; - top: 1.1em; - right: 1em; + top: $search-pad-vertical + 0.4em; + right: $search-pad-horizontal; + min-height: 20px; .spinner { - width: 10px; - height: 10px; + width: 12px; + height: 12px; border-width: 2px; margin: 0; + margin-top: 2px; + } + + a.show-advanced-search, + a.clear-search { + padding: 0px 3px; + display: inline-block; + background-color: transparent; + .d-icon { + color: var(--primary-low-mid); + } + &:hover .d-icon { + color: var(--primary-high); + } + } + + a.clear-search { + margin-right: 3px; } } .no-results { - padding: var(--search-padding); + padding: $search-pad-vertical $search-pad-horizontal; } .search-link { - padding: var(--search-padding); + display: block; + padding: $search-pad-vertical $search-pad-horizontal; - .badge-category-parent { - line-height: $line-height-small; + // This is purposefully redundant + // the search widget can be used outside of the header + // and the focus/hover styles from the header in those cases wouldn't follow + &:focus, + &:hover { + background-color: var(--highlight-medium); } .topic { @@ -319,4 +287,10 @@ margin-right: 0.25em; } } + .search-result-topic, + .search-result-post { + .search-link { + padding: 0.5em; + } + } } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 7c915104b91..8cadc977629 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2341,11 +2341,13 @@ en: select_all: "Select All" clear_all: "Clear All" too_short: "Your search term is too short." + open_advanced: "Open advanced search" + clear_search: "Clear search" sort_or_bulk_actions: "Sort or bulk select results" result_count: one: "%{count} result for%{term}" other: "%{count}%{plus} results for%{term}" - title: "search topics, posts, users, or categories" + title: "Search" full_page_title: "Search" no_results: "No results found." no_more_results: "No more results found." @@ -2361,6 +2363,10 @@ en: search_term_label: "enter search keyword" categories: "Categories" tags: "Tags" + in: "in" + in_this_topic: "in this topic" + in_topics_posts: "in all topics and posts" + in_posts_by: "in posts by %{username}" type: default: "Topics/posts" @@ -2375,6 +2381,14 @@ en: topic: "Search this topic" private_messages: "Search messages" + tips: + category_tag: "filters by category or tag" + author: "filters by post author" + in: "filters by metadata (e.g. in:title, in:personal, in:pinned)" + status: "filters by topic status" + full_search: "launches full page search" + full_search_key: "%{modifier} + Enter" + advanced: title: Advanced filters posted_by: diff --git a/lib/search.rb b/lib/search.rb index c843e13aad1..dd5581d8863 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -33,7 +33,7 @@ class Search end def self.facets - %w(topic category user private_messages tags all_topics) + %w(topic category user private_messages tags all_topics exclude_topics) end def self.ts_config(locale = SiteSetting.default_locale) @@ -230,7 +230,7 @@ class Search end def limit - if @opts[:type_filter].present? + if @opts[:type_filter].present? && @opts[:type_filter] != "exclude_topics" Search.per_filter + 1 else Search.per_facet + 1 @@ -862,13 +862,13 @@ class Search groups = Group .visible_groups(@guardian.user, "name ASC", include_everyone: false) .where("name ILIKE :term OR full_name ILIKE :term", term: "%#{@term}%") + .limit(limit) groups.each { |group| @results.add(group) } end def tags_search return unless SiteSetting.tagging_enabled - tags = Tag.includes(:tag_search_data) .where("tag_search_data.search_data @@ #{ts_query}") .references(:tag_search_data) @@ -882,6 +882,15 @@ class Search end end + def exclude_topics_search + if @term.present? + user_search + category_search + tags_search + groups_search + end + end + PHRASE_MATCH_REGEXP_PATTERN = '"([^"]+)"' def posts_query(limit, type_filter: nil, aggregate_search: false) diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index bcebe7245e0..f4b1294ea55 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -180,6 +180,7 @@ module SvgSprite "sign-in-alt", "sign-out-alt", "signal", + "sliders-h", "star", "step-backward", "step-forward", diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb index be25dbd0e06..c8f2a5c9521 100644 --- a/spec/components/search_spec.rb +++ b/spec/components/search_spec.rb @@ -1921,4 +1921,33 @@ describe Search do expect(Search.new("advanced order:chars").execute.posts).to eq([post0, post1]) end end + + context 'exclude_topics filter' do + before { SiteSetting.tagging_enabled = true } + let!(:user) { Fabricate(:user) } + fab!(:group) { Fabricate(:group, name: 'bruce-world-fans') } + fab!(:topic) { Fabricate(:topic, title: 'Bruce topic not a result') } + + it 'works' do + category = Fabricate(:category_with_definition, name: 'bruceland', user: user) + tag = Fabricate(:tag, name: 'brucealicious') + + result = Search.execute('bruce', type_filter: 'exclude_topics') + + expect(result.users.map(&:id)).to contain_exactly(user.id) + + expect(result.categories.map(&:id)).to contain_exactly(category.id) + + expect(result.groups.map(&:id)).to contain_exactly(group.id) + + expect(result.tags.map(&:id)).to contain_exactly(tag.id) + + expect(result.posts.length).to eq(0) + end + + it 'does not fail when parsed term is empty' do + result = Search.execute('#cat ', type_filter: 'exclude_topics') + expect(result.categories.length).to eq(0) + end + end end