mirror of
https://github.com/discourse/discourse.git
synced 2025-10-03 17:21:20 +08:00
FEATURE: dynamic search when in /filter route (#33614)
Introduces new dynamic autocomplete for filter to make discovery easier: <img width="930" height="585" alt="image" src="https://github.com/user-attachments/assets/17e7f746-a170-4f10-9ddf-77ef651e5325" /> https://github.com/user-attachments/assets/e9d7bc29-a593-4ef3-82a2-f10fc35ed47c --------- Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
parent
de1fb8c955
commit
68b901586a
22 changed files with 1522 additions and 38 deletions
|
@ -1,12 +1,15 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { Input } from "@ember/component";
|
||||
import { fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { next } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { and } from "truth-helpers";
|
||||
import BulkSelectToggle from "discourse/components/bulk-select-toggle";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import FilterTips from "discourse/components/discovery/filter-tips";
|
||||
import PluginOutlet from "discourse/components/plugin-outlet";
|
||||
import bodyClass from "discourse/helpers/body-class";
|
||||
import icon from "discourse/helpers/d-icon";
|
||||
|
@ -14,12 +17,14 @@ import lazyHash from "discourse/helpers/lazy-hash";
|
|||
import discourseDebounce from "discourse/lib/debounce";
|
||||
import { bind } from "discourse/lib/decorators";
|
||||
import { resettableTracked } from "discourse/lib/tracked-tools";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class DiscoveryFilterNavigation extends Component {
|
||||
@service site;
|
||||
|
||||
@tracked copyIcon = "link";
|
||||
@tracked copyClass = "btn-default";
|
||||
@tracked inputElement = null;
|
||||
@resettableTracked newQueryString = this.args.queryString;
|
||||
|
||||
@bind
|
||||
|
@ -27,10 +32,22 @@ export default class DiscoveryFilterNavigation extends Component {
|
|||
this.newQueryString = string;
|
||||
}
|
||||
|
||||
@action
|
||||
storeInputElement(element) {
|
||||
this.inputElement = element;
|
||||
}
|
||||
|
||||
@action
|
||||
clearInput() {
|
||||
this.newQueryString = "";
|
||||
this.args.updateTopicsListQueryParams(this.newQueryString);
|
||||
next(() => {
|
||||
if (this.inputElement) {
|
||||
// required so child component is aware of the change
|
||||
this.inputElement.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
this.inputElement.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -52,6 +69,18 @@ export default class DiscoveryFilterNavigation extends Component {
|
|||
this.copyClass = "btn-default";
|
||||
}
|
||||
|
||||
@action
|
||||
handleKeydown(event) {
|
||||
if (event.key === "Enter" && this._allowEnterSubmit) {
|
||||
this.args.updateTopicsListQueryParams(this.newQueryString);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
blockEnterSubmit(value) {
|
||||
this._allowEnterSubmit = !value;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{bodyClass "navigation-filter"}}
|
||||
|
||||
|
@ -68,11 +97,21 @@ export default class DiscoveryFilterNavigation extends Component {
|
|||
<Input
|
||||
class="topic-query-filter__filter-term"
|
||||
@value={{this.newQueryString}}
|
||||
@enter={{fn @updateTopicsListQueryParams this.newQueryString}}
|
||||
{{on "keydown" this.handleKeydown}}
|
||||
@type="text"
|
||||
id="queryStringInput"
|
||||
autocomplete="off"
|
||||
placeholder={{i18n "filter.placeholder"}}
|
||||
{{didInsert this.storeInputElement}}
|
||||
/>
|
||||
{{#if this.newQueryString}}
|
||||
<DButton
|
||||
@icon="xmark"
|
||||
@action={{this.clearInput}}
|
||||
@disabled={{unless this.newQueryString "true"}}
|
||||
class="topic-query-filter__clear-btn btn-flat"
|
||||
/>
|
||||
{{/if}}
|
||||
{{! EXPERIMENTAL OUTLET - don't use because it will be removed soon }}
|
||||
<PluginOutlet
|
||||
@name="below-filter-input"
|
||||
|
@ -81,25 +120,14 @@ export default class DiscoveryFilterNavigation extends Component {
|
|||
newQueryString=this.newQueryString
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{{#if this.newQueryString}}
|
||||
<div class="topic-query-filter__controls">
|
||||
<DButton
|
||||
@icon="xmark"
|
||||
@action={{this.clearInput}}
|
||||
@disabled={{unless this.newQueryString "true"}}
|
||||
<FilterTips
|
||||
@queryString={{this.newQueryString}}
|
||||
@onSelectTip={{this.updateQueryString}}
|
||||
@tips={{@tips}}
|
||||
@blockEnterSubmit={{this.blockEnterSubmit}}
|
||||
@inputElement={{this.inputElement}}
|
||||
/>
|
||||
|
||||
{{#if this.discoveryFilter.q}}
|
||||
<DButton
|
||||
@icon={{this.copyIcon}}
|
||||
@action={{this.copyQueryString}}
|
||||
@disabled={{unless this.newQueryString "true"}}
|
||||
class={{this.copyClass}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,588 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { fn } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { cancel, later, next } from "@ember/runloop";
|
||||
import { service } from "@ember/service";
|
||||
import { and, eq } from "truth-helpers";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseDebounce from "discourse/lib/debounce";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
export default class FilterTips extends Component {
|
||||
@service currentUser;
|
||||
@service site;
|
||||
|
||||
@tracked showTips = false;
|
||||
@tracked currentInputValue = "";
|
||||
@tracked searchResults = [];
|
||||
|
||||
activeFilter = null;
|
||||
searchTimer = null;
|
||||
handleBlurTimer = null;
|
||||
|
||||
@tracked _selectedIndex = -1;
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
if (this.searchTimer) {
|
||||
cancel(this.searchTimer);
|
||||
this.searchTimer = null;
|
||||
}
|
||||
|
||||
if (this.handleBlurTimer) {
|
||||
cancel(this.handleBlurTimer);
|
||||
this.handleBlurTimer = null;
|
||||
}
|
||||
if (this.inputElement) {
|
||||
this.inputElement.removeEventListener("focus", this.handleInputFocus);
|
||||
this.inputElement.removeEventListener("blur", this.handleInputBlur);
|
||||
this.inputElement.removeEventListener("keydown", this.handleKeyDown);
|
||||
this.inputElement.removeEventListener("input", this.handleInput);
|
||||
}
|
||||
}
|
||||
|
||||
get selectedIndex() {
|
||||
return this._selectedIndex;
|
||||
}
|
||||
|
||||
set selectedIndex(value) {
|
||||
this._selectedIndex = value;
|
||||
this.args.blockEnterSubmit(value !== -1);
|
||||
}
|
||||
|
||||
get currentItems() {
|
||||
return this.filteredTips;
|
||||
}
|
||||
|
||||
get filteredTips() {
|
||||
if (!this.args.tips) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const words = this.currentInputValue.split(/\s+/);
|
||||
const lastWord = words.at(-1).toLowerCase();
|
||||
|
||||
// If we have search results from placeholder search, show those
|
||||
if (this.activeFilter && this.searchResults.length > 0) {
|
||||
return this.searchResults;
|
||||
}
|
||||
|
||||
// Check if we're in the middle of a filter with a value
|
||||
const colonIndex = lastWord.indexOf(":");
|
||||
const prefix = this.extractPrefix(lastWord) || "";
|
||||
if (colonIndex > 0) {
|
||||
const filterName = lastWord.substring(prefix.length).split(":")[0];
|
||||
const valueText = lastWord.substring(colonIndex + 1);
|
||||
|
||||
// Find matching tip
|
||||
const tip = this.args.tips.find((t) => {
|
||||
return t.name === filterName + ":";
|
||||
});
|
||||
|
||||
// If the tip has a type and we have value text, do placeholder search
|
||||
if (tip?.type && valueText !== undefined) {
|
||||
this.handlePlaceholderSearch(filterName, valueText, tip, prefix);
|
||||
return this.searchResults.length > 0 ? this.searchResults : [];
|
||||
}
|
||||
}
|
||||
|
||||
// This handles blank, the default state when nothing is typed
|
||||
if (!this.currentInputValue || lastWord === "") {
|
||||
return this.args.tips
|
||||
.filter((tip) => tip.priority)
|
||||
.sort((a, b) => (b.priority || 0) - (a.priority || 0))
|
||||
.slice(0, MAX_RESULTS);
|
||||
}
|
||||
|
||||
const tips = [];
|
||||
|
||||
this.args.tips.forEach((tip) => {
|
||||
if (tips.length >= MAX_RESULTS) {
|
||||
return;
|
||||
}
|
||||
const tipName = tip.name;
|
||||
const searchTerm = lastWord.substring(prefix.length);
|
||||
|
||||
// Skip exact matches with colon
|
||||
if (searchTerm.endsWith(":") && tipName === searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefixMatch =
|
||||
searchTerm === "" &&
|
||||
prefix &&
|
||||
tipName.prefixes &&
|
||||
tipName.prefixes.find((p) => p.name === prefix);
|
||||
|
||||
if (prefixMatch || tipName.indexOf(searchTerm) > -1) {
|
||||
this.pushPrefixTips(tip, tips, null, prefix);
|
||||
if (!prefix) {
|
||||
tips.push(tip);
|
||||
}
|
||||
} else if (tip.alias && tip.alias.indexOf(searchTerm) > -1) {
|
||||
this.pushPrefixTips(tip, tips, tip.alias, prefix);
|
||||
tips.push({
|
||||
...tip,
|
||||
name: tip.alias,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tips.sort((a, b) => {
|
||||
const aName = a.name;
|
||||
const bName = b.name;
|
||||
const searchTerm = lastWord;
|
||||
|
||||
const aStartsWith = aName.startsWith(searchTerm);
|
||||
const bStartsWith = bName.startsWith(searchTerm);
|
||||
|
||||
if (aStartsWith && !bStartsWith) {
|
||||
return -1;
|
||||
}
|
||||
if (!aStartsWith && bStartsWith) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (aStartsWith && bStartsWith) {
|
||||
if (aName.length !== bName.length) {
|
||||
return aName.length - bName.length;
|
||||
}
|
||||
}
|
||||
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}
|
||||
|
||||
pushPrefixTips(tip, tips, alias = null, currentPrefix = null) {
|
||||
if (tip.prefixes && tip.prefixes.length > 0) {
|
||||
tip.prefixes.forEach((prefix) => {
|
||||
if (currentPrefix && !prefix.name.startsWith(currentPrefix)) {
|
||||
return;
|
||||
}
|
||||
tips.push({
|
||||
...tip,
|
||||
name: `${prefix.name}${alias || tip.name}`,
|
||||
description: prefix.description || tip.description,
|
||||
isPlaceholderCompletion: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extractPrefix(word) {
|
||||
const match = word.match(/^(-=|=-|-|=)/);
|
||||
return match ? match[0] : "";
|
||||
}
|
||||
|
||||
@action
|
||||
handlePlaceholderSearch(filterName, valueText, tip, prefix = "") {
|
||||
this.activeFilter = filterName;
|
||||
|
||||
if (this.searchTimer) {
|
||||
cancel(this.searchTimer);
|
||||
}
|
||||
|
||||
this.searchTimer = discourseDebounce(
|
||||
this,
|
||||
this._performPlaceholderSearch,
|
||||
filterName,
|
||||
valueText,
|
||||
tip,
|
||||
prefix,
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
async _performPlaceholderSearch(filterName, valueText, tip, prefix) {
|
||||
const type = tip.type;
|
||||
let lastTerm = valueText;
|
||||
let results = [];
|
||||
|
||||
let prevTerms = "";
|
||||
let splitTerms;
|
||||
|
||||
if (tip.delimiters) {
|
||||
const delimiters = tip.delimiters.map((s) => s.name);
|
||||
splitTerms = lastTerm.split(new RegExp(`[${delimiters.join("")}]`));
|
||||
lastTerm = splitTerms[splitTerms.length - 1];
|
||||
if (lastTerm === "") {
|
||||
prevTerms = valueText;
|
||||
} else {
|
||||
prevTerms = valueText.slice(0, -lastTerm.length);
|
||||
}
|
||||
}
|
||||
|
||||
lastTerm = (lastTerm || "").toLowerCase().trim();
|
||||
|
||||
if (type === "tag") {
|
||||
try {
|
||||
const response = await ajax("/tags/filter/search.json", {
|
||||
data: { q: lastTerm || "", limit: 5 },
|
||||
});
|
||||
results = response.results.map((tag) => ({
|
||||
name: `${prefix}${filterName}:${prevTerms}${tag.name}`,
|
||||
description: `${tag.count}`,
|
||||
isPlaceholderCompletion: true,
|
||||
term: tag.name,
|
||||
}));
|
||||
} catch {
|
||||
results = [];
|
||||
}
|
||||
} else if (type === "category") {
|
||||
const categories = this.site.categories || [];
|
||||
const filtered = categories
|
||||
.filter((c) => {
|
||||
const name = c.name.toLowerCase();
|
||||
const slug = c.slug.toLowerCase();
|
||||
return name.includes(lastTerm) || slug.includes(lastTerm);
|
||||
})
|
||||
.slice(0, 10)
|
||||
.map((c) => ({
|
||||
name: `${prefix}${filterName}:${prevTerms}${c.slug}`,
|
||||
description: `${c.name}`,
|
||||
isPlaceholderCompletion: true,
|
||||
term: c.slug,
|
||||
}));
|
||||
results = filtered;
|
||||
} else if (type === "username") {
|
||||
try {
|
||||
const data = {
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
if ((lastTerm || "").length > 0) {
|
||||
data.term = lastTerm;
|
||||
} else {
|
||||
data.last_seen_users = true;
|
||||
}
|
||||
const response = await ajax("/u/search/users.json", {
|
||||
data,
|
||||
});
|
||||
results = response.users.map((user) => ({
|
||||
name: `${prefix}${filterName}:${prevTerms}${user.username}`,
|
||||
description: user.name || "",
|
||||
term: user.username,
|
||||
isPlaceholderCompletion: true,
|
||||
}));
|
||||
} catch {
|
||||
results = [];
|
||||
}
|
||||
} else if (type === "tag_group") {
|
||||
// Handle tag group search if needed
|
||||
results = [];
|
||||
} else if (type === "date") {
|
||||
results = this.getDateSuggestions(
|
||||
prefix,
|
||||
filterName,
|
||||
prevTerms,
|
||||
lastTerm
|
||||
);
|
||||
} else if (type === "number") {
|
||||
results = this.getNumberSuggestions(
|
||||
prefix,
|
||||
filterName,
|
||||
prevTerms,
|
||||
lastTerm
|
||||
);
|
||||
}
|
||||
|
||||
// special handling for exact matches
|
||||
if (tip.delimiters) {
|
||||
let lastMatches = false;
|
||||
|
||||
results = results.map((r) => {
|
||||
r.delimiters = tip.delimiters;
|
||||
return r;
|
||||
});
|
||||
|
||||
results = results.filter((r) => {
|
||||
lastMatches ||= lastTerm === r.term;
|
||||
if (splitTerms.includes(r.term)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (lastMatches) {
|
||||
tip.delimiters.forEach((delimiter) => {
|
||||
results.push({
|
||||
name: `${prefix}${filterName}:${prevTerms}${lastTerm}${delimiter.name}`,
|
||||
description: delimiter.description,
|
||||
isPlaceholderCompletion: true,
|
||||
delimiters: tip.delimiters,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.searchResults = results;
|
||||
}
|
||||
|
||||
getDateSuggestions(prefix, filterName, prevTerms, lastTerm) {
|
||||
const dateOptions = [
|
||||
{ value: "1", key: "yesterday" },
|
||||
{ value: "7", key: "last_week" },
|
||||
{ value: "30", key: "last_month" },
|
||||
{ value: "365", key: "last_year" },
|
||||
];
|
||||
|
||||
return dateOptions
|
||||
.filter((option) => {
|
||||
const description = i18n(`filter.description.days.${option.key}`);
|
||||
return (
|
||||
!lastTerm ||
|
||||
option.value.includes(lastTerm) ||
|
||||
description.toLowerCase().includes(lastTerm.toLowerCase())
|
||||
);
|
||||
})
|
||||
.map((option) => ({
|
||||
name: `${prefix}${filterName}:${prevTerms}${option.value}`,
|
||||
description: i18n(`filter.description.${option.key}`),
|
||||
isPlaceholderCompletion: true,
|
||||
term: option.value,
|
||||
}));
|
||||
}
|
||||
|
||||
getNumberSuggestions(prefix, filterName, prevTerms, lastTerm) {
|
||||
const numberOptions = [
|
||||
{ value: "0" },
|
||||
{ value: "1" },
|
||||
{ value: "5" },
|
||||
{ value: "10" },
|
||||
{ value: "20" },
|
||||
];
|
||||
|
||||
return numberOptions
|
||||
.filter((option) => {
|
||||
return !lastTerm || option.value.includes(lastTerm);
|
||||
})
|
||||
.map((option) => ({
|
||||
name: `${prefix}${filterName}:${prevTerms}${option.value}`,
|
||||
isPlaceholderCompletion: true,
|
||||
term: option.value,
|
||||
}));
|
||||
}
|
||||
|
||||
@action
|
||||
setupEventListeners() {
|
||||
this.inputElement = this.args.inputElement;
|
||||
|
||||
if (!this.inputElement) {
|
||||
throw new Error(
|
||||
"FilterTips requires an inputElement to be passed in the args."
|
||||
);
|
||||
}
|
||||
|
||||
this.inputElement.addEventListener("focus", this.handleInputFocus);
|
||||
this.inputElement.addEventListener("blur", this.handleInputBlur);
|
||||
this.inputElement.addEventListener("keydown", this.handleKeyDown);
|
||||
this.inputElement.addEventListener("input", this.handleInput);
|
||||
}
|
||||
|
||||
@action
|
||||
handleInput() {
|
||||
this.currentInputValue = this.inputElement.value;
|
||||
this.updateResults();
|
||||
}
|
||||
|
||||
updateResults() {
|
||||
this.selectedIndex = -1;
|
||||
|
||||
const words = this.currentInputValue.split(/\s+/);
|
||||
const lastWord = words.at(-1);
|
||||
const colonIndex = lastWord.indexOf(":");
|
||||
|
||||
if (colonIndex > 0) {
|
||||
const prefix = this.extractPrefix(lastWord);
|
||||
const filterName = lastWord.substring(
|
||||
prefix.length,
|
||||
colonIndex + prefix.length
|
||||
);
|
||||
const valueText = lastWord.substring(colonIndex + 1);
|
||||
|
||||
const tip = this.args.tips.find((t) => {
|
||||
const tipFilterName = t.name.replace(/^[-=]/, "").split(":")[0];
|
||||
return tipFilterName === filterName && t.type;
|
||||
});
|
||||
|
||||
if (tip?.type) {
|
||||
this.activeFilter = filterName;
|
||||
this.handlePlaceholderSearch(filterName, valueText, tip, prefix);
|
||||
} else {
|
||||
this.activeFilter = null;
|
||||
this.searchResults = [];
|
||||
}
|
||||
} else {
|
||||
this.activeFilter = null;
|
||||
this.searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleInputFocus() {
|
||||
this.currentInputValue = this.inputElement.value;
|
||||
this.showTips = true;
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
@action
|
||||
handleInputBlur() {
|
||||
if (this.handleBlurTimer) {
|
||||
cancel(this.handleBlurTimer);
|
||||
}
|
||||
// delay this cause we need to handle click events on tips
|
||||
this.handleBlurTimer = later(() => {
|
||||
this.hideTipsIfNeeded();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
hideTipsIfNeeded() {
|
||||
this.handleBlurTimer = null;
|
||||
if (document.activeElement !== this.inputElement && this.showTips) {
|
||||
this.hideTips();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleKeyDown(event) {
|
||||
if (!this.showTips || this.currentItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
if (this.selectedIndex === -1) {
|
||||
this.selectedIndex = 0;
|
||||
} else {
|
||||
this.selectedIndex =
|
||||
(this.selectedIndex + 1) % this.currentItems.length;
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (this.selectedIndex === -1) {
|
||||
this.selectedIndex = this.currentItems.length - 1;
|
||||
} else {
|
||||
this.selectedIndex =
|
||||
(this.selectedIndex - 1 + this.currentItems.length) %
|
||||
this.currentItems.length;
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const indexToUse = this.selectedIndex === -1 ? 0 : this.selectedIndex;
|
||||
if (indexToUse < this.currentItems.length) {
|
||||
this.selectItem(this.currentItems[indexToUse]);
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
if (this.selectedIndex >= 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
this.selectItem(this.currentItems[this.selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
this.hideTips();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
hideTips() {
|
||||
this.showTips = false;
|
||||
this.args.blockEnterSubmit(false);
|
||||
}
|
||||
|
||||
@action
|
||||
selectItem(item) {
|
||||
const words = this.currentInputValue.split(/\s+/);
|
||||
|
||||
if (item.isPlaceholderCompletion) {
|
||||
words[words.length - 1] = item.name;
|
||||
let updatedValue = words.join(" ");
|
||||
|
||||
if (
|
||||
!updatedValue.endsWith(":") &&
|
||||
(!item.delimiters || item.delimiters.length < 2)
|
||||
) {
|
||||
updatedValue += " ";
|
||||
}
|
||||
|
||||
this.updateValue(updatedValue);
|
||||
this.searchResults = [];
|
||||
this.updateResults();
|
||||
} else {
|
||||
const lastWord = words.at(-1);
|
||||
const prefix = this.extractPrefix(lastWord);
|
||||
|
||||
const supportsPrefix = item.prefixes && item.prefixes.length > 0;
|
||||
const filterName =
|
||||
supportsPrefix && prefix ? `${prefix}${item.name}` : item.name;
|
||||
|
||||
words[words.length - 1] = filterName;
|
||||
|
||||
if (!filterName.endsWith(":") && !item.delimiters?.length) {
|
||||
words[words.length - 1] += " ";
|
||||
}
|
||||
|
||||
const updatedValue = words.join(" ");
|
||||
this.updateValue(updatedValue);
|
||||
|
||||
const baseFilterName = item.name.replace(/^[-=]/, "").split(":")[0];
|
||||
|
||||
if (item.type) {
|
||||
this.activeFilter = baseFilterName;
|
||||
this.handlePlaceholderSearch(baseFilterName, "", item, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedIndex = -1;
|
||||
|
||||
next(() => {
|
||||
this.inputElement.focus();
|
||||
this.inputElement.setSelectionRange(
|
||||
this.currentInputValue.length,
|
||||
this.currentInputValue.length
|
||||
);
|
||||
this.updateResults();
|
||||
});
|
||||
}
|
||||
|
||||
updateValue(value) {
|
||||
this.currentInputValue = value;
|
||||
this.args.onSelectTip(value);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="filter-tips" {{didInsert this.setupEventListeners}}>
|
||||
{{#if (and this.showTips this.currentItems.length)}}
|
||||
<div class="filter-tips__dropdown">
|
||||
{{#each this.currentItems as |item index|}}
|
||||
<DButton
|
||||
class={{concatClass
|
||||
"filter-tip__button"
|
||||
(if (eq index this.selectedIndex) "filter-tip__selected")
|
||||
}}
|
||||
@action={{fn this.selectItem item}}
|
||||
>
|
||||
<span class="filter-tip__name">{{item.name}}</span>
|
||||
{{#if item.description}}
|
||||
<span class="filter-tip__description">—
|
||||
{{item.description}}</span>
|
||||
{{/if}}
|
||||
</DButton>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import BaseSectionLink from "discourse/lib/sidebar/base-community-section-link";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class FilterSectionLink extends BaseSectionLink {
|
||||
get name() {
|
||||
return "filter";
|
||||
}
|
||||
|
||||
get route() {
|
||||
return "discovery.filter";
|
||||
}
|
||||
|
||||
get title() {
|
||||
return i18n("sidebar.sections.community.links.filter.title");
|
||||
}
|
||||
|
||||
get text() {
|
||||
return i18n(
|
||||
`sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`,
|
||||
{ defaultValue: this.overridenName }
|
||||
);
|
||||
}
|
||||
|
||||
get shouldDisplay() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get defaultPrefixValue() {
|
||||
return "filter";
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import AboutSectionLink from "discourse/lib/sidebar/common/community-section/abo
|
|||
import BadgesSectionLink from "discourse/lib/sidebar/common/community-section/badges-section-link";
|
||||
import EverythingSectionLink from "discourse/lib/sidebar/common/community-section/everything-section-link";
|
||||
import FAQSectionLink from "discourse/lib/sidebar/common/community-section/faq-section-link";
|
||||
import FilterSectionLink from "discourse/lib/sidebar/common/community-section/filter-section-link";
|
||||
import GroupsSectionLink from "discourse/lib/sidebar/common/community-section/groups-section-link";
|
||||
import UsersSectionLink from "discourse/lib/sidebar/common/community-section/users-section-link";
|
||||
import {
|
||||
|
@ -27,6 +28,7 @@ const SPECIAL_LINKS_MAP = {
|
|||
"/my/messages": MyMessagesSectionLink,
|
||||
"/review": ReviewSectionLink,
|
||||
"/badges": BadgesSectionLink,
|
||||
"/filter": FilterSectionLink,
|
||||
"/admin": AdminSectionLink,
|
||||
"/g": GroupsSectionLink,
|
||||
"/new-invite": InviteSectionLink,
|
||||
|
|
|
@ -12,6 +12,7 @@ export default RouteTemplate(
|
|||
@updateTopicsListQueryParams={{@controller.updateTopicsListQueryParams}}
|
||||
@canBulkSelect={{@controller.canBulkSelect}}
|
||||
@bulkSelectHelper={{@controller.bulkSelectHelper}}
|
||||
@tips={{@controller.model.topic_list.filter_option_info}}
|
||||
/>
|
||||
</:navigation>
|
||||
<:list>
|
||||
|
|
|
@ -0,0 +1,346 @@
|
|||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import {
|
||||
fillIn,
|
||||
render,
|
||||
triggerEvent,
|
||||
triggerKeyEvent,
|
||||
} from "@ember/test-helpers";
|
||||
import { module, test } from "qunit";
|
||||
import FilterTips from "discourse/components/discovery/filter-tips";
|
||||
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
|
||||
import pretender, { response } from "discourse/tests/helpers/create-pretender";
|
||||
|
||||
module("Integration | Component | discovery | filter-tips", function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.tips = [
|
||||
{
|
||||
name: "category:",
|
||||
description: "Filter category",
|
||||
priority: 1,
|
||||
type: "category",
|
||||
},
|
||||
{
|
||||
name: "tag:",
|
||||
description: "Filter tag",
|
||||
priority: 1,
|
||||
type: "tag",
|
||||
delimiters: [
|
||||
{ name: "+", description: "intersect" },
|
||||
{ name: ",", description: "add" },
|
||||
],
|
||||
},
|
||||
{ name: "status:", description: "Filter status", priority: 1 },
|
||||
{ name: "status:open", description: "Open topics" },
|
||||
];
|
||||
this.query = "";
|
||||
this.update = (value) => {
|
||||
this.set("query", value);
|
||||
this.inputElement.value = value;
|
||||
};
|
||||
this.blockEnter = () => {};
|
||||
this.capture = (el) => (this.inputElement = el);
|
||||
|
||||
this.site.categories = [
|
||||
{ id: 1, name: "Bug", slug: "bugs" },
|
||||
{ id: 2, name: "Feature", slug: "feature" },
|
||||
];
|
||||
pretender.get("/tags/filter/search.json", () =>
|
||||
response({ results: [{ name: "ember", count: 1 }] })
|
||||
);
|
||||
pretender.get("/u/search/users", () => response({ users: [] }));
|
||||
});
|
||||
|
||||
test("basic navigation", async function (assert) {
|
||||
const self = this;
|
||||
await render(
|
||||
<template>
|
||||
<input id="filter-input" {{didInsert self.capture}} />
|
||||
<FilterTips
|
||||
@tips={{self.tips}}
|
||||
@queryString={{self.query}}
|
||||
@onSelectTip={{self.update}}
|
||||
@blockEnterSubmit={{self.blockEnter}}
|
||||
@inputElement={{self.inputElement}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
await triggerEvent("#filter-input", "focus");
|
||||
assert
|
||||
.dom(".filter-tip__button")
|
||||
.exists({ count: 3 }, "shows tips on focus");
|
||||
assert
|
||||
.dom(".filter-tip__button.filter-tip__selected")
|
||||
.doesNotExist("no selection yet");
|
||||
assert.dom("#filter-input").hasValue("");
|
||||
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowDown");
|
||||
assert
|
||||
.dom(".filter-tip__button.filter-tip__selected .filter-tip__name")
|
||||
.hasText("category:");
|
||||
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowDown");
|
||||
assert
|
||||
.dom(".filter-tip__button.filter-tip__selected .filter-tip__name")
|
||||
.hasText("tag:");
|
||||
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowUp");
|
||||
assert
|
||||
.dom(".filter-tip__button.filter-tip__selected .filter-tip__name")
|
||||
.hasText("category:");
|
||||
});
|
||||
|
||||
test("selecting a tip with tab", async function (assert) {
|
||||
const self = this;
|
||||
await render(
|
||||
<template>
|
||||
<input id="filter-input" {{didInsert self.capture}} />
|
||||
<FilterTips
|
||||
@tips={{self.tips}}
|
||||
@queryString={{self.query}}
|
||||
@onSelectTip={{self.update}}
|
||||
@blockEnterSubmit={{self.blockEnter}}
|
||||
@inputElement={{self.inputElement}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
await triggerEvent("#filter-input", "focus");
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowDown");
|
||||
await triggerKeyEvent("#filter-input", "keydown", "Tab");
|
||||
|
||||
assert.strictEqual(this.query, "category:", "tab adds filter");
|
||||
assert.dom("#filter-input").hasValue("category:");
|
||||
|
||||
assert
|
||||
.dom(".filter-tip__button")
|
||||
.exists({ count: 2 }, "tips for category shows up");
|
||||
|
||||
await triggerEvent("#filter-input", "focus");
|
||||
await triggerKeyEvent("#filter-input", "keydown", "Tab");
|
||||
|
||||
assert
|
||||
.dom("#filter-input")
|
||||
.hasValue("category:bugs ", "category slug added");
|
||||
|
||||
assert
|
||||
.dom(".filter-tip__button")
|
||||
.exists({ count: 3 }, "tips for next section shows up");
|
||||
assert
|
||||
.dom(".filter-tip__button.selected")
|
||||
.doesNotExist("selection cleared");
|
||||
});
|
||||
|
||||
test("searching tag values", async function (assert) {
|
||||
const self = this;
|
||||
await render(
|
||||
<template>
|
||||
<input id="filter-input" {{didInsert self.capture}} />
|
||||
<FilterTips
|
||||
@tips={{self.tips}}
|
||||
@queryString={{self.query}}
|
||||
@onSelectTip={{self.update}}
|
||||
@blockEnterSubmit={{self.blockEnter}}
|
||||
@inputElement={{self.inputElement}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
await triggerEvent("#filter-input", "focus");
|
||||
await fillIn("#filter-input", "tag:e");
|
||||
|
||||
assert.dom(".filter-tip__button").exists("shows tag results");
|
||||
assert.dom(".filter-tip__name").hasText("tag:ember");
|
||||
assert.dom(".filter-tip__description").hasText("— 1");
|
||||
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowDown");
|
||||
assert
|
||||
.dom(".filter-tip__button.filter-tip__selected .filter-tip__name")
|
||||
.hasText("tag:ember");
|
||||
|
||||
await triggerKeyEvent("#filter-input", "keydown", "Enter");
|
||||
assert.strictEqual(this.query, "tag:ember", "enter selects result");
|
||||
});
|
||||
|
||||
test("escape hides suggestions", async function (assert) {
|
||||
const self = this;
|
||||
await render(
|
||||
<template>
|
||||
<input id="filter-input" {{didInsert self.capture}} />
|
||||
<FilterTips
|
||||
@tips={{self.tips}}
|
||||
@queryString={{self.query}}
|
||||
@onSelectTip={{self.update}}
|
||||
@blockEnterSubmit={{self.blockEnter}}
|
||||
@inputElement={{self.inputElement}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
await triggerEvent("#filter-input", "focus");
|
||||
assert.dom(".filter-tip__button").exists("tips visible");
|
||||
await triggerKeyEvent("#filter-input", "keydown", "Escape");
|
||||
assert.dom(".filter-tip__button").doesNotExist("tips hidden on escape");
|
||||
|
||||
await fillIn("#filter-input", "status");
|
||||
await triggerEvent("#filter-input", "input");
|
||||
await triggerKeyEvent("#filter-input", "keydown", "Escape");
|
||||
assert.strictEqual(this.query, "", "query not changed");
|
||||
assert.dom("#filter-input").hasValue("status", "input unchanged");
|
||||
assert.dom(".filter-tip__button").doesNotExist("tips remain hidden");
|
||||
});
|
||||
|
||||
test("blockEnterSubmit is called correctly", async function (assert) {
|
||||
let blockEnterCalled = false;
|
||||
let blockEnterValue = null;
|
||||
|
||||
this.blockEnter = (shouldBlock) => {
|
||||
blockEnterCalled = true;
|
||||
blockEnterValue = shouldBlock;
|
||||
};
|
||||
|
||||
let self = this;
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<input id="filter-input" {{didInsert self.capture}} />
|
||||
<FilterTips
|
||||
@tips={{self.tips}}
|
||||
@queryString={{self.query}}
|
||||
@onSelectTip={{self.update}}
|
||||
@blockEnterSubmit={{self.blockEnter}}
|
||||
@inputElement={{self.inputElement}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
// Initially, no selection, so blockEnter should be called with false
|
||||
await triggerEvent("#filter-input", "focus");
|
||||
assert.true(blockEnterCalled, "blockEnter was called");
|
||||
assert.false(
|
||||
blockEnterValue,
|
||||
"blockEnter called with false when no selection"
|
||||
);
|
||||
|
||||
// Reset tracking
|
||||
blockEnterCalled = false;
|
||||
blockEnterValue = null;
|
||||
|
||||
// Arrow down to select first item
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowDown");
|
||||
assert.true(blockEnterCalled, "blockEnter called when selection changes");
|
||||
assert.true(
|
||||
blockEnterValue,
|
||||
"blockEnter called with true when item selected"
|
||||
);
|
||||
|
||||
// Reset and arrow up to wrap to last item
|
||||
blockEnterCalled = false;
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowUp");
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowUp");
|
||||
assert.true(blockEnterCalled, "blockEnter called on arrow navigation");
|
||||
assert.true(blockEnterValue, "blockEnter still true with selection");
|
||||
|
||||
// Select an item with Tab
|
||||
await triggerKeyEvent("#filter-input", "keydown", "Tab");
|
||||
assert.true(blockEnterCalled, "blockEnter called after selection");
|
||||
assert.false(
|
||||
blockEnterValue,
|
||||
"blockEnter called with false after selecting"
|
||||
);
|
||||
|
||||
// Type to trigger search for tag values
|
||||
await fillIn("#filter-input", "tag:e");
|
||||
assert.true(blockEnterCalled, "blockEnter called when typing");
|
||||
assert.false(blockEnterValue, "blockEnter false when typing");
|
||||
|
||||
// Select a search result
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowDown");
|
||||
assert.true(blockEnterValue, "blockEnter true when search result selected");
|
||||
|
||||
// Escape to clear
|
||||
await triggerKeyEvent("#filter-input", "keydown", "Escape");
|
||||
assert.false(blockEnterValue, "blockEnter false after escape");
|
||||
});
|
||||
|
||||
test("prefix support for categories", async function (assert) {
|
||||
// Add prefix data to tips
|
||||
this.tips = [
|
||||
{
|
||||
name: "category:",
|
||||
description: "Filter category",
|
||||
priority: 1,
|
||||
type: "category",
|
||||
prefixes: [
|
||||
{ name: "-", description: "Exclude category" },
|
||||
{ name: "=", description: "Category without subcategories" },
|
||||
],
|
||||
},
|
||||
{ name: "tag:", description: "Filter tag", priority: 1, type: "tag" },
|
||||
];
|
||||
|
||||
const self = this;
|
||||
await render(
|
||||
<template>
|
||||
<input id="filter-input" {{didInsert self.capture}} />
|
||||
<FilterTips
|
||||
@tips={{self.tips}}
|
||||
@queryString={{self.query}}
|
||||
@onSelectTip={{self.update}}
|
||||
@blockEnterSubmit={{self.blockEnter}}
|
||||
@inputElement={{self.inputElement}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
await triggerEvent("#filter-input", "focus");
|
||||
await fillIn("#filter-input", "cat");
|
||||
|
||||
const buttons = document.querySelectorAll(".filter-tip__button");
|
||||
const lastButton = buttons[buttons.length - 1];
|
||||
|
||||
assert.dom(lastButton).exists("shows filtered results");
|
||||
assert
|
||||
.dom(lastButton.querySelector(".filter-tip__name"))
|
||||
.hasText("=category:");
|
||||
assert
|
||||
.dom(lastButton.querySelector(".filter-tip__description"))
|
||||
.includesText("without", "shows prefix description");
|
||||
|
||||
// we skip the "category" and go to negative prefix
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowDown");
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowDown");
|
||||
await triggerKeyEvent("#filter-input", "keydown", "Tab");
|
||||
|
||||
assert.strictEqual(this.query, "-category:", "prefix included in query");
|
||||
assert.dom("#filter-input").hasValue("-category:");
|
||||
|
||||
assert
|
||||
.dom(".filter-tip__button")
|
||||
.exists({ count: 2 }, "shows category options after prefix");
|
||||
assert
|
||||
.dom(".filter-tip__button:first-child .filter-tip__name")
|
||||
.hasText("-category:bugs", "shows category slug");
|
||||
|
||||
// Select a category
|
||||
await triggerKeyEvent("#filter-input", "keydown", "Tab");
|
||||
assert
|
||||
.dom("#filter-input")
|
||||
.hasValue("-category:bugs ", "full filter with prefix applied");
|
||||
|
||||
// Test with equals prefix
|
||||
await fillIn("#filter-input", "=cat");
|
||||
assert
|
||||
.dom(".filter-tip__description")
|
||||
.includesText(
|
||||
"Category without subcategories",
|
||||
"shows = prefix description"
|
||||
);
|
||||
|
||||
await triggerKeyEvent("#filter-input", "keydown", "ArrowDown");
|
||||
await triggerKeyEvent("#filter-input", "keydown", "Tab");
|
||||
assert.strictEqual(this.query, "=category:", "equals prefix included");
|
||||
});
|
||||
});
|
|
@ -68,6 +68,7 @@
|
|||
@import "notifications-tracking";
|
||||
@import "emoji-picker";
|
||||
@import "filter-input";
|
||||
@import "filter-tips";
|
||||
@import "dropdown-menu";
|
||||
@import "welcome-banner";
|
||||
@import "d-multi-select";
|
||||
|
|
50
app/assets/stylesheets/common/components/filter-tips.scss
Normal file
50
app/assets/stylesheets/common/components/filter-tips.scss
Normal file
|
@ -0,0 +1,50 @@
|
|||
.filter-tips {
|
||||
position: relative;
|
||||
|
||||
&__dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--primary-low);
|
||||
border-radius: var(--d-border-radius);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
z-index: z("dropdown") + 1;
|
||||
margin-top: 0.25em;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
|
||||
.filter-tip__button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5em 1em;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-down-1);
|
||||
color: var(--primary);
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover,
|
||||
&.filter-tip__selected {
|
||||
background-color: var(--tertiary-low);
|
||||
}
|
||||
|
||||
&.filter-tip__selected {
|
||||
outline: 2px solid var(--tertiary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.filter-tip__name {
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.filter-tip__description {
|
||||
color: var(--primary-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,32 +16,73 @@
|
|||
|
||||
&__input {
|
||||
position: relative;
|
||||
flex: 1 1;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
left: 0.5em;
|
||||
top: 0.65em;
|
||||
color: var(--primary-low-mid);
|
||||
left: 0.75em;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--primary-medium);
|
||||
font-size: 0.9em;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
color 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__bulk-action-btn {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
&__clear-btn {
|
||||
position: absolute;
|
||||
right: 0.5em;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0.25em;
|
||||
min-width: unset;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--primary-medium);
|
||||
transition: all 0.15s;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-low);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
input.topic-query-filter__filter-term {
|
||||
margin: 0 0.5em 0 0;
|
||||
margin: 0;
|
||||
border-color: var(--primary-low-mid);
|
||||
padding-left: 1.75em;
|
||||
padding-left: 2.25em;
|
||||
padding-right: 2.5em;
|
||||
color: var(--primary);
|
||||
width: 100%;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--tertiary);
|
||||
outline: none;
|
||||
outline-offset: 0;
|
||||
box-shadow: inset 0 0 0 1px var(--tertiary);
|
||||
|
||||
~ .topic-query-filter__icon {
|
||||
opacity: 1;
|
||||
color: var(--tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--primary-low-mid);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,6 +53,7 @@ class SidebarUrl < ActiveRecord::Base
|
|||
icon: "certificate",
|
||||
segment: SidebarUrl.segments["secondary"],
|
||||
},
|
||||
{ name: "Filter", path: "/filter", icon: "filter", segment: SidebarUrl.segments["secondary"] },
|
||||
]
|
||||
|
||||
validates :icon, presence: true, length: { maximum: MAX_ICON_LENGTH }
|
||||
|
|
|
@ -45,6 +45,7 @@ class TopicList
|
|||
:shared_drafts,
|
||||
:category,
|
||||
:publish_read_state,
|
||||
:filter_option_info,
|
||||
)
|
||||
|
||||
def initialize(filter, current_user, topics, opts = nil)
|
||||
|
|
|
@ -7,7 +7,8 @@ class TopicListSerializer < ApplicationSerializer
|
|||
:per_page,
|
||||
:top_tags,
|
||||
:tags,
|
||||
:shared_drafts
|
||||
:shared_drafts,
|
||||
:filter_option_info
|
||||
|
||||
has_many :topics, serializer: TopicListItemSerializer, embed: :objects
|
||||
has_many :shared_drafts, serializer: TopicListItemSerializer, embed: :objects
|
||||
|
@ -27,6 +28,10 @@ class TopicListSerializer < ApplicationSerializer
|
|||
object.shared_drafts.present?
|
||||
end
|
||||
|
||||
def include_filter_option_info?
|
||||
object.filter_option_info.present?
|
||||
end
|
||||
|
||||
def include_for_period?
|
||||
for_period.present?
|
||||
end
|
||||
|
|
|
@ -5214,6 +5214,9 @@ en:
|
|||
badges:
|
||||
content: "Badges"
|
||||
title: "All the badges available to earn"
|
||||
filter:
|
||||
content: "Filter"
|
||||
title: "Filter topics by category, tag, or other criteria"
|
||||
topics:
|
||||
content: "Topics"
|
||||
title: "All topics"
|
||||
|
@ -5375,6 +5378,13 @@ en:
|
|||
powered_by_discourse: "Powered by Discourse"
|
||||
safari_15_warning: "Your browser will soon be incompatible with this community. To keep participating here, please upgrade your browser or <a href='%{url}'>learn more</a>."
|
||||
|
||||
filter:
|
||||
placeholder: "Filter topics by category, tag, or other criteria"
|
||||
description:
|
||||
yesterday: "Yesterday"
|
||||
last_week: "Last week"
|
||||
last_month: "Last month"
|
||||
last_year: "Last year"
|
||||
discovery:
|
||||
headings:
|
||||
all:
|
||||
|
|
|
@ -2837,6 +2837,79 @@ en:
|
|||
audio: "[audio]"
|
||||
video: "[video]"
|
||||
|
||||
filter:
|
||||
description:
|
||||
status: "Filter topics by their status"
|
||||
in: "Filter topics by in personal lists"
|
||||
order: "Sort topics by a specific field"
|
||||
category: "Show topics in a specific category"
|
||||
category_any: "Show topics in any of the specified categories (comma-separated)"
|
||||
exclude_category: "Exclude topics from a specific category"
|
||||
category_without_subcategories: "Show topics only in the parent category, excluding subcategories"
|
||||
exclude_category_without_subcategories: "Exclude topics only from the parent category, not subcategories"
|
||||
tag: "Show topics with a specific tag"
|
||||
tags_any: "Show topics with any of the specified tags (comma-separated)"
|
||||
tags_all: "Show topics with all of the specified tags (plus-separated)"
|
||||
exclude_tag: "Exclude topics with a specific tag"
|
||||
exclude_tags_any: "Exclude topics with any of the specified tags (comma-separated)"
|
||||
exclude_tags_all: "Exclude topics with all of the specified tags (plus-separated)"
|
||||
tags_alias: "Alias for 'tag:' filter"
|
||||
tag_group: "Show topics with tags from a specific tag group"
|
||||
exclude_tag_group: "Exclude topics with tags from a specific tag group"
|
||||
activity_before: "Show topics with last activity before a date (YYYY-MM-DD or days ago)"
|
||||
activity_after: "Show topics with last activity after a date (YYYY-MM-DD or days ago)"
|
||||
created_before: "Show topics created before a date (YYYY-MM-DD or days ago)"
|
||||
created_after: "Show topics created after a date (YYYY-MM-DD or days ago)"
|
||||
created_by: "Show topics created by a specific user"
|
||||
created_by_user: "Show topics created by username (without @)"
|
||||
created_by_multiple: "Show topics created by any of the specified users (comma-separated)"
|
||||
latest_post_before: "Show topics with last post before a date (YYYY-MM-DD or days ago)"
|
||||
latest_post_after: "Show topics with last post after a date (YYYY-MM-DD or days ago)"
|
||||
likes_min: "Show topics with at least this many likes"
|
||||
likes_max: "Show topics with at most this many likes"
|
||||
likes_op_min: "Show topics where first post has at least this many likes"
|
||||
likes_op_max: "Show topics where first post has at most this many likes"
|
||||
posts_min: "Show topics with at least this many posts"
|
||||
posts_max: "Show topics with at most this many posts"
|
||||
posters_min: "Show topics with at least this many participants"
|
||||
posters_max: "Show topics with at most this many participants"
|
||||
views_min: "Show topics with at least this many views"
|
||||
views_max: "Show topics with at most this many views"
|
||||
status_open: "Show open topics (not closed or archived)"
|
||||
status_closed: "Show closed topics"
|
||||
status_archived: "Show archived topics"
|
||||
status_listed: "Show listed (visible) topics"
|
||||
status_unlisted: "Show unlisted (hidden) topics"
|
||||
status_deleted: "Show deleted topics (if you have permission)"
|
||||
status_public: "Show topics in public categories"
|
||||
in_pinned: "Show pinned topics"
|
||||
in_bookmarked: "Show topics you've bookmarked"
|
||||
in_watching: "Show topics you're watching"
|
||||
in_tracking: "Show topics you're tracking"
|
||||
in_muted: "Show muted topics"
|
||||
in_normal: "Show topics with normal notification level"
|
||||
in_watching_first_post: "Show topics you're watching for first post"
|
||||
order_activity: "Sort by last activity (newest first)"
|
||||
order_activity_asc: "Sort by last activity (oldest first)"
|
||||
order_category: "Sort by category name (Z-A)"
|
||||
order_category_asc: "Sort by category name (A-Z)"
|
||||
order_created: "Sort by creation date (newest first)"
|
||||
order_created_asc: "Sort by creation date (oldest first)"
|
||||
order_latest_post: "Sort by last post date (newest first)"
|
||||
order_latest_post_asc: "Sort by last post date (oldest first)"
|
||||
order_likes: "Sort by number of likes (most first)"
|
||||
order_likes_asc: "Sort by number of likes (least first)"
|
||||
order_likes_op: "Sort by likes on first post (most first)"
|
||||
order_likes_op_asc: "Sort by likes on first post (least first)"
|
||||
order_posters: "Sort by number of participants (most first)"
|
||||
order_posters_asc: "Sort by number of participants (least first)"
|
||||
order_title: "Sort by title (Z-A)"
|
||||
order_title_asc: "Sort by title (A-Z)"
|
||||
order_views: "Sort by view count (most first)"
|
||||
order_views_asc: "Sort by view count (least first)"
|
||||
order_read: "Sort by when you last read (most recent first)"
|
||||
order_read_asc: "Sort by when you last read (oldest first)"
|
||||
|
||||
discourse_connect:
|
||||
login_error: "Login Error"
|
||||
not_found: "Your account couldn't be found. Please contact the site's administrator."
|
||||
|
|
54
db/migrate/20250721043317_add_filter_link_to_sidebar.rb
Normal file
54
db/migrate/20250721043317_add_filter_link_to_sidebar.rb
Normal file
|
@ -0,0 +1,54 @@
|
|||
# frozen_string_literal: true
|
||||
class AddFilterLinkToSidebar < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
# Find the community section
|
||||
community_section_id = execute(<<~SQL).first&.fetch("id")
|
||||
SELECT id FROM sidebar_sections WHERE section_type = 0 LIMIT 1
|
||||
SQL
|
||||
return if !community_section_id
|
||||
|
||||
# Find or insert the filter url
|
||||
filter_url_id = execute(<<~SQL).first&.fetch("id")
|
||||
SELECT id FROM sidebar_urls WHERE value = '/filter' AND name = 'Filter' AND NOT external LIMIT 1
|
||||
SQL
|
||||
|
||||
filter_url_id ||= execute(<<~SQL).first["id"]
|
||||
INSERT INTO sidebar_urls (name, value, icon, segment, external, created_at, updated_at)
|
||||
VALUES ('Filter', '/filter', 'filter', 1, false, NOW(), NOW())
|
||||
RETURNING id
|
||||
SQL
|
||||
|
||||
exists = execute(<<~SQL).first
|
||||
SELECT 1 FROM sidebar_section_links
|
||||
WHERE sidebar_section_id = #{community_section_id.to_i}
|
||||
AND linkable_id = #{filter_url_id.to_i}
|
||||
AND linkable_type = 'SidebarUrl'
|
||||
LIMIT 1
|
||||
SQL
|
||||
|
||||
if !exists
|
||||
position = execute(<<~SQL).first&.fetch("pos") || 0
|
||||
SELECT MAX(position) pos FROM sidebar_section_links
|
||||
WHERE sidebar_section_id = #{community_section_id}
|
||||
AND user_id = -1
|
||||
SQL
|
||||
position += 1
|
||||
execute(<<~SQL)
|
||||
INSERT INTO sidebar_section_links
|
||||
(user_id, linkable_id, linkable_type, sidebar_section_id, position, created_at, updated_at)
|
||||
VALUES (-1, #{filter_url_id}, 'SidebarUrl', #{community_section_id}, #{position.to_i}, NOW(), NOW())
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
filter_url_id =
|
||||
execute("SELECT id FROM sidebar_urls WHERE value = '/filter' LIMIT 1").first&.fetch("id")
|
||||
if filter_url_id
|
||||
execute(
|
||||
"DELETE FROM sidebar_section_links WHERE linkable_id = #{filter_url_id} AND linkable_type = 'SidebarUrl'",
|
||||
)
|
||||
execute("DELETE FROM sidebar_urls WHERE id = #{filter_url_id}")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -315,7 +315,11 @@ class TopicQuery
|
|||
|
||||
results = apply_ordering(results) if results.order_values.empty?
|
||||
|
||||
create_list(:filter, {}, results)
|
||||
create_list(
|
||||
:filter,
|
||||
{ include_filter_option_info: @options[:include_filter_option_info].to_s != "false" },
|
||||
results,
|
||||
)
|
||||
end
|
||||
|
||||
def list_read
|
||||
|
@ -561,6 +565,10 @@ class TopicQuery
|
|||
|
||||
list = TopicList.new(filter, @user, topics, options.merge(@options))
|
||||
list.per_page = options[:per_page]&.to_i || per_page_setting
|
||||
|
||||
if filter == :filter && options[:include_filter_option_info]
|
||||
list.filter_option_info = TopicsFilter.option_info(@guardian)
|
||||
end
|
||||
list
|
||||
end
|
||||
|
||||
|
|
|
@ -89,6 +89,21 @@ class TopicsFilter
|
|||
end
|
||||
end
|
||||
|
||||
keywords =
|
||||
query_string.split(/\s+/).reject { |word| word.include?(":") }.map(&:strip).reject(&:empty?)
|
||||
|
||||
if keywords.present? && keywords.join(" ").length >= SiteSetting.min_search_term_length
|
||||
ts_query = Search.ts_query(term: keywords.join(" "))
|
||||
@scope = @scope.where(<<~SQL)
|
||||
topics.id IN (
|
||||
SELECT topic_id
|
||||
FROM post_search_data
|
||||
JOIN posts ON posts.id = post_search_data.post_id
|
||||
WHERE search_data @@ #{ts_query}
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
||||
@scope
|
||||
end
|
||||
|
||||
|
@ -129,6 +144,167 @@ class TopicsFilter
|
|||
@scope
|
||||
end
|
||||
|
||||
def self.option_info(guardian)
|
||||
results = [
|
||||
{
|
||||
name: "category:",
|
||||
alias: "categories:",
|
||||
description: I18n.t("filter.description.category"),
|
||||
priority: 1,
|
||||
type: "category",
|
||||
delimiters: [{ name: ",", description: I18n.t("filter.description.category_any") }],
|
||||
prefixes: [
|
||||
{ name: "-", description: I18n.t("filter.description.exclude_category") },
|
||||
{ name: "=", description: I18n.t("filter.description.category_without_subcategories") },
|
||||
{
|
||||
name: "-=",
|
||||
description: I18n.t("filter.description.exclude_category_without_subcategories"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "activity-before:",
|
||||
description: I18n.t("filter.description.activity_before"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "activity-after:",
|
||||
description: I18n.t("filter.description.activity_after"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "created-before:",
|
||||
description: I18n.t("filter.description.created_before"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "created-after:",
|
||||
description: I18n.t("filter.description.created_after"),
|
||||
priority: 1,
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "created-by:",
|
||||
description: I18n.t("filter.description.created_by"),
|
||||
type: "username",
|
||||
delimiters: [{ name: ",", description: I18n.t("filter.description.created_by_multiple") }],
|
||||
},
|
||||
{
|
||||
name: "latest-post-before:",
|
||||
description: I18n.t("filter.description.latest_post_before"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "latest-post-after:",
|
||||
description: I18n.t("filter.description.latest_post_after"),
|
||||
type: "date",
|
||||
},
|
||||
{ name: "likes-min:", description: I18n.t("filter.description.likes_min"), type: "number" },
|
||||
{ name: "likes-max:", description: I18n.t("filter.description.likes_max"), type: "number" },
|
||||
{
|
||||
name: "likes-op-min:",
|
||||
description: I18n.t("filter.description.likes_op_min"),
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
name: "likes-op-max:",
|
||||
description: I18n.t("filter.description.likes_op_max"),
|
||||
type: "number",
|
||||
},
|
||||
{ name: "posts-min:", description: I18n.t("filter.description.posts_min"), type: "number" },
|
||||
{ name: "posts-max:", description: I18n.t("filter.description.posts_max"), type: "number" },
|
||||
{
|
||||
name: "posters-min:",
|
||||
description: I18n.t("filter.description.posters_min"),
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
name: "posters-max:",
|
||||
description: I18n.t("filter.description.posters_max"),
|
||||
type: "number",
|
||||
},
|
||||
{ name: "views-min:", description: I18n.t("filter.description.views_min"), type: "number" },
|
||||
{ name: "views-max:", description: I18n.t("filter.description.views_max"), type: "number" },
|
||||
{ name: "status:", description: I18n.t("filter.description.status"), priority: 1 },
|
||||
{ name: "status:open", description: I18n.t("filter.description.status_open") },
|
||||
{ name: "status:closed", description: I18n.t("filter.description.status_closed") },
|
||||
{ name: "status:archived", description: I18n.t("filter.description.status_archived") },
|
||||
{ name: "status:listed", description: I18n.t("filter.description.status_listed") },
|
||||
{ name: "status:unlisted", description: I18n.t("filter.description.status_unlisted") },
|
||||
{ name: "status:deleted", description: I18n.t("filter.description.status_deleted") },
|
||||
{ name: "status:public", description: I18n.t("filter.description.status_public") },
|
||||
{ name: "order:", description: I18n.t("filter.description.order"), priority: 1 },
|
||||
{ name: "order:activity", description: I18n.t("filter.description.order_activity") },
|
||||
{ name: "order:activity-asc", description: I18n.t("filter.description.order_activity_asc") },
|
||||
{ name: "order:category", description: I18n.t("filter.description.order_category") },
|
||||
{ name: "order:category-asc", description: I18n.t("filter.description.order_category_asc") },
|
||||
{ name: "order:created", description: I18n.t("filter.description.order_created") },
|
||||
{ name: "order:created-asc", description: I18n.t("filter.description.order_created_asc") },
|
||||
{ name: "order:latest-post", description: I18n.t("filter.description.order_latest_post") },
|
||||
{
|
||||
name: "order:latest-post-asc",
|
||||
description: I18n.t("filter.description.order_latest_post_asc"),
|
||||
},
|
||||
{ name: "order:likes", description: I18n.t("filter.description.order_likes") },
|
||||
{ name: "order:likes-asc", description: I18n.t("filter.description.order_likes_asc") },
|
||||
{ name: "order:likes-op", description: I18n.t("filter.description.order_likes_op") },
|
||||
{ name: "order:likes-op-asc", description: I18n.t("filter.description.order_likes_op_asc") },
|
||||
{ name: "order:posters", description: I18n.t("filter.description.order_posters") },
|
||||
{ name: "order:posters-asc", description: I18n.t("filter.description.order_posters_asc") },
|
||||
{ name: "order:title", description: I18n.t("filter.description.order_title") },
|
||||
{ name: "order:title-asc", description: I18n.t("filter.description.order_title_asc") },
|
||||
{ name: "order:views", description: I18n.t("filter.description.order_views") },
|
||||
{ name: "order:views-asc", description: I18n.t("filter.description.order_views_asc") },
|
||||
{ name: "order:read", description: I18n.t("filter.description.order_read") },
|
||||
{ name: "order:read-asc", description: I18n.t("filter.description.order_read_asc") },
|
||||
]
|
||||
|
||||
if guardian.authenticated?
|
||||
results.concat(
|
||||
[
|
||||
{ name: "in:", description: I18n.t("filter.description.in"), priority: 1 },
|
||||
{ name: "in:pinned", description: I18n.t("filter.description.in_pinned") },
|
||||
{ name: "in:bookmarked", description: I18n.t("filter.description.in_bookmarked") },
|
||||
{ name: "in:watching", description: I18n.t("filter.description.in_watching") },
|
||||
{ name: "in:tracking", description: I18n.t("filter.description.in_tracking") },
|
||||
{ name: "in:muted", description: I18n.t("filter.description.in_muted") },
|
||||
{ name: "in:normal", description: I18n.t("filter.description.in_normal") },
|
||||
{
|
||||
name: "in:watching_first_post",
|
||||
description: I18n.t("filter.description.in_watching_first_post"),
|
||||
},
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
if SiteSetting.tagging_enabled?
|
||||
results.push(
|
||||
{
|
||||
name: "tag:",
|
||||
description: I18n.t("filter.description.tag"),
|
||||
alias: "tags:",
|
||||
priority: 1,
|
||||
type: "tag",
|
||||
delimiters: [
|
||||
{ name: ",", description: I18n.t("filter.description.tags_any") },
|
||||
{ name: "+", description: I18n.t("filter.description.tags_all") },
|
||||
],
|
||||
prefixes: [{ name: "-", description: I18n.t("filter.description.exclude_tag") }],
|
||||
},
|
||||
)
|
||||
results.push(
|
||||
{
|
||||
name: "tag_group:",
|
||||
description: I18n.t("filter.description.tag_group"),
|
||||
type: "tag_group",
|
||||
prefixes: [{ name: "-", description: I18n.t("filter.description.exclude_tag_group") }],
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
YYYY_MM_DD_REGEXP =
|
||||
|
@ -546,15 +722,15 @@ class TopicsFilter
|
|||
column: "topics.views",
|
||||
},
|
||||
"read" => {
|
||||
column: "tu.last_visited_at",
|
||||
column: "tu1.last_visited_at",
|
||||
scope: -> do
|
||||
if @guardian.user
|
||||
@scope.joins(
|
||||
"JOIN topic_users tu ON tu.topic_id = topics.id AND tu.user_id = #{@guardian.user.id.to_i}",
|
||||
).where("tu.last_visited_at IS NOT NULL")
|
||||
"JOIN topic_users tu1 ON tu1.topic_id = topics.id AND tu1.user_id = #{@guardian.user.id.to_i}",
|
||||
).where("tu1.last_visited_at IS NOT NULL")
|
||||
else
|
||||
# make sure this works for anon
|
||||
@scope.joins("LEFT JOIN topic_users tu ON 1 = 0")
|
||||
@scope.joins("LEFT JOIN topic_users tu1 ON 1 = 0")
|
||||
end
|
||||
end,
|
||||
},
|
||||
|
@ -575,7 +751,7 @@ class TopicsFilter
|
|||
|
||||
@scope = @scope.order("#{column_name} #{match_data[:asc] ? "ASC" : "DESC"}")
|
||||
else
|
||||
match_data = value.match /^(?<column>.*?)(?:-(?<asc>asc))?$/
|
||||
match_data = value.match(/^(?<column>.*?)(?:-(?<asc>asc))?$/)
|
||||
key = "order:#{match_data[:column]}"
|
||||
if custom_match =
|
||||
DiscoursePluginRegistry.custom_filter_mappings.find { |hash| hash.key?(key) }
|
||||
|
|
|
@ -5,6 +5,53 @@ RSpec.describe TopicsFilter do
|
|||
fab!(:admin)
|
||||
fab!(:group)
|
||||
|
||||
describe "#option_info" do
|
||||
let(:options) { TopicsFilter.option_info(Guardian.new) }
|
||||
it "should return a correct hash with name and description keys for all" do
|
||||
expect(options).to be_an(Array)
|
||||
expect(options).to all(be_a(Hash))
|
||||
expect(options).to all(include(:name, :description))
|
||||
|
||||
# 10 is arbitray, but better than just checking for 1
|
||||
expect(options.length).to be > 10
|
||||
end
|
||||
|
||||
it "should include nothing about tags when disabled" do
|
||||
SiteSetting.tagging_enabled = false
|
||||
|
||||
tag_options = options.find { |o| o[:name].include? "tag" }
|
||||
expect(tag_options).to be_nil
|
||||
|
||||
SiteSetting.tagging_enabled = true
|
||||
options = TopicsFilter.option_info(Guardian.new)
|
||||
|
||||
tag_options = options.find { |o| o[:name].include? "tag" }
|
||||
expect(tag_options).not_to be_nil
|
||||
end
|
||||
|
||||
it "should not include user-specific options for anonymous users" do
|
||||
anon_options = TopicsFilter.option_info(Guardian.new)
|
||||
logged_in_options = TopicsFilter.option_info(Guardian.new(user))
|
||||
|
||||
anon_option_names = anon_options.map { |o| o[:name] }.to_set
|
||||
logged_in_option_names = logged_in_options.map { |o| o[:name] }.to_set
|
||||
|
||||
user_specific_options = %w[
|
||||
in:
|
||||
in:pinned
|
||||
in:bookmarked
|
||||
in:watching
|
||||
in:tracking
|
||||
in:muted
|
||||
in:normal
|
||||
in:watching_first_post
|
||||
]
|
||||
|
||||
user_specific_options.each { |option| expect(anon_option_names).not_to include(option) }
|
||||
user_specific_options.each { |option| expect(logged_in_option_names).to include(option) }
|
||||
end
|
||||
end
|
||||
|
||||
describe "#filter_from_query_string" do
|
||||
describe "when filtering with multiple filters" do
|
||||
fab!(:tag) { Fabricate(:tag, name: "tag1") }
|
||||
|
@ -863,7 +910,7 @@ RSpec.describe TopicsFilter do
|
|||
end
|
||||
|
||||
it "should only return topics that are tagged with tag1 and tag2 but not tag3 when query string is `tags:tag1 tags:tag2 -tags:tag3`" do
|
||||
topic_with_tag_and_tag2_and_tag3 = Fabricate(:topic, tags: [tag, tag2, tag3])
|
||||
_topic_with_tag_and_tag2_and_tag3 = Fabricate(:topic, tags: [tag, tag2, tag3])
|
||||
|
||||
expect(
|
||||
TopicsFilter
|
||||
|
@ -1335,7 +1382,7 @@ RSpec.describe TopicsFilter do
|
|||
describe "when query string is `#{filter}-after:1`" do
|
||||
it "should only return topics with #{description} after 1 day ago" do
|
||||
freeze_time do
|
||||
old_topic = Fabricate(:topic, column => 2.days.ago)
|
||||
_old_topic = Fabricate(:topic, column => 2.days.ago)
|
||||
recent_topic = Fabricate(:topic, column => Time.zone.now)
|
||||
|
||||
expect(
|
||||
|
@ -1369,7 +1416,7 @@ RSpec.describe TopicsFilter do
|
|||
describe "when query string is `#{filter}-after:0`" do
|
||||
it "should only return topics with #{description} after today" do
|
||||
freeze_time do
|
||||
old_topic = Fabricate(:topic, column => 2.days.ago)
|
||||
_old_topic = Fabricate(:topic, column => 2.days.ago)
|
||||
recent_topic = Fabricate(:topic, column => Time.zone.now)
|
||||
|
||||
expect(
|
||||
|
@ -1644,6 +1691,17 @@ RSpec.describe TopicsFilter do
|
|||
end
|
||||
end
|
||||
|
||||
it "performs AND search for multiple keywords" do
|
||||
SearchIndexer.enable
|
||||
post1 = Fabricate(:post, raw: "keyword1 keyword2")
|
||||
_post2 = Fabricate(:post, raw: "keyword1")
|
||||
_post3 = Fabricate(:post, raw: "keyword2")
|
||||
guardian = Guardian.new(post1.user)
|
||||
filter = TopicsFilter.new(guardian: guardian)
|
||||
scope = filter.filter_from_query_string("keyword1 keyword2")
|
||||
expect(scope.pluck(:id)).to eq([post1.topic_id])
|
||||
end
|
||||
|
||||
describe "with a custom filter" do
|
||||
fab!(:topic)
|
||||
|
||||
|
|
|
@ -52,7 +52,7 @@ RSpec.describe Category do
|
|||
|
||||
expect { category_sidebar_section_link.linkable.destroy! }.to change {
|
||||
SidebarSectionLink.count
|
||||
}.from(13).to(11)
|
||||
}.by(-2)
|
||||
expect(
|
||||
SidebarSectionLink.where(
|
||||
id: [category_sidebar_section_link.id, category_sidebar_section_link_2.id],
|
||||
|
|
|
@ -34,6 +34,7 @@ RSpec.describe SidebarSection do
|
|||
"FAQ",
|
||||
"Groups",
|
||||
"Badges",
|
||||
"Filter",
|
||||
],
|
||||
)
|
||||
end
|
||||
|
|
|
@ -27,7 +27,7 @@ RSpec.describe Tag do
|
|||
|
||||
expect { tag_sidebar_section_link.linkable.destroy! }.to change {
|
||||
SidebarSectionLink.count
|
||||
}.from(13).to(11)
|
||||
}.by(-2)
|
||||
expect(
|
||||
SidebarSectionLink.where(
|
||||
id: [tag_sidebar_section_link.id, tag_sidebar_section_link_2.id],
|
||||
|
|
|
@ -1443,6 +1443,15 @@ RSpec.describe ListController do
|
|||
end
|
||||
end
|
||||
|
||||
it "should include filter_option_info in the response" do
|
||||
get "/filter.json"
|
||||
parsed = response.parsed_body
|
||||
expect(response.status).to eq(200)
|
||||
expect(parsed["topic_list"]["filter_option_info"].length).to eq(
|
||||
TopicsFilter.option_info(Guardian.new).length,
|
||||
)
|
||||
end
|
||||
|
||||
it "should filter with tag_group option" do
|
||||
topic_with_tag = Fabricate(:topic, tags: [tag])
|
||||
topic2_with_tag = Fabricate(:topic, tags: [tag])
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue