From 438a7629561397830fa9622f517ecec46e003e82 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 16 Jul 2021 11:08:20 -0400 Subject: [PATCH] FEATURE: Add assistant to quick search widget (#13650) Replaces the autocomplete overlay for categories and usernames on the search input and adds suggestions as items in the search results instead. Also adds the same behaviour for @mentions as well as special `in: status: order:` keywords. See PR for more details. --- .../discourse/app/components/site-header.js | 1 - .../discourse/app/lib/plugin-api.js | 15 +- .../javascripts/discourse/app/lib/search.js | 61 ++----- .../discourse/app/widgets/header.js | 15 +- .../app/widgets/search-menu-controls.js | 12 +- .../app/widgets/search-menu-results.js | 166 ++++++++++++++++++ .../discourse/app/widgets/search-menu.js | 97 +++++++++- .../discourse/tests/acceptance/search-test.js | 55 ++++++ .../stylesheets/common/base/search-menu.scss | 17 ++ 9 files changed, 370 insertions(+), 69 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js index d9895801fa8..9ac49c9cfec 100644 --- a/app/assets/javascripts/discourse/app/components/site-header.js +++ b/app/assets/javascripts/discourse/app/components/site-header.js @@ -218,7 +218,6 @@ const SiteHeaderComponent = MountWidget.extend( this.dispatch("notifications:changed", "user-notifications"); this.dispatch("header:keyboard-trigger", "header"); - this.dispatch("search-autocomplete:after-complete", "search-term"); this.dispatch("user-menu:navigation", "user-menu"); this.appEvents.on("dom:clean", this, "_cleanDom"); diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index 994d45dfbd6..cbf157d6cbc 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -73,9 +73,10 @@ import { replaceFormatter } from "discourse/lib/utilities"; import { replaceTagRenderer } from "discourse/lib/render-tag"; import { setNewCategoryDefaultColors } from "discourse/routes/new-category"; import { addSearchResultsCallback } from "discourse/lib/search"; +import { addInSearchShortcut } from "discourse/widgets/search-menu-results"; // If you add any methods to the API ensure you bump up this number -const PLUGIN_API_VERSION = "0.11.5"; +const PLUGIN_API_VERSION = "0.11.6"; class PluginApi { constructor(version, container) { @@ -1295,6 +1296,18 @@ class PluginApi { addSearchResultsCallback(callback) { addSearchResultsCallback(callback); } + + /** + * Add a in: shortcut to search menu panel. + * + * ``` + * addInSearchShortcut("in:assigned"); + * ``` + * + */ + addInSearchShortcut(value) { + addInSearchShortcut(value); + } } // from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number diff --git a/app/assets/javascripts/discourse/app/lib/search.js b/app/assets/javascripts/discourse/app/lib/search.js index b2de359e912..a53769b5123 100644 --- a/app/assets/javascripts/discourse/app/lib/search.js +++ b/app/assets/javascripts/discourse/app/lib/search.js @@ -206,53 +206,30 @@ export function isValidSearchTerm(searchTerm, siteSettings) { } } -export function applySearchAutocomplete( - $input, - siteSettings, - appEvents, - options -) { - const afterComplete = function () { - if (appEvents) { - appEvents.trigger("search-autocomplete:after-complete"); - } - }; - +export function applySearchAutocomplete($input, siteSettings) { $input.autocomplete( - deepMerge( - { - template: findRawTemplate("category-tag-autocomplete"), - key: "#", - width: "100%", - treatAsTextarea: true, - autoSelectFirstSuggestion: false, - transformComplete(obj) { - return obj.text; - }, - dataSource(term) { - return searchCategoryTag(term, siteSettings); - }, - afterComplete, - }, - options - ) + deepMerge({ + template: findRawTemplate("category-tag-autocomplete"), + key: "#", + width: "100%", + treatAsTextarea: true, + autoSelectFirstSuggestion: false, + transformComplete: (obj) => obj.text, + dataSource: (term) => searchCategoryTag(term, siteSettings), + }) ); if (siteSettings.enable_mentions) { $input.autocomplete( - deepMerge( - { - template: findRawTemplate("user-selector-autocomplete"), - key: "@", - width: "100%", - treatAsTextarea: true, - autoSelectFirstSuggestion: false, - transformComplete: (v) => v.username || v.name, - dataSource: (term) => userSearch({ term, includeGroups: true }), - afterComplete, - }, - options - ) + deepMerge({ + template: findRawTemplate("user-selector-autocomplete"), + key: "@", + width: "100%", + treatAsTextarea: true, + autoSelectFirstSuggestion: false, + transformComplete: (v) => v.username || v.name, + dataSource: (term) => userSearch({ term, includeGroups: true }), + }) ); } } diff --git a/app/assets/javascripts/discourse/app/widgets/header.js b/app/assets/javascripts/discourse/app/widgets/header.js index 784d5265db7..0c292dea95e 100644 --- a/app/assets/javascripts/discourse/app/widgets/header.js +++ b/app/assets/javascripts/discourse/app/widgets/header.js @@ -2,7 +2,6 @@ import DiscourseURL, { userPath } from "discourse/lib/url"; import I18n from "I18n"; import { addExtraUserClasses } from "discourse/helpers/user-avatar"; import { ajax } from "discourse/lib/ajax"; -import { applySearchAutocomplete } from "discourse/lib/search"; import { avatarImg } from "discourse/widgets/post"; import { createWidget } from "discourse/widgets/widget"; import { get } from "@ember/object"; @@ -484,17 +483,9 @@ export default createWidget("header", { if (this.state.searchVisible) { schedule("afterRender", () => { - const $searchInput = $("#search-term"); - $searchInput.focus().select(); - - applySearchAutocomplete( - $searchInput, - this.siteSettings, - this.appEvents, - { - appendSelector: ".menu-panel", - } - ); + const searchInput = document.querySelector("#search-term"); + searchInput.focus(); + searchInput.select(); }); } }, 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 dd2f485467b..dc329df3e26 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu-controls.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu-controls.js @@ -13,10 +13,6 @@ createWidget("search-term", { return { afterAutocomplete: false }; }, - searchAutocompleteAfterComplete() { - this.state.afterAutocomplete = true; - }, - buildAttributes(attrs) { return { type: "text", @@ -28,12 +24,8 @@ createWidget("search-term", { }, keyUp(e) { - if (e.which === 13) { - if (this.state.afterAutocomplete) { - this.state.afterAutocomplete = false; - } else { - return this.sendWidgetAction("fullSearch"); - } + if (e.which === 13 && !this.state.afterAutocomplete) { + return this.sendWidgetAction("fullSearch"); } const val = this.attrs.value; 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 3a5885d9479..5ef9d058c18 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js @@ -10,6 +10,33 @@ import highlightSearch from "discourse/lib/highlight-search"; import { iconNode } from "discourse-common/lib/icon-library"; import renderTag from "discourse/lib/render-tag"; +const inSearchShortcuts = [ + "in:title", + "in:personal", + "in:seen", + "in:likes", + "in:bookmarks", + "in:created", +]; +const statusSearchShortcuts = [ + "status:open", + "status:closed", + "status:public", + "status:noreplies", +]; +const orderSearchShortcuts = [ + "order:latest", + "order:views", + "order:likes", + "order:latest_topic", +]; + +export function addInSearchShortcut(value) { + if (inSearchShortcuts.indexOf(value) === -1) { + inSearchShortcuts.push(value); + } +} + class Highlighted extends RawHtml { constructor(html, term) { super({ html: `${html}` }); @@ -207,6 +234,14 @@ createWidget("search-menu-results", { tagName: "div.results", html(attrs) { + if (attrs.suggestionKeyword) { + return this.attach("search-menu-assistant", { + fullTerm: attrs.term, + suggestionKeyword: attrs.suggestionKeyword, + results: attrs.suggestionResults || [], + }); + } + if (attrs.invalidTerm) { return h("div.no-results", I18n.t("search.too_short")); } @@ -320,3 +355,134 @@ createWidget("search-menu-results", { return content; }, }); + +createWidget("search-menu-assistant", { + tagName: "ul.search-menu-assistant", + + html(attrs) { + if (this.siteSettings.tagging_enabled) { + addInSearchShortcut("in:tagged"); + } + + const content = []; + const { fullTerm, suggestionKeyword } = attrs; + const prefix = fullTerm.split(suggestionKeyword)[0].trim() || null; + + switch (suggestionKeyword) { + case "#": + attrs.results.map((category) => { + const slug = prefix + ? `${prefix} #${category.slug} ` + : `#${category.slug} `; + + content.push( + this.attach("search-menu-assistant-item", { + prefix: prefix, + category, + slug, + }) + ); + }); + break; + case "@": + attrs.results.map((user) => { + const slug = prefix + ? `${prefix} @${user.username} ` + : `@${user.username} `; + + content.push( + this.attach("search-menu-assistant-item", { + prefix: prefix, + user, + slug, + }) + ); + }); + break; + case "in:": + inSearchShortcuts.map((item) => { + const slug = prefix ? `${prefix} ${item} ` : item; + content.push(this.attach("search-menu-assistant-item", { slug })); + }); + break; + case "status:": + statusSearchShortcuts.map((item) => { + const slug = prefix ? `${prefix} ${item} ` : item; + content.push(this.attach("search-menu-assistant-item", { slug })); + }); + break; + case "order:": + orderSearchShortcuts.map((item) => { + const slug = prefix ? `${prefix} ${item} ` : item; + content.push(this.attach("search-menu-assistant-item", { slug })); + }); + break; + } + + return content; + }, +}); + +createWidget("search-menu-assistant-item", { + tagName: "li.search-menu-assistant-item", + + html(attrs) { + if (attrs.category) { + return h( + "a.widget-link.search-link", + { + attributes: { + href: attrs.category.url, + }, + }, + [ + h("span.search-item-prefix", attrs.prefix), + this.attach("category-link", { + category: attrs.category, + allowUncategorized: true, + }), + ] + ); + } else if (attrs.user) { + const userResult = [ + avatarImg("small", { + template: attrs.user.avatar_template, + username: attrs.user.username, + }), + h("span.username", formatUsername(attrs.user.username)), + ]; + + return h( + "a.widget-link.search-link", + { + attributes: { + href: "#", + }, + }, + [ + h("span.search-item-prefix", attrs.prefix), + h("span.search-item-user", userResult), + ] + ); + } else { + return h( + "a.widget-link.search-link", + { + attributes: { + href: "#", + }, + }, + h("span.search-item-slug", attrs.slug) + ); + } + }, + + click(e) { + const searchInput = document.querySelector("#search-term"); + searchInput.value = this.attrs.slug; + searchInput.focus(); + this.sendWidgetAction("triggerAutocomplete", this.attrs.slug); + e.preventDefault(); + return false; + }, +}); diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu.js b/app/assets/javascripts/discourse/app/widgets/search-menu.js index 246aeb4626b..6514e9a1d4e 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu.js @@ -1,4 +1,5 @@ import { isValidSearchTerm, searchForTerm } from "discourse/lib/search"; +import Category from "discourse/models/category"; import DiscourseURL from "discourse/lib/url"; import { createWidget } from "discourse/widgets/widget"; import discourseDebounce from "discourse-common/lib/debounce"; @@ -6,8 +7,15 @@ import { get } from "@ember/object"; import getURL from "discourse-common/lib/get-url"; import { h } from "virtual-dom"; import { popupAjaxError } from "discourse/lib/ajax-error"; +import userSearch from "discourse/lib/user-search"; + +const CATEGORY_SLUG_REGEXP = /(\#[a-zA-Z0-9\-:]*)$/gi; +// The backend user search query returns zero results for a term-free search +// so the regexp below only matches @ followed by a valid character +const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]+)$/gi; const searchData = {}; +const suggestionTriggers = ["in:", "status:", "order:"]; export function initSearchData() { searchData.loading = false; @@ -17,6 +25,7 @@ export function initSearchData() { searchData.typeFilter = null; searchData.invalidTerm = false; searchData.topicId = null; + searchData.afterAutocomplete = false; } initSearchData(); @@ -39,6 +48,49 @@ const SearchHelper = { const { term, typeFilter, contextEnabled } = searchData; const searchContext = contextEnabled ? widget.searchContext() : null; const fullSearchUrl = widget.fullSearchUrl(); + const matchSuggestions = this.matchesSuggestions(); + + if (matchSuggestions) { + searchData.noResults = true; + searchData.results = []; + searchData.loading = false; + + if (typeof matchSuggestions === "string") { + searchData.suggestionKeyword = matchSuggestions; + widget.scheduleRerender(); + return; + } else { + if (matchSuggestions.type === "category") { + const categorySearchTerm = matchSuggestions.categoriesMatch[0].replace( + "#", + "" + ); + + searchData.suggestionResults = Category.search(categorySearchTerm); + searchData.suggestionKeyword = "#"; + widget.scheduleRerender(); + return; + } + if (matchSuggestions.type === "username") { + userSearch({ + term: matchSuggestions.usernamesMatch[0], + includeGroups: true, + }).then((result) => { + if (result?.users.length > 0) { + searchData.suggestionResults = result.users; + searchData.suggestionKeyword = "@"; + } else { + searchData.noResults = true; + searchData.suggestionKeyword = false; + } + widget.scheduleRerender(); + }); + return; + } + } + } + + searchData.suggestionKeyword = false; if (!isValidSearchTerm(term, widget.siteSettings)) { searchData.noResults = true; @@ -73,10 +125,38 @@ const SearchHelper = { .catch(popupAjaxError) .finally(() => { searchData.loading = false; + searchData.afterAutocomplete = false; widget.scheduleRerender(); }); } }, + + matchesSuggestions() { + if (searchData.term === undefined) { + return false; + } + + const simpleSuggestion = suggestionTriggers.find( + (mod) => searchData.term === mod || searchData.term.endsWith(` ${mod}`) + ); + + if (simpleSuggestion) { + return simpleSuggestion; + } + + const categoriesMatch = searchData.term.match(CATEGORY_SLUG_REGEXP); + + if (categoriesMatch) { + return { type: "category", categoriesMatch }; + } + + const usernamesMatch = searchData.term.match(USERNAME_REGEXP); + if (usernamesMatch) { + return { type: "username", usernamesMatch }; + } + + return false; + }, }; export default createWidget("search-menu", { @@ -132,10 +212,14 @@ export default createWidget("search-menu", { }, panelContents() { - const contextEnabled = searchData.contextEnabled; + const { contextEnabled, afterAutocomplete } = searchData; let searchInput = [ - this.attach("search-term", { value: searchData.term, contextEnabled }), + this.attach( + "search-term", + { value: searchData.term, contextEnabled }, + { state: { afterAutocomplete } } + ), ]; if (searchData.term && searchData.loading) { searchInput.push(h("div.searching", h("div.spinner"))); @@ -157,6 +241,8 @@ export default createWidget("search-menu", { results: searchData.results, invalidTerm: searchData.invalidTerm, searchContextEnabled: searchData.contextEnabled, + suggestionKeyword: searchData.suggestionKeyword, + suggestionResults: searchData.suggestionResults, }) ); } @@ -211,7 +297,7 @@ export default createWidget("search-menu", { return false; } - if (searchData.loading || searchData.noResults) { + if (searchData.loading) { return; } @@ -306,6 +392,11 @@ export default createWidget("search-menu", { this.triggerSearch(); }, + triggerAutocomplete(term) { + searchData.afterAutocomplete = true; + this.searchTermChanged(term); + }, + fullSearch() { if (!isValidSearchTerm(searchData.term, this.siteSettings)) { return; diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-test.js index c759a1aa3d7..a61257e3c3d 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/search-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/search-test.js @@ -2,6 +2,7 @@ import { acceptance, count, exists, + query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; @@ -247,3 +248,57 @@ acceptance("Search - with tagging enabled", function (needs) { assert.equal(tags, "dev slow"); }); }); + +acceptance("Search - assistant", function (needs) { + needs.user(); + + 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` + ).innerText.trim(); + + await click(firstCategory); + assert.equal(query("#search-term").value, `#${firstResultSlug} `); + + await fillIn("#search-term", "sam #"); + await triggerKeyEvent("#search-term", "keyup", 51); + + assert.ok(exists(query(firstCategory))); + assert.equal( + query( + ".search-menu .results ul.search-menu-assistant .search-item-prefix" + ).innerText, + "sam" + ); + + await click(firstCategory); + assert.equal(query("#search-term").value, `sam #${firstResultSlug} `); + }); + + test("shows in: shortcuts", async function (assert) { + await visit("/"); + await click("#search-button"); + + const firstTarget = + ".search-menu .results ul.search-menu-assistant .search-link .search-item-slug"; + + await fillIn("#search-term", "in:"); + await triggerKeyEvent("#search-term", "keyup", 51); + assert.equal(query(firstTarget).innerText, "in:title"); + + await fillIn("#search-term", "sam in:"); + await triggerKeyEvent("#search-term", "keyup", 51); + assert.equal(query(firstTarget).innerText, "sam in:title"); + }); +}); diff --git a/app/assets/stylesheets/common/base/search-menu.scss b/app/assets/stylesheets/common/base/search-menu.scss index 883318421a6..b6c6926f229 100644 --- a/app/assets/stylesheets/common/base/search-menu.scss +++ b/app/assets/stylesheets/common/base/search-menu.scss @@ -251,6 +251,23 @@ } } } + + .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 { + margin-right: 0.5em; + } + } } .searching {