2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-08-17 18:04:11 +08:00

FEATURE: filter tips for assigned topics (#33992)

Refactors filter navigation menu: 

* Replaces local filtering logic with async `FilterSuggestions` service
(new parser, prefixes, multi-value delimiters, active filter handling)
* Introduces topics_filter_options modifier hook and custom filter
mapping support (including comma‑separated values) in TopicsFilter
* Adds assigned: (multi user/group, *, nobody) and status:solved /
status:unsolved options via plugins. Also updates solved/assign locales.
* Adds in:new-replies and in:new-topics filters for tracking
* Enhances assign filter query logic. 
* Adds custom in: filter mapping tests.

---------

Co-authored-by: Martin Brennan <martin@discourse.org>
Co-authored-by: Jarek Radosz <jradosz@gmail.com>
This commit is contained in:
Sam 2025-08-13 16:35:33 +10:00 committed by GitHub
parent 9f12dd28f9
commit ee094b99cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1414 additions and 456 deletions

View file

@ -4,6 +4,7 @@ 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 didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { cancel } from "@ember/runloop";
import { service } from "@ember/service";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
@ -16,16 +17,13 @@ import icon from "discourse/helpers/d-icon";
import withEventValue from "discourse/helpers/with-event-value";
import discourseDebounce from "discourse/lib/debounce";
import FilterSuggestions from "discourse/lib/filter-suggestions";
import { resettableTracked } from "discourse/lib/tracked-tools";
import { i18n } from "discourse-i18n";
import { VISIBILITY_OPTIMIZERS } from "float-kit/lib/constants";
const MAX_RESULTS = 20;
const FilterNavigationMenuList = <template>
{{#if @data.filteredTips.length}}
{{#if @data.suggestions.length}}
<DropdownMenu as |dropdown|>
{{#each @data.filteredTips as |item index|}}
{{#each @data.suggestions as |item index|}}
<dropdown.item
class={{concatClass
"filter-navigation__tip-item"
@ -52,38 +50,37 @@ const FilterNavigationMenuList = <template>
</template>;
/**
* This component provides an input field and parsing logic for filter
* queries. Every time the input changes, we recalculate the list of
* filter tips that match the current input value.
* FilterNavigationMenu - A simpler UI component for filter input and suggestions
*
* We start from an initial list of tips provided by the server
* (see TopicsFilter.option_info) which are reduced to a list of "high priority/top-level"
* filters if there is no user input value.
* This component manages:
* - User input field
* - Keyboard navigation
* - Dropdown menu display
* - Selection handling
*
* Once the user starts typing, we parse the input value to determine
* the last word and its prefix (if any). If the last word contains a colon,
* we treat it as a filter name and look for matching tips via the FilterSuggestions service.
* For example after "category:" is typed we show a list of categories the user
* has access to.
*
* Each filter tip can have prefixes (like "-", "=", and "-=") that modify the filter behavior,
* as well as delimiters (like ",") that allow for multiple values.
* The actual suggestion generation is delegated to FilterSuggestions
*/
export default class FilterNavigationMenu extends Component {
@service menu;
@service site;
@resettableTracked currentInputValue = this.args.initialInputValue || "";
@tracked currentInputValue = this.args.initialInputValue || "";
@tracked suggestions = [];
@tracked activeFilter = null;
lastSuggestionInput = "";
suggestionRequestId = 0;
filterSuggessionResults = [];
activeFilter = null;
trackedMenuListData = new TrackedObject({
filteredTips: this.filteredTips,
suggestions: [],
selectedIndex: null,
selectItem: this.selectItem,
});
@tracked _selectedIndex = -1;
searchTimer = null;
inputElement = null;
dMenuInstance = null;
_selectedIndex = -1;
get selectedIndex() {
return this._selectedIndex;
@ -102,274 +99,121 @@ export default class FilterNavigationMenu extends Component {
return this.selectedIndex === -1;
}
get filteredTips() {
if (!this.args.tips) {
return [];
}
const words = this.currentInputValue.split(/\s+/);
const lastWord = words.at(-1).toLowerCase();
// If we're already filtering by a type like "category:" that has suggestions,
// we want to only show those suggestions.
if (this.activeFilter && this.filterSuggessionResults.length > 0) {
return this.filterSuggessionResults;
}
// We are filtering by a type here like "category:", "tag:", etc.
// since the last word contains a colon.
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);
const tip = this.args.tips.find((t) => t.name === filterName + ":");
if (tip?.type && valueText !== undefined) {
this.handleFilterSuggestionSearch(filterName, valueText, tip, prefix);
return this.filterSuggessionResults.length > 0
? this.filterSuggessionResults
: [];
}
}
// Get a list of the "top-level" filters that have a priority of 1,
// such as category:, created-after:, tags:, etc.
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);
}
return this.filterAllTips(lastWord, prefix);
}
/**
* Filters all available tips based on a search term from the user input
*
* This method searches through the complete list of filter tips and finds matches based on:
* 1. Direct name matches with the search term
* 2. Matches against tip aliases
* 3. Support for prefixed tips (like "-", "=", "-=")
*
* Results are sorted to prioritize exact matches first and are limited to MAX_RESULTS
*
* @param {string} lastWord - The last word in the input string (what user is currently typing)
* @param {string} prefix - Any detected prefix modifier like "-", "=", or "-="
* @returns {Array} - Array of matching tip objects for display in the menu
*/
filterAllTips(lastWord, prefix) {
const tips = [];
this.args.tips.forEach((tip) => {
if (tips.length >= MAX_RESULTS) {
return;
}
const tipName = tip.name;
const searchTerm = lastWord.substring(prefix.length);
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.toLowerCase();
const bName = b.name.toLowerCase();
const aStartsWith = aName.startsWith(lastWord.toLowerCase());
const bStartsWith = bName.startsWith(lastWord.toLowerCase());
if (aStartsWith && !bStartsWith) {
return -1;
}
if (!aStartsWith && bStartsWith) {
return 1;
}
if (aStartsWith && bStartsWith && aName.length !== bName.length) {
return aName.length - bName.length;
}
return aName.localeCompare(bName);
});
}
/**
* Updates the component state based on the current input value
*
* Unlike the filteredTips getter which just returns the current suggestions,
* this method actively parses the input and updates internal state:
*
* - Resets selection state
* - Sets or clears the activeFilter based on detected filter types
* - Triggers filter-specific suggestion searches when appropriate
* - Updates the reactive state to ensure UI reflects current filter state
*
* This method should be called after actions that modify the input value
* to ensure the component's internal state is synchronized with the input.
*/
updateResults() {
this.clearSelection();
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.handleFilterSuggestionSearch(filterName, valueText, tip, prefix);
} else {
this.activeFilter = null;
this.filterSuggessionResults = [];
}
} else {
this.activeFilter = null;
this.filterSuggessionResults = [];
}
this.trackedMenuListData.filteredTips = this.filteredTips;
}
#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,
isSuggestion: true,
});
});
}
}
#extractPrefix(word) {
const match = word.match(/^(-=|=-|-|=)/);
return match ? match[0] : "";
}
@action
storeInputElement(element) {
this.inputElement = element;
}
@action
handleFilterSuggestionSearch(filterName, valueText, tip, prefix = "") {
this.activeFilter = filterName;
this.searchTimer = discourseDebounce(
this,
this.#performFilterSuggestionSearch,
filterName,
valueText,
tip,
prefix,
300
);
async updateSuggestions() {
cancel(this.searchTimer);
this.searchTimer = discourseDebounce(this, this.fetchSuggestions, 300);
}
/**
* Handles selection of a filter tip item from the dropdown menu.
* See TopicsFilter.option_info for the structure of the item
* on the server.
*
* @param {Object} item - A filter tip object from the initial list or from the filter suggestions
* @param {string} item.name - The name of the filter (e.g. "category:", "tag:")
* @param {string} [item.alias] - Alternative name for the filter (e.g. "categories:")
* @param {string} [item.description] - Human-readable description of the filter
* @param {number} [item.priority] - Priority value for sorting (higher appears first)
* @param {string} [item.type] - Type of filter for suggestions (category, tag, username, date, number)
* @param {Array<Object>} [item.delimiters] - Delimiter options for multiple values
* @param {Array<Object>} [item.prefixes] - Prefix modifiers for this filter (-, =, -=)
* @param {boolean} [item.isSuggestion] - Whether this is a suggestion for a specific filter value
*/
@action
selectItem(item) {
// Split up the string from the text input into words.
const words = this.currentInputValue.split(/\s+/);
async fetchSuggestions() {
const input = this.currentInputValue || "";
const requestId = ++this.suggestionRequestId;
// If we are selecting an item that was suggested based on the initial
// word selected (e.g. after picking a "category:" the user selects a
// category from the list), we replace the last word with the selected item.
if (item.isSuggestion) {
words[words.length - 1] = item.name;
let updatedInputValue = words.join(" ");
try {
const result = await FilterSuggestions.getSuggestions(
input,
this.args.tips,
{
site: this.site,
}
);
// Drop stale responses or results for outdated input
if (
!updatedInputValue.endsWith(":") &&
requestId !== this.suggestionRequestId ||
this.currentInputValue !== input
) {
return;
}
this.suggestions = result.suggestions || [];
this.activeFilter = result.activeFilter;
this.trackedMenuListData.suggestions = this.suggestions;
this.trackedMenuListData.selectedIndex = this.selectedIndex;
this.clearSelection();
this.lastSuggestionInput = input;
if (this.dMenuInstance) {
if (!this.suggestions.length) {
this.dMenuInstance.close();
} else {
this.dMenuInstance.show();
}
}
} catch {
// ignore fetch errors (rate limits, etc)
}
}
async ensureFreshSuggestions() {
if (this.lastSuggestionInput === (this.currentInputValue || "")) {
return;
}
cancel(this.searchTimer);
this.searchTimer = null;
await this.fetchSuggestions();
}
@action
async selectItem(item) {
const words = this.currentInputValue.split(/\s+/);
let newValue;
if (item.isSuggestion) {
// Replace the last word with the selected suggestion
words[words.length - 1] = item.name;
newValue = words.join(" ");
// Add space unless it's a filter that takes delimiters
if (
!newValue.endsWith(":") &&
(!item.delimiters || item.delimiters.length < 2)
) {
updatedInputValue += " ";
newValue += " ";
}
this.updateInput(updatedInputValue);
} else {
// Otherwise if the user is selecting a filter from the initial tips,
// we add a colon to the end of it as needed, and fire off the
// suggestion search based on the filter type.
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;
// Selecting a filter tip - add it to the input
words[words.length - 1] = item.name;
words[words.length - 1] = filterName;
if (!filterName.endsWith(":") && !item.delimiters?.length) {
// Don't add space if this filter uses delimiters
if (!item.name.endsWith(":") && !item.delimiters?.length) {
words[words.length - 1] += " ";
}
const updatedInputValue = words.join(" ");
this.updateInput(updatedInputValue);
const baseFilterName = item.name.replace(/^[-=]/, "").split(":")[0];
if (item.type) {
this.activeFilter = baseFilterName;
this.handleFilterSuggestionSearch(baseFilterName, "", item, prefix);
}
newValue = words.join(" ");
}
this.clearSelection();
this.inputElement.focus();
await this.updateInput(newValue);
this.inputElement?.focus();
}
@action
updateInput(updatedInputValue, refreshQuery = false) {
this.currentInputValue = updatedInputValue;
this.args.onChange(updatedInputValue, refreshQuery);
this.trackedMenuListData.filteredTips = this.filteredTips;
this.updateResults();
async updateInput(value, submitQuery = false) {
value ??= "";
this.currentInputValue = value;
this.clearSelection();
if (submitQuery) {
// Cancel pending searches before submitting
cancel(this.searchTimer);
this.args.onChange(value, true);
} else {
this.args.onChange(value, false);
await this.updateSuggestions();
}
}
@action
clearInput() {
this.updateInput("", true);
this.inputElement.focus();
this.inputElement?.focus();
}
@action
@ -379,6 +223,8 @@ export default class FilterNavigationMenu extends Component {
return;
}
await this.fetchSuggestions();
this.dMenuInstance = await this.menu.show(this.inputElement, {
identifier: "filter-navigation-menu-list",
component: FilterNavigationMenuList,
@ -387,12 +233,16 @@ export default class FilterNavigationMenu extends Component {
matchTriggerWidth: true,
visibilityOptimizer: VISIBILITY_OPTIMIZERS.AUTO_PLACEMENT,
});
if (!this.suggestions.length) {
this.dMenuInstance.close();
}
}
@action
handleKeydown(event) {
async handleKeydown(event) {
if (
this.filteredTips.length === 0 &&
this.suggestions.length === 0 &&
["ArrowDown", "ArrowUp", "Tab"].includes(event.key)
) {
return;
@ -401,102 +251,86 @@ export default class FilterNavigationMenu extends Component {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
this.selectedIndex = this.nothingSelected
? 0
: (this.selectedIndex + 1) % this.filteredTips.length;
this.navigateDown();
break;
case "ArrowUp":
event.preventDefault();
this.selectedIndex = this.nothingSelected
? this.filteredTips.length - 1
: (this.selectedIndex - 1 + this.filteredTips.length) %
this.filteredTips.length;
this.navigateUp();
break;
case "Tab":
event.preventDefault();
event.stopPropagation();
this.selectItem(
this.filteredTips[this.nothingSelected ? 0 : this.selectedIndex]
);
await this.ensureFreshSuggestions();
this.selectCurrent();
break;
case " ":
if (!this.dMenuInstance) {
this.openFilterMenu();
}
break;
case "Enter":
if (this.selectedIndex >= 0) {
event.preventDefault();
event.stopPropagation();
this.selectItem(this.filteredTips[this.selectedIndex]);
await this.ensureFreshSuggestions();
this.selectCurrent();
} else {
cancel(this.searchTimer);
if (!this.dMenuInstance) {
this.args.onChange(this.currentInputValue, true);
} else {
this.dMenuInstance.close().then(() => {
this.args.onChange(this.currentInputValue, true);
});
}
this.submitQuery();
}
break;
case "Escape":
this.dMenuInstance?.close();
break;
}
}
async #performFilterSuggestionSearch(filterName, valueText, tip, prefix) {
const type = tip.type;
let lastTerm = valueText;
let results = [];
let prevTerms = "";
let splitTerms;
navigateDown() {
if (this.nothingSelected) {
this.selectedIndex = 0;
} else {
this.selectedIndex = (this.selectedIndex + 1) % this.suggestions.length;
}
this.trackedMenuListData.selectedIndex = this.selectedIndex;
}
if (tip.delimiters) {
const delimiters = tip.delimiters.map((s) => s.name);
splitTerms = lastTerm.split(new RegExp(`[${delimiters.join("")}]`));
lastTerm = splitTerms[splitTerms.length - 1];
prevTerms =
lastTerm === "" ? valueText : valueText.slice(0, -lastTerm.length);
navigateUp() {
if (this.nothingSelected) {
this.selectedIndex = this.suggestions.length - 1;
} else {
this.selectedIndex =
(this.selectedIndex - 1 + this.suggestions.length) %
this.suggestions.length;
}
this.trackedMenuListData.selectedIndex = this.selectedIndex;
}
selectCurrent() {
const index = this.nothingSelected ? 0 : this.selectedIndex;
if (this.suggestions[index]) {
this.selectItem(this.suggestions[index]);
}
}
async submitQuery() {
cancel(this.searchTimer);
if (this.dMenuInstance) {
await this.dMenuInstance.close();
}
lastTerm = (lastTerm || "").toLowerCase().trim();
this.args.onChange(this.currentInputValue, true);
}
results = await FilterSuggestions.getFilterSuggestionsByType(
type,
prefix,
filterName,
prevTerms,
lastTerm,
{ site: this.site }
);
if (tip.delimiters) {
let lastMatches = false;
results.forEach((result) => (result.delimiters = tip.delimiters));
results = results.filter((result) => {
lastMatches ||= lastTerm === result.term;
return !splitTerms.includes(result.term);
});
if (lastMatches) {
tip.delimiters.forEach((delimiter) => {
results.push({
name: `${prefix}${filterName}:${prevTerms}${lastTerm}${delimiter.name}`,
description: delimiter.description,
isSuggestion: true,
delimiters: tip.delimiters,
});
});
}
@action
syncFromInitialValue() {
if (this.currentInputValue !== this.args.initialInputValue) {
this.currentInputValue = this.args.initialInputValue || "";
}
this.filterSuggessionResults = results;
this.trackedMenuListData.filteredTips = this.filteredTips;
}
<template>
@ -514,6 +348,7 @@ export default class FilterNavigationMenu extends Component {
id="topic-query-filter-input"
autocomplete="off"
placeholder={{i18n "filter.placeholder"}}
{{didUpdate this.syncFromInitialValue @initialInputValue}}
/>
{{#if this.currentInputValue}}

View file

@ -1,82 +1,359 @@
import { ajax } from "discourse/lib/ajax";
import { i18n } from "discourse-i18n";
const MAX_RESULTS = 20;
export default class FilterSuggestions {
static async getFilterSuggestionsByType(
type,
prefix,
filterName,
prevTerms,
lastTerm,
deps = {}
) {
switch (type) {
/**
* Main entry point - takes raw input text and available tips, returns suggestions
* @param {string} text - The full input text from the user
* @param {Array} tips - Available filter tips from the server
* @param {Object} context - Additional context (site data, etc.)
* @returns {Object} { suggestions: Array, activeFilter: string|null }
*/
static async getSuggestions(text, tips = [], context = {}) {
const parser = new FilterParser(text);
const lastSegment = parser.getLastSegment();
if (!lastSegment.word) {
return {
suggestions: this.getTopLevelTips(tips),
activeFilter: null,
};
}
if (lastSegment.filterName && lastSegment.hasColon) {
const tip = this.findTipForFilter(lastSegment.filterName, tips);
if (tip?.type) {
const suggestions = await this.getFilterSuggestionsByType(
tip,
lastSegment,
context
);
return {
suggestions,
activeFilter: lastSegment.filterName,
};
}
}
// Otherwise, filter the available tips based on what user typed
return {
suggestions: this.filterTips(tips, lastSegment.word, lastSegment.prefix),
activeFilter: null,
};
}
static getTopLevelTips(tips) {
return tips
.filter((tip) => tip.priority === 1)
.sort((a, b) => {
// First by priority (descending)
const priorityDiff = (b.priority || 0) - (a.priority || 0);
if (priorityDiff !== 0) {
return priorityDiff;
}
// Then alphabetically
return a.name.localeCompare(b.name);
})
.slice(0, MAX_RESULTS);
}
static findTipForFilter(filterName, tips) {
return tips.find((tip) => {
const normalize = (str) => (str ? str.replace(/:$/, "") : str);
return (
normalize(tip.name) === filterName ||
normalize(tip.alias) === filterName
);
});
}
static filterTips(tips, searchTerm, prefix = "") {
const filtered = [];
searchTerm = searchTerm.toLowerCase();
// remove prefix from search term
if (prefix) {
searchTerm = searchTerm.replace(prefix, "");
}
for (const tip of tips) {
if (filtered.length >= MAX_RESULTS) {
break;
}
const tipName = tip.name;
let matches =
tipName.includes(searchTerm) ||
(tip.alias && tip.alias.includes(searchTerm));
if (tipName === searchTerm) {
matches = false;
}
if (!matches) {
continue;
}
if (tip.prefixes) {
if (prefix) {
const matchingPrefix = tip.prefixes.find((p) => p.name === prefix);
if (matchingPrefix) {
filtered.push({
...tip,
name: `${prefix}${tip.name}`,
description: matchingPrefix.description || tip.description,
isSuggestion: true,
});
}
} else {
filtered.push(tip);
tip.prefixes.forEach((pfx) => {
filtered.push({
...tip,
name: `${pfx.name}${tip.name}`,
description: pfx.description || tip.description,
isSuggestion: true,
});
});
}
} else {
filtered.push({
...tip,
name: `${prefix}${tip.name}`,
isSuggestion: true,
});
}
}
return filtered.sort((a, b) => {
const aStarts = a.name.toLowerCase().startsWith(searchTerm);
const bStarts = b.name.toLowerCase().startsWith(searchTerm);
if (aStarts && !bStarts) {
return -1;
}
if (!aStarts && bStarts) {
return 1;
}
return a.name.length - b.name.length;
});
}
static async getFilterSuggestionsByType(tip, segment, context) {
const suggester = new FilterTypeValueSuggester(tip, segment, context);
switch (tip.type) {
case "category":
return await FilterSuggestions.getCategorySuggestions(
deps.site,
prefix,
filterName,
prevTerms,
lastTerm
);
return await suggester.getCategorySuggestions();
case "tag":
return await FilterSuggestions.getTagSuggestions(
prefix,
filterName,
prevTerms,
lastTerm
);
return await suggester.getTagSuggestions();
case "username":
return await FilterSuggestions.getUserSuggestions(
prefix,
filterName,
prevTerms,
lastTerm
);
return await suggester.getUserSuggestions();
case "username_group_list":
return await suggester.getUsernameGroupListSuggestions();
case "date":
return await FilterSuggestions.getDateSuggestions(
prefix,
filterName,
prevTerms,
lastTerm
);
return suggester.getDateSuggestions();
case "number":
return await FilterSuggestions.getNumberSuggestions(
prefix,
filterName,
prevTerms,
lastTerm
);
return suggester.getNumberSuggestions();
default:
return [];
}
}
}
/**
* Parses filter input text into structured segments
*/
class FilterParser {
constructor(text) {
this.text = text || "";
this.segments = this.parse();
}
parse() {
const words = this.text.split(/\s+/).filter(Boolean);
this.endsWithSpace = this.text.endsWith(" ");
return words.map((word) => this.parseWord(word));
}
parseWord(word) {
const prefix = this.extractPrefix(word);
const withoutPrefix = word.substring(prefix.length);
const colonIndex = withoutPrefix.indexOf(":");
if (colonIndex > 0) {
const filterName = withoutPrefix.substring(0, colonIndex);
const value = withoutPrefix.substring(colonIndex + 1);
return {
word,
prefix,
filterName,
value,
hasColon: true,
};
}
return {
word,
prefix,
filterName: null,
value: null,
hasColon: false,
};
}
extractPrefix(word) {
const match = word.match(/^(-=|=-|-|=)/);
return match ? match[0] : "";
}
getLastSegment() {
const empty = {
word: "",
prefix: "",
filterName: null,
value: null,
hasColon: false,
};
if (this.endsWithSpace) {
return empty;
}
return this.segments[this.segments.length - 1] || empty;
}
}
class FilterTypeValueSuggester {
constructor(tip, segment, context) {
this.tip = tip;
this.segment = segment;
this.context = context;
this.prefix = segment.prefix || "";
this.filterName = segment.filterName;
this.parseMultiValue();
}
parseMultiValue() {
const value = this.segment.value || "";
if (this.tip.delimiters) {
const delimiterPattern = new RegExp(
`[${this.tip.delimiters.map((d) => this.escapeRegex(d.name)).join("")}]`
);
const parts = value.split(delimiterPattern);
this.previousValues = parts
.slice(0, -1)
.map((p) => p.trim())
.filter(Boolean);
this.searchTerm = parts.at(-1).trim();
this.valuePrefix = value.substring(
0,
value.length - this.searchTerm.length
);
} else {
this.previousValues = [];
this.searchTerm = value;
this.valuePrefix = "";
}
}
static async getTagSuggestions(prefix, filterName, prevTerms, lastTerm) {
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
buildSuggestionName(term) {
return `${this.prefix}${this.filterName}:${this.valuePrefix}${term}`;
}
prepareDelimiterSuggestions(results) {
if (!this.tip.delimiters || this.tip.delimiters.length === 0) {
return results;
}
results.forEach((r) => (r.delimiters = this.tip.delimiters));
const used = new Set(this.previousValues.map((v) => v.toLowerCase()));
results = results.filter((r) => !used.has((r.term || "").toLowerCase()));
const searchLower = (this.searchTerm || "").toLowerCase();
if (
searchLower &&
results.some((r) => (r.term || "").toLowerCase() === searchLower)
) {
this.tip.delimiters.forEach((delimiter) => {
results.push({
name: this.buildSuggestionName(`${this.searchTerm}${delimiter.name}`),
description: delimiter.description,
isSuggestion: true,
delimiters: this.tip.delimiters,
});
});
}
return results;
}
async getCategorySuggestions() {
const categories = this.context.site?.categories || [];
const searchLower = this.searchTerm.toLowerCase();
return categories
.filter((cat) => {
const name = cat.name.toLowerCase();
const slug = cat.slug.toLowerCase();
return (
!searchLower ||
name.includes(searchLower) ||
slug.includes(searchLower)
);
})
.slice(0, 10)
.map((cat) => ({
name: this.buildSuggestionName(cat.slug),
description: cat.name,
term: cat.slug,
category: cat,
isSuggestion: true,
}));
}
async getTagSuggestions() {
try {
const response = await ajax("/tags/filter/search.json", {
data: { q: lastTerm || "", limit: 5 },
data: { q: this.searchTerm || "", limit: 5 },
});
return response.results.map((tag) => ({
name: `${prefix}${filterName}:${prevTerms}${tag.name}`,
let results = response.results.map((tag) => ({
name: this.buildSuggestionName(tag.name),
description: `${tag.count}`,
isSuggestion: true,
term: tag.name,
isSuggestion: true,
}));
results = this.prepareDelimiterSuggestions(results);
return results;
} catch {
return [];
}
}
static async getUserSuggestions(prefix, filterName, prevTerms, lastTerm) {
async getUserSuggestions() {
try {
const data = { limit: 10 };
if ((lastTerm || "").length > 0) {
data.term = lastTerm;
if (this.searchTerm) {
data.term = this.searchTerm;
} else {
data.last_seen_users = true;
}
const response = await ajax("/u/search/users.json", { data });
return response.users.map((user) => ({
name: `${prefix}${filterName}:${prevTerms}${user.username}`,
name: this.buildSuggestionName(user.username),
description: user.name || "",
term: user.username,
isSuggestion: true,
@ -86,57 +363,128 @@ export default class FilterSuggestions {
}
}
static async getCategorySuggestions(
site,
prefix,
filterName,
prevTerms,
lastTerm
) {
const categories = site.categories || [];
return categories
.filter((category) => {
const name = category.name.toLowerCase();
const slug = category.slug.toLowerCase();
return name.includes(lastTerm) || slug.includes(lastTerm);
async getUsernameGroupListSuggestions() {
const usedTerms = new Set(this.previousValues.map((v) => v.toLowerCase()));
let suggestions = [];
// Add special entries (*, nobody, etc.) if no values selected yet
if (this.tip.extra_entries && usedTerms.size === 0) {
suggestions = this.tip.extra_entries
.filter((entry) => {
if (!this.searchTerm) {
return true;
}
const searchLower = this.searchTerm.toLowerCase();
return (
entry.name.toLowerCase().includes(searchLower) ||
entry.description.toLowerCase().includes(searchLower)
);
})
.map((entry) => ({
name: this.buildSuggestionName(entry.name),
description: entry.description,
term: entry.name,
isSuggestion: true,
}));
}
try {
const userData = { limit: 10 };
if (this.searchTerm) {
userData.term = this.searchTerm;
} else {
userData.last_seen_users = true;
}
const userResponse = await ajax("/u/search/users.json", {
data: userData,
});
const userSuggestions = userResponse.users
.filter((user) => !usedTerms.has(user.username.toLowerCase()))
.map((user) => ({
name: this.buildSuggestionName(user.username),
description: user.name || "",
term: user.username,
isSuggestion: true,
}));
suggestions = suggestions.concat(userSuggestions);
} catch {
// Continue without user suggestions
}
// Add group suggestions
try {
const groupData = { limit: 5 };
if (this.searchTerm) {
groupData.term = this.searchTerm;
}
const groupResponse = await ajax("/groups/search.json", {
data: groupData,
});
const groupSuggestions = groupResponse
.filter((group) => !usedTerms.has(group.name.toLowerCase()))
.map((group) => ({
name: this.buildSuggestionName(group.name),
description: group.full_name || group.name,
term: group.name,
isSuggestion: true,
}));
suggestions = suggestions.concat(groupSuggestions);
} catch {
// Continue without group suggestions
}
suggestions = this.prepareDelimiterSuggestions(suggestions);
return suggestions
.sort((a, b) => {
const searchLower = this.searchTerm?.toLowerCase();
const aExact = a.term.toLowerCase() === searchLower;
const bExact = b.term.toLowerCase() === searchLower;
if (aExact && !bExact) {
return -1;
}
if (!aExact && bExact) {
return 1;
}
return 0;
})
.slice(0, 10)
.map((category) => ({
name: `${prefix}${filterName}:${prevTerms}${category.slug}`,
description: `${category.name}`,
isSuggestion: true,
term: category.slug,
category,
}));
.slice(0, MAX_RESULTS);
}
static async getDateSuggestions(prefix, filterName, prevTerms, lastTerm) {
const dateOptions = [
getDateSuggestions() {
const options = [
{ 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 options
.filter((opt) => {
if (!this.searchTerm) {
return true;
}
const desc = i18n(`filter.description.days.${opt.key}`);
return (
!lastTerm ||
option.value.includes(lastTerm) ||
description.toLowerCase().includes(lastTerm.toLowerCase())
opt.value.includes(this.searchTerm) ||
desc.toLowerCase().includes(this.searchTerm.toLowerCase())
);
})
.map((option) => ({
name: `${prefix}${filterName}:${prevTerms}${option.value}`,
description: i18n(`filter.description.${option.key}`),
.map((opt) => ({
name: this.buildSuggestionName(opt.value),
description: i18n(`filter.description.${opt.key}`),
term: opt.value,
isSuggestion: true,
term: option.value,
}));
}
static async getNumberSuggestions(prefix, filterName, prevTerms, lastTerm) {
const numberOptions = [
getNumberSuggestions() {
const options = [
{ value: "0" },
{ value: "1" },
{ value: "5" },
@ -144,12 +492,12 @@ export default class FilterSuggestions {
{ value: "20" },
];
return numberOptions
.filter((option) => !lastTerm || option.value.includes(lastTerm))
.map((option) => ({
name: `${prefix}${filterName}:${prevTerms}${option.value}`,
return options
.filter((opt) => !this.searchTerm || opt.value.includes(this.searchTerm))
.map((opt) => ({
name: this.buildSuggestionName(opt.value),
term: opt.value,
isSuggestion: true,
term: option.value,
}));
}
}

View file

@ -23,6 +23,7 @@ module(
priority: 1,
type: "category",
},
{ name: "status:", description: "Filter status", priority: 1 },
{
name: "tag:",
description: "Filter tag",
@ -33,7 +34,6 @@ module(
{ name: ",", description: "add" },
],
},
{ name: "status:", description: "Filter status", priority: 1 },
{ name: "status:open", description: "Open topics" },
];
@ -91,7 +91,7 @@ module(
.dom(
".filter-navigation__tip-item.--selected .filter-navigation__tip-name"
)
.hasText("tag:");
.hasText("status:");
await triggerKeyEvent("#topic-query-filter-input", "keydown", "ArrowUp");
assert

View file

@ -0,0 +1,283 @@
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import FilterSuggestions from "discourse/lib/filter-suggestions";
import pretender, { response } from "discourse/tests/helpers/create-pretender";
function buildTips() {
return [
{
name: "category:",
description: "Pick a category",
type: "category",
priority: 1,
prefixes: [
{ name: "-", description: "exclude category" },
{ name: "=", description: "no subcategories" },
],
},
{
name: "tag:",
description: "Pick tags",
type: "tag",
priority: 1,
delimiters: [
{ name: ",", description: "add" },
{ name: "+", description: "intersect" },
],
},
{ name: "status:", description: "Pick a status" },
{
name: "status:solved",
description: "Solved topics",
priority: 1,
},
{
name: "status:unsolved",
description: "Unsolved topics",
priority: 1,
},
{
name: "after:",
description: "Filter by date",
type: "date",
priority: 1,
},
{ name: "min:", description: "Min number", type: "number" },
];
}
function buildContext() {
return {
site: {
categories: [
{ id: 1, name: "Support", slug: "support" },
{ id: 2, name: "Meta", slug: "meta" },
],
},
};
}
module("Unit | Utility | FilterSuggestions", function (hooks) {
setupTest(hooks);
test("top-level tips for empty input are priority=1 and sorted by name", async function (assert) {
const tips = buildTips();
const { suggestions } = await FilterSuggestions.getSuggestions(
"",
tips,
buildContext()
);
const names = suggestions.map((s) => s.name);
assert.deepEqual(
names,
["after:", "category:", "status:solved", "status:unsolved", "tag:"],
"returns only top-level tips sorted alphabetically within same priority"
);
});
test("-cat finds -categories", async function (assert) {
const tips = buildTips();
const { suggestions, activeFilter } =
await FilterSuggestions.getSuggestions("-cat", tips, buildContext());
const names = suggestions.map((s) => s.name);
assert.deepEqual(names, ["-category:"], "returns only -category: for -cat");
assert.strictEqual(activeFilter, null, "activeFilter is not set yet");
});
test("trailing space switches back to top-level tips", async function (assert) {
const tips = buildTips();
const baseline = await FilterSuggestions.getSuggestions(
"",
tips,
buildContext()
);
const { suggestions, activeFilter } =
await FilterSuggestions.getSuggestions(
"category:support ",
tips,
buildContext()
);
assert.strictEqual(
activeFilter,
null,
"no active filter after trailing space"
);
assert.deepEqual(
suggestions.map((s) => s.name),
baseline.suggestions.map((s) => s.name),
"shows top-level tips after trailing space"
);
});
test("prefix filters works as expected", async function (assert) {
const tips = buildTips();
const { suggestions, activeFilter } =
await FilterSuggestions.getSuggestions("cat", tips, buildContext());
const names = suggestions.map((s) => s.name);
assert.deepEqual(names, ["category:", "-category:", "=category:"]);
assert.strictEqual(activeFilter, null, "activeFilter is not set yet");
});
test("does not show suggestions for already matched filters", async function (assert) {
const tips = buildTips();
const { suggestions, activeFilter } =
await FilterSuggestions.getSuggestions("status:", tips, buildContext());
const names = suggestions.map((s) => s.name);
assert.deepEqual(
names,
["status:solved", "status:unsolved"],
"shows only status suggestions"
);
assert.strictEqual(activeFilter, null, "activeFilter is not set yet");
});
test("category value suggestions match category slug and set activeFilter", async function (assert) {
const tips = buildTips();
const { suggestions, activeFilter } =
await FilterSuggestions.getSuggestions(
"category:sup",
tips,
buildContext()
);
const slugs = suggestions.map((s) => s.term);
assert.strictEqual(activeFilter, "category", "activeFilter is category");
assert.true(slugs.includes("support"), "suggests matching category");
assert.true(suggestions[0].isSuggestion, "marks as suggestion items");
});
test("date suggestions filter by value and description", async function (assert) {
const tips = buildTips();
const valueFiltered = await FilterSuggestions.getSuggestions(
"after:7",
tips,
buildContext()
);
assert.true(
valueFiltered.suggestions.some((s) => s.term === "7"),
"includes 7 when filtering by value"
);
const descFiltered = await FilterSuggestions.getSuggestions(
"after:week",
tips,
buildContext()
);
assert.true(
descFiltered.suggestions.length > 0,
"filters by description substring"
);
});
test("number suggestions include defaults and filter by partial", async function (assert) {
const tips = buildTips();
const defaults = await FilterSuggestions.getSuggestions(
"min:",
tips,
buildContext()
);
assert.deepEqual(
defaults.suggestions.map((s) => s.term),
["0", "1", "5", "10", "20"],
"default number options are provided"
);
const filtered = await FilterSuggestions.getSuggestions(
"min:1",
tips,
buildContext()
);
assert.true(
filtered.suggestions.some((s) => s.term === "1"),
"includes number matching partial"
);
});
test("filtering tips by partial name returns matching tips", async function (assert) {
const tips = buildTips();
const { suggestions } = await FilterSuggestions.getSuggestions(
"sta",
tips,
buildContext()
);
const names = suggestions.map((s) => s.name);
assert.true(names.includes("status:solved"), "includes status:solved");
assert.true(names.includes("status:unsolved"), "includes status:unsolved");
});
test("top-level tips are limited to 20", async function (assert) {
const many = [];
for (let i = 0; i < 30; i++) {
many.push({
name: `z${i}:`,
description: "x",
type: "text",
priority: 1,
});
}
const { suggestions } = await FilterSuggestions.getSuggestions(
"",
many,
buildContext()
);
assert.strictEqual(suggestions.length, 20, "limits results to 20");
});
test("username_group_list suggests users and filters out already-used values", async function (assert) {
pretender.get("/u/search/users.json", () =>
response({
users: [
{ username: "sam", name: "Sam" },
{ username: "cam", name: "Cam" },
],
})
);
pretender.get("/groups/search.json", () => response([]));
const tips = [
{
name: "assigned:",
description: "Assigned",
type: "username_group_list",
priority: 1,
delimiters: [{ name: ",", description: "add" }],
},
];
const first = await FilterSuggestions.getSuggestions(
"assigned:am",
tips,
buildContext()
);
const firstTerms = first.suggestions.map((s) => s.term);
assert.true(firstTerms.includes("sam"), "finds user sam");
assert.true(firstTerms.includes("cam"), "finds user cam");
const second = await FilterSuggestions.getSuggestions(
"assigned:cam,",
tips,
buildContext()
);
const secondTerms = second.suggestions.map((s) => s.term);
assert.true(secondTerms.includes("sam"), "sam remains available");
assert.false(
secondTerms.includes("cam"),
"cam is excluded after being used"
);
});
});

View file

@ -2902,6 +2902,9 @@ en:
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"
in_new: "Show topics that are new or have new replies"
in_new_replies: "Show topics with new replies"
in_new_topics: "Show new topics"
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)"

View file

@ -305,6 +305,7 @@ class TopicQuery
TopicsFilter.new(
guardian: @guardian,
scope: latest_results(include_muted: false, skip_ordering: true),
loaded_topic_users_reference: @guardian.authenticated?,
)
results = topics_filter.filter_from_query_string(@options[:q])

View file

@ -3,8 +3,9 @@
class TopicsFilter
attr_reader :topic_notification_levels
def initialize(guardian:, scope: Topic.all)
@guardian = guardian
def initialize(guardian:, scope: Topic.all, loaded_topic_users_reference: false)
@loaded_topic_users_reference = loaded_topic_users_reference
@guardian = guardian || Guardian.new
@scope = scope
@topic_notification_levels = Set.new
end
@ -272,6 +273,9 @@ class TopicsFilter
name: "in:watching_first_post",
description: I18n.t("filter.description.in_watching_first_post"),
},
{ name: "in:new", description: I18n.t("filter.description.in_new") },
{ name: "in:new-replies", description: I18n.t("filter.description.in_new_replies") },
{ name: "in:new-topics", description: I18n.t("filter.description.in_new_topics") },
],
)
end
@ -301,7 +305,8 @@ class TopicsFilter
)
end
results
# this modifier allows custom plugins to add UI tips in the /filter route
DiscoursePluginRegistry.apply_modifier(:topics_filter_options, results, guardian)
end
private
@ -474,9 +479,37 @@ class TopicsFilter
)
end
def apply_custom_filter!(scope:, filter_name:, values:)
values.dup.each do |value|
custom_key = "#{filter_name}:#{value}"
if custom_match =
DiscoursePluginRegistry.custom_filter_mappings.find { |hash| hash.key?(custom_key) }
scope = custom_match[custom_key].call(scope, custom_key, @guardian) || scope
values.delete(value)
end
end
scope
end
def ensure_topic_users_reference!
if @guardian.authenticated?
if !@loaded_topic_users_reference
@scope =
@scope.joins(
"LEFT JOIN topic_users tu ON tu.topic_id = topics.id
AND tu.user_id = #{@guardian.user.id.to_i}",
)
@loaded_topic_users_reference = true
end
end
end
def filter_in(values:)
values.uniq!
# handle edge case of comma-separated values
values.map! { |value| value.split(",") }.flatten!
if values.delete("pinned")
@scope =
@scope.where(
@ -485,14 +518,39 @@ class TopicsFilter
)
end
if @guardian.user
if values.delete("bookmarked")
@scope = apply_custom_filter!(scope: @scope, filter_name: "in", values:)
if @guardian.authenticated?
if values.delete("new-topics")
ensure_topic_users_reference!
@scope =
@scope.joins(:topic_users).where(
"topic_users.bookmarked AND topic_users.user_id = ?",
@guardian.user.id,
TopicQuery.new_filter(
@scope,
treat_as_new_topic_start_date: @guardian.user.user_option.treat_as_new_topic_start_date,
)
end
if values.delete("new-replies")
ensure_topic_users_reference!
@scope = TopicQuery.unread_filter(@scope, whisperer: @guardian.user.whisperer?)
end
if values.delete("new")
ensure_topic_users_reference!
new_topics =
TopicQuery.new_filter(
@scope,
treat_as_new_topic_start_date: @guardian.user.user_option.treat_as_new_topic_start_date,
)
unread_topics = TopicQuery.unread_filter(@scope, whisperer: @guardian.user.whisperer?)
base = @scope
base.joins_values.dup.concat(new_topics.joins_values, unread_topics.joins_values)
base.joins_values.uniq!
@scope = base.merge(new_topics.or(unread_topics))
end
if values.delete("bookmarked")
ensure_topic_users_reference!
@scope = @scope.where("tu.bookmarked")
end
if values.present?
values.each do |value|
@ -505,12 +563,14 @@ class TopicsFilter
end
end
@scope =
@scope.joins(:topic_users).where(
"topic_users.notification_level IN (:topic_notification_levels) AND topic_users.user_id = :user_id",
topic_notification_levels: @topic_notification_levels.to_a,
user_id: @guardian.user.id,
)
if @topic_notification_levels.present?
ensure_topic_users_reference!
@scope =
@scope.where(
"tu.notification_level IN (:topic_notification_levels)",
topic_notification_levels: @topic_notification_levels.to_a,
)
end
end
elsif values.present?
@scope = @scope.none
@ -695,15 +755,14 @@ class TopicsFilter
column: "topics.views",
},
"read" => {
column: "tu1.last_visited_at",
column: "tu.last_visited_at",
scope: -> do
if @guardian.user
@scope.joins(
"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")
if @guardian.authenticated?
ensure_topic_users_reference!
@scope.where("tu.last_visited_at IS NOT NULL")
else
# make sure this works for anon
@scope.joins("LEFT JOIN topic_users tu1 ON 1 = 0")
# make sure this works for anon (particularly selection)
@scope.joins("LEFT JOIN topic_users tu ON 1 = 0")
end
end,
},

View file

@ -27,6 +27,11 @@ en:
discourse_assign:
assigned_to: "Topic assigned to @%{username}"
unassigned: "Topic was unassigned"
filter:
description:
assigned: "Filter topics assigned to a specific user or group"
nobody: "Filter topics that are not assigned to anyone"
anyone: "Filter topics assigned to anyone"
already_claimed: "That topic has already been claimed."
already_assigned: "Topic is already assigned to @%{username}"
too_many_assigns: "@%{username} has already reached the maximum number of assigned topics (%{max})."

View file

@ -87,7 +87,7 @@ after_initialize do
add_to_class(:user, :can_assign?) do
return @can_assign if defined?(@can_assign)
allowed_groups = SiteSetting.assign_allowed_on_groups.split("|").compact
allowed_groups = SiteSetting.assign_allowed_on_groups_map
@can_assign = admin? || (allowed_groups.present? && groups.where(id: allowed_groups).exists?)
end
@ -892,19 +892,67 @@ after_initialize do
add_filter_custom_filter("assigned") do |scope, filter_values, guardian|
next if !guardian.can_assign? || filter_values.blank?
user_or_group_name = filter_values.compact.first
# Handle multiple comma-separated values (user1,group1,user2)
names =
filter_values.compact.flat_map { |value| value.to_s.split(",") }.map(&:strip).reject(&:blank?)
next if user_or_group_name.blank?
next if names.blank?
if user_id = User.find_by_username(user_or_group_name)&.id
scope.where(<<~SQL, user_id)
topics.id IN (SELECT a.topic_id FROM assignments a WHERE a.assigned_to_id = ? AND a.assigned_to_type = 'User' AND a.active)
SQL
elsif group_id = Group.find_by(name: user_or_group_name)&.id
scope.where(<<~SQL, group_id)
topics.id IN (SELECT a.topic_id FROM assignments a WHERE a.assigned_to_id = ? AND a.assigned_to_type = 'Group' AND a.active)
SQL
if names.include?("nobody")
next scope.where("topics.id NOT IN (SELECT a.topic_id FROM assignments a WHERE a.active)")
end
if names.include?("*")
next scope.where("topics.id IN (SELECT a.topic_id FROM assignments a WHERE a.active)")
end
found_names, user_ids =
User.where(username_lower: names.map(&:downcase)).pluck(:username, :id).transpose
found_names ||= []
user_ids ||= []
# a bit edge casey cause we have username_lower for users but not for groups
# we share a namespace though so in practice this is ok
remaining_names = names - found_names
group_ids = []
group_ids.concat(Group.where(name: remaining_names).pluck(:id)) if remaining_names.present?
next scope.none if user_ids.empty? && group_ids.empty?
assignment_query = Assignment.none # needed cause we are adding .or later
if user_ids.present?
assignment_query =
assignment_query.or(
Assignment.active.where(assigned_to_type: "User", assigned_to_id: user_ids),
)
end
if group_ids.present?
assignment_query =
assignment_query.or(
Assignment.active.where(assigned_to_type: "Group", assigned_to_id: group_ids),
)
end
scope.where(id: assignment_query.select(:topic_id))
end
register_modifier(:topics_filter_options) do |results, guardian|
if guardian.can_assign?
results << {
name: "assigned:",
description: I18n.t("discourse_assign.filter.description.assigned"),
type: "username_group_list",
extra_entries: [
{ name: "nobody", description: I18n.t("discourse_assign.filter.description.nobody") },
{ name: "*", description: I18n.t("discourse_assign.filter.description.anyone") },
],
priority: 1,
}
end
results
end
register_search_advanced_filter(/in:assigned/) do |posts|

View file

@ -3,6 +3,45 @@
RSpec.describe DiscourseAssign do
before { SiteSetting.assign_enabled = true }
describe "discourse-assign topics_filter_options modifier" do
let(:user) { Fabricate(:user) }
before do
SiteSetting.assign_allowed_on_groups = Group::AUTO_GROUPS[:staff]
user.update!(admin: true)
end
it "adds assigned filter option for users who can assign" do
guardian = user.guardian
options = TopicsFilter.option_info(guardian)
assigned_option = options.find { |o| o[:name] == "assigned:" }
expect(assigned_option).to be_present
expect(assigned_option).to include(
name: "assigned:",
description: I18n.t("discourse_assign.filter.description.assigned"),
type: "username_group_list",
priority: 1,
)
end
it "does not add assigned filter option for users who cannot assign" do
regular_user = Fabricate(:user)
guardian = regular_user.guardian
options = TopicsFilter.option_info(guardian)
assigned_option = options.find { |o| o[:name] == "assigned:" }
expect(assigned_option).to be_nil
end
it "does not add assigned filter option for anonymous users" do
options = TopicsFilter.option_info(Guardian.new)
assigned_option = options.find { |o| o[:name] == "assigned:" }
expect(assigned_option).to be_nil
end
end
describe "Events" do
describe "on 'user_removed_from_group'" do
let(:group) { Fabricate(:group) }

View file

@ -409,6 +409,142 @@ describe ListController do
response.parsed_body.dig("topic_list", "topics").map { _1["id"] },
).to contain_exactly(topic_2.id)
end
it "filters topics by multiple assigned users and groups" do
add_to_assign_allowed_group(user)
user2 = Fabricate(:user)
add_to_assign_allowed_group(user2)
# Assign topics to different users and groups
Assigner.new(topic_1, admin).assign(user)
Assigner.new(topic_2, admin).assign(group)
Assigner.new(topic_3, admin).assign(user2)
# Test filtering by multiple users and groups (comma-separated)
get "/filter",
params: {
q: "assigned:#{user.username_lower},#{group.name},#{user2.username_lower}",
format: :json,
}
expect(response.status).to eq(200)
expect(
response.parsed_body.dig("topic_list", "topics").map { _1["id"] },
).to contain_exactly(topic_1.id, topic_2.id, topic_3.id)
end
it "filters topics by subset of multiple assigned users and groups" do
add_to_assign_allowed_group(user)
user2 = Fabricate(:user)
add_to_assign_allowed_group(user2)
# Assign topics to different users and groups
Assigner.new(topic_1, admin).assign(user)
Assigner.new(topic_2, admin).assign(group)
# topic_3 remains unassigned
# Test filtering by specific users only
get "/filter",
params: {
q: "assigned:#{user.username_lower},#{user2.username_lower}",
format: :json,
}
expect(response.status).to eq(200)
expect(
response.parsed_body.dig("topic_list", "topics").map { _1["id"] },
).to contain_exactly(topic_1.id)
end
it "filters topics by assigned:*" do
add_to_assign_allowed_group(user)
Assigner.new(topic_1, admin).assign(user)
Assigner.new(topic_2, admin).assign(group)
# topic_3 remains unassigned
get "/filter", params: { q: "assigned:*", format: :json }
expect(response.status).to eq(200)
expect(
response.parsed_body.dig("topic_list", "topics").map { _1["id"] },
).to contain_exactly(topic_1.id, topic_2.id)
end
it "filters topics by assigned:nobody" do
add_to_assign_allowed_group(user)
Assigner.new(topic_1, admin).assign(user)
Assigner.new(topic_2, admin).assign(group)
# topic_3 remains unassigned
get "/filter", params: { q: "assigned:nobody", format: :json }
expect(response.status).to eq(200)
expect(
response.parsed_body.dig("topic_list", "topics").map { _1["id"] },
).to contain_exactly(topic_3.id)
end
it "handles empty multi-value assigned filter gracefully" do
add_to_assign_allowed_group(user)
Assigner.new(topic_1, admin).assign(user)
# Test with empty values and spaces
get "/filter", params: { q: "assigned:, ,", format: :json }
expect(response.status).to eq(200)
expect(
response.parsed_body.dig("topic_list", "topics").map { _1["id"] },
).to contain_exactly(topic_1.id, topic_2.id, topic_3.id)
end
it "handles non-existent users and groups in multi-value filter" do
add_to_assign_allowed_group(user)
Assigner.new(topic_1, admin).assign(user)
# Test with mix of existing and non-existing users/groups
get "/filter",
params: {
q: "assigned:#{user.username_lower},nonexistent_user,nonexistent_group",
format: :json,
}
expect(response.status).to eq(200)
expect(
response.parsed_body.dig("topic_list", "topics").map { _1["id"] },
).to contain_exactly(topic_1.id)
end
end
describe "when user cannot assign" do
it "ignores the assigned:* filter" do
add_to_assign_allowed_group(admin)
Assigner.new(topic_1, admin).assign(admin)
get "/filter", params: { q: "assigned:*", format: :json }
expect(response.status).to eq(200)
expect(
response.parsed_body.dig("topic_list", "topics").map { _1["id"] },
).to contain_exactly(topic_1.id, topic_2.id, topic_3.id)
end
it "ignores the assigned:nobody filter" do
add_to_assign_allowed_group(admin)
Assigner.new(topic_1, admin).assign(admin)
get "/filter", params: { q: "assigned:nobody", format: :json }
expect(response.status).to eq(200)
expect(
response.parsed_body.dig("topic_list", "topics").map { _1["id"] },
).to contain_exactly(topic_1.id, topic_2.id, topic_3.id)
end
end
end
end

View file

@ -33,6 +33,10 @@ en:
no_solutions:
self: "You have no accepted solutions yet."
others: "No accepted solutions."
filter:
description:
solved: "Filter topics that have been solved"
unsolved: "Filter topics that have not been solved"
badges:
solved_1:

View file

@ -44,6 +44,20 @@ module DiscourseSolved
plugin.register_custom_filter_by_status("solved", &solved_callback)
plugin.register_custom_filter_by_status("unsolved", &unsolved_callback)
plugin.register_modifier(:topics_filter_options) do |results, guardian|
results << {
name: "status:solved",
description: I18n.t("solved.filter.description.solved"),
type: "text",
}
results << {
name: "status:unsolved",
description: I18n.t("solved.filter.description.unsolved"),
type: "text",
}
results
end
plugin.register_search_advanced_filter(/status:solved/, &solved_callback)
plugin.register_search_advanced_filter(/status:unsolved/, &unsolved_callback)

View file

@ -7,7 +7,7 @@ RSpec.describe "Managing Posts solved status" do
before { SiteSetting.allow_solved_on_all_topics = true }
describe "customer filters" do
describe "custom filters" do
before do
SiteSetting.allow_solved_on_all_topics = false
SiteSetting.enable_solved_tags = solvable_tag.name
@ -67,6 +67,42 @@ RSpec.describe "Managing Posts solved status" do
.pluck(:id),
).to contain_exactly(unsolved_in_category.id, unsolved_in_tag.id)
end
describe "topics_filter_options modifier" do
it "adds solved and unsolved filter options when plugin is enabled" do
options = TopicsFilter.option_info(Guardian.new)
solved_option = options.find { |o| o[:name] == "status:solved" }
unsolved_option = options.find { |o| o[:name] == "status:unsolved" }
expect(solved_option).to be_present
expect(solved_option).to include(
name: "status:solved",
description: I18n.t("solved.filter.description.solved"),
type: "text",
)
expect(unsolved_option).to be_present
expect(unsolved_option).to include(
name: "status:unsolved",
description: I18n.t("solved.filter.description.unsolved"),
type: "text",
)
end
it "does not add filter options when plugin is disabled" do
SiteSetting.solved_enabled = false
guardian = Guardian.new
options = TopicsFilter.option_info(guardian)
solved_option = options.find { |o| o[:name] == "status:solved" }
unsolved_option = options.find { |o| o[:name] == "status:unsolved" }
expect(solved_option).to be_nil
expect(unsolved_option).to be_nil
end
end
end
describe "search" do

View file

@ -31,7 +31,7 @@ RSpec.describe TopicsFilter do
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))
logged_in_options = TopicsFilter.option_info(user.guardian)
anon_option_names = anon_options.map { |o| o[:name] }.to_set
logged_in_option_names = logged_in_options.map { |o| o[:name] }.to_set
@ -50,6 +50,41 @@ RSpec.describe TopicsFilter do
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
it "should apply the topics_filter_options modifier for authenticated users" do
plugin_instance = Plugin::Instance.new
DiscoursePluginRegistry.register_modifier(
plugin_instance,
:topics_filter_options,
) do |results, guardian|
if guardian.authenticated?
results << {
name: "custom-filter:",
description: "A custom filter option from modifier",
type: "text",
}
end
results
end
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] }
logged_in_option_names = logged_in_options.map { |o| o[:name] }
expect(anon_option_names).not_to include("custom-filter:")
expect(logged_in_option_names).to include("custom-filter:")
custom_option = logged_in_options.find { |o| o[:name] == "custom-filter:" }
expect(custom_option).to include(
name: "custom-filter:",
description: "A custom filter option from modifier",
type: "text",
)
ensure
DiscoursePluginRegistry.reset_register!(:modifiers)
end
end
describe "#filter_from_query_string" do
@ -104,6 +139,60 @@ RSpec.describe TopicsFilter do
end
end
describe "new / unread operators" do
fab!(:user_for_new_filters) { Fabricate(:user) }
let!(:new_topic) { Fabricate(:topic) }
let!(:unread_topic) do
Fabricate(:topic, created_at: 2.days.ago).tap do |t|
Fabricate(:post, topic: t)
Fabricate(:post, topic: t)
TopicUser.update_last_read(user_for_new_filters, t.id, 1, 1, 0)
TopicUser.change(
user_for_new_filters.id,
t.id,
notification_level: TopicUser.notification_levels[:tracking],
)
end
end
before { user_for_new_filters.user_option.update!(new_topic_duration_minutes: 1.day.ago) }
it "in:new-topics returns only new topics" do
ids =
TopicsFilter
.new(guardian: user_for_new_filters.guardian)
.filter_from_query_string("in:new-topics")
.pluck(:id)
expect(ids).to contain_exactly(new_topic.id)
end
it "in:new-replies returns only unread (non-new) topics" do
ids =
TopicsFilter
.new(guardian: user_for_new_filters.guardian)
.filter_from_query_string("in:new-replies")
.where(id: [new_topic.id, unread_topic.id])
.pluck(:id)
expect(ids).to contain_exactly(unread_topic.id)
end
it "in:new returns union of new and unread topics" do
ids =
TopicsFilter
.new(guardian: user_for_new_filters.guardian)
.filter_from_query_string("in:new")
.where(id: [new_topic.id, unread_topic.id])
.pluck(:id)
expect(ids).to contain_exactly(new_topic.id, unread_topic.id)
end
it "anonymous user with in:new returns none" do
ids =
TopicsFilter.new(guardian: Guardian.new).filter_from_query_string("in:new").pluck(:id)
expect(ids).to be_empty
end
end
describe "when query string is `in:bookmarked`" do
fab!(:bookmark) do
BookmarkManager.new(user).create_for(
@ -1821,4 +1910,62 @@ RSpec.describe TopicsFilter do
end
end
end
describe "custom filter mappings for in: and status: operators" do
fab!(:topic)
fab!(:solved_topic) { Fabricate(:topic, closed: true) }
describe "custom in: filter" do
before do
plugin_instance = Plugin::Instance.new
DiscoursePluginRegistry.register_modifier(
plugin_instance,
:topics_filter_options,
) do |results, guardian|
results << { name: "in:solved", description: "Topics that are solved", type: "text" }
results
end
Plugin::Instance.new.add_filter_custom_filter(
"in:solved",
&->(scope, value, guardian) { scope.where(closed: true) }
)
end
after do
DiscoursePluginRegistry.reset_register!(:custom_filter_mappings)
DiscoursePluginRegistry.reset_register!(:modifiers)
end
it "applies custom in: filter" do
expect(
TopicsFilter
.new(guardian: Guardian.new(user))
.filter_from_query_string("in:solved")
.pluck(:id),
).to contain_exactly(solved_topic.id)
end
it "handles comma-separated values with custom filters" do
TopicUser.change(
user.id,
topic.id,
notification_level: TopicUser.notification_levels[:watching],
)
TopicUser.change(
user.id,
solved_topic.id,
notification_level: TopicUser.notification_levels[:watching],
)
expect(
TopicsFilter
.new(guardian: Guardian.new(user))
.filter_from_query_string("in:watching,solved")
.pluck(:id),
).to contain_exactly(solved_topic.id)
end
end
end
end