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]; const name = pair[1];
if (results[name].length > 0) { if (results[name].length > 0) {
const componentName = const componentName =
opts.searchContext && opts.showPosts && type === "topic" ? "post" : type;
opts.searchContext.type === "topic" &&
type === "topic"
? "post"
: type;
const result = { const result = {
results: results[name], results: results[name],
componentName: `search-result-${componentName}`, componentName: `search-result-${componentName}`,
@ -157,12 +154,8 @@ export function searchForTerm(term, opts) {
data.restrict_to_archetype = opts.restrictToArchetype; data.restrict_to_archetype = opts.restrictToArchetype;
} }
if (opts.searchContext) { if (term.includes("topic:")) {
data.search_context = { opts.showPosts = true;
type: opts.searchContext.type,
id: opts.searchContext.id,
name: opts.searchContext.name,
};
} }
let ajaxPromise = ajax("/search/query", { data }); let ajaxPromise = ajax("/search/query", { data });

View File

@ -1,17 +1,11 @@
import EmberObject, { get } from "@ember/object"; 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({ export default EmberObject.extend({
searchContextEnabled: false, // checkbox to scope search searchContextEnabled: false, // checkbox to scope search
searchContext: null, searchContext: null,
term: null,
highlightTerm: null, highlightTerm: null,
@observes("term")
_sethighlightTerm() {
this.set("highlightTerm", this.term);
},
@discourseComputed("searchContext") @discourseComputed("searchContext")
contextType: { contextType: {
get(searchContext) { get(searchContext) {

View File

@ -1,34 +1,21 @@
import I18n from "I18n"; import I18n from "I18n";
import { createWidget } from "discourse/widgets/widget"; 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", { createWidget("search-term", {
tagName: "input", tagName: "input",
buildId: () => "search-term", buildId: () => "search-term",
buildKey: () => "search-term", buildKey: () => "search-term",
defaultState() {
return { afterAutocomplete: false };
},
buildAttributes(attrs) { buildAttributes(attrs) {
return { return {
type: "text", type: "text",
value: attrs.value || "", value: attrs.value || "",
autocomplete: "discourse", autocomplete: "off",
placeholder: attrs.contextEnabled ? "" : I18n.t("search.title"), placeholder: I18n.t("search.title"),
"aria-label": 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) { input(e) {
const val = this.attrs.value; const val = this.attrs.value;
@ -41,47 +28,9 @@ createWidget("search-term", {
}, },
}); });
// TODO: No longer used, remove in December 2021
createWidget("search-context", { createWidget("search-context", {
tagName: "div.search-context", html() {
return false;
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);
}
}, },
}); });

View File

@ -1,4 +1,5 @@
import { escapeExpression, formatUsername } from "discourse/lib/utilities"; import { escapeExpression, formatUsername } from "discourse/lib/utilities";
import { deepMerge } from "discourse-common/lib/object";
import I18n from "I18n"; import I18n from "I18n";
import RawHtml from "discourse/widgets/raw-html"; import RawHtml from "discourse/widgets/raw-html";
import { avatarImg } from "discourse/widgets/post"; import { avatarImg } from "discourse/widgets/post";
@ -10,6 +11,10 @@ import { h } from "virtual-dom";
import highlightSearch from "discourse/lib/highlight-search"; import highlightSearch from "discourse/lib/highlight-search";
import { iconNode } from "discourse-common/lib/icon-library"; import { iconNode } from "discourse-common/lib/icon-library";
import renderTag from "discourse/lib/render-tag"; import renderTag from "discourse/lib/render-tag";
import {
MODIFIER_REGEXP,
TOPIC_REPLACE_REGEXP,
} from "discourse/widgets/search-menu";
const suggestionShortcuts = [ const suggestionShortcuts = [
"in:title", "in:title",
@ -24,6 +29,29 @@ const suggestionShortcuts = [
"order:latest_topic", "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) { export function addSearchSuggestion(value) {
if (suggestionShortcuts.indexOf(value) === -1) { if (suggestionShortcuts.indexOf(value) === -1) {
suggestionShortcuts.push(value); suggestionShortcuts.push(value);
@ -33,7 +61,7 @@ export function addSearchSuggestion(value) {
class Highlighted extends RawHtml { class Highlighted extends RawHtml {
constructor(html, term) { constructor(html, term) {
super({ html: `<span>${html}</span>` }); super({ html: `<span>${html}</span>` });
this.term = term; this.term = term.replace(TOPIC_REPLACE_REGEXP, "");
} }
decorate($html) { decorate($html) {
@ -63,7 +91,6 @@ function createSearchResult({ type, linkField, builder }) {
className: "search-link", className: "search-link",
searchResultId, searchResultId,
searchResultType: type, searchResultType: type,
searchContextEnabled: attrs.searchContextEnabled,
searchLogId: attrs.searchLogId, searchLogId: attrs.searchLogId,
}) })
); );
@ -95,7 +122,10 @@ createSearchResult({
linkField: "url", linkField: "url",
builder(t) { builder(t) {
const tag = escapeExpression(t.id); 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", tagName: "div.results",
html(attrs) { html(attrs) {
if (attrs.suggestionKeyword) { const { term, suggestionKeyword, results, searchTopics } = attrs;
if (suggestionKeyword) {
return this.attach("search-menu-assistant", { return this.attach("search-menu-assistant", {
fullTerm: attrs.term, term,
suggestionKeyword: attrs.suggestionKeyword, suggestionKeyword,
results: attrs.suggestionResults || [], results: attrs.suggestionResults || [],
}); });
} }
if (attrs.invalidTerm) { if (searchTopics && attrs.invalidTerm) {
return h("div.no-results", I18n.t("search.too_short")); 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")); 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 resultTypes = results.resultTypes || [];
const mainResultsContent = []; const mainResultsContent = [];
const usersAndGroups = []; const usersAndGroups = [];
const categoriesAndTags = []; const categoriesAndTags = [];
const usersAndGroupsMore = [];
const categoriesAndTagsMore = [];
const buildMoreNode = (result) => { const buildMoreNode = (result) => {
const more = [];
const moreArgs = { const moreArgs = {
className: "filter", className: "filter search-link",
contents: () => [I18n.t("more"), "..."], contents: () => [I18n.t("more"), "..."],
}; };
if (result.moreUrl) { if (result.moreUrl) {
more.push( return this.attach(
this.attach("link", $.extend(moreArgs, { href: result.moreUrl })) "link",
deepMerge(moreArgs, {
href: result.moreUrl,
})
); );
} else if (result.more) { } else if (result.more) {
more.push( return this.attach(
this.attach( "link",
"link", deepMerge(moreArgs, {
$.extend(moreArgs, { action: "moreOfType",
action: "moreOfType", actionParam: result.type,
actionParam: result.type, })
className: "filter filter-type",
})
)
); );
} }
if (more.length) {
return more;
}
}; };
const assignContainer = (result, node) => { const assignContainer = (result, node) => {
if (["topic"].includes(result.type)) { if (searchTopics) {
mainResultsContent.push(node); if (["topic"].includes(result.type)) {
} mainResultsContent.push(node);
}
} else {
if (["user", "group"].includes(result.type)) {
usersAndGroups.push(node);
}
if (["user", "group"].includes(result.type)) { if (["category", "tag"].includes(result.type)) {
usersAndGroups.push(node); categoriesAndTags.push(node);
usersAndGroupsMore.push(buildMoreNode(result)); }
}
if (["category", "tag"].includes(result.type)) {
categoriesAndTags.push(node);
categoriesAndTagsMore.push(buildMoreNode(result));
} }
}; };
resultTypes.forEach((rt) => { resultTypes.forEach((rt) => {
const resultNodeContents = [ const resultNodeContents = [
this.attach(rt.componentName, { this.attach(rt.componentName, {
searchContextEnabled: attrs.searchContextEnabled,
searchLogId: attrs.results.grouped_search_result.search_log_id, searchLogId: attrs.results.grouped_search_result.search_log_id,
results: rt.results, results: rt.results,
term: attrs.term, term,
}), }),
]; ];
@ -320,31 +348,19 @@ createWidget("search-menu-results", {
const content = []; const content = [];
if (mainResultsContent.length) { if (!searchTopics) {
content.push(h("div.main-results", mainResultsContent)); content.push(this.attach("search-menu-initial-options", { term }));
} } else {
if (mainResultsContent.length) {
if (usersAndGroups.length || categoriesAndTags.length) { content.push(mainResultsContent);
const secondaryResultsContents = []; } else {
return h("div.no-results", I18n.t("search.no_results"));
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; return content;
}, },
}); });
@ -369,8 +385,8 @@ createWidget("search-menu-assistant", {
} }
const content = []; const content = [];
const { fullTerm, suggestionKeyword } = attrs; const { suggestionKeyword, term } = attrs;
let prefix = fullTerm.split(suggestionKeyword)[0].trim() || ""; let prefix = term?.split(suggestionKeyword)[0].trim() || "";
if (prefix.length) { if (prefix.length) {
prefix = `${prefix} `; prefix = `${prefix} `;
@ -388,7 +404,8 @@ createWidget("search-menu-assistant", {
this.attach("search-menu-assistant-item", { this.attach("search-menu-assistant-item", {
prefix, prefix,
category: item.model, category: item.model,
slug: `${prefix}${fullSlug} `, slug: `${prefix}${fullSlug}`,
withInLabel: attrs.withInLabel,
}) })
); );
} else { } else {
@ -396,7 +413,8 @@ createWidget("search-menu-assistant", {
this.attach("search-menu-assistant-item", { this.attach("search-menu-assistant-item", {
prefix, prefix,
tag: item.name, tag: item.name,
slug: `${prefix}#${item.name} `, slug: `${prefix}#${item.name}`,
withInLabel: attrs.withInLabel,
}) })
); );
} }
@ -408,17 +426,17 @@ createWidget("search-menu-assistant", {
this.attach("search-menu-assistant-item", { this.attach("search-menu-assistant-item", {
prefix, prefix,
user, user,
slug: `${prefix}@${user.username} `, slug: `${prefix}@${user.username}`,
}) })
); );
}); });
break; break;
default: default:
suggestionShortcuts.forEach((item) => { suggestionShortcuts.forEach((item) => {
if (item.includes(suggestionKeyword)) { if (item.includes(suggestionKeyword) || !suggestionKeyword) {
content.push( content.push(
this.attach("search-menu-assistant-item", { this.attach("search-menu-assistant-item", {
slug: `${prefix}${item} `, slug: `${prefix}${item}`,
}) })
); );
} }
@ -430,42 +448,146 @@ createWidget("search-menu-assistant", {
}, },
}); });
createWidget("search-menu-initial-options", {
tagName: "ul.search-menu-initial-options",
html(attrs) {
if (attrs.term?.match(MODIFIER_REGEXP)) {
return this.defaultRow(attrs.term);
}
const service = this.register.lookup("search-service:main");
const ctx = service.get("searchContext");
const content = [];
if (attrs.term) {
if (ctx) {
const term = attrs.term ? `${attrs.term} ` : "";
switch (ctx.type) {
case "topic":
content.push(
this.attach("search-menu-assistant-item", {
slug: `${term}topic:${ctx.id}`,
label: [
h("span", term),
h("span.label-suffix", I18n.t("search.in_this_topic")),
],
})
);
break;
case "private_messages":
content.push(
this.attach("search-menu-assistant-item", {
slug: `${term}in:personal`,
})
);
break;
case "category":
const fullSlug = ctx.category.parentCategory
? `#${ctx.category.parentCategory.slug}:${ctx.category.slug}`
: `#${ctx.category.slug}`;
content.push(
this.attach("search-menu-assistant", {
term: `${term}${fullSlug}`,
suggestionKeyword: "#",
results: [{ model: ctx.category }],
withInLabel: true,
})
);
break;
case "tag":
content.push(
this.attach("search-menu-assistant", {
term: `${term}#${ctx.name}`,
suggestionKeyword: "#",
results: [{ name: ctx.name }],
withInLabel: true,
})
);
break;
case "user":
content.push(
this.attach("search-menu-assistant-item", {
slug: `${term}@${ctx.user.username}`,
label: [
h("span", term),
h(
"span.label-suffix",
I18n.t("search.in_posts_by", {
username: ctx.user.username,
})
),
],
})
);
break;
}
}
const rowOptions = { withLabel: true };
content.push(this.defaultRow(attrs.term, rowOptions));
return content;
}
if (content.length === 0) {
content.push(this.attach("random-quick-tip"));
}
return content;
},
defaultRow(term, opts = { withLabel: false }) {
return this.attach("search-menu-assistant-item", {
slug: term,
label: [
h("span", `${term} `),
h(
"span.label-suffix",
opts.withLabel ? I18n.t("search.in_topics_posts") : null
),
],
});
},
});
createWidget("search-menu-assistant-item", { createWidget("search-menu-assistant-item", {
tagName: "li.search-menu-assistant-item", tagName: "li.search-menu-assistant-item",
html(attrs) { html(attrs) {
const prefix = attrs.prefix?.trim(); 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) { if (attrs.category) {
return h( attributes.href = attrs.category.url;
"a.widget-link.search-link",
{ content.push(
attributes: { this.attach("category-link", {
href: attrs.category.url, category: attrs.category,
}, allowUncategorized: true,
}, recursive: true,
[ })
h("span.search-item-prefix", prefix),
this.attach("category-link", {
category: attrs.category,
allowUncategorized: true,
recursive: true,
}),
]
); );
} else if (attrs.tag) { } else if (attrs.tag) {
return h( attributes.href = getURL(`/tag/${attrs.tag}`);
"a.widget-link.search-link",
{ content.push(iconNode("tag"));
attributes: { content.push(h("span.search-item-tag", attrs.tag));
href: getURL(`/tag/${attrs.tag}`),
},
},
[
h("span.search-item-prefix", prefix),
iconNode("tag"),
h("span.search-item-tag", attrs.tag),
]
);
} else if (attrs.user) { } else if (attrs.user) {
const userResult = [ const userResult = [
avatarImg("small", { avatarImg("small", {
@ -474,30 +596,11 @@ createWidget("search-menu-assistant-item", {
}), }),
h("span.username", formatUsername(attrs.user.username)), h("span.username", formatUsername(attrs.user.username)),
]; ];
content.push(h("span.search-item-user", userResult));
return h(
"a.widget-link.search-link",
{
attributes: {
href: "#",
},
},
[
h("span.search-item-prefix", prefix),
h("span.search-item-user", userResult),
]
);
} else { } else {
return h( content.push(h("span.search-item-slug", attrs.label || attrs.slug));
"a.widget-link.search-link",
{
attributes: {
href: "#",
},
},
h("span.search-item-slug", attrs.slug)
);
} }
return h("a.widget-link.search-link", { attributes }, content);
}, },
click(e) { click(e) {
@ -509,3 +612,15 @@ createWidget("search-menu-assistant-item", {
return false; 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 DiscourseURL from "discourse/lib/url";
import { createWidget } from "discourse/widgets/widget"; import { createWidget } from "discourse/widgets/widget";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import { get } from "@ember/object";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
import { h } from "virtual-dom"; 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 { popupAjaxError } from "discourse/lib/ajax-error";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import { search as searchCategoryTag } from "discourse/lib/category-tag-search"; 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 CATEGORY_SLUG_REGEXP = /(\#[a-zA-Z0-9\-:]*)$/gi;
const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]*)$/gi; const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]*)$/gi;
const SUGGESTIONS_REGEXP = /(in:|status:|order:|:)([a-zA-Z]*)$/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 = {}; const searchData = {};
@ -22,10 +27,8 @@ export function initSearchData() {
searchData.results = {}; searchData.results = {};
searchData.noResults = false; searchData.noResults = false;
searchData.term = undefined; searchData.term = undefined;
searchData.typeFilter = null; searchData.typeFilter = DEFAULT_TYPE_FILTER;
searchData.invalidTerm = false; searchData.invalidTerm = false;
searchData.topicId = null;
searchData.afterAutocomplete = false;
searchData.suggestionResults = []; searchData.suggestionResults = [];
} }
@ -46,8 +49,7 @@ const SearchHelper = {
perform(widget) { perform(widget) {
this.cancel(); this.cancel();
const { term, typeFilter, contextEnabled } = searchData; const { term, typeFilter } = searchData;
const searchContext = contextEnabled ? widget.searchContext() : null;
const fullSearchUrl = widget.fullSearchUrl(); const fullSearchUrl = widget.fullSearchUrl();
const matchSuggestions = this.matchesSuggestions(); const matchSuggestions = this.matchesSuggestions();
@ -105,7 +107,14 @@ const SearchHelper = {
searchData.suggestionKeyword = false; 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.noResults = true;
searchData.results = []; searchData.results = [];
searchData.loading = false; searchData.loading = false;
@ -114,9 +123,9 @@ const SearchHelper = {
widget.scheduleRerender(); widget.scheduleRerender();
} else { } else {
searchData.invalidTerm = false; searchData.invalidTerm = false;
this._activeSearch = searchForTerm(term, { this._activeSearch = searchForTerm(term, {
typeFilter, typeFilter,
searchContext,
fullSearchUrl, fullSearchUrl,
}); });
this._activeSearch this._activeSearch
@ -124,49 +133,50 @@ const SearchHelper = {
// we ensure the current search term is the one used // we ensure the current search term is the one used
// when starting the query // when starting the query
if (results && term === searchData.term) { if (results && term === searchData.term) {
if (term.includes("topic:")) {
widget.appEvents.trigger("post-stream:refresh", { force: true });
}
searchData.noResults = results.resultTypes.length === 0; searchData.noResults = results.resultTypes.length === 0;
searchData.results = results; 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) .catch(popupAjaxError)
.finally(() => { .finally(() => {
searchData.loading = false; searchData.loading = false;
searchData.afterAutocomplete = false;
widget.scheduleRerender(); widget.scheduleRerender();
}); });
} }
}, },
matchesSuggestions() { matchesSuggestions() {
if (searchData.term === undefined) { if (searchData.term === undefined || this.includesTopics()) {
return false; return false;
} }
const categoriesMatch = searchData.term.match(CATEGORY_SLUG_REGEXP); const term = searchData.term.trim();
const categoriesMatch = term.match(CATEGORY_SLUG_REGEXP);
if (categoriesMatch) { if (categoriesMatch) {
return { type: "category", categoriesMatch }; return { type: "category", categoriesMatch };
} }
const usernamesMatch = searchData.term.match(USERNAME_REGEXP); const usernamesMatch = term.match(USERNAME_REGEXP);
if (usernamesMatch) { if (usernamesMatch) {
return { type: "username", usernamesMatch }; return { type: "username", usernamesMatch };
} }
const suggestionsMatch = searchData.term.match(SUGGESTIONS_REGEXP); const suggestionsMatch = term.match(SUGGESTIONS_REGEXP);
if (suggestionsMatch) { if (suggestionsMatch) {
return suggestionsMatch; return suggestionsMatch;
} }
return false; return false;
}, },
includesTopics() {
return searchData.typeFilter !== DEFAULT_TYPE_FILTER;
},
}; };
export default createWidget("search-menu", { export default createWidget("search-menu", {
@ -174,11 +184,6 @@ export default createWidget("search-menu", {
searchData, searchData,
fullSearchUrl(opts) { fullSearchUrl(opts) {
const contextEnabled = searchData.contextEnabled;
const ctx = contextEnabled ? this.searchContext() : null;
const type = ctx ? get(ctx, "type") : null;
let url = "/search"; let url = "/search";
const params = []; const params = [];
@ -187,24 +192,6 @@ export default createWidget("search-menu", {
query += `q=${encodeURIComponent(searchData.term)}`; 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) { if (query) {
params.push(query); params.push(query);
} }
@ -222,37 +209,47 @@ export default createWidget("search-menu", {
}, },
panelContents() { panelContents() {
const { contextEnabled, afterAutocomplete } = searchData; let searchInput = [this.attach("search-term", { value: searchData.term })];
if (searchData.loading) {
let searchInput = [
this.attach(
"search-term",
{ value: searchData.term, contextEnabled },
{ state: { afterAutocomplete } }
),
];
if (searchData.term && searchData.loading) {
searchInput.push(h("div.searching", h("div.spinner"))); 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 = [ const results = [h("div.search-input", searchInput)];
h("div.search-input", searchInput),
this.attach("search-context", {
contextEnabled,
url: this.fullSearchUrl({ expanded: true }),
}),
];
if (searchData.term && !searchData.loading) { if (!searchData.loading) {
results.push( results.push(
this.attach("search-menu-results", { this.attach("search-menu-results", {
term: searchData.term, term: searchData.term,
noResults: searchData.noResults, noResults: searchData.noResults,
results: searchData.results, results: searchData.results,
invalidTerm: searchData.invalidTerm, invalidTerm: searchData.invalidTerm,
searchContextEnabled: searchData.contextEnabled,
suggestionKeyword: searchData.suggestionKeyword, suggestionKeyword: searchData.suggestionKeyword,
suggestionResults: searchData.suggestionResults, suggestionResults: searchData.suggestionResults,
searchTopics: SearchHelper.includesTopics(),
}) })
); );
} }
@ -260,6 +257,14 @@ export default createWidget("search-menu", {
return results; return results;
}, },
clearSearch() {
searchData.term = "";
const searchInput = document.getElementById("search-term");
searchInput.value = "";
searchInput.focus();
this.triggerSearch();
},
searchService() { searchService() {
if (!this._searchService) { if (!this._searchService) {
this._searchService = this.register.lookup("search-service:main"); this._searchService = this.register.lookup("search-service:main");
@ -267,29 +272,7 @@ export default createWidget("search-menu", {
return this._searchService; return this._searchService;
}, },
searchContext() { html() {
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;
return this.attach("menu-panel", { return this.attach("menu-panel", {
maxWidth: 500, maxWidth: 500,
contents: () => this.panelContents(), contents: () => this.panelContents(),
@ -312,18 +295,21 @@ export default createWidget("search-menu", {
} }
if (e.which === 65 /* a */) { if (e.which === 65 /* a */) {
let focused = $("header .results .search-link:focus"); if (document.activeElement?.classList.contains("search-link")) {
if (focused.length === 1) { if (document.querySelector("#reply-control.open")) {
if ($("#reply-control.open").length === 1) {
// add a link and focus composer // add a link and focus composer
this.appEvents.trigger("composer:insert-text", focused[0].href, { this.appEvents.trigger(
ensureSpace: true, "composer:insert-text",
}); document.activeElement.getAttribute("href"),
{
ensureSpace: true,
}
);
this.appEvents.trigger("header:keyboard-trigger", { type: "search" }); this.appEvents.trigger("header:keyboard-trigger", { type: "search" });
e.preventDefault(); e.preventDefault();
$("#reply-control.open textarea").focus(); document.querySelector("#reply-control.open textarea").focus();
return false; return false;
} }
} }
@ -332,20 +318,28 @@ export default createWidget("search-menu", {
const up = e.which === 38; const up = e.which === 38;
const down = e.which === 40; const down = e.which === 40;
if (up || down) { if (up || down) {
let focused = $(".search-menu *:focus")[0]; let focused = document.activeElement.closest(".search-menu")
? document.activeElement
: null;
if (!focused) { if (!focused) {
return; return;
} }
let links = $(".search-menu .results a"); let links = document.querySelectorAll(".search-menu .results a");
let results = $(".search-menu .results .search-link"); let results = document.querySelectorAll(
".search-menu .results .search-link"
);
if (!results.length) {
return;
}
let prevResult; let prevResult;
let result; let result;
links.each((idx, item) => { links.forEach((item) => {
if ($(item).hasClass("search-link")) { if (item.classList.contains("search-link")) {
prevResult = item; prevResult = item;
} }
@ -357,30 +351,46 @@ export default createWidget("search-menu", {
let index = -1; let index = -1;
if (result) { if (result) {
index = results.index(result); index = Array.prototype.indexOf.call(results, result);
} }
if (index === -1 && down) { if (index === -1 && down) {
$(".search-menu .search-link:first").focus(); document.querySelector(".search-menu .results .search-link").focus();
} else if (index === 0 && up) { } else if (index === 0 && up) {
$(".search-menu input:first").focus(); document.querySelector(".search-menu input#search-term").focus();
} else if (index > -1) { } else if (index > -1) {
index += down ? 1 : -1; index += down ? 1 : -1;
if (index >= 0 && index < results.length) { if (index >= 0 && index < results.length) {
$(results[index]).focus(); results[index].focus();
} }
} }
e.preventDefault(); e.preventDefault();
return false; 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() { triggerSearch() {
searchData.noResults = false; searchData.noResults = false;
this.searchService().set("highlightTerm", searchData.term); if (searchData.term.includes("topic:")) {
searchData.loading = true; const highlightTerm = searchData.term.replace(TOPIC_REPLACE_REGEXP, "");
discourseDebounce(SearchHelper, SearchHelper.perform, this, 400); 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) { moreOfType(type) {
@ -388,30 +398,17 @@ export default createWidget("search-menu", {
this.triggerSearch(); this.triggerSearch();
}, },
searchContextChanged(enabled) { searchTermChanged(term, opts = {}) {
// This indicates the checkbox has been clicked, NOT that the context has changed. searchData.typeFilter = opts.searchTopics ? null : DEFAULT_TYPE_FILTER;
searchData.typeFilter = null;
this.sendWidgetAction("searchMenuContextChanged", enabled);
searchData.contextEnabled = enabled;
this.triggerSearch();
},
searchTermChanged(term) {
searchData.typeFilter = null;
searchData.term = term; searchData.term = term;
this.triggerSearch(); this.triggerSearch();
}, },
triggerAutocomplete(term) { triggerAutocomplete(term) {
searchData.afterAutocomplete = true; this.searchTermChanged(term, { searchTopics: true });
this.searchTermChanged(term);
}, },
fullSearch() { fullSearch() {
if (!isValidSearchTerm(searchData.term, this.siteSettings)) {
return;
}
searchData.results = []; searchData.results = [];
searchData.loading = false; searchData.loading = false;
SearchHelper.cancel(); SearchHelper.cancel();

View File

@ -245,9 +245,14 @@ acceptance("Group - Authenticated", function (needs) {
); );
await click("#search-button"); await click("#search-button");
assert.ok( await fillIn("#search-term", "smth");
exists(".search-context input:checked"),
"scope to message checkbox is checked" 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, count,
exists, exists,
query, query,
queryAll,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers";
import I18n from "I18n";
import searchFixtures from "discourse/tests/fixtures/search-fixtures"; import searchFixtures from "discourse/tests/fixtures/search-fixtures";
import selectKit from "discourse/tests/helpers/select-kit-helper"; import selectKit from "discourse/tests/helpers/select-kit-helper";
import { test } from "qunit"; import { test } from "qunit";
import { DEFAULT_TYPE_FILTER } from "discourse/widgets/search-menu";
acceptance("Search - Anonymous", function (needs) { acceptance("Search - Anonymous", function (needs) {
let calledEmpty = false;
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
server.get("/search/query", (request) => { server.get("/search/query", (request) => {
if (!request.queryParams["search_context[type]"]) { if (request.queryParams.type_filter === DEFAULT_TYPE_FILTER) {
calledEmpty = true; // 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"]); return helper.response(searchFixtures["search/query"]);
}); });
}); });
@ -28,24 +34,61 @@ acceptance("Search - Anonymous", function (needs) {
await click("#search-button"); await click("#search-button");
assert.ok(exists("#search-term"), "it shows the search bar"); assert.ok(exists("#search-term"), "it shows the search input");
assert.ok(!exists(".search-menu .results ul li"), "no results by default"); 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 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( assert.ok(
exists(".search-menu .results ul li .topic-title[data-topic-id]"), exists(".search-menu .results ul li .topic-title[data-topic-id]"),
"topic has data-topic-id" "topic has data-topic-id"
); );
await click(".show-help"); await click(".show-advanced-search");
assert.equal( assert.equal(
queryAll(".full-page-search").val(), query(".full-page-search").value,
"dev", "dev",
"it shows the search term" "it goes to full search page and preserves the search term"
); );
assert.ok( assert.ok(
exists(".search-advanced-options"), exists(".search-advanced-options"),
"advanced search is expanded" "advanced search is expanded"
@ -68,85 +111,75 @@ acceptance("Search - Anonymous", function (needs) {
assert.ok(!exists(".search-menu")); assert.ok(!exists(".search-menu"));
}); });
test("search for a tag", async function (assert) { test("search scope", async function (assert) {
await visit("/"); 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 visit("/tag/important");
await click("#search-button"); await click("#search-button");
assert.ok( await fillIn("#search-term", "smth");
exists(".search-context input:checked"),
"scope to tag checkbox is checked" 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 visit("/c/bug");
await click("#search-button"); await click("#search-button");
assert.ok(
exists(".search-context input:checked"), assert.equal(
"scope to category checkbox is checked" 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 visit("/t/internationalization-localization/280");
await click("#search-button"); await click("#search-button");
assert.not(
exists(".search-context input:checked"), assert.equal(
"scope to topic checkbox is not checked" 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 visit("/u/eviltrout");
await click("#search-button"); await click("#search-button");
assert.ok(
exists(".search-context input:checked"), assert.equal(
"scope to user checkbox is checked" 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 visit("/t/internationalization-localization/280/1");
await click("#search-button"); await click("#search-button");
await fillIn("#search-term", "a proper"); await fillIn("#search-term", "a proper");
await click(".search-context input[type='checkbox']"); await focus("input#search-term");
await triggerKeyEvent("#search-term", "keyup", 16); await triggerKeyEvent(".search-menu", "keydown", 40);
assert.ok(exists(".search-menu .results ul li"), "it shows results"); await click(document.activeElement);
assert.ok(
const highlighted = []; exists(".search-menu .search-result-post ul li"),
"clicking first option formats results as posts"
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"
); );
calledEmpty = false; assert.equal(
await visit("/"); query("#post_7 span.highlighted").textContent.trim(),
await click("#search-button"); "a proper",
"highlights the post correctly"
);
assert.ok(!exists(".search-context input[type='checkbox']")); await click(".clear-search");
assert.ok(calledEmpty, "it triggers a new search"); assert.equal(query("#search-term").value, "", "clear button works");
await visit("/t/internationalization-localization/280/1");
await click("#search-button");
assert.ok(!$(".search-context input[type=checkbox]").is(":checked"));
}); });
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"); const inSelector = selectKit(".select-kit#in");
await visit("/search?expanded=true"); 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"); const inSelector = selectKit(".select-kit#in");
await visit("/search?expanded=true"); await visit("/search?expanded=true");
@ -230,16 +263,96 @@ acceptance("Search - Authenticated", function (needs) {
test("Works with empty result sets", async function (assert) { test("Works with empty result sets", async function (assert) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click(".search-dropdown"); await click("#search-button");
await click(".search-context input[type=checkbox]");
await fillIn("#search-term", "plans"); await fillIn("#search-term", "plans");
await triggerKeyEvent("#search-term", "keyup", 32); await focus("input#search-term");
assert.notEqual(count(".item"), 0); 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 fillIn("#search-term", "plans empty");
await triggerKeyEvent("#search-term", "keyup", 32); await triggerKeyEvent("#search-term", "keydown", 13);
assert.equal(count(".item"), 0);
assert.equal(count(".no-results"), 1); 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) { test("displays tags", async function (assert) {
await visit("/"); await visit("/");
await click("#search-button"); await click("#search-button");
await fillIn("#search-term", "dev"); await fillIn("#search-term", "dev");
await triggerKeyEvent("#search-term", "keyup", 16); await triggerKeyEvent("#search-term", "keydown", 13);
const tags = queryAll( assert.equal(
".search-menu .results ul li:nth-of-type(1) .discourse-tags" query(
) ".search-menu .results ul li:nth-of-type(1) .discourse-tags"
.text() ).textContent.trim(),
.trim(); "dev slow",
"tags displayed in search results"
assert.equal(tags, "dev slow"); );
}); });
test("displays tag shortcuts", async function (assert) { 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"; ".search-menu .results ul.search-menu-assistant .search-link";
assert.ok(exists(query(firstItem))); 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"); assert.equal(firstTag, "monkey");
}); });
}); });
@ -325,10 +436,10 @@ acceptance("Search - assistant", function (needs) {
const firstResultSlug = query( const firstResultSlug = query(
`${firstCategory} .category-name` `${firstCategory} .category-name`
).innerText.trim(); ).textContent.trim();
await click(firstCategory); await click(firstCategory);
assert.equal(query("#search-term").value, `#${firstResultSlug} `); assert.equal(query("#search-term").value, `#${firstResultSlug}`);
await fillIn("#search-term", "sam #"); await fillIn("#search-term", "sam #");
await triggerKeyEvent("#search-term", "keyup", 51); await triggerKeyEvent("#search-term", "keyup", 51);
@ -338,11 +449,11 @@ acceptance("Search - assistant", function (needs) {
query( query(
".search-menu .results ul.search-menu-assistant .search-item-prefix" ".search-menu .results ul.search-menu-assistant .search-item-prefix"
).innerText, ).innerText,
"sam" "sam "
); );
await click(firstCategory); await click(firstCategory);
assert.equal(query("#search-term").value, `sam #${firstResultSlug} `); assert.equal(query("#search-term").value, `sam #${firstResultSlug}`);
}); });
test("shows in: shortcuts", async function (assert) { test("shows in: shortcuts", async function (assert) {
@ -379,6 +490,6 @@ acceptance("Search - assistant", function (needs) {
assert.equal(firstUsername, "TeaMoe"); assert.equal(firstUsername, "TeaMoe");
await click(query(firstUser)); await click(query(firstUser));
assert.equal(query("#search-term").value, `@${firstUsername} `); assert.equal(query("#search-term").value, `@${firstUsername}`);
}); });
}); });

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-menu {
--search-padding: 0.5em;
.menu-panel .panel-body-contents { .menu-panel .panel-body-contents {
overflow-y: auto; overflow-y: auto;
} }
.search-input { .search-input {
position: relative; position: relative;
padding: var(--search-padding); padding: $search-pad-vertical 0.1em;
}
.search-context {
label {
padding: 0.25em var(--search-padding);
}
.show-help {
margin-left: auto;
line-height: var(--line-height-medium);
}
} }
.heading { .heading {
@ -27,28 +32,15 @@
} }
input[type="text"] { input[type="text"] {
box-sizing: border-box;
width: 100%; width: 100%;
min-height: 32px;
margin-bottom: 0; margin-bottom: 0;
} }
.search-context {
display: flex;
align-items: center;
label {
margin-bottom: 0;
}
}
.search-context + .results {
margin-top: 1em;
}
.results { .results {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
padding-top: $search-pad-vertical;
padding-bottom: $search-pad-vertical;
.list { .list {
min-width: 100px; min-width: 100px;
@ -79,11 +71,16 @@
.second-line { .second-line {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: baseline;
.discourse-tags { .badge-wrapper {
.discourse-tag { margin-right: 0.5em;
margin-right: 0.25em; }
.discourse-tags .discourse-tag {
margin-right: 0.25em;
.d-icon {
margin-right: 0;
} }
} }
} }
@ -95,215 +92,186 @@
} }
} }
.main-results { .search-result-category {
.widget-link {
margin-bottom: 0;
}
}
.search-result-group .group-result,
.search-result-user .user-result {
display: flex; display: flex;
flex: 1 1 auto; align-items: center;
.topic-statuses { font-size: var(--font-down-1);
color: var(--primary-medium);
}
} }
.search-result-group .group-result {
.main-results + .secondary-results { .d-icon,
border-left: 1px solid var(--primary-low); .avatar-flair {
margin-left: 1em; width: 20px;
padding-left: 1em; height: 20px;
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 { .avatar-flair {
.discourse-tag {
font-size: $font-down-1;
}
}
.search-result-category {
.widget-link {
margin-bottom: 0;
}
}
.search-result-group {
.search-link {
color: var(--primary-high);
&:hover {
color: var(--primary);
}
}
.group-result {
display: flex;
align-items: center;
.d-icon,
.avatar-flair {
min-width: 25px;
margin-right: 0.5em;
.d-icon {
margin-right: 0;
}
}
.avatar-flair-image {
background-repeat: no-repeat;
background-size: 100% 100%;
min-height: 25px;
}
.group-names {
display: flex;
flex-direction: column;
overflow: auto;
line-height: $line-height-medium;
&:hover {
.name,
.slug {
color: var(--primary-high);
}
}
.name,
.slug {
@include ellipsis;
}
.name {
font-weight: 700;
}
.slug {
font-size: $font-down-1;
color: var(--primary-high);
}
}
}
}
.search-result-category,
.search-result-user,
.search-result-group,
.search-result-tag {
.list {
display: block;
.item {
.widget-link.search-link {
flex: 1;
font-size: $font-0;
padding: 0.25em;
}
}
}
}
.search-result-user {
.user-result {
display: flex;
flex-direction: row;
align-items: center;
.avatar {
margin-right: 0.5em;
display: block;
min-width: 25px;
}
.user-titles {
display: flex;
flex-direction: column;
overflow: auto;
line-height: $line-height-medium;
.username,
.name {
@include ellipsis;
}
.username {
color: var(--primary-high-or-secondary-low);
font-size: $font-down-1;
}
.custom-field {
color: var(--primary-high-or-secondary-low);
font-size: $font-down-2;
}
.name {
color: var(--primary-high-or-secondary-low);
font-size: $font-0;
font-weight: 700;
}
}
}
}
}
.search-menu-assistant {
min-width: 100%;
margin-top: -1em;
.search-menu-assistant-item {
> span {
vertical-align: baseline;
display: inline-block;
}
}
.search-item-user .avatar,
.search-item-prefix {
margin-right: 0.5em; margin-right: 0.5em;
border-radius: 50%;
&.avatar-flair-image {
background-repeat: no-repeat;
background-size: 100% 100%;
}
.d-icon {
margin-right: 0;
}
} }
.search-item-tag { .group-names {
color: var(--primary-high); @include user-item-flex;
font-size: var(--font-down-1); .name,
} .slug {
@include ellipsis;
}
.d-icon-tag { .name {
// match category badge styling font-weight: 700;
// tag/category suggestions can be displayed simultaneously }
font-size: var(--font-down-2); }
}
.search-result-user .user-result {
.user-titles {
@include user-item-flex;
.username,
.name {
@include ellipsis;
}
.name {
font-weight: 700;
}
.username,
.name,
.custom-field {
color: var(--primary-high-or-secondary-low);
}
.custom-field {
font-size: var(--font-down-2);
}
}
}
.search-result-category,
.search-result-tag {
+ .search-result-user,
+ .search-result-group {
@include separator;
}
}
.search-result-user .user-result img.avatar,
.search-item-user img.avatar {
width: 20px;
height: 20px;
margin-right: 5px;
}
.label-suffix {
color: var(--primary-medium);
}
.badge-wrapper {
font-size: var(--font-0);
margin-left: 2px;
}
.search-menu-initial-options {
+ .search-result-tag,
+ .search-result-category,
+ .search-result-user,
+ .search-result-group {
@include separator;
}
}
.search-menu-initial-options,
.search-result-tag,
.search-menu-assistant {
.search-link {
.d-icon {
margin-right: 5px;
vertical-align: middle;
}
.d-icon-tag {
font-size: var(--font-down-2);
}
.d-icon-search {
font-size: var(--font-down-1);
}
}
}
.search-random-quick-tip {
padding: $search-pad-vertical $search-pad-horizontal;
padding-bottom: 0;
font-size: var(--font-down-2);
color: var(--primary-medium);
.tip-label {
background-color: rgba(var(--tertiary-rgb), 0.1);
margin-right: 4px; margin-right: 4px;
padding: 2px 4px;
display: inline-block;
} }
} }
} }
.searching { .searching {
position: absolute; position: absolute;
top: 1.1em; top: $search-pad-vertical + 0.4em;
right: 1em; right: $search-pad-horizontal;
min-height: 20px;
.spinner { .spinner {
width: 10px; width: 12px;
height: 10px; height: 12px;
border-width: 2px; border-width: 2px;
margin: 0; 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 { .no-results {
padding: var(--search-padding); padding: $search-pad-vertical $search-pad-horizontal;
} }
.search-link { .search-link {
padding: var(--search-padding); display: block;
padding: $search-pad-vertical $search-pad-horizontal;
.badge-category-parent { // This is purposefully redundant
line-height: $line-height-small; // 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 { .topic {
@ -319,4 +287,10 @@
margin-right: 0.25em; 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" select_all: "Select All"
clear_all: "Clear All" clear_all: "Clear All"
too_short: "Your search term is too short." 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" sort_or_bulk_actions: "Sort or bulk select results"
result_count: result_count:
one: "<span>%{count} result for</span><span class='term'>%{term}</span>" one: "<span>%{count} result for</span><span class='term'>%{term}</span>"
other: "<span>%{count}%{plus} results 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" full_page_title: "Search"
no_results: "No results found." no_results: "No results found."
no_more_results: "No more results found." no_more_results: "No more results found."
@ -2361,6 +2363,10 @@ en:
search_term_label: "enter search keyword" search_term_label: "enter search keyword"
categories: "Categories" categories: "Categories"
tags: "Tags" 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: type:
default: "Topics/posts" default: "Topics/posts"
@ -2375,6 +2381,14 @@ en:
topic: "Search this topic" topic: "Search this topic"
private_messages: "Search messages" 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: advanced:
title: Advanced filters title: Advanced filters
posted_by: posted_by:

View File

@ -33,7 +33,7 @@ class Search
end end
def self.facets def self.facets
%w(topic category user private_messages tags all_topics) %w(topic category user private_messages tags all_topics exclude_topics)
end end
def self.ts_config(locale = SiteSetting.default_locale) def self.ts_config(locale = SiteSetting.default_locale)
@ -230,7 +230,7 @@ class Search
end end
def limit def limit
if @opts[:type_filter].present? if @opts[:type_filter].present? && @opts[:type_filter] != "exclude_topics"
Search.per_filter + 1 Search.per_filter + 1
else else
Search.per_facet + 1 Search.per_facet + 1
@ -862,13 +862,13 @@ class Search
groups = Group groups = Group
.visible_groups(@guardian.user, "name ASC", include_everyone: false) .visible_groups(@guardian.user, "name ASC", include_everyone: false)
.where("name ILIKE :term OR full_name ILIKE :term", term: "%#{@term}%") .where("name ILIKE :term OR full_name ILIKE :term", term: "%#{@term}%")
.limit(limit)
groups.each { |group| @results.add(group) } groups.each { |group| @results.add(group) }
end end
def tags_search def tags_search
return unless SiteSetting.tagging_enabled return unless SiteSetting.tagging_enabled
tags = Tag.includes(:tag_search_data) tags = Tag.includes(:tag_search_data)
.where("tag_search_data.search_data @@ #{ts_query}") .where("tag_search_data.search_data @@ #{ts_query}")
.references(:tag_search_data) .references(:tag_search_data)
@ -882,6 +882,15 @@ class Search
end end
end end
def exclude_topics_search
if @term.present?
user_search
category_search
tags_search
groups_search
end
end
PHRASE_MATCH_REGEXP_PATTERN = '"([^"]+)"' PHRASE_MATCH_REGEXP_PATTERN = '"([^"]+)"'
def posts_query(limit, type_filter: nil, aggregate_search: false) def posts_query(limit, type_filter: nil, aggregate_search: false)

View File

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

View File

@ -1921,4 +1921,33 @@ describe Search do
expect(Search.new("advanced order:chars").execute.posts).to eq([post0, post1]) expect(Search.new("advanced order:chars").execute.posts).to eq([post0, post1])
end end
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 end