DEV: Change HashtagAutocompleteService to use DiscoursePluginRegistry (#19491)

Follow up to a review in #18937, this commit changes the HashtagAutocompleteService to no longer use class variables to register hashtag data sources or types in context priority order. This is to address multisite concerns, where one site could e.g. have chat disabled and another might not. The filtered plugin registers I added will not be included if the plugin is disabled.
This commit is contained in:
Martin Brennan 2022-12-19 13:46:17 +10:00 committed by GitHub
parent 68d5bdefdd
commit 6b9c0ee554
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 230 additions and 124 deletions

View File

@ -8,6 +8,10 @@ class CategoryHashtagDataSource
"folder"
end
def self.type
"category"
end
def self.category_to_hashtag_item(category)
HashtagAutocompleteService::HashtagItem.new.tap do |item|
item.text = category.name

View File

@ -3,49 +3,61 @@
class HashtagAutocompleteService
HASHTAGS_PER_REQUEST = 20
SEARCH_MAX_LIMIT = 50
DEFAULT_DATA_SOURCES = [CategoryHashtagDataSource, TagHashtagDataSource]
DEFAULT_CONTEXTUAL_TYPE_PRIORITIES = [
{ type: "category", context: "topic-composer", priority: 100 },
{ type: "tag", context: "topic-composer", priority: 50 },
]
def self.search_conditions
@search_conditions ||= Enum.new(contains: 0, starts_with: 1)
end
attr_reader :guardian
cattr_reader :data_sources, :contexts
def self.register_data_source(type, klass)
@@data_sources[type] = klass
def self.data_sources
# Category and Tag data sources are in core and always should be
# included for searches and lookups.
Set.new(DEFAULT_DATA_SOURCES | DiscoursePluginRegistry.hashtag_autocomplete_data_sources)
end
def self.clear_registered
@@data_sources = {}
@@contexts = {}
register_data_source("category", CategoryHashtagDataSource)
register_data_source("tag", TagHashtagDataSource)
register_type_in_context("category", "topic-composer", 100)
register_type_in_context("tag", "topic-composer", 50)
def self.contextual_type_priorities
# Category and Tag type priorities for the composer are default and
# always are included.
Set.new(
DEFAULT_CONTEXTUAL_TYPE_PRIORITIES |
DiscoursePluginRegistry.hashtag_autocomplete_contextual_type_priorities,
)
end
def self.register_type_in_context(type, context, priority)
@@contexts[context] = @@contexts[context] || {}
@@contexts[context][type] = priority
def self.data_source_types
data_sources.map(&:type)
end
def self.data_source_icons
@@data_sources.values.map(&:icon)
data_sources.map(&:icon)
end
def self.data_source_from_type(type)
data_sources.find { |ds| ds.type == type }
end
def self.find_priorities_for_context(context)
contextual_type_priorities.select { |ctp| ctp[:context] == context }
end
def self.unique_contexts
contextual_type_priorities.map { |ctp| ctp[:context] }.uniq
end
def self.ordered_types_for_context(context)
return [] if @@contexts[context].blank?
@@contexts[context].sort_by { |param, priority| priority }.reverse.map(&:first)
find_priorities_for_context(context).sort_by { |ctp| -ctp[:priority] }.map { |ctp| ctp[:type] }
end
def self.contexts_with_ordered_types
Hash[@@contexts.keys.map { |context| [context, ordered_types_for_context(context)] }]
Hash[unique_contexts.map { |context| [context, ordered_types_for_context(context)] }]
end
clear_registered
class HashtagItem
# The text to display in the UI autocomplete menu for the item.
attr_accessor :text
@ -120,13 +132,15 @@ class HashtagAutocompleteService
raise Discourse::InvalidParameters.new(:order) if !types_in_priority_order.is_a?(Array)
types_in_priority_order =
types_in_priority_order.select { |type| @@data_sources.keys.include?(type) }
types_in_priority_order.select do |type|
HashtagAutocompleteService.data_source_types.include?(type)
end
lookup_results = Hash[types_in_priority_order.collect { |type| [type.to_sym, []] }]
limited_slugs = slugs[0..HashtagAutocompleteService::HASHTAGS_PER_REQUEST]
slugs_without_suffixes =
limited_slugs.reject do |slug|
@@data_sources.keys.any? { |type| slug.ends_with?("::#{type}") }
HashtagAutocompleteService.data_source_types.any? { |type| slug.ends_with?("::#{type}") }
end
slugs_with_suffixes = (limited_slugs - slugs_without_suffixes)
@ -208,7 +222,9 @@ class HashtagAutocompleteService
top_ranked_type = nil
term = term.downcase
types_in_priority_order =
types_in_priority_order.select { |type| @@data_sources.keys.include?(type) }
types_in_priority_order.select do |type|
HashtagAutocompleteService.data_source_types.include?(type)
end
# Float exact matches by slug to the top of the list, any of these will be excluded
# from further results.
@ -318,7 +334,7 @@ class HashtagAutocompleteService
return limited_results if search_results.empty?
search_results =
@@data_sources[type].search_sort(
HashtagAutocompleteService.data_source_from_type(type).search_sort(
search_results.reject do |item|
limited_results.any? { |exact| exact.type == type && exact.slug === item.slug }
end,
@ -334,7 +350,12 @@ class HashtagAutocompleteService
types_in_priority_order.each do |type|
search_results =
filter_valid_data_items(@@data_sources[type].search_without_term(guardian, split_limit))
filter_valid_data_items(
HashtagAutocompleteService.data_source_from_type(type).search_without_term(
guardian,
split_limit,
),
)
next if search_results.empty?
# This is purposefully unsorted as search_without_term should sort
@ -369,7 +390,17 @@ class HashtagAutocompleteService
condition = HashtagAutocompleteService.search_conditions[:contains]
)
filter_valid_data_items(
set_types(set_refs(@@data_sources[type].search(guardian, term, limit, condition)), type),
set_types(
set_refs(
HashtagAutocompleteService.data_source_from_type(type).search(
guardian,
term,
limit,
condition,
),
),
type,
),
)
end
@ -385,7 +416,10 @@ class HashtagAutocompleteService
end
def lookup_for_type(type, guardian, slugs)
set_types(set_refs(@@data_sources[type].lookup(guardian, slugs)), type)
set_types(
set_refs(HashtagAutocompleteService.data_source_from_type(type).lookup(guardian, slugs)),
type,
)
end
def append_types_to_conflicts(limited_results, top_ranked_type, limit)

View File

@ -8,6 +8,10 @@ class TagHashtagDataSource
"tag"
end
def self.type
"tag"
end
def self.tag_to_hashtag_item(tag, include_count: false)
tag = Tag.new(tag.slice(:id, :name, :description).merge(topic_count: tag[:count])) if tag.is_a?(
Hash,

View File

@ -5,11 +5,16 @@
#
class DiscoursePluginRegistry
# Plugins often need to be able to register additional handlers, data, or
# classes that will be used by core classes. This should be used if you
# need to control which type the registry is, and if it doesn't need to
# be removed if the plugin is disabled.
#
# Shortcut to create new register in the plugin registry
# - Register is created in a class variable using the specified name/type
# - Defines singleton method to access the register
# - Defines instance method as a shortcut to the singleton method
# - Automatically deletes the register on ::clear!
# - Automatically deletes the register on registry.reset!
def self.define_register(register_name, type)
@@register_names ||= Set.new
@@register_names << register_name
@ -24,11 +29,15 @@ class DiscoursePluginRegistry
end
end
# Plugins often need to add values to a list, and we need to filter those
# lists at runtime to ignore values from disabled plugins. Unlike define_register,
# the type of the register cannot be defined, and is always Array.
#
# Create a new register (see `define_register`) with some additions:
# - Register is created in a class variable using the specified name/type
# - Defines singleton method to access the register
# - Defines instance method as a shortcut to the singleton method
# - Automatically deletes the register on ::clear!
# - Automatically deletes the register on registry.reset!
def self.define_filtered_register(register_name)
define_register(register_name, Array)
@ -100,6 +109,9 @@ class DiscoursePluginRegistry
define_filtered_register :user_destroyer_on_content_deletion_callbacks
define_filtered_register :hashtag_autocomplete_data_sources
define_filtered_register :hashtag_autocomplete_contextual_type_priorities
def self.register_auth_provider(auth_provider)
self.auth_providers << auth_provider
end

View File

@ -1097,13 +1097,21 @@ class Plugin::Instance
# Used to register data sources for HashtagAutocompleteService to look
# up results based on a #hashtag string.
#
# @param {String} type - Roughly corresponding to a model, this is used as a unique
# key for the datasource and is also used when allowing different
# contexts to search for and lookup these types. The `category`
# and `tag` types are registered by default.
# @param {Class} klass - Must be a class that implements methods with the following
# signatures:
#
# Roughly corresponding to a model, this is used as a unique
# key for the datasource and is also used when allowing different
# contexts to search for and lookup these types. The `category`
# and `tag` types are registered by default.
# def self.type
# end
#
# The FontAwesome icon to use for the data source in the search results
# and cooked markdown.
# def self.icon
# end
#
# @param {Guardian} guardian - Current user's guardian, used for permission-based filtering
# @param {Array} slugs - An array of strings that represent slugs to search this type for,
# e.g. category slugs.
@ -1117,8 +1125,20 @@ class Plugin::Instance
# @returns {Array} An Array of HashtagAutocompleteService::HashtagItem
# def self.search(guardian, term, limit)
# end
def register_hashtag_data_source(type, klass)
HashtagAutocompleteService.register_data_source(type, klass)
#
# @param {Array} search_results - An array of HashtagAutocompleteService::HashtagItem to sort
# @param {String} term - The search term which was used, which may help with sorting.
# @returns {Array} An Array of HashtagAutocompleteService::HashtagItem
# def self.search_sort(search_results, term)
# end
#
# @param {Guardian} guardian - Current user's guardian, used for permission-based filtering
# @param {Integer} limit - The number of search results that should be returned by the query
# @returns {Array} An Array of HashtagAutocompleteService::HashtagItem
# def self.search_without_term(guardian, limit)
# end
def register_hashtag_data_source(klass)
DiscoursePluginRegistry.register_hashtag_autocomplete_data_source(klass, self)
end
##
@ -1134,8 +1154,10 @@ class Plugin::Instance
# for certain types of hashtag result.
# @param {Integer} priority - A number value for ordering type results when hashtag searches
# or lookups occur. Priority is ordered by DESCENDING order.
def register_hashtag_type_in_context(type, context, priority)
HashtagAutocompleteService.register_type_in_context(type, context, priority)
def register_hashtag_type_priority_for_context(type, context, priority)
DiscoursePluginRegistry.register_hashtag_autocomplete_contextual_type_priority(
{ type: type, context: context, priority: priority }, self
)
end
##

View File

@ -5,6 +5,10 @@ class Chat::ChatChannelHashtagDataSource
"comment"
end
def self.type
"channel"
end
def self.channel_to_hashtag_item(guardian, channel)
HashtagAutocompleteService::HashtagItem.new.tap do |item|
item.text = channel.title

View File

@ -730,11 +730,11 @@ after_initialize do
register_about_stat_group("chat_users") { Chat::Statistics.about_users }
# Make sure to update spec/system/hashtag_autocomplete_spec.rb when changing this.
register_hashtag_data_source("channel", Chat::ChatChannelHashtagDataSource)
register_hashtag_type_in_context("channel", "chat-composer", 200)
register_hashtag_type_in_context("category", "chat-composer", 100)
register_hashtag_type_in_context("tag", "chat-composer", 50)
register_hashtag_type_in_context("channel", "topic-composer", 10)
register_hashtag_data_source(Chat::ChatChannelHashtagDataSource)
register_hashtag_type_priority_for_context("channel", "chat-composer", 200)
register_hashtag_type_priority_for_context("category", "chat-composer", 100)
register_hashtag_type_priority_for_context("tag", "chat-composer", 50)
register_hashtag_type_priority_for_context("channel", "topic-composer", 10)
Site.markdown_additional_options["chat"] = {
limited_pretty_text_features: ChatMessage::MARKDOWN_FEATURES,

View File

@ -5,18 +5,6 @@ require "rails_helper"
describe ChatMessage do
fab!(:message) { Fabricate(:chat_message, message: "hey friend, what's up?!") }
# TODO (martin) Remove this after https://github.com/discourse/discourse/pull/19491 is merged
def register_hashtag_contexts
# This is annoying, but we need to reset the hashtag data sources inbetween
# tests, and since this is normally done in plugin.rb with the plugin API
# there is not an easier way to do this.
HashtagAutocompleteService.register_data_source("channel", Chat::ChatChannelHashtagDataSource)
HashtagAutocompleteService.register_type_in_context("channel", "chat-composer", 200)
HashtagAutocompleteService.register_type_in_context("category", "chat-composer", 100)
HashtagAutocompleteService.register_type_in_context("tag", "chat-composer", 50)
HashtagAutocompleteService.register_type_in_context("channel", "topic-composer", 10)
end
describe ".cook" do
it "does not support HTML tags" do
cooked = ChatMessage.cook("<h1>test</h1>")
@ -258,7 +246,7 @@ describe ChatMessage do
end
it "supports hashtag-autocomplete plugin" do
register_hashtag_contexts
SiteSetting.chat_enabled = true
SiteSetting.enable_experimental_hashtag_autocomplete = true
category = Fabricate(:category)
@ -521,7 +509,7 @@ describe ChatMessage do
fab!(:secure_category) { Fabricate(:private_category, group: group) }
before do
register_hashtag_contexts
SiteSetting.chat_enabled = true
SiteSetting.enable_experimental_hashtag_autocomplete = true
SiteSetting.suppress_secured_categories_from_admin = true
end

View File

@ -19,15 +19,6 @@ describe "Using #hashtag autocompletion to search for and lookup channels",
before do
SiteSetting.enable_experimental_hashtag_autocomplete = true
# This is annoying, but we need to reset the hashtag data sources inbetween
# tests, and since this is normally done in plugin.rb with the plugin API
# there is not an easier way to do this.
HashtagAutocompleteService.register_data_source("channel", Chat::ChatChannelHashtagDataSource)
HashtagAutocompleteService.register_type_in_context("channel", "chat-composer", 200)
HashtagAutocompleteService.register_type_in_context("category", "chat-composer", 100)
HashtagAutocompleteService.register_type_in_context("tag", "chat-composer", 50)
HashtagAutocompleteService.register_type_in_context("channel", "topic-composer", 10)
chat_system_bootstrap(user, [channel1, channel2])
sign_in(user)
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
describe "HashtagAutocompleteService multisite registry", type: :multisite do
class MockPlugin
def initialize(setting_provider)
@setting_provider = setting_provider
end
def enabled?
@setting_provider.find("bookmark_hashtag_enabled")&.value == "true"
end
end
it "does not include the data source if one of the multisites has the plugin disabled" do
setting_provider = SiteSettings::LocalProcessProvider.new
DiscoursePluginRegistry.register_hashtag_autocomplete_data_source(
FakeBookmarkHashtagDataSource,
MockPlugin.new(setting_provider),
)
test_multisite_connection("default") do
setting_provider.save("bookmark_hashtag_enabled", true, 5)
expect(HashtagAutocompleteService.data_source_types).to eq(%w[category tag bookmark])
end
test_multisite_connection("second") do
expect(HashtagAutocompleteService.data_source_types).to eq(%w[category tag])
end
end
end

View File

@ -154,9 +154,6 @@ 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_registered
OmniAuth.config.test_mode = false
end
end

View File

@ -9,64 +9,21 @@ RSpec.describe HashtagAutocompleteService do
subject { described_class.new(guardian) }
before { Site.clear_cache }
class BookmarkDataSource
def self.icon
"bookmark"
end
def self.lookup(guardian_scoped, slugs)
guardian_scoped
.user
.bookmarks
.where("LOWER(name) IN (:slugs)", slugs: slugs)
.map do |bm|
HashtagAutocompleteService::HashtagItem.new.tap do |item|
item.text = bm.name
item.slug = bm.name.gsub(" ", "-")
item.icon = icon
end
end
end
def self.search(
guardian_scoped,
term,
limit,
condition = HashtagAutocompleteService.search_conditions[:starts_with]
)
query = guardian_scoped.user.bookmarks
if condition == HashtagAutocompleteService.search_conditions[:starts_with]
query = query.where("name ILIKE ?", "#{term}%")
else
query = query.where("name ILIKE ?", "%#{term}%")
end
query
.limit(limit)
.map do |bm|
HashtagAutocompleteService::HashtagItem.new.tap do |item|
item.text = bm.name
item.slug = bm.name.gsub(" ", "-")
item.icon = icon
end
end
end
def self.search_sort(search_results, _)
search_results.sort_by { |item| item.text.downcase }
end
end
after { DiscoursePluginRegistry.reset! }
describe ".contexts_with_ordered_types" do
it "returns a hash of all the registrered search contexts and their types in the defined priority order" do
expect(HashtagAutocompleteService.contexts_with_ordered_types).to eq(
{ "topic-composer" => %w[category tag] },
)
HashtagAutocompleteService.register_type_in_context("category", "awesome-composer", 50)
HashtagAutocompleteService.register_type_in_context("tag", "awesome-composer", 100)
DiscoursePluginRegistry.register_hashtag_autocomplete_contextual_type_priority(
{ type: "category", context: "awesome-composer", priority: 50 },
stub(enabled?: true),
)
DiscoursePluginRegistry.register_hashtag_autocomplete_contextual_type_priority(
{ type: "tag", context: "awesome-composer", priority: 100 },
stub(enabled?: true),
)
expect(HashtagAutocompleteService.contexts_with_ordered_types).to eq(
{ "topic-composer" => %w[category tag], "awesome-composer" => %w[tag category] },
)
@ -148,7 +105,10 @@ RSpec.describe HashtagAutocompleteService do
Fabricate(:bookmark, user: user, name: "cool rock song")
guardian.user.reload
HashtagAutocompleteService.register_data_source("bookmark", BookmarkDataSource)
DiscoursePluginRegistry.register_hashtag_autocomplete_data_source(
FakeBookmarkHashtagDataSource,
stub(enabled?: true),
)
expect(subject.search("book", %w[category tag bookmark]).map(&:text)).to eq(
["The Book Club", "great-books x 22", "read review of this fantasy book"],
@ -173,7 +133,10 @@ RSpec.describe HashtagAutocompleteService do
Fabricate(:bookmark, user: user, name: "book club")
guardian.user.reload
HashtagAutocompleteService.register_data_source("bookmark", BookmarkDataSource)
DiscoursePluginRegistry.register_hashtag_autocomplete_data_source(
FakeBookmarkHashtagDataSource,
stub(enabled?: true),
)
expect(subject.search("book", %w[category tag bookmark]).map(&:ref)).to eq(
%w[book-club the-book-club great-books the-book-club::tag],
@ -428,7 +391,10 @@ RSpec.describe HashtagAutocompleteService do
Fabricate(:bookmark, user: user, name: "coolrock")
guardian.user.reload
HashtagAutocompleteService.register_data_source("bookmark", BookmarkDataSource)
DiscoursePluginRegistry.register_hashtag_autocomplete_data_source(
FakeBookmarkHashtagDataSource,
stub(enabled?: true),
)
result = subject.lookup(["coolrock"], %w[category tag bookmark])
expect(result[:bookmark].map(&:slug)).to eq(["coolrock"])

View File

@ -0,0 +1,54 @@
# frozen_string_literal: true
class FakeBookmarkHashtagDataSource
def self.icon
"bookmark"
end
def self.type
"bookmark"
end
def self.lookup(guardian_scoped, slugs)
guardian_scoped
.user
.bookmarks
.where("LOWER(name) IN (:slugs)", slugs: slugs)
.map do |bm|
HashtagAutocompleteService::HashtagItem.new.tap do |item|
item.text = bm.name
item.slug = bm.name.gsub(" ", "-")
item.icon = icon
end
end
end
def self.search(
guardian_scoped,
term,
limit,
condition = HashtagAutocompleteService.search_conditions[:starts_with]
)
query = guardian_scoped.user.bookmarks
if condition == HashtagAutocompleteService.search_conditions[:starts_with]
query = query.where("name ILIKE ?", "#{term}%")
else
query = query.where("name ILIKE ?", "%#{term}%")
end
query
.limit(limit)
.map do |bm|
HashtagAutocompleteService::HashtagItem.new.tap do |item|
item.text = bm.name
item.slug = bm.name.gsub(" ", "-")
item.icon = icon
end
end
end
def self.search_sort(search_results, _)
search_results.sort_by { |item| item.text.downcase }
end
end