UX: Revamp quick search (#14499)

Co-authored-by: Robin Ward <robin.ward@gmail.com>
Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
This commit is contained in:
Penar Musaraj 2021-10-06 11:42:52 -04:00 committed by GitHub
parent 98d2836eb4
commit e9b1d29d8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 815 additions and 624 deletions

View File

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

View File

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

View File

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

View File

@ -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: `<span>${html}</span>` });
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(
return this.attach(
"link",
$.extend(moreArgs, {
deepMerge(moreArgs, {
action: "moreOfType",
actionParam: result.type,
className: "filter filter-type",
})
)
);
}
if (more.length) {
return more;
}
};
const assignContainer = (result, node) => {
if (searchTopics) {
if (["topic"].includes(result.type)) {
mainResultsContent.push(node);
}
} else {
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));
}
}
};
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,30 +348,18 @@ createWidget("search-menu-results", {
const content = [];
if (!searchTopics) {
content.push(this.attach("search-menu-initial-options", { term }));
} else {
if (mainResultsContent.length) {
content.push(h("div.main-results", mainResultsContent));
content.push(mainResultsContent);
} else {
return h("div.no-results", I18n.t("search.no_results"));
}
}
if (usersAndGroups.length || categoriesAndTags.length) {
const secondaryResultsContents = [];
secondaryResultsContents.push(usersAndGroups);
secondaryResultsContents.push(usersAndGroupsMore);
if (usersAndGroups.length && categoriesAndTags.length) {
secondaryResultsContents.push(h("div.separator"));
}
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} `;
@ -389,6 +405,7 @@ createWidget("search-menu-assistant", {
prefix,
category: item.model,
slug: `${prefix}${fullSlug}`,
withInLabel: attrs.withInLabel,
})
);
} else {
@ -397,6 +414,7 @@ createWidget("search-menu-assistant", {
prefix,
tag: item.name,
slug: `${prefix}#${item.name}`,
withInLabel: attrs.withInLabel,
})
);
}
@ -415,7 +433,7 @@ createWidget("search-menu-assistant", {
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}`,
@ -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),
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),
];
},
});

View File

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

View File

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

View File

@ -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(
assert.equal(
query(
".search-menu .results ul li:nth-of-type(1) .discourse-tags"
)
.text()
.trim();
assert.equal(tags, "dev slow");
).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,7 +436,7 @@ 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}`);

View File

@ -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 {
.badge-wrapper {
margin-right: 0.5em;
}
.discourse-tags .discourse-tag {
margin-right: 0.25em;
.d-icon {
margin-right: 0;
}
}
}
@ -95,86 +92,39 @@
}
}
.main-results {
display: flex;
flex: 1 1 auto;
.topic-statuses {
color: var(--primary-medium);
}
}
.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-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 {
.search-result-group .group-result,
.search-result-user .user-result {
display: flex;
align-items: center;
font-size: var(--font-down-1);
}
.search-result-group .group-result {
.d-icon,
.avatar-flair {
min-width: 25px;
margin-right: 0.5em;
width: 20px;
height: 20px;
}
.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;
}
}
.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);
}
}
@include user-item-flex;
.name,
.slug {
@include ellipsis;
@ -183,127 +133,145 @@
.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;
}
.search-result-user .user-result {
.user-titles {
display: flex;
flex-direction: column;
overflow: auto;
line-height: $line-height-medium;
@include user-item-flex;
.username,
.name {
@include ellipsis;
}
.username {
.name {
font-weight: 700;
}
.username,
.name,
.custom-field {
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;
}
}
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 {
min-width: 100%;
margin-top: -1em;
.search-menu-assistant-item {
> span {
vertical-align: baseline;
display: inline-block;
.search-link {
.d-icon {
margin-right: 5px;
vertical-align: middle;
}
.d-icon-tag {
font-size: var(--font-down-2);
}
.search-item-user .avatar,
.search-item-prefix {
margin-right: 0.5em;
}
.search-item-tag {
color: var(--primary-high);
.d-icon-search {
font-size: var(--font-down-1);
}
}
}
.d-icon-tag {
// match category badge styling
// tag/category suggestions can be displayed simultaneously
.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;
}
}
}

View File

@ -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: "<span>%{count} result for</span><span class='term'>%{term}</span>"
other: "<span>%{count}%{plus} results for</span><span class='term'>%{term}</span>"
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:

View File

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

View File

@ -180,6 +180,7 @@ module SvgSprite
"sign-in-alt",
"sign-out-alt",
"signal",
"sliders-h",
"star",
"step-backward",
"step-forward",

View File

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