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.
This commit is contained in:
Penar Musaraj 2021-07-16 11:08:20 -04:00 committed by GitHub
parent 361c8be547
commit 438a762956
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 370 additions and 69 deletions

View File

@ -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");

View File

@ -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

View File

@ -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 }),
})
);
}
}

View File

@ -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();
});
}
},

View File

@ -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;

View File

@ -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: `<span>${html}</span>` });
@ -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;
},
});

View File

@ -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;

View File

@ -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");
});
});

View File

@ -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 {