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:
Martin Brennan 2022-10-19 14:03:57 +10:00 committed by GitHub
parent 45bdfa1c84
commit 7c25597da2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 554 additions and 94 deletions

View File

@ -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) {

View File

@ -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);
}
});
},
};

View File

@ -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);
}

View File

@ -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]);
}

View File

@ -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

View File

@ -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>

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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