2
0
Fork 0
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:
Sam 2025-07-22 16:08:10 +10:00 committed by GitHub
parent de1fb8c955
commit 68b901586a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1522 additions and 38 deletions

View file

@ -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
}}
/>
<FilterTips
@queryString={{this.newQueryString}}
@onSelectTip={{this.updateQueryString}}
@tips={{@tips}}
@blockEnterSubmit={{this.blockEnterSubmit}}
@inputElement={{this.inputElement}}
/>
</div>
{{#if this.newQueryString}}
<div class="topic-query-filter__controls">
<DButton
@icon="xmark"
@action={{this.clearInput}}
@disabled={{unless this.newQueryString "true"}}
/>

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -45,6 +45,7 @@ class TopicList
:shared_drafts,
:category,
:publish_read_state,
:filter_option_info,
)

def initialize(filter, current_user, topics, opts = nil)

View file

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

View file

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

View file

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

View 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

View file

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


View file

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

View file

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


View file

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

View file

@ -34,6 +34,7 @@ RSpec.describe SidebarSection do
"FAQ",
"Groups",
"Badges",
"Filter",
],
)
end

View file

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

View file

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