diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js
index bb3fbbeb15e..3dcdfd0f7e2 100644
--- a/app/assets/javascripts/discourse/app/components/d-editor.js
+++ b/app/assets/javascripts/discourse/app/components/d-editor.js
@@ -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) {
diff --git a/app/assets/javascripts/discourse/app/initializers/composer-hashtag-autocomplete.js b/app/assets/javascripts/discourse/app/initializers/composer-hashtag-autocomplete.js
new file mode 100644
index 00000000000..b46fab8cd2c
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/initializers/composer-hashtag-autocomplete.js
@@ -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);
+ }
+ });
+ },
+};
diff --git a/app/assets/javascripts/discourse/app/lib/category-hashtags.js b/app/assets/javascripts/discourse/app/lib/category-hashtags.js
index 18cf98b8993..b8ed69c74f5 100644
--- a/app/assets/javascripts/discourse/app/lib/category-hashtags.js
+++ b/app/assets/javascripts/discourse/app/lib/category-hashtags.js
@@ -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);
}
diff --git a/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js b/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js
index eba9dcf0d63..e62e0ea507a 100644
--- a/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js
+++ b/app/assets/javascripts/discourse/app/lib/hashtag-autocomplete.js
@@ -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]);
+}
diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js
index 7074c83d137..9635da273a7 100644
--- a/app/assets/javascripts/discourse/app/lib/plugin-api.js
+++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js
@@ -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
diff --git a/app/assets/javascripts/discourse/app/templates/hashtag-autocomplete.hbr b/app/assets/javascripts/discourse/app/templates/hashtag-autocomplete.hbr
index f1ddde71118..2b52969887c 100644
--- a/app/assets/javascripts/discourse/app/templates/hashtag-autocomplete.hbr
+++ b/app/assets/javascripts/discourse/app/templates/hashtag-autocomplete.hbr
@@ -1,12 +1,8 @@
diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss
index 6ded31f48d1..78d8411ccb4 100644
--- a/app/assets/stylesheets/common/base/tagging.scss
+++ b/app/assets/stylesheets/common/base/tagging.scss
@@ -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 {
diff --git a/app/controllers/hashtags_controller.rb b/app/controllers/hashtags_controller.rb
index 6fa0cf282ca..ec625b7f221 100644
--- a/app/controllers/hashtags_controller.rb
+++ b/app/controllers/hashtags_controller.rb
@@ -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
diff --git a/app/services/hashtag_autocomplete_service.rb b/app/services/hashtag_autocomplete_service.rb
new file mode 100644
index 00000000000..3d1efe22275
--- /dev/null
+++ b/app/services/hashtag_autocomplete_service.rb
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index ade3bfa2f38..cbb1ee51977 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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)
diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb
index 84306edbce9..dfa943792de 100644
--- a/lib/plugin/instance.rb
+++ b/lib/plugin/instance.rb
@@ -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
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index e825618a942..0a057d49622 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -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
diff --git a/spec/services/hashtag_autocomplete_service_spec.rb b/spec/services/hashtag_autocomplete_service_spec.rb
new file mode 100644
index 00000000000..c098dd3f5e7
--- /dev/null
+++ b/spec/services/hashtag_autocomplete_service_spec.rb
@@ -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