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