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:
parent
361c8be547
commit
438a762956
|
@ -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");
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue