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.
*
* 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.
*/
initialize(owner) {
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");
cssTag.type = "text/css";
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);
},
};

View File

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

View File

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

View File

@ -1,8 +1,37 @@
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 {
// 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) {
setOwner(this, owner);
this.loadedIds = new Set();
}
get type() {
@ -13,6 +42,15 @@ export default class HashtagTypeBase {
throw "not implemented";
}
generatePreloadedCssClasses() {
const cssClasses = [];
this.preloadedData.forEach((model) => {
this.loadedIds.add(model.id);
cssClasses.push(this.generateColorCssClasses(model));
});
return cssClasses.flat();
}
generateColorCssClasses() {
throw "not implemented";
}
@ -20,4 +58,29 @@ export default class HashtagTypeBase {
generateIconHTML() {
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 || [];
}
generateColorCssClasses(category) {
const generatedCssClasses = [];
const backgroundGradient = [`var(--category-${category.id}-color) 50%`];
if (category.parentCategory) {
backgroundGradient.push(
`var(--category-${category.parentCategory.id}-color) 50%`
);
generatePreloadedCssClasses() {
return [
// Set a default color for category hashtags. This is added here instead
// of `hashtag.scss` because of the CSS precedence rules (<link> has a
// higher precedence than <style>)
".hashtag-category-badge { background-color: var(--primary-medium); }",
...super.generatePreloadedCssClasses(),
];
}
generateColorCssClasses(categoryOrHashtag) {
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 {
backgroundGradient.push(`var(--category-${category.id}-color) 50%`);
color = categoryOrHashtag.color;
if (categoryOrHashtag.parentCategory) {
parentColor = categoryOrHashtag.parentCategory.color;
}
}
generatedCssClasses.push(`.hashtag-color--category-${category.id} {
background: linear-gradient(-90deg, ${backgroundGradient.join(", ")});
}`);
let style;
if (parentColor) {
style = `background: linear-gradient(-90deg, #${color} 50%, #${parentColor} 50%);`;
} else {
style = `background-color: #${color};`;
}
return generatedCssClasses;
return [`.hashtag-color--category-${categoryOrHashtag.id} { ${style} }`];
}
generateIconHTML(hashtag) {
const hashtagId = parseInt(hashtag.id, 10);
const colorCssClass = !this.preloadedData.mapBy("id").includes(hashtagId)
? "hashtag-missing"
: `hashtag-color--${this.type}-${hashtag.id}`;
hashtag.preloaded ? this.onLoad(hashtag) : this.load(hashtag.id);
const colorCssClass = `hashtag-color--${this.type}-${hashtag.id}`;
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");
assert.equal(
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");
assert.equal(
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");
assert.equal(
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({
results: [
{
id: 28,
text: ":bug: Other Languages",
slug: "other-languages",
colors: ["FF0000"],
icon: "folder",
relative_url: "/c/other-languages/28",
ref: "other-languages",
type: "category",
},
{
id: 300,
text: "notes x 300",
slug: "notes",
icon: "tag",
@ -37,6 +40,7 @@ acceptance("#hashtag autocompletion in composer", function (needs) {
type: "tag",
},
{
id: 281,
text: "photos x 281",
slug: "photos",
icon: "tag",

View File

@ -33,16 +33,6 @@ a.hashtag {
.hashtag-icon-placeholder {
font-size: var(--font-down-2);
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 {
@ -62,10 +52,6 @@ a.hashtag {
margin-right: 0.25em;
margin-left: 0.1em;
display: inline-block;
&.hashtag-missing {
background-color: var(--primary-medium);
}
}
}

View File

@ -1,7 +1,19 @@
# frozen_string_literal: true
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
render json: HashtagAutocompleteService.new(guardian).lookup(params[:slugs], params[:order])

View File

@ -22,6 +22,7 @@ class CategoryHashtagDataSource
item.slug = category.slug
item.description = category.description_text
item.icon = icon
item.colors = [category.parent_category&.color, category.color].compact
item.relative_url = category.url
item.id = category.id
@ -31,6 +32,10 @@ class CategoryHashtagDataSource
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)
user_categories =
Category
@ -51,7 +56,7 @@ class CategoryHashtagDataSource
base_search =
Category
.secured(guardian)
.select(:id, :parent_category_id, :slug, :name, :description)
.select(:id, :parent_category_id, :slug, :name, :description, :color)
.includes(:parent_category)
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.
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.
attr_accessor :type
@ -106,6 +109,7 @@ class HashtagAutocompleteService
@text = params[:text]
@description = params[:description]
@icon = params[:icon]
@colors = params[:colors]
@type = params[:type]
@ref = params[:ref]
@slug = params[:slug]
@ -118,6 +122,7 @@ class HashtagAutocompleteService
text: self.text,
description: self.description,
icon: self.icon,
colors: self.colors,
type: self.type,
ref: self.ref,
slug: self.slug,
@ -130,6 +135,22 @@ class HashtagAutocompleteService
@guardian = guardian
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
# search which can search partial names, slugs, etc. Used for cooking

View File

@ -1207,6 +1207,7 @@ Discourse::Application.routes.draw do
end
get "hashtags" => "hashtags#lookup"
get "hashtags/by-ids" => "hashtags#by_ids"
get "hashtags/search" => "hashtags#search"
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 {
@service chatChannelsManager;
@service currentUser;
@service site;
get type() {
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 [
`.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) {
const hashtagId = parseInt(hashtag.id, 10);
const colorCssClass = !this.preloadedData.mapBy("id").includes(hashtagId)
? "hashtag-missing"
: `hashtag-color--${this.type}-${hashtag.id}`;
hashtag.colors ? this.onLoad(hashtag) : this.load(hashtag.id);
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.slug = channel.slug
item.icon = icon
item.colors = [channel.category.color] if channel.category_channel?
item.relative_url = channel.relative_url
item.type = "channel"
item.id = channel.id
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)
return [] if !guardian.can_chat?
Chat::ChannelFetcher

View File

@ -50,6 +50,49 @@ RSpec.describe Chat::ChannelHashtagDataSource do
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
it "finds a channel by a slug" do
result = described_class.lookup(guardian, ["random"]).first
@ -58,6 +101,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
relative_url: channel1.relative_url,
text: "Zany Things",
description: "Just weird stuff",
colors: [channel1.chatable.color],
icon: "comment",
id: channel1.id,
type: "channel",
@ -78,6 +122,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
relative_url: channel2.relative_url,
text: "Secret Stuff",
description: nil,
colors: [channel2.chatable.color],
icon: "comment",
id: channel2.id,
type: "channel",
@ -137,6 +182,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
relative_url: channel1.relative_url,
text: "Zany Things",
description: "Just weird stuff",
colors: [channel1.chatable.color],
icon: "comment",
id: channel1.id,
type: "channel",
@ -153,6 +199,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
relative_url: channel1.relative_url,
text: "Zany Things",
description: "Just weird stuff",
colors: [channel1.chatable.color],
icon: "comment",
id: channel1.id,
type: "channel",
@ -172,6 +219,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
relative_url: channel2.relative_url,
text: "Secret Stuff",
description: nil,
colors: [channel2.chatable.color],
icon: "comment",
id: channel2.id,
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
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 .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
it "shows a default color and css class for the channel icon in a channel" do
chat_page.visit_channel(channel1)
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

View File

@ -63,8 +63,13 @@ acceptance("Chat | Hashtag CSS Generator", function (needs) {
const cssTag = document.querySelector("style#hashtag-css-generator");
assert.equal(
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}\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-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; }\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,
text: "somecooltag",
description: "Coolest things ever",
colors: nil,
icon: "tag",
id: tag.id,
slug: "somecooltag",
@ -54,6 +55,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: category.url,
text: "Some Awesome Category",
description: "Really great stuff here",
colors: [category.color],
icon: "folder",
id: category.id,
slug: "someawesomecategory",
@ -71,6 +73,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: category.url,
text: "Some Awesome Category",
description: "Really great stuff here",
colors: [category.color],
icon: "folder",
id: category.id,
slug: "someawesomecategory",
@ -86,6 +89,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: tag.url,
text: "somecooltag",
description: "Coolest things ever",
colors: nil,
icon: "tag",
id: tag.id,
slug: "somecooltag",
@ -100,6 +104,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: category.url,
text: "Some Awesome Category",
description: "Really great stuff here",
colors: [category.color],
icon: "folder",
id: category.id,
slug: "someawesomecategory",
@ -123,6 +128,7 @@ RSpec.describe PrettyText::Helpers do
relative_url: private_category.url,
text: "Manager Hideout",
description: nil,
colors: [private_category.color],
icon: "folder",
id: private_category.id,
slug: "secretcategory",

View File

@ -19,6 +19,133 @@ RSpec.describe HashtagsController do
tag_group
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
context "when logged in" do
context "as regular user" do
@ -39,6 +166,7 @@ RSpec.describe HashtagsController do
"relative_url" => category.url,
"text" => category.name,
"description" => nil,
"colors" => [category.color],
"icon" => "folder",
"type" => "category",
"ref" => category.slug,
@ -51,6 +179,7 @@ RSpec.describe HashtagsController do
"relative_url" => tag.url,
"text" => tag.name,
"description" => nil,
"colors" => nil,
"icon" => "tag",
"type" => "tag",
"ref" => tag.name,
@ -75,6 +204,7 @@ RSpec.describe HashtagsController do
"relative_url" => tag.url,
"text" => tag.name,
"description" => nil,
"colors" => nil,
"icon" => "tag",
"type" => "tag",
"ref" => "#{tag.name}::tag",
@ -121,6 +251,7 @@ RSpec.describe HashtagsController do
"relative_url" => private_category.url,
"text" => private_category.name,
"description" => nil,
"colors" => [private_category.color],
"icon" => "folder",
"type" => "category",
"ref" => private_category.slug,
@ -133,6 +264,7 @@ RSpec.describe HashtagsController do
"relative_url" => hidden_tag.url,
"text" => hidden_tag.name,
"description" => nil,
"colors" => nil,
"icon" => "tag",
"type" => "tag",
"ref" => hidden_tag.name,
@ -218,6 +350,7 @@ RSpec.describe HashtagsController do
"text" => category.name,
"description" => nil,
"icon" => "folder",
"colors" => [category.color],
"type" => "category",
"ref" => category.slug,
"slug" => category.slug,
@ -227,6 +360,7 @@ RSpec.describe HashtagsController do
"relative_url" => tag_2.url,
"text" => tag_2.name,
"description" => nil,
"colors" => nil,
"icon" => "tag",
"type" => "tag",
"ref" => "#{tag_2.name}::tag",
@ -261,6 +395,7 @@ RSpec.describe HashtagsController do
"relative_url" => private_category.url,
"text" => private_category.name,
"description" => nil,
"colors" => [private_category.color],
"icon" => "folder",
"type" => "category",
"ref" => private_category.slug,
@ -278,6 +413,7 @@ RSpec.describe HashtagsController do
"relative_url" => hidden_tag.url,
"text" => hidden_tag.name,
"description" => nil,
"colors" => nil,
"icon" => "tag",
"type" => "tag",
"ref" => "#{hidden_tag.name}",

View File

@ -14,6 +14,20 @@ RSpec.describe CategoryHashtagDataSource do
let(:guardian) { Guardian.new(user) }
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
it "finds categories using their slug, downcasing for matches" do
result = described_class.lookup(guardian, ["movies"]).first

View File

@ -307,6 +307,14 @@ RSpec.describe HashtagAutocompleteService do
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
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
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