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:
parent
6b596151ff
commit
1403217ca4
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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; }'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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") }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue