FEATURE: Async load of category and chat hashtags (#25526)

This commit includes several changes to make hashtags work when "lazy
load categories" is enabled. The previous hashtag implementation use the
category colors CSS variables, but these are not defined when the site
setting is enabled because categories are no longer preloaded.

This commit implements two fundamental changes:

1. load colors together with the other hashtag information

2. load cooked hashtag data asynchronously

The first change is implemented by adding "colors" to the HashtagItem
model. It is a list because two colors are returned for subcategories:
the color of the parent category and subcategory.

The second change is implemented on the server-side in a new route
/hashtags/by-ids and on the client side by loading previously unseen
hashtags, generating the CSS on the fly and injecting it into the page.

There have been minimal changes outside of these two fundamental ones,
but a refactoring will be coming soon to reuse as much of the code
and maybe favor use of `style` rather than injecting CSS into the page,
which can lead to page rerenders and indefinite grow of the styles.
This commit is contained in:
Bianca Nenciu 2024-02-12 12:07:14 +02:00 committed by GitHub
parent 6b596151ff
commit 1403217ca4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 420 additions and 67 deletions

View File

@ -9,33 +9,19 @@ export default {
* cooked posts, and the sidebar. * cooked posts, and the sidebar.
* *
* Each type has its own corresponding class, which is registered * Each type has its own corresponding class, which is registered
* with the hastag type via api.registerHashtagType. The default * with the hashtag type via api.registerHashtagType. The default
* ones in core are CategoryHashtagType and TagHashtagType. * ones in core are CategoryHashtagType and TagHashtagType.
*/ */
initialize(owner) { initialize(owner) {
this.site = owner.lookup("service:site"); this.site = owner.lookup("service:site");
// If the site is login_required and the user is anon there will be no categories
// preloaded, so there will be no category color CSS variables generated by
// the category-color-css-generator initializer.
if (!this.site.categories?.length) {
return;
}
let generatedCssClasses = [];
Object.values(getHashtagTypeClasses()).forEach((hashtagType) => {
hashtagType.preloadedData.forEach((model) => {
generatedCssClasses = generatedCssClasses.concat(
hashtagType.generateColorCssClasses(model)
);
});
});
const cssTag = document.createElement("style"); const cssTag = document.createElement("style");
cssTag.type = "text/css"; cssTag.type = "text/css";
cssTag.id = "hashtag-css-generator"; cssTag.id = "hashtag-css-generator";
cssTag.innerHTML = generatedCssClasses.join("\n"); cssTag.innerHTML = Object.values(getHashtagTypeClasses())
.map((hashtagType) => hashtagType.generatePreloadedCssClasses())
.flat()
.join("\n");
document.head.appendChild(cssTag); document.head.appendChild(cssTag);
}, },
}; };

View File

@ -228,6 +228,8 @@ function _searchRequest(term, contextualHashtagConfiguration, resultFunc) {
const hashtagType = getHashtagTypeClassesNew()[result.type]; const hashtagType = getHashtagTypeClassesNew()[result.type];
result.icon = hashtagType.generateIconHTML({ result.icon = hashtagType.generateIconHTML({
preloaded: true,
colors: result.colors,
icon: result.icon, icon: result.icon,
id: result.id, id: result.id,
}); });

View File

@ -63,7 +63,10 @@ function _findAndReplaceSeenHashtagPlaceholder(
// Replace raw span for the hashtag with a cooked one // Replace raw span for the hashtag with a cooked one
const matchingSeenHashtag = seenHashtags[type]?.[slugRef]; const matchingSeenHashtag = seenHashtags[type]?.[slugRef];
if (matchingSeenHashtag) { if (matchingSeenHashtag) {
generatePlaceholderHashtagHTML(type, hashtagSpan, matchingSeenHashtag); generatePlaceholderHashtagHTML(type, hashtagSpan, {
preloaded: true,
...matchingSeenHashtag,
});
} }
}); });
} }

View File

@ -1,8 +1,37 @@
import { setOwner } from "@ember/application"; import { setOwner } from "@ember/application";
import { debounce } from "@ember/runloop";
import { ajax } from "discourse/lib/ajax";
import { getHashtagTypeClasses } from "discourse/lib/hashtag-type-registry";
export default class HashtagTypeBase { export default class HashtagTypeBase {
// Store a list of IDs that are currently being loaded globally to make it
// easier to batch requests for multiple types of hashtags
static loadingIds = {};
static async _load() {
const data = HashtagTypeBase.loadingIds;
HashtagTypeBase.loadingIds = {};
let hasData = false;
Object.keys(data).forEach((type) => {
hasData ||= data[type].size > 0;
data[type] = [...data[type]]; // Set to Array
});
if (!hasData) {
return;
}
const hashtags = await ajax("/hashtags/by-ids", { data });
const typeClasses = getHashtagTypeClasses();
Object.entries(typeClasses).forEach(([type, typeClass]) =>
hashtags[type]?.forEach((hashtag) => typeClass.onLoad(hashtag))
);
}
constructor(owner) { constructor(owner) {
setOwner(this, owner); setOwner(this, owner);
this.loadedIds = new Set();
} }
get type() { get type() {
@ -13,6 +42,15 @@ export default class HashtagTypeBase {
throw "not implemented"; throw "not implemented";
} }
generatePreloadedCssClasses() {
const cssClasses = [];
this.preloadedData.forEach((model) => {
this.loadedIds.add(model.id);
cssClasses.push(this.generateColorCssClasses(model));
});
return cssClasses.flat();
}
generateColorCssClasses() { generateColorCssClasses() {
throw "not implemented"; throw "not implemented";
} }
@ -20,4 +58,29 @@ export default class HashtagTypeBase {
generateIconHTML() { generateIconHTML() {
throw "not implemented"; throw "not implemented";
} }
isLoaded(id) {
id = parseInt(id, 10);
return this.loadedIds.has(id);
}
load(id) {
id = parseInt(id, 10);
if (!this.isLoaded(id)) {
(HashtagTypeBase.loadingIds[this.type] ||= new Set()).add(id);
debounce(HashtagTypeBase, HashtagTypeBase._load, 100, false);
}
}
onLoad(hashtag) {
const hashtagId = parseInt(hashtag.id, 10);
if (!this.isLoaded(hashtagId)) {
this.loadedIds.add(hashtagId);
// Append the styles for the loaded hashtag to the CSS generated by the
// `hashtag-css-generator` initializer for preloaded models
document.querySelector("#hashtag-css-generator").innerHTML +=
"\n" + this.generateColorCssClasses(hashtag).join("\n");
}
}
} }

View File

@ -12,29 +12,50 @@ export default class CategoryHashtagType extends HashtagTypeBase {
return this.site.categories || []; return this.site.categories || [];
} }
generateColorCssClasses(category) { generatePreloadedCssClasses() {
const generatedCssClasses = []; return [
const backgroundGradient = [`var(--category-${category.id}-color) 50%`]; // Set a default color for category hashtags. This is added here instead
if (category.parentCategory) { // of `hashtag.scss` because of the CSS precedence rules (<link> has a
backgroundGradient.push( // higher precedence than <style>)
`var(--category-${category.parentCategory.id}-color) 50%` ".hashtag-category-badge { background-color: var(--primary-medium); }",
); ...super.generatePreloadedCssClasses(),
} else { ];
backgroundGradient.push(`var(--category-${category.id}-color) 50%`);
} }
generatedCssClasses.push(`.hashtag-color--category-${category.id} { generateColorCssClasses(categoryOrHashtag) {
background: linear-gradient(-90deg, ${backgroundGradient.join(", ")}); let color, parentColor;
}`); if (categoryOrHashtag.colors) {
if (categoryOrHashtag.colors.length === 1) {
color = categoryOrHashtag.colors[0];
} else {
parentColor = categoryOrHashtag.colors[0];
color = categoryOrHashtag.colors[1];
}
} else {
color = categoryOrHashtag.color;
if (categoryOrHashtag.parentCategory) {
parentColor = categoryOrHashtag.parentCategory.color;
}
}
return generatedCssClasses; let style;
if (parentColor) {
style = `background: linear-gradient(-90deg, #${color} 50%, #${parentColor} 50%);`;
} else {
style = `background-color: #${color};`;
}
return [`.hashtag-color--category-${categoryOrHashtag.id} { ${style} }`];
} }
generateIconHTML(hashtag) { generateIconHTML(hashtag) {
const hashtagId = parseInt(hashtag.id, 10); hashtag.preloaded ? this.onLoad(hashtag) : this.load(hashtag.id);
const colorCssClass = !this.preloadedData.mapBy("id").includes(hashtagId)
? "hashtag-missing" const colorCssClass = `hashtag-color--${this.type}-${hashtag.id}`;
: `hashtag-color--${this.type}-${hashtag.id}`;
return `<span class="hashtag-category-badge ${colorCssClass}"></span>`; return `<span class="hashtag-category-badge ${colorCssClass}"></span>`;
} }
isLoaded(id) {
return !this.site.lazy_load_categories || super.isLoaded(id);
}
} }

View File

@ -24,7 +24,11 @@ acceptance("CSS Generator", function (needs) {
const cssTag = document.querySelector("style#category-color-css-generator"); const cssTag = document.querySelector("style#category-color-css-generator");
assert.equal( assert.equal(
cssTag.innerHTML, cssTag.innerHTML,
":root {\n--category-1-color: #ff0000;\n--category-2-color: #333;\n--category-4-color: #2B81AF;\n}" ":root {\n" +
"--category-1-color: #ff0000;\n" +
"--category-2-color: #333;\n" +
"--category-4-color: #2B81AF;\n" +
"}"
); );
}); });
@ -33,7 +37,10 @@ acceptance("CSS Generator", function (needs) {
const cssTag = document.querySelector("style#hashtag-css-generator"); const cssTag = document.querySelector("style#hashtag-css-generator");
assert.equal( assert.equal(
cssTag.innerHTML, cssTag.innerHTML,
".hashtag-color--category-1 {\n background: linear-gradient(-90deg, var(--category-1-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--category-2 {\n background: linear-gradient(-90deg, var(--category-2-color) 50%, var(--category-2-color) 50%);\n}\n.hashtag-color--category-4 {\n background: linear-gradient(-90deg, var(--category-4-color) 50%, var(--category-1-color) 50%);\n}" ".hashtag-category-badge { background-color: var(--primary-medium); }\n" +
".hashtag-color--category-1 { background-color: #ff0000; }\n" +
".hashtag-color--category-2 { background-color: #333; }\n" +
".hashtag-color--category-4 { background-color: #2B81AF; }"
); );
}); });
@ -42,7 +49,9 @@ acceptance("CSS Generator", function (needs) {
const cssTag = document.querySelector("style#category-badge-css-generator"); const cssTag = document.querySelector("style#category-badge-css-generator");
assert.equal( assert.equal(
cssTag.innerHTML, cssTag.innerHTML,
'.badge-category[data-category-id="1"] { --category-badge-color: var(--category-1-color); --category-badge-text-color: #ffffff; }\n.badge-category[data-category-id="2"] { --category-badge-color: var(--category-2-color); --category-badge-text-color: #ffffff; }\n.badge-category[data-category-id="4"] { --category-badge-color: var(--category-4-color); --category-badge-text-color: #ffffff; }' '.badge-category[data-category-id="1"] { --category-badge-color: var(--category-1-color); --category-badge-text-color: #ffffff; }\n' +
'.badge-category[data-category-id="2"] { --category-badge-color: var(--category-2-color); --category-badge-text-color: #ffffff; }\n' +
'.badge-category[data-category-id="4"] { --category-badge-color: var(--category-4-color); --category-badge-text-color: #ffffff; }'
); );
}); });
}); });

View File

@ -21,14 +21,17 @@ acceptance("#hashtag autocompletion in composer", function (needs) {
return helper.response({ return helper.response({
results: [ results: [
{ {
id: 28,
text: ":bug: Other Languages", text: ":bug: Other Languages",
slug: "other-languages", slug: "other-languages",
colors: ["FF0000"],
icon: "folder", icon: "folder",
relative_url: "/c/other-languages/28", relative_url: "/c/other-languages/28",
ref: "other-languages", ref: "other-languages",
type: "category", type: "category",
}, },
{ {
id: 300,
text: "notes x 300", text: "notes x 300",
slug: "notes", slug: "notes",
icon: "tag", icon: "tag",
@ -37,6 +40,7 @@ acceptance("#hashtag autocompletion in composer", function (needs) {
type: "tag", type: "tag",
}, },
{ {
id: 281,
text: "photos x 281", text: "photos x 281",
slug: "photos", slug: "photos",
icon: "tag", icon: "tag",

View File

@ -33,16 +33,6 @@ a.hashtag {
.hashtag-icon-placeholder { .hashtag-icon-placeholder {
font-size: var(--font-down-2); font-size: var(--font-down-2);
margin: 0 0.33em 0 0.1em; margin: 0 0.33em 0 0.1em;
&.hashtag-missing {
color: var(--primary-medium);
&.d-icon-square-full {
width: 8px;
height: 10px;
margin-bottom: 0;
margin-right: 0.7em;
}
}
} }
img.emoji { img.emoji {
@ -62,10 +52,6 @@ a.hashtag {
margin-right: 0.25em; margin-right: 0.25em;
margin-left: 0.1em; margin-left: 0.1em;
display: inline-block; display: inline-block;
&.hashtag-missing {
background-color: var(--primary-medium);
}
} }
} }

View File

@ -1,7 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
class HashtagsController < ApplicationController class HashtagsController < ApplicationController
requires_login # Anonymous users can still see public posts which may contain hashtags
requires_login except: [:by_ids]
def by_ids
raise Discourse::NotFound if SiteSetting.login_required? && !current_user
ids =
HashtagAutocompleteService
.data_source_types
.each_with_object({}) { |type, hash| hash[type] = params[type]&.map(&:to_i) }
render json: HashtagAutocompleteService.new(guardian).find_by_ids(ids)
end
def lookup def lookup
render json: HashtagAutocompleteService.new(guardian).lookup(params[:slugs], params[:order]) render json: HashtagAutocompleteService.new(guardian).lookup(params[:slugs], params[:order])

View File

@ -22,6 +22,7 @@ class CategoryHashtagDataSource
item.slug = category.slug item.slug = category.slug
item.description = category.description_text item.description = category.description_text
item.icon = icon item.icon = icon
item.colors = [category.parent_category&.color, category.color].compact
item.relative_url = category.url item.relative_url = category.url
item.id = category.id item.id = category.id
@ -31,6 +32,10 @@ class CategoryHashtagDataSource
end end
end end
def self.find_by_ids(guardian, ids)
Category.secured(guardian).where(id: ids).map { |category| category_to_hashtag_item(category) }
end
def self.lookup(guardian, slugs) def self.lookup(guardian, slugs)
user_categories = user_categories =
Category Category
@ -51,7 +56,7 @@ class CategoryHashtagDataSource
base_search = base_search =
Category Category
.secured(guardian) .secured(guardian)
.select(:id, :parent_category_id, :slug, :name, :description) .select(:id, :parent_category_id, :slug, :name, :description, :color)
.includes(:parent_category) .includes(:parent_category)
if condition == HashtagAutocompleteService.search_conditions[:starts_with] if condition == HashtagAutocompleteService.search_conditions[:starts_with]

View File

@ -85,6 +85,9 @@ class HashtagAutocompleteService
# The icon to display in the UI autocomplete menu for the item. # The icon to display in the UI autocomplete menu for the item.
attr_accessor :icon attr_accessor :icon
# The colors to use when displaying the symbol/icon for the hashtag, e.g. category badge
attr_accessor :colors
# Distinguishes between different entities e.g. tag, category. # Distinguishes between different entities e.g. tag, category.
attr_accessor :type attr_accessor :type
@ -106,6 +109,7 @@ class HashtagAutocompleteService
@text = params[:text] @text = params[:text]
@description = params[:description] @description = params[:description]
@icon = params[:icon] @icon = params[:icon]
@colors = params[:colors]
@type = params[:type] @type = params[:type]
@ref = params[:ref] @ref = params[:ref]
@slug = params[:slug] @slug = params[:slug]
@ -118,6 +122,7 @@ class HashtagAutocompleteService
text: self.text, text: self.text,
description: self.description, description: self.description,
icon: self.icon, icon: self.icon,
colors: self.colors,
type: self.type, type: self.type,
ref: self.ref, ref: self.ref,
slug: self.slug, slug: self.slug,
@ -130,6 +135,22 @@ class HashtagAutocompleteService
@guardian = guardian @guardian = guardian
end end
def find_by_ids(ids_by_type)
HashtagAutocompleteService
.data_source_types
.each_with_object({}) do |type, hash|
next if ids_by_type[type].blank?
data_source = HashtagAutocompleteService.data_source_from_type(type)
next if !data_source.respond_to?(:find_by_ids)
hashtags = data_source.find_by_ids(guardian, ids_by_type[type])
next if hashtags.blank?
hash[type] = set_types(hashtags, type).map(&:to_h)
end
end
## ##
# Finds resources of the provided types by their exact slugs, unlike # Finds resources of the provided types by their exact slugs, unlike
# search which can search partial names, slugs, etc. Used for cooking # search which can search partial names, slugs, etc. Used for cooking

View File

@ -1207,6 +1207,7 @@ Discourse::Application.routes.draw do
end end
get "hashtags" => "hashtags#lookup" get "hashtags" => "hashtags#lookup"
get "hashtags/by-ids" => "hashtags#by_ids"
get "hashtags/search" => "hashtags#search" get "hashtags/search" => "hashtags#search"
TopTopic.periods.each do |period| TopTopic.periods.each do |period|

View File

@ -5,6 +5,7 @@ import { iconHTML } from "discourse-common/lib/icon-library";
export default class ChannelHashtagType extends HashtagTypeBase { export default class ChannelHashtagType extends HashtagTypeBase {
@service chatChannelsManager; @service chatChannelsManager;
@service currentUser; @service currentUser;
@service site;
get type() { get type() {
return "channel"; return "channel";
@ -18,19 +19,25 @@ export default class ChannelHashtagType extends HashtagTypeBase {
} }
} }
generateColorCssClasses(channel) { generateColorCssClasses(channelOrHashtag) {
const color = channelOrHashtag.colors
? channelOrHashtag.colors[0]
: channelOrHashtag.chatable.color;
return [ return [
`.d-icon.hashtag-color--${this.type}-${channel.id} { color: var(--category-${channel.chatable.id}-color); }`, `.d-icon.hashtag-color--${this.type}-${channelOrHashtag.id} { color: #${color} }`,
]; ];
} }
generateIconHTML(hashtag) { generateIconHTML(hashtag) {
const hashtagId = parseInt(hashtag.id, 10); hashtag.colors ? this.onLoad(hashtag) : this.load(hashtag.id);
const colorCssClass = !this.preloadedData.mapBy("id").includes(hashtagId)
? "hashtag-missing"
: `hashtag-color--${this.type}-${hashtag.id}`;
return iconHTML(hashtag.icon, { return iconHTML(hashtag.icon, {
class: colorCssClass, class: `hashtag-color--${this.type}-${hashtag.id}`,
}); });
} }
isLoaded(id) {
return !this.site.lazy_load_categories || super.isLoaded(id);
}
} }

View File

@ -20,12 +20,23 @@ module Chat
item.description = channel.description item.description = channel.description
item.slug = channel.slug item.slug = channel.slug
item.icon = icon item.icon = icon
item.colors = [channel.category.color] if channel.category_channel?
item.relative_url = channel.relative_url item.relative_url = channel.relative_url
item.type = "channel" item.type = "channel"
item.id = channel.id item.id = channel.id
end end
end end
def self.find_by_ids(guardian, ids)
allowed_channel_ids_sql =
Chat::ChannelFetcher.generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true)
Chat::Channel
.where(id: ids)
.where("id IN (#{allowed_channel_ids_sql})")
.map { |channel| channel_to_hashtag_item(guardian, channel) }
end
def self.lookup(guardian, slugs) def self.lookup(guardian, slugs)
return [] if !guardian.can_chat? return [] if !guardian.can_chat?
Chat::ChannelFetcher Chat::ChannelFetcher

View File

@ -50,6 +50,49 @@ RSpec.describe Chat::ChannelHashtagDataSource do
end end
end end
describe "#find_by_ids" do
it "finds a channel by ID" do
result = described_class.find_by_ids(guardian, [channel1.id]).first
expect(result.to_h).to eq(
{
relative_url: channel1.relative_url,
text: "Zany Things",
description: "Just weird stuff",
colors: [channel1.chatable.color],
icon: "comment",
id: channel1.id,
type: "channel",
ref: nil,
slug: "random",
},
)
end
it "does not return a channel that a user does not have permission to view" do
result = described_class.find_by_ids(Guardian.new, [channel2.id]).first
expect(result).to eq(nil)
result = described_class.find_by_ids(guardian, [channel2.id]).first
expect(result).to eq(nil)
GroupUser.create(user: user, group: group)
result = described_class.find_by_ids(Guardian.new(user), [channel2.id]).first
expect(result.to_h).to eq(
{
relative_url: channel2.relative_url,
text: "Secret Stuff",
description: nil,
colors: [channel2.chatable.color],
icon: "comment",
id: channel2.id,
type: "channel",
ref: nil,
slug: "secret",
},
)
end
end
describe "#lookup" do describe "#lookup" do
it "finds a channel by a slug" do it "finds a channel by a slug" do
result = described_class.lookup(guardian, ["random"]).first result = described_class.lookup(guardian, ["random"]).first
@ -58,6 +101,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
relative_url: channel1.relative_url, relative_url: channel1.relative_url,
text: "Zany Things", text: "Zany Things",
description: "Just weird stuff", description: "Just weird stuff",
colors: [channel1.chatable.color],
icon: "comment", icon: "comment",
id: channel1.id, id: channel1.id,
type: "channel", type: "channel",
@ -78,6 +122,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
relative_url: channel2.relative_url, relative_url: channel2.relative_url,
text: "Secret Stuff", text: "Secret Stuff",
description: nil, description: nil,
colors: [channel2.chatable.color],
icon: "comment", icon: "comment",
id: channel2.id, id: channel2.id,
type: "channel", type: "channel",
@ -137,6 +182,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
relative_url: channel1.relative_url, relative_url: channel1.relative_url,
text: "Zany Things", text: "Zany Things",
description: "Just weird stuff", description: "Just weird stuff",
colors: [channel1.chatable.color],
icon: "comment", icon: "comment",
id: channel1.id, id: channel1.id,
type: "channel", type: "channel",
@ -153,6 +199,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
relative_url: channel1.relative_url, relative_url: channel1.relative_url,
text: "Zany Things", text: "Zany Things",
description: "Just weird stuff", description: "Just weird stuff",
colors: [channel1.chatable.color],
icon: "comment", icon: "comment",
id: channel1.id, id: channel1.id,
type: "channel", type: "channel",
@ -172,6 +219,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
relative_url: channel2.relative_url, relative_url: channel2.relative_url,
text: "Secret Stuff", text: "Secret Stuff",
description: nil, description: nil,
colors: [channel2.chatable.color],
icon: "comment", icon: "comment",
id: channel2.id, id: channel2.id,
type: "channel", type: "channel",

View File

@ -159,13 +159,15 @@ describe "Using #hashtag autocompletion to search for and lookup channels", type
it "shows a default color and css class for the channel icon in a post" do it "shows a default color and css class for the channel icon in a post" do
topic_page.visit_topic(topic, post_number: post_with_private_category.post_number) topic_page.visit_topic(topic, post_number: post_with_private_category.post_number)
expect(page).to have_css(".hashtag-cooked") expect(page).to have_css(".hashtag-cooked")
expect(page).to have_css(".hashtag-cooked .hashtag-missing") css_class = ".hashtag-color--channel--#{management_channel.id}"
expect(find("#hashtag-css-generator", visible: false).text(:all)).not_to include(css_class)
end end
it "shows a default color and css class for the channel icon in a channel" do it "shows a default color and css class for the channel icon in a channel" do
chat_page.visit_channel(channel1) chat_page.visit_channel(channel1)
expect(page).to have_css(".hashtag-cooked") expect(page).to have_css(".hashtag-cooked")
expect(page).to have_css(".hashtag-cooked .hashtag-missing") css_class = ".hashtag-color--channel-#{management_channel.id}"
expect(find("#hashtag-css-generator", visible: false).text(:all)).not_to include(css_class)
end end
end end
end end

View File

@ -63,8 +63,13 @@ acceptance("Chat | Hashtag CSS Generator", function (needs) {
const cssTag = document.querySelector("style#hashtag-css-generator"); const cssTag = document.querySelector("style#hashtag-css-generator");
assert.equal( assert.equal(
cssTag.innerHTML, cssTag.innerHTML,
".hashtag-category-badge { background-color: var(--primary-medium); }\n" +
".hashtag-color--category-1 {\n background: linear-gradient(-90deg, var(--category-1-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--category-2 {\n background: linear-gradient(-90deg, var(--category-2-color) 50%, var(--category-2-color) 50%);\n}\n.hashtag-color--category-4 {\n background: linear-gradient(-90deg, var(--category-4-color) 50%, var(--category-1-color) 50%);\n}\n.d-icon.hashtag-color--channel-44 { color: var(--category-1-color); }\n.d-icon.hashtag-color--channel-74 { color: var(--category-2-color); }\n.d-icon.hashtag-color--channel-88 { color: var(--category-4-color); }" ".hashtag-color--category-1 { background-color: #ff0000; }\n" +
".hashtag-color--category-2 { background-color: #333; }\n" +
".hashtag-color--category-4 { background-color: #2B81AF; }\n" +
".d-icon.hashtag-color--channel-44 { color: #ff0000 }\n" +
".d-icon.hashtag-color--channel-74 { color: #333 }\n" +
".d-icon.hashtag-color--channel-88 { color: #2B81AF }"
); );
}); });
}); });

View File

@ -36,6 +36,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: tag.url, relative_url: tag.url,
text: "somecooltag", text: "somecooltag",
description: "Coolest things ever", description: "Coolest things ever",
colors: nil,
icon: "tag", icon: "tag",
id: tag.id, id: tag.id,
slug: "somecooltag", slug: "somecooltag",
@ -54,6 +55,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: category.url, relative_url: category.url,
text: "Some Awesome Category", text: "Some Awesome Category",
description: "Really great stuff here", description: "Really great stuff here",
colors: [category.color],
icon: "folder", icon: "folder",
id: category.id, id: category.id,
slug: "someawesomecategory", slug: "someawesomecategory",
@ -71,6 +73,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: category.url, relative_url: category.url,
text: "Some Awesome Category", text: "Some Awesome Category",
description: "Really great stuff here", description: "Really great stuff here",
colors: [category.color],
icon: "folder", icon: "folder",
id: category.id, id: category.id,
slug: "someawesomecategory", slug: "someawesomecategory",
@ -86,6 +89,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: tag.url, relative_url: tag.url,
text: "somecooltag", text: "somecooltag",
description: "Coolest things ever", description: "Coolest things ever",
colors: nil,
icon: "tag", icon: "tag",
id: tag.id, id: tag.id,
slug: "somecooltag", slug: "somecooltag",
@ -100,6 +104,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: category.url, relative_url: category.url,
text: "Some Awesome Category", text: "Some Awesome Category",
description: "Really great stuff here", description: "Really great stuff here",
colors: [category.color],
icon: "folder", icon: "folder",
id: category.id, id: category.id,
slug: "someawesomecategory", slug: "someawesomecategory",
@ -123,6 +128,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: private_category.url, relative_url: private_category.url,
text: "Manager Hideout", text: "Manager Hideout",
description: nil, description: nil,
colors: [private_category.color],
icon: "folder", icon: "folder",
id: private_category.id, id: private_category.id,
slug: "secretcategory", slug: "secretcategory",

View File

@ -19,6 +19,133 @@ RSpec.describe HashtagsController do
tag_group tag_group
end end
describe "#by_ids" do
context "when logged in" do
context "as anonymous user" do
it "does not return private categories" do
get "/hashtags/by-ids.json", params: { category: [category.id, private_category.id, -1] }
expect(response.status).to eq(200)
expect(response.parsed_body).to eq(
{
"category" => [
{
"relative_url" => category.url,
"text" => category.name,
"description" => nil,
"colors" => [category.color],
"icon" => "folder",
"type" => "category",
"ref" => category.slug,
"slug" => category.slug,
"id" => category.id,
},
],
},
)
end
it "does not return categories on login_required sites" do
SiteSetting.login_required = true
get "/hashtags/by-ids.json", params: { category: [category.id, private_category.id, -1] }
expect(response.status).to eq(403)
end
end
context "as regular user" do
before { sign_in(Fabricate(:user)) }
it "does not return private categories" do
get "/hashtags/by-ids.json", params: { category: [category.id, private_category.id, -1] }
expect(response.status).to eq(200)
expect(response.parsed_body).to eq(
{
"category" => [
{
"relative_url" => category.url,
"text" => category.name,
"description" => nil,
"colors" => [category.color],
"icon" => "folder",
"type" => "category",
"ref" => category.slug,
"slug" => category.slug,
"id" => category.id,
},
],
},
)
end
end
context "as admin" do
before { sign_in(Fabricate(:admin)) }
it "returns private categories" do
get "/hashtags/by-ids.json", params: { category: [category.id, private_category.id, -1] }
expect(response.status).to eq(200)
expect(response.parsed_body).to eq(
{
"category" => [
{
"relative_url" => category.url,
"text" => category.name,
"description" => nil,
"colors" => [category.color],
"icon" => "folder",
"type" => "category",
"ref" => category.slug,
"slug" => category.slug,
"id" => category.id,
},
{
"relative_url" => private_category.url,
"text" => private_category.name,
"description" => nil,
"colors" => [private_category.color],
"icon" => "folder",
"type" => "category",
"ref" => private_category.slug,
"slug" => private_category.slug,
"id" => private_category.id,
},
],
},
)
end
end
end
context "when not logged in" do
it "does not return private categories" do
get "/hashtags/by-ids.json", params: { category: [category.id, private_category.id, -1] }
expect(response.status).to eq(200)
expect(response.parsed_body).to eq(
{
"category" => [
{
"relative_url" => category.url,
"text" => category.name,
"description" => nil,
"colors" => [category.color],
"icon" => "folder",
"type" => "category",
"ref" => category.slug,
"slug" => category.slug,
"id" => category.id,
},
],
},
)
end
end
end
describe "#lookup" do describe "#lookup" do
context "when logged in" do context "when logged in" do
context "as regular user" do context "as regular user" do
@ -39,6 +166,7 @@ RSpec.describe HashtagsController do
"relative_url" => category.url, "relative_url" => category.url,
"text" => category.name, "text" => category.name,
"description" => nil, "description" => nil,
"colors" => [category.color],
"icon" => "folder", "icon" => "folder",
"type" => "category", "type" => "category",
"ref" => category.slug, "ref" => category.slug,
@ -51,6 +179,7 @@ RSpec.describe HashtagsController do
"relative_url" => tag.url, "relative_url" => tag.url,
"text" => tag.name, "text" => tag.name,
"description" => nil, "description" => nil,
"colors" => nil,
"icon" => "tag", "icon" => "tag",
"type" => "tag", "type" => "tag",
"ref" => tag.name, "ref" => tag.name,
@ -75,6 +204,7 @@ RSpec.describe HashtagsController do
"relative_url" => tag.url, "relative_url" => tag.url,
"text" => tag.name, "text" => tag.name,
"description" => nil, "description" => nil,
"colors" => nil,
"icon" => "tag", "icon" => "tag",
"type" => "tag", "type" => "tag",
"ref" => "#{tag.name}::tag", "ref" => "#{tag.name}::tag",
@ -121,6 +251,7 @@ RSpec.describe HashtagsController do
"relative_url" => private_category.url, "relative_url" => private_category.url,
"text" => private_category.name, "text" => private_category.name,
"description" => nil, "description" => nil,
"colors" => [private_category.color],
"icon" => "folder", "icon" => "folder",
"type" => "category", "type" => "category",
"ref" => private_category.slug, "ref" => private_category.slug,
@ -133,6 +264,7 @@ RSpec.describe HashtagsController do
"relative_url" => hidden_tag.url, "relative_url" => hidden_tag.url,
"text" => hidden_tag.name, "text" => hidden_tag.name,
"description" => nil, "description" => nil,
"colors" => nil,
"icon" => "tag", "icon" => "tag",
"type" => "tag", "type" => "tag",
"ref" => hidden_tag.name, "ref" => hidden_tag.name,
@ -218,6 +350,7 @@ RSpec.describe HashtagsController do
"text" => category.name, "text" => category.name,
"description" => nil, "description" => nil,
"icon" => "folder", "icon" => "folder",
"colors" => [category.color],
"type" => "category", "type" => "category",
"ref" => category.slug, "ref" => category.slug,
"slug" => category.slug, "slug" => category.slug,
@ -227,6 +360,7 @@ RSpec.describe HashtagsController do
"relative_url" => tag_2.url, "relative_url" => tag_2.url,
"text" => tag_2.name, "text" => tag_2.name,
"description" => nil, "description" => nil,
"colors" => nil,
"icon" => "tag", "icon" => "tag",
"type" => "tag", "type" => "tag",
"ref" => "#{tag_2.name}::tag", "ref" => "#{tag_2.name}::tag",
@ -261,6 +395,7 @@ RSpec.describe HashtagsController do
"relative_url" => private_category.url, "relative_url" => private_category.url,
"text" => private_category.name, "text" => private_category.name,
"description" => nil, "description" => nil,
"colors" => [private_category.color],
"icon" => "folder", "icon" => "folder",
"type" => "category", "type" => "category",
"ref" => private_category.slug, "ref" => private_category.slug,
@ -278,6 +413,7 @@ RSpec.describe HashtagsController do
"relative_url" => hidden_tag.url, "relative_url" => hidden_tag.url,
"text" => hidden_tag.name, "text" => hidden_tag.name,
"description" => nil, "description" => nil,
"colors" => nil,
"icon" => "tag", "icon" => "tag",
"type" => "tag", "type" => "tag",
"ref" => "#{hidden_tag.name}", "ref" => "#{hidden_tag.name}",

View File

@ -14,6 +14,20 @@ RSpec.describe CategoryHashtagDataSource do
let(:guardian) { Guardian.new(user) } let(:guardian) { Guardian.new(user) }
let(:uncategorized_category) { Category.find(SiteSetting.uncategorized_category_id) } let(:uncategorized_category) { Category.find(SiteSetting.uncategorized_category_id) }
describe "#find_by_ids" do
it "finds categories by their IDs" do
expect(
described_class.find_by_ids(guardian, [parent_category.id, category1.id]).map(&:slug),
).to contain_exactly("fun", "random")
end
it "does not find categories the user cannot access" do
expect(described_class.find_by_ids(guardian, [category4.id]).first).to eq(nil)
group.add(user)
expect(described_class.find_by_ids(Guardian.new(user), [category4.id]).first).not_to eq(nil)
end
end
describe "#lookup" do describe "#lookup" do
it "finds categories using their slug, downcasing for matches" do it "finds categories using their slug, downcasing for matches" do
result = described_class.lookup(guardian, ["movies"]).first result = described_class.lookup(guardian, ["movies"]).first

View File

@ -307,6 +307,14 @@ RSpec.describe HashtagAutocompleteService do
end end
end end
describe "#find_by_ids" do
it "can lookup and return only categories" do
results = service.find_by_ids({ "category" => [category1.id] })
expect(results["category"].map { |r| r[:slug] }).to eq(["the-book-club"])
end
end
describe "#lookup" do describe "#lookup" do
fab!(:tag2) { Fabricate(:tag, name: "fiction-books") } fab!(:tag2) { Fabricate(:tag, name: "fiction-books") }

View File

@ -255,7 +255,10 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
it "shows a default color and css class for the category icon square" do it "shows a default color and css class for the category icon square" do
topic_page.visit_topic(topic, post_number: post_with_private_category.post_number) topic_page.visit_topic(topic, post_number: post_with_private_category.post_number)
expect(page).to have_css(".hashtag-cooked .hashtag-missing") expect(page).to have_css(".hashtag-cooked .hashtag-category-badge")
generated_css = find("#hashtag-css-generator", visible: false).text(:all)
expect(generated_css).to include(".hashtag-category-badge")
expect(generated_css).not_to include(".hashtag-color--category--#{private_category.id}")
end end
end end
end end