FEATURE: Generic hashtag autocomplete part 1 (#18592)
This commit adds a new `/hashtag/search` endpoint and both relevant JS and ruby plugin APIs to handle plugins adding their own data sources and priority orders for types of things to search when `#` is pressed. A `context` param is added to `setupHashtagAutocomplete` which a corresponding chat PR https://github.com/discourse/discourse-chat/pull/1302 will now use. The UI calls `registerHashtagSearchParam` for each context that will require a `#` search (e.g. the topic composer), for each type of record that the context needs to search for, as well as a priority order for that type. Core uses this call to add the `category` and `tag` data sources to the topic composer. The `register_hashtag_data_source` ruby plugin API call is for plugins to add a new data source for the hashtag searching endpoint, e.g. discourse-chat may add a `channel` data source. This functionality is hidden behind the `enable_experimental_hashtag_autocomplete` flag, except for the change to `setupHashtagAutocomplete` since only core and discourse-chat are using that function. Note this PR does **not** include required changes for hashtag lookup or new styling.
This commit is contained in:
parent
45bdfa1c84
commit
7c25597da2
|
@ -462,10 +462,15 @@ export default Component.extend(TextareaTextManipulation, {
|
|||
},
|
||||
|
||||
_applyCategoryHashtagAutocomplete() {
|
||||
setupHashtagAutocomplete(this._$textarea, this.siteSettings, (value) => {
|
||||
this.set("value", value);
|
||||
schedule("afterRender", this, this.focusTextArea);
|
||||
});
|
||||
setupHashtagAutocomplete(
|
||||
"topic-composer",
|
||||
this._$textarea,
|
||||
this.siteSettings,
|
||||
(value) => {
|
||||
this.set("value", value);
|
||||
schedule("afterRender", this, this.focusTextArea);
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
_applyEmojiAutocomplete($textarea) {
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||
|
||||
export default {
|
||||
name: "composer-hashtag-autocomplete",
|
||||
|
||||
initialize(container) {
|
||||
const siteSettings = container.lookup("service:site-settings");
|
||||
|
||||
withPluginApi("1.4.0", (api) => {
|
||||
if (siteSettings.enable_experimental_hashtag_autocomplete) {
|
||||
api.registerHashtagSearchParam("category", "topic-composer", 100);
|
||||
api.registerHashtagSearchParam("tag", "topic-composer", 50);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
|
@ -1,9 +1,7 @@
|
|||
import { hashtagTriggerRule } from "discourse/lib/hashtag-autocomplete";
|
||||
import deprecated from "discourse-common/lib/deprecated";
|
||||
|
||||
export const SEPARATOR = ":";
|
||||
import {
|
||||
caretPosition,
|
||||
caretRowCol,
|
||||
inCodeBlock,
|
||||
} from "discourse/lib/utilities";
|
||||
|
||||
export function replaceSpan($elem, categorySlug, categoryLink, type) {
|
||||
type = type ? ` data-type="${type}"` : "";
|
||||
|
@ -13,29 +11,12 @@ export function replaceSpan($elem, categorySlug, categoryLink, type) {
|
|||
}
|
||||
|
||||
export function categoryHashtagTriggerRule(textarea, opts) {
|
||||
const result = caretRowCol(textarea);
|
||||
const row = result.rowNum;
|
||||
let col = result.colNum;
|
||||
let line = textarea.value.split("\n")[row - 1];
|
||||
|
||||
if (opts && opts.backSpace) {
|
||||
col = col - 1;
|
||||
line = line.slice(0, line.length - 1);
|
||||
|
||||
// Don't trigger autocomplete when backspacing into a `#category |` => `#category|`
|
||||
if (/^#{1}\w+/.test(line)) {
|
||||
return false;
|
||||
deprecated(
|
||||
"categoryHashtagTriggerRule is being replaced by hashtagTriggerRule and the new hashtag-autocomplete plugin APIs",
|
||||
{
|
||||
since: "2.9.0.beta10",
|
||||
dropFrom: "3.0.0.beta1",
|
||||
}
|
||||
}
|
||||
|
||||
// Don't trigger autocomplete when ATX-style headers are used
|
||||
if (col < 6 && line.slice(0, col) === "#".repeat(col)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (inCodeBlock(textarea.value, caretPosition(textarea))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
);
|
||||
return hashtagTriggerRule(textarea, opts);
|
||||
}
|
||||
|
|
|
@ -1,39 +1,81 @@
|
|||
import { findRawTemplate } from "discourse-common/lib/raw-templates";
|
||||
|
||||
// TODO: (martin) Make a more generic version of these functions.
|
||||
import { categoryHashtagTriggerRule } from "discourse/lib/category-hashtags";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { INPUT_DELAY, isTesting } from "discourse-common/config/environment";
|
||||
import { cancel } from "@ember/runloop";
|
||||
import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import {
|
||||
caretPosition,
|
||||
caretRowCol,
|
||||
inCodeBlock,
|
||||
} from "discourse/lib/utilities";
|
||||
import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
|
||||
|
||||
export function setupHashtagAutocomplete(
|
||||
context,
|
||||
$textArea,
|
||||
siteSettings,
|
||||
afterComplete
|
||||
) {
|
||||
if (siteSettings.enable_experimental_hashtag_autocomplete) {
|
||||
_setupExperimental($textArea, siteSettings, afterComplete);
|
||||
_setupExperimental(context, $textArea, siteSettings, afterComplete);
|
||||
} else {
|
||||
_setup($textArea, siteSettings, afterComplete);
|
||||
}
|
||||
}
|
||||
|
||||
function _setupExperimental($textArea, siteSettings, afterComplete) {
|
||||
const contextBasedParams = {};
|
||||
|
||||
export function registerHashtagSearchParam(param, context, priority) {
|
||||
if (!contextBasedParams[context]) {
|
||||
contextBasedParams[context] = {};
|
||||
}
|
||||
contextBasedParams[context][param] = priority;
|
||||
}
|
||||
|
||||
export function hashtagTriggerRule(textarea, opts) {
|
||||
const result = caretRowCol(textarea);
|
||||
const row = result.rowNum;
|
||||
let col = result.colNum;
|
||||
let line = textarea.value.split("\n")[row - 1];
|
||||
|
||||
if (opts && opts.backSpace) {
|
||||
col = col - 1;
|
||||
line = line.slice(0, line.length - 1);
|
||||
|
||||
// Don't trigger autocomplete when backspacing into a `#category |` => `#category|`
|
||||
if (/^#{1}\w+/.test(line)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't trigger autocomplete when ATX-style headers are used
|
||||
if (col < 6 && line.slice(0, col) === "#".repeat(col)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (inCodeBlock(textarea.value, caretPosition(textarea))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function _setupExperimental(context, $textArea, siteSettings, afterComplete) {
|
||||
$textArea.autocomplete({
|
||||
template: findRawTemplate("hashtag-autocomplete"),
|
||||
key: "#",
|
||||
afterComplete,
|
||||
treatAsTextarea: $textArea[0].tagName === "INPUT",
|
||||
transformComplete: (obj) => {
|
||||
return obj.text;
|
||||
},
|
||||
transformComplete: (obj) => obj.ref,
|
||||
dataSource: (term) => {
|
||||
if (term.match(/\s/)) {
|
||||
return null;
|
||||
}
|
||||
return searchCategoryTag(term, siteSettings);
|
||||
},
|
||||
triggerRule: (textarea, opts) => {
|
||||
return categoryHashtagTriggerRule(textarea, opts);
|
||||
return _searchGeneric(term, siteSettings, context);
|
||||
},
|
||||
triggerRule: (textarea, opts) => hashtagTriggerRule(textarea, opts),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -42,17 +84,78 @@ function _setup($textArea, siteSettings, afterComplete) {
|
|||
template: findRawTemplate("category-tag-autocomplete"),
|
||||
key: "#",
|
||||
afterComplete,
|
||||
transformComplete: (obj) => {
|
||||
return obj.text;
|
||||
},
|
||||
transformComplete: (obj) => obj.text,
|
||||
dataSource: (term) => {
|
||||
if (term.match(/\s/)) {
|
||||
return null;
|
||||
}
|
||||
return searchCategoryTag(term, siteSettings);
|
||||
},
|
||||
triggerRule: (textarea, opts) => {
|
||||
return categoryHashtagTriggerRule(textarea, opts);
|
||||
},
|
||||
triggerRule: (textarea, opts) => hashtagTriggerRule(textarea, opts),
|
||||
});
|
||||
}
|
||||
|
||||
let searchCache = {};
|
||||
let searchCacheTime;
|
||||
let currentSearch;
|
||||
|
||||
function _updateSearchCache(term, results) {
|
||||
searchCache[term] = results;
|
||||
searchCacheTime = new Date();
|
||||
return results;
|
||||
}
|
||||
|
||||
function _searchGeneric(term, siteSettings, context) {
|
||||
if (currentSearch) {
|
||||
currentSearch.abort();
|
||||
currentSearch = null;
|
||||
}
|
||||
if (new Date() - searchCacheTime > 30000) {
|
||||
searchCache = {};
|
||||
}
|
||||
const cached = searchCache[term];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timeoutPromise = isTesting()
|
||||
? null
|
||||
: discourseLater(() => {
|
||||
resolve(CANCELLED_STATUS);
|
||||
}, 5000);
|
||||
|
||||
if (term === "") {
|
||||
return resolve(CANCELLED_STATUS);
|
||||
}
|
||||
|
||||
const debouncedSearch = (q, ctx, resultFunc) => {
|
||||
discourseDebounce(this, _searchRequest, q, ctx, resultFunc, INPUT_DELAY);
|
||||
};
|
||||
|
||||
debouncedSearch(term, context, (result) => {
|
||||
cancel(timeoutPromise);
|
||||
resolve(_updateSearchCache(term, result));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _searchRequest(term, context, resultFunc) {
|
||||
currentSearch = ajax("/hashtags/search.json", {
|
||||
data: { term, order: _sortedContextParams(context) },
|
||||
});
|
||||
currentSearch
|
||||
.then((r) => {
|
||||
resultFunc(r.results || CANCELLED_STATUS);
|
||||
})
|
||||
.finally(() => {
|
||||
currentSearch = null;
|
||||
});
|
||||
return currentSearch;
|
||||
}
|
||||
|
||||
function _sortedContextParams(context) {
|
||||
return Object.entries(contextBasedParams[context])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((item) => item[0]);
|
||||
}
|
||||
|
|
|
@ -104,6 +104,7 @@ import DiscourseURL from "discourse/lib/url";
|
|||
import { registerNotificationTypeRenderer } from "discourse/lib/notification-types-manager";
|
||||
import { registerUserMenuTab } from "discourse/lib/user-menu/tab";
|
||||
import { registerModelTransformer } from "discourse/lib/model-transformers";
|
||||
import { registerHashtagSearchParam } from "discourse/lib/hashtag-autocomplete";
|
||||
|
||||
// If you add any methods to the API ensure you bump up the version number
|
||||
// based on Semantic Versioning 2.0.0. Please update the changelog at
|
||||
|
@ -1981,6 +1982,35 @@ class PluginApi {
|
|||
registerModelTransformer(modelName, transformer) {
|
||||
registerModelTransformer(modelName, transformer);
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL. Do not use.
|
||||
*
|
||||
* When initiating a search inside the composer or other designated inputs
|
||||
* with the `#` key, we search records based on params registered with
|
||||
* this function, and order them by type using the priority here. Since
|
||||
* there can be many different inputs that use `#` and some may need to
|
||||
* weight different types higher in priority, we also require a context
|
||||
* parameter.
|
||||
*
|
||||
* For example, the topic composer may wish to search for categories
|
||||
* and tags, with categories appearing first in the results. The usage
|
||||
* looks like this:
|
||||
*
|
||||
* api.registerHashtagSearchParam("category", "topic-composer", 100);
|
||||
* api.registerHashtagSearchParam("tag", "topic-composer", 50);
|
||||
*
|
||||
* Additional types of records used for the hashtag search results
|
||||
* can be registered via the #register_hashtag_data_source plugin API
|
||||
* method.
|
||||
*
|
||||
* @param {string} param - The type of record to be fetched.
|
||||
* @param {string} context - Where the hashtag search is being initiated using `#`
|
||||
* @param {number} priority - Used for ordering types of records. Priority order is descending.
|
||||
*/
|
||||
registerHashtagSearchParam(param, context, priority) {
|
||||
registerHashtagSearchParam(param, context, priority);
|
||||
}
|
||||
}
|
||||
|
||||
// from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
<div class='autocomplete hashtag-autocomplete'>
|
||||
<ul>
|
||||
{{#each options as |option|}}
|
||||
<li>
|
||||
{{#if option.model}}
|
||||
<a href>{{category-link option.model allowUncategorized="true" link="false"}}</a>
|
||||
{{else}}
|
||||
<a href>{{d-icon 'tag'}}{{option.name}} x {{option.count}}</a>
|
||||
{{/if}}
|
||||
<li class="hashtag-autocomplete__option">
|
||||
<a class="hashtag-autocomplete__link" href>{{d-icon option.icon}}<span class="hashtag-autocomplete__text">{{option.text}}</span></a>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
|
|
@ -191,7 +191,7 @@ header .discourse-tag {
|
|||
}
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
.autocomplete.ac-category-or-tag {
|
||||
a {
|
||||
color: var(--primary-medium);
|
||||
}
|
||||
|
@ -203,6 +203,24 @@ header .discourse-tag {
|
|||
}
|
||||
}
|
||||
|
||||
.hashtag-autocomplete {
|
||||
.hashtag-autocomplete__option {
|
||||
.hashtag-autocomplete__link {
|
||||
align-items: center;
|
||||
color: var(--primary-medium);
|
||||
display: flex;
|
||||
|
||||
.d-icon {
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.hashtag-autocomplete__text {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tags-admin-menu {
|
||||
margin-top: 20px;
|
||||
ul {
|
||||
|
|
|
@ -3,46 +3,18 @@
|
|||
class HashtagsController < ApplicationController
|
||||
requires_login
|
||||
|
||||
HASHTAGS_PER_REQUEST = 20
|
||||
|
||||
def show
|
||||
raise Discourse::InvalidParameters.new(:slugs) if !params[:slugs].is_a?(Array)
|
||||
render json: HashtagAutocompleteService.new(guardian).lookup(params[:slugs])
|
||||
end
|
||||
|
||||
all_slugs = []
|
||||
tag_slugs = []
|
||||
def search
|
||||
params.require(:term)
|
||||
params.require(:order)
|
||||
raise Discourse::InvalidParameters.new(:order) if !params[:order].is_a?(Array)
|
||||
|
||||
params[:slugs][0..HASHTAGS_PER_REQUEST].each do |slug|
|
||||
if slug.end_with?(PrettyText::Helpers::TAG_HASHTAG_POSTFIX)
|
||||
tag_slugs << slug.chomp(PrettyText::Helpers::TAG_HASHTAG_POSTFIX)
|
||||
else
|
||||
all_slugs << slug
|
||||
end
|
||||
end
|
||||
results = HashtagAutocompleteService.new(guardian).search(params[:term], params[:order])
|
||||
|
||||
# Try to resolve hashtags as categories first
|
||||
category_slugs_and_ids = all_slugs.map { |slug| [slug, Category.query_from_hashtag_slug(slug)&.id] }.to_h
|
||||
category_ids_and_urls = Category
|
||||
.secured(guardian)
|
||||
.select(:id, :slug, :parent_category_id) # fields required for generating category URL
|
||||
.where(id: category_slugs_and_ids.values)
|
||||
.map { |c| [c.id, c.url] }
|
||||
.to_h
|
||||
categories_hashtags = {}
|
||||
category_slugs_and_ids.each do |slug, id|
|
||||
if category_url = category_ids_and_urls[id]
|
||||
categories_hashtags[slug] = category_url
|
||||
end
|
||||
end
|
||||
|
||||
# Resolve remaining hashtags as tags
|
||||
tag_hashtags = {}
|
||||
if SiteSetting.tagging_enabled
|
||||
tag_slugs += (all_slugs - categories_hashtags.keys)
|
||||
DiscourseTagging.filter_visible(Tag.where_name(tag_slugs), guardian).each do |tag|
|
||||
tag_hashtags[tag.name] = tag.full_url
|
||||
end
|
||||
end
|
||||
|
||||
render json: { categories: categories_hashtags, tags: tag_hashtags }
|
||||
render json: success_json.merge(results: results)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,192 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class HashtagAutocompleteService
|
||||
HASHTAGS_PER_REQUEST = 20
|
||||
|
||||
attr_reader :guardian
|
||||
cattr_reader :data_sources
|
||||
|
||||
def self.register_data_source(type, &block)
|
||||
@@data_sources[type] = block
|
||||
end
|
||||
|
||||
def self.clear_data_sources
|
||||
@@data_sources = {}
|
||||
|
||||
register_data_source("category") do |guardian, term, limit|
|
||||
guardian_categories = Site.new(guardian).categories
|
||||
|
||||
guardian_categories
|
||||
.select { |category| category[:name].downcase.include?(term) }
|
||||
.take(limit)
|
||||
.map do |category|
|
||||
HashtagItem.new.tap do |item|
|
||||
item.text = category[:name]
|
||||
item.slug = category[:slug]
|
||||
|
||||
# Single-level category heirarchy should be enough to distinguish between
|
||||
# categories here.
|
||||
item.ref =
|
||||
if category[:parent_category_id]
|
||||
parent_category =
|
||||
guardian_categories.find { |c| c[:id] === category[:parent_category_id] }
|
||||
category[:slug] if !parent_category
|
||||
|
||||
parent_slug = parent_category[:slug]
|
||||
"#{parent_slug}:#{category[:slug]}"
|
||||
else
|
||||
category[:slug]
|
||||
end
|
||||
item.icon = "folder"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
register_data_source("tag") do |guardian, term, limit|
|
||||
if SiteSetting.tagging_enabled
|
||||
tags_with_counts, _ =
|
||||
DiscourseTagging.filter_allowed_tags(
|
||||
guardian,
|
||||
term: term,
|
||||
with_context: true,
|
||||
limit: limit,
|
||||
for_input: true,
|
||||
)
|
||||
TagsController
|
||||
.tag_counts_json(tags_with_counts)
|
||||
.take(limit)
|
||||
.map do |tag|
|
||||
HashtagItem.new.tap do |item|
|
||||
item.text = "#{tag[:name]} x #{tag[:count]}"
|
||||
item.slug = tag[:name]
|
||||
item.icon = "tag"
|
||||
end
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
clear_data_sources
|
||||
|
||||
class HashtagItem
|
||||
# The text to display in the UI autocomplete menu for the item.
|
||||
attr_accessor :text
|
||||
|
||||
# Canonical slug for the item. Different from the ref, which can
|
||||
# have the type as a suffix to distinguish between conflicts.
|
||||
attr_accessor :slug
|
||||
|
||||
# The icon to display in the UI autocomplete menu for the item.
|
||||
attr_accessor :icon
|
||||
|
||||
# Distinguishes between different entities e.g. tag, category.
|
||||
attr_accessor :type
|
||||
|
||||
# Inserted into the textbox when an autocomplete item is selected,
|
||||
# and must be unique so it can be used for lookups via the #lookup
|
||||
# method above.
|
||||
attr_accessor :ref
|
||||
end
|
||||
|
||||
def initialize(guardian)
|
||||
@guardian = guardian
|
||||
end
|
||||
|
||||
def lookup(slugs)
|
||||
raise Discourse::InvalidParameters.new(:slugs) if !slugs.is_a?(Array)
|
||||
|
||||
all_slugs = []
|
||||
tag_slugs = []
|
||||
|
||||
slugs[0..HashtagAutocompleteService::HASHTAGS_PER_REQUEST].each do |slug|
|
||||
if slug.end_with?(PrettyText::Helpers::TAG_HASHTAG_POSTFIX)
|
||||
tag_slugs << slug.chomp(PrettyText::Helpers::TAG_HASHTAG_POSTFIX)
|
||||
else
|
||||
all_slugs << slug
|
||||
end
|
||||
end
|
||||
|
||||
# Try to resolve hashtags as categories first
|
||||
category_slugs_and_ids =
|
||||
all_slugs.map { |slug| [slug, Category.query_from_hashtag_slug(slug)&.id] }.to_h
|
||||
category_ids_and_urls =
|
||||
Category
|
||||
.secured(guardian)
|
||||
.select(:id, :slug, :parent_category_id) # fields required for generating category URL
|
||||
.where(id: category_slugs_and_ids.values)
|
||||
.map { |c| [c.id, c.url] }
|
||||
.to_h
|
||||
categories_hashtags = {}
|
||||
category_slugs_and_ids.each do |slug, id|
|
||||
if category_url = category_ids_and_urls[id]
|
||||
categories_hashtags[slug] = category_url
|
||||
end
|
||||
end
|
||||
|
||||
# Resolve remaining hashtags as tags
|
||||
tag_hashtags = {}
|
||||
if SiteSetting.tagging_enabled
|
||||
tag_slugs += (all_slugs - categories_hashtags.keys)
|
||||
DiscourseTagging
|
||||
.filter_visible(Tag.where_name(tag_slugs), guardian)
|
||||
.each { |tag| tag_hashtags[tag.name] = tag.full_url }
|
||||
end
|
||||
|
||||
{ categories: categories_hashtags, tags: tag_hashtags }
|
||||
end
|
||||
|
||||
def search(term, types_in_priority_order, limit = 5)
|
||||
raise Discourse::InvalidParameters.new(:order) if !types_in_priority_order.is_a?(Array)
|
||||
|
||||
results = []
|
||||
slugs_by_type = {}
|
||||
term = term.downcase
|
||||
types_in_priority_order =
|
||||
types_in_priority_order.select { |type| @@data_sources.keys.include?(type) }
|
||||
|
||||
types_in_priority_order.each do |type|
|
||||
data = @@data_sources[type].call(guardian, term, limit - results.length)
|
||||
next if data.empty?
|
||||
|
||||
all_data_items_valid = data.all? do |item|
|
||||
item.kind_of?(HashtagItem) && item.slug.present? && item.text.present?
|
||||
end
|
||||
next if !all_data_items_valid
|
||||
|
||||
data.each do |item|
|
||||
item.type = type
|
||||
item.ref = item.ref || item.slug
|
||||
end
|
||||
slugs_by_type[type] = data.map(&:slug)
|
||||
|
||||
results.concat(data)
|
||||
|
||||
break if results.length >= limit
|
||||
end
|
||||
|
||||
# Any items that are _not_ the top-ranked type (which could possibly not be
|
||||
# the same as the first item in the types_in_priority_order if there was
|
||||
# no data for that type) that have conflicting slugs with other items for
|
||||
# other types need to have a ::type suffix added to their ref.
|
||||
#
|
||||
# This will be used for the lookup method above if one of these items is
|
||||
# chosen in the UI, otherwise there is no way to determine whether a hashtag is
|
||||
# for a category or a tag etc.
|
||||
#
|
||||
# For example, if there is a category with the slug #general and a tag
|
||||
# with the slug #general, then the tag will have its ref changed to #general::tag
|
||||
top_ranked_type = slugs_by_type.keys.first
|
||||
results.each do |hashtag_item|
|
||||
next if hashtag_item.type == top_ranked_type
|
||||
|
||||
other_slugs = results.reject { |r| r.type === hashtag_item.type }.map(&:slug)
|
||||
if other_slugs.include?(hashtag_item.slug)
|
||||
hashtag_item.ref = "#{hashtag_item.slug}::#{hashtag_item.type}"
|
||||
end
|
||||
end
|
||||
|
||||
results.take(limit)
|
||||
end
|
||||
end
|
|
@ -771,6 +771,7 @@ Discourse::Application.routes.draw do
|
|||
end
|
||||
|
||||
get "hashtags" => "hashtags#show"
|
||||
get "hashtags/search" => "hashtags#search"
|
||||
|
||||
TopTopic.periods.each do |period|
|
||||
get "top/#{period}.rss", to: redirect("top.rss?period=#{period}", status: 301)
|
||||
|
|
|
@ -1086,6 +1086,16 @@ class Plugin::Instance
|
|||
About.add_plugin_stat_group(plugin_stat_group_name, show_in_ui: show_in_ui, &block)
|
||||
end
|
||||
|
||||
# Registers a new record type to be searched via the HashtagAutocompleteService and the
|
||||
# /hashtags/search endpoint. The data returned by the block must be an array
|
||||
# with each item an instance of HashtagAutocompleteService::HashtagItem.
|
||||
#
|
||||
# See also registerHashtagSearchParam in the plugin JS API, otherwise the
|
||||
# clientside hashtag search code will use the new type registered here.
|
||||
def register_hashtag_data_source(type, &block)
|
||||
HashtagAutocompleteService.register_data_source(type, &block)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def self.js_path
|
||||
|
|
|
@ -155,6 +155,9 @@ module TestSetup
|
|||
# Make sure the default Post and Topic bookmarkables are registered
|
||||
Bookmark.reset_bookmarkables
|
||||
|
||||
# Make sure only the default category and tag hashtag data sources are registered.
|
||||
HashtagAutocompleteService.clear_data_sources
|
||||
|
||||
OmniAuth.config.test_mode = false
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe HashtagAutocompleteService do
|
||||
fab!(:user) { Fabricate(:user) }
|
||||
fab!(:category1) { Fabricate(:category, name: "Book Club", slug: "book-club") }
|
||||
fab!(:tag1) { Fabricate(:tag, name: "great-books") }
|
||||
let(:guardian) { Guardian.new(user) }
|
||||
|
||||
subject { described_class.new(guardian) }
|
||||
|
||||
before { Site.clear_cache }
|
||||
|
||||
def register_bookmark_data_source
|
||||
HashtagAutocompleteService.register_data_source("bookmark") do |guardian_scoped, term, limit|
|
||||
guardian_scoped
|
||||
.user
|
||||
.bookmarks
|
||||
.where("name ILIKE ?", "%#{term}%")
|
||||
.limit(limit)
|
||||
.map do |bm|
|
||||
HashtagAutocompleteService::HashtagItem.new.tap do |item|
|
||||
item.text = bm.name
|
||||
item.slug = bm.name.gsub(" ", "-")
|
||||
item.icon = "bookmark"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#search" do
|
||||
it "returns search results for tags and categories by default" do
|
||||
expect(subject.search("book", %w[category tag]).map(&:text)).to eq(
|
||||
["Book Club", "great-books x 0"],
|
||||
)
|
||||
end
|
||||
|
||||
it "respects the types_in_priority_order param" do
|
||||
expect(subject.search("book", %w[tag category]).map(&:text)).to eq(
|
||||
["great-books x 0", "Book Club"],
|
||||
)
|
||||
end
|
||||
|
||||
it "respects the limit param" do
|
||||
expect(subject.search("book", %w[tag category], 1).map(&:text)).to eq(["great-books x 0"])
|
||||
end
|
||||
|
||||
it "includes the tag count" do
|
||||
tag1.update!(topic_count: 78)
|
||||
expect(subject.search("book", %w[tag category], 1).map(&:text)).to eq(["great-books x 78"])
|
||||
end
|
||||
|
||||
it "does case-insensitive search" do
|
||||
expect(subject.search("book", %w[category tag]).map(&:text)).to eq(
|
||||
["Book Club", "great-books x 0"],
|
||||
)
|
||||
expect(subject.search("bOOk", %w[category tag]).map(&:text)).to eq(
|
||||
["Book Club", "great-books x 0"],
|
||||
)
|
||||
end
|
||||
|
||||
it "does not include categories the user cannot access" do
|
||||
category1.update!(read_restricted: true)
|
||||
expect(subject.search("book", %w[tag category]).map(&:text)).to eq(["great-books x 0"])
|
||||
end
|
||||
|
||||
it "does not include tags the user cannot access" do
|
||||
Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: ["great-books"])
|
||||
expect(subject.search("book", %w[tag]).map(&:text)).to be_empty
|
||||
end
|
||||
|
||||
it "does not search other data sources if the limit is reached by earlier type data sources" do
|
||||
Site.any_instance.expects(:categories).never
|
||||
subject.search("book", %w[tag category], 1)
|
||||
end
|
||||
|
||||
it "includes other data sources" do
|
||||
Fabricate(:bookmark, user: user, name: "read review of this fantasy book")
|
||||
Fabricate(:bookmark, user: user, name: "cool rock song")
|
||||
guardian.user.reload
|
||||
|
||||
HashtagAutocompleteService.register_data_source("bookmark") do |guardian_scoped, term, limit|
|
||||
guardian_scoped
|
||||
.user
|
||||
.bookmarks
|
||||
.where("name ILIKE ?", "%#{term}%")
|
||||
.limit(limit)
|
||||
.map do |bm|
|
||||
HashtagAutocompleteService::HashtagItem.new.tap do |item|
|
||||
item.text = bm.name
|
||||
item.slug = bm.name.dasherize
|
||||
item.icon = "bookmark"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
expect(subject.search("book", %w[category tag bookmark]).map(&:text)).to eq(
|
||||
["Book Club", "great-books x 0", "read review of this fantasy book"],
|
||||
)
|
||||
end
|
||||
|
||||
it "handles refs for categories that have a parent" do
|
||||
parent = Fabricate(:category, name: "Hobbies", slug: "hobbies")
|
||||
category1.update!(parent_category: parent)
|
||||
expect(subject.search("book", %w[category tag]).map(&:ref)).to eq(
|
||||
%w[hobbies:book-club great-books],
|
||||
)
|
||||
end
|
||||
|
||||
it "appends type suffixes for the ref on conflicting slugs on items that are not the top priority type" do
|
||||
Fabricate(:tag, name: "book-club")
|
||||
expect(subject.search("book", %w[category tag]).map(&:ref)).to eq(
|
||||
%w[book-club great-books book-club::tag],
|
||||
)
|
||||
|
||||
Fabricate(:bookmark, user: user, name: "book club")
|
||||
guardian.user.reload
|
||||
|
||||
register_bookmark_data_source
|
||||
|
||||
expect(subject.search("book", %w[category tag bookmark]).map(&:ref)).to eq(
|
||||
%w[book-club great-books book-club::tag book-club::bookmark],
|
||||
)
|
||||
end
|
||||
|
||||
context "when not tagging_enabled" do
|
||||
before { SiteSetting.tagging_enabled = false }
|
||||
|
||||
it "does not return any tags" do
|
||||
expect(subject.search("book", %w[category tag]).map(&:text)).to eq(["Book Club"])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue