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