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:
parent
9f12dd28f9
commit
ee094b99cc
15 changed files with 1414 additions and 456 deletions
|
@ -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}}
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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)"
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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})."
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue