From 68b901586a63fa58d25c6dc0d93c8f9a9dfc554c Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 22 Jul 2025 16:08:10 +1000 Subject: [PATCH] FEATURE: dynamic search when in /filter route (#33614) Introduces new dynamic autocomplete for filter to make discovery easier: image https://github.com/user-attachments/assets/e9d7bc29-a593-4ef3-82a2-f10fc35ed47c --------- Co-authored-by: Martin Brennan --- .../discovery/filter-navigation.gjs | 68 +- .../app/components/discovery/filter-tips.gjs | 588 ++++++++++++++++++ .../community-section/filter-section-link.js | 31 + .../common/community-section/section.js | 2 + .../app/templates/discovery/filter.gjs | 1 + .../components/discovery/filter-tips-test.gjs | 346 +++++++++++ .../stylesheets/common/components/_index.scss | 1 + .../common/components/filter-tips.scss | 50 ++ .../common/components/topic-query-filter.scss | 53 +- app/models/sidebar_url.rb | 1 + app/models/topic_list.rb | 1 + app/serializers/topic_list_serializer.rb | 7 +- config/locales/client.en.yml | 10 + config/locales/server.en.yml | 73 +++ ...250721043317_add_filter_link_to_sidebar.rb | 54 ++ lib/topic_query.rb | 10 +- lib/topics_filter.rb | 186 +++++- spec/lib/topics_filter_spec.rb | 64 +- spec/models/category_spec.rb | 2 +- spec/models/sidebar_section_spec.rb | 1 + spec/models/tag_spec.rb | 2 +- spec/requests/list_controller_spec.rb | 9 + 22 files changed, 1522 insertions(+), 38 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/discovery/filter-tips.gjs create mode 100644 app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/filter-section-link.js create mode 100644 app/assets/javascripts/discourse/tests/integration/components/discovery/filter-tips-test.gjs create mode 100644 app/assets/stylesheets/common/components/filter-tips.scss create mode 100644 db/migrate/20250721043317_add_filter_link_to_sidebar.rb diff --git a/app/assets/javascripts/discourse/app/components/discovery/filter-navigation.gjs b/app/assets/javascripts/discourse/app/components/discovery/filter-navigation.gjs index a57eca91dad..979fa9565ef 100644 --- a/app/assets/javascripts/discourse/app/components/discovery/filter-navigation.gjs +++ b/app/assets/javascripts/discourse/app/components/discovery/filter-navigation.gjs @@ -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; + } + diff --git a/app/assets/javascripts/discourse/app/components/discovery/filter-tips.gjs b/app/assets/javascripts/discourse/app/components/discovery/filter-tips.gjs new file mode 100644 index 00000000000..81453f83af1 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/discovery/filter-tips.gjs @@ -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); + } + + +} diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/filter-section-link.js b/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/filter-section-link.js new file mode 100644 index 00000000000..373feceff2a --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/filter-section-link.js @@ -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"; + } +} diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/section.js b/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/section.js index 3fe4387a292..08fae558ee3 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/section.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/section.js @@ -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, diff --git a/app/assets/javascripts/discourse/app/templates/discovery/filter.gjs b/app/assets/javascripts/discourse/app/templates/discovery/filter.gjs index 1673cad0db9..2507fd5b08a 100644 --- a/app/assets/javascripts/discourse/app/templates/discovery/filter.gjs +++ b/app/assets/javascripts/discourse/app/templates/discovery/filter.gjs @@ -12,6 +12,7 @@ export default RouteTemplate( @updateTopicsListQueryParams={{@controller.updateTopicsListQueryParams}} @canBulkSelect={{@controller.canBulkSelect}} @bulkSelectHelper={{@controller.bulkSelectHelper}} + @tips={{@controller.model.topic_list.filter_option_info}} /> <:list> diff --git a/app/assets/javascripts/discourse/tests/integration/components/discovery/filter-tips-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/discovery/filter-tips-test.gjs new file mode 100644 index 00000000000..42fe4332a88 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/discovery/filter-tips-test.gjs @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + // 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( + + ); + + 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"); + }); +}); diff --git a/app/assets/stylesheets/common/components/_index.scss b/app/assets/stylesheets/common/components/_index.scss index 7031c42d321..3f06c6a5a69 100644 --- a/app/assets/stylesheets/common/components/_index.scss +++ b/app/assets/stylesheets/common/components/_index.scss @@ -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"; diff --git a/app/assets/stylesheets/common/components/filter-tips.scss b/app/assets/stylesheets/common/components/filter-tips.scss new file mode 100644 index 00000000000..3015c037db6 --- /dev/null +++ b/app/assets/stylesheets/common/components/filter-tips.scss @@ -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); + } + } + } +} diff --git a/app/assets/stylesheets/common/components/topic-query-filter.scss b/app/assets/stylesheets/common/components/topic-query-filter.scss index 464313f1acc..ebd3abc9f19 100644 --- a/app/assets/stylesheets/common/components/topic-query-filter.scss +++ b/app/assets/stylesheets/common/components/topic-query-filter.scss @@ -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; } } } diff --git a/app/models/sidebar_url.rb b/app/models/sidebar_url.rb index 8ac94b57335..faa30f8f734 100644 --- a/app/models/sidebar_url.rb +++ b/app/models/sidebar_url.rb @@ -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 } diff --git a/app/models/topic_list.rb b/app/models/topic_list.rb index c2cb107b6e7..b356ee59e63 100644 --- a/app/models/topic_list.rb +++ b/app/models/topic_list.rb @@ -45,6 +45,7 @@ class TopicList :shared_drafts, :category, :publish_read_state, + :filter_option_info, ) def initialize(filter, current_user, topics, opts = nil) diff --git a/app/serializers/topic_list_serializer.rb b/app/serializers/topic_list_serializer.rb index 6462c85755d..a034834e82e 100644 --- a/app/serializers/topic_list_serializer.rb +++ b/app/serializers/topic_list_serializer.rb @@ -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 diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a3b18d3d5f8..6faf8413b04 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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 learn more." + 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: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index dc8ba93bec6..e0e495b48b5 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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." diff --git a/db/migrate/20250721043317_add_filter_link_to_sidebar.rb b/db/migrate/20250721043317_add_filter_link_to_sidebar.rb new file mode 100644 index 00000000000..edf56d3d658 --- /dev/null +++ b/db/migrate/20250721043317_add_filter_link_to_sidebar.rb @@ -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 diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 44ac19fa9a6..03569a8ad23 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -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 diff --git a/lib/topics_filter.rb b/lib/topics_filter.rb index 1381f242235..8ae7e5f4a5e 100644 --- a/lib/topics_filter.rb +++ b/lib/topics_filter.rb @@ -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 /^(?.*?)(?:-(?asc))?$/ + match_data = value.match(/^(?.*?)(?:-(?asc))?$/) key = "order:#{match_data[:column]}" if custom_match = DiscoursePluginRegistry.custom_filter_mappings.find { |hash| hash.key?(key) } diff --git a/spec/lib/topics_filter_spec.rb b/spec/lib/topics_filter_spec.rb index 370f1bd8bcc..672ca8149c2 100644 --- a/spec/lib/topics_filter_spec.rb +++ b/spec/lib/topics_filter_spec.rb @@ -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) diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index 1f77c77027b..271badaf25a 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -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], diff --git a/spec/models/sidebar_section_spec.rb b/spec/models/sidebar_section_spec.rb index e7295b2c861..2abce077df6 100644 --- a/spec/models/sidebar_section_spec.rb +++ b/spec/models/sidebar_section_spec.rb @@ -34,6 +34,7 @@ RSpec.describe SidebarSection do "FAQ", "Groups", "Badges", + "Filter", ], ) end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index b720af58244..30d039f6098 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -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], diff --git a/spec/requests/list_controller_spec.rb b/spec/requests/list_controller_spec.rb index a92cabc2444..ebdc1f89548 100644 --- a/spec/requests/list_controller_spec.rb +++ b/spec/requests/list_controller_spec.rb @@ -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])