FIX: Do not cook icon with hashtags (#21676)

This commit makes some fundamental changes to how hashtag cooking and
icon generation works in the new experimental hashtag autocomplete mode.
Previously we cooked the appropriate SVG icon with the cooked hashtag,
though this has proved inflexible especially for theming purposes.

Instead, we now cook a data-ID attribute with the hashtag and add a new
span as an icon placeholder. This is replaced on the client side with an
icon (or a square span in the case of categories) on the client side via
the decorateCooked API for posts and chat messages.

This client side logic uses the generated hashtag, category, and channel
CSS classes added in a previous commit.

This is missing changes to the sidebar to use the new generated CSS
classes and also colors and the split square for categories in the
hashtag autocomplete menu -- I will tackle this in a separate PR so it
is clearer.
This commit is contained in:
Martin Brennan 2023-05-23 09:33:55 +02:00 committed by GitHub
parent ecb9a27e55
commit 0b3cf83e3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 235 additions and 102 deletions

View File

@ -25,8 +25,7 @@ export default {
let generatedCssClasses = []; let generatedCssClasses = [];
Object.values(getHashtagTypeClasses()).forEach((hashtagTypeClass) => { Object.values(getHashtagTypeClasses()).forEach((hashtagType) => {
const hashtagType = new hashtagTypeClass(container);
hashtagType.preloadedData.forEach((model) => { hashtagType.preloadedData.forEach((model) => {
generatedCssClasses = generatedCssClasses.concat( generatedCssClasses = generatedCssClasses.concat(
hashtagType.generateColorCssClasses(model) hashtagType.generateColorCssClasses(model)

View File

@ -0,0 +1,24 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import { replaceHashtagIconPlaceholder } from "discourse/lib/hashtag-autocomplete";
export default {
name: "hashtag-post-decorations",
after: "hashtag-css-generator",
initialize(container) {
const siteSettings = container.lookup("service:site-settings");
const site = container.lookup("service:site");
withPluginApi("0.8.7", (api) => {
if (siteSettings.enable_experimental_hashtag_autocomplete) {
api.decorateCookedElement(
(post) => replaceHashtagIconPlaceholder(post, site),
{
onlyStream: true,
id: "hashtag-icons",
}
);
}
});
},
};

View File

@ -6,10 +6,10 @@ export default {
name: "register-hashtag-types", name: "register-hashtag-types",
before: "hashtag-css-generator", before: "hashtag-css-generator",
initialize() { initialize(container) {
withPluginApi("0.8.7", (api) => { withPluginApi("0.8.7", (api) => {
api.registerHashtagType("category", CategoryHashtagType); api.registerHashtagType("category", new CategoryHashtagType(container));
api.registerHashtagType("tag", TagHashtagType); api.registerHashtagType("tag", new TagHashtagType(container));
}); });
}, },
}; };

View File

@ -46,7 +46,7 @@ export function isValidLink(link) {
return ( return (
link.classList.contains("track-link") || link.classList.contains("track-link") ||
!link.closest( !link.closest(
".hashtag, .hashtag-cooked, .badge-category, .onebox-result, .onebox-body" ".hashtag, .hashtag-cooked, .hashtag-icon-placeholder, .badge-category, .onebox-result, .onebox-body"
) )
); );
} }

View File

@ -1,4 +1,5 @@
import { findRawTemplate } from "discourse-common/lib/raw-templates"; import { findRawTemplate } from "discourse-common/lib/raw-templates";
import domFromString from "discourse-common/lib/dom-from-string";
import discourseLater from "discourse-common/lib/later"; import discourseLater from "discourse-common/lib/later";
import { INPUT_DELAY, isTesting } from "discourse-common/config/environment"; import { INPUT_DELAY, isTesting } from "discourse-common/config/environment";
import { cancel } from "@ember/runloop"; import { cancel } from "@ember/runloop";
@ -15,8 +16,8 @@ import { emojiUnescape } from "discourse/lib/text";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
let hashtagTypeClasses = {}; let hashtagTypeClasses = {};
export function registerHashtagType(type, typeClass) { export function registerHashtagType(type, typeClassInstance) {
hashtagTypeClasses[type] = typeClass; hashtagTypeClasses[type] = typeClassInstance;
} }
export function cleanUpHashtagTypeClasses() { export function cleanUpHashtagTypeClasses() {
hashtagTypeClasses = {}; hashtagTypeClasses = {};
@ -24,6 +25,24 @@ export function cleanUpHashtagTypeClasses() {
export function getHashtagTypeClasses() { export function getHashtagTypeClasses() {
return hashtagTypeClasses; return hashtagTypeClasses;
} }
export function replaceHashtagIconPlaceholder(element, site) {
element.querySelectorAll(".hashtag-cooked").forEach((hashtagEl) => {
const iconPlaceholderEl = hashtagEl.querySelector(
".hashtag-icon-placeholder"
);
const hashtagType = hashtagEl.dataset.type;
const hashtagTypeClass = getHashtagTypeClasses()[hashtagType];
if (iconPlaceholderEl && hashtagTypeClass) {
const hashtagIconHTML = hashtagTypeClass
.generateIconHTML({
icon: site.hashtag_icons[hashtagType],
id: hashtagEl.dataset.id,
})
.trim();
iconPlaceholderEl.replaceWith(domFromString(hashtagIconHTML)[0]);
}
});
}
/** /**
* Sets up a textarea using the jQuery autocomplete plugin, specifically * Sets up a textarea using the jQuery autocomplete plugin, specifically
@ -216,12 +235,12 @@ function _searchRequest(term, contextualHashtagConfiguration, resultFunc) {
data: { term, order: contextualHashtagConfiguration }, data: { term, order: contextualHashtagConfiguration },
}); });
currentSearch currentSearch
.then((r) => { .then((response) => {
r.results?.forEach((result) => { response.results?.forEach((result) => {
// Convert :emoji: in the result text to HTML safely. // Convert :emoji: in the result text to HTML safely.
result.text = htmlSafe(emojiUnescape(escapeExpression(result.text))); result.text = htmlSafe(emojiUnescape(escapeExpression(result.text)));
}); });
resultFunc(r.results || CANCELLED_STATUS); resultFunc(response.results || CANCELLED_STATUS);
}) })
.finally(() => { .finally(() => {
currentSearch = null; currentSearch = null;
@ -235,7 +254,7 @@ function _findAndReplaceSeenHashtagPlaceholder(
hashtagSpan hashtagSpan
) { ) {
contextualHashtagConfiguration.forEach((type) => { contextualHashtagConfiguration.forEach((type) => {
// 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) {
// NOTE: When changing the HTML structure here, you must also change // NOTE: When changing the HTML structure here, you must also change
@ -244,8 +263,12 @@ function _findAndReplaceSeenHashtagPlaceholder(
link.classList.add("hashtag-cooked"); link.classList.add("hashtag-cooked");
link.href = matchingSeenHashtag.relative_url; link.href = matchingSeenHashtag.relative_url;
link.dataset.type = type; link.dataset.type = type;
link.dataset.id = matchingSeenHashtag.id;
link.dataset.slug = matchingSeenHashtag.slug; link.dataset.slug = matchingSeenHashtag.slug;
link.innerHTML = `<svg class="fa d-icon d-icon-${matchingSeenHashtag.icon} svg-icon svg-node"><use href="#${matchingSeenHashtag.icon}"></use></svg><span>${matchingSeenHashtag.text}</span>`; const hashtagType = new getHashtagTypeClasses()[type];
link.innerHTML = `${hashtagType.generateIconHTML(
matchingSeenHashtag
)}<span>${emojiUnescape(matchingSeenHashtag.text)}</span>`;
hashtagSpan.replaceWith(link); hashtagSpan.replaceWith(link);
} }
}); });

View File

@ -16,4 +16,8 @@ export default class HashtagTypeBase {
generateColorCssClasses() { generateColorCssClasses() {
throw "not implemented"; throw "not implemented";
} }
generateIconHTML() {
throw "not implemented";
}
} }

View File

@ -12,21 +12,25 @@ export default class CategoryHashtagType extends HashtagTypeBase {
return this.site.categories || []; return this.site.categories || [];
} }
generateColorCssClasses(model) { generateColorCssClasses(category) {
const generatedCssClasses = []; const generatedCssClasses = [];
const backgroundGradient = [`var(--category-${model.id}-color) 50%`]; const backgroundGradient = [`var(--category-${category.id}-color) 50%`];
if (model.parentCategory) { if (category.parentCategory) {
backgroundGradient.push( backgroundGradient.push(
`var(--category-${model.parentCategory.id}-color) 50%` `var(--category-${category.parentCategory.id}-color) 50%`
); );
} else { } else {
backgroundGradient.push(`var(--category-${model.id}-color) 50%`); backgroundGradient.push(`var(--category-${category.id}-color) 50%`);
} }
generatedCssClasses.push(`.hashtag-color--category-${model.id} { generatedCssClasses.push(`.hashtag-color--category-${category.id} {
background: linear-gradient(90deg, ${backgroundGradient.join(", ")}); background: linear-gradient(90deg, ${backgroundGradient.join(", ")});
}`); }`);
return generatedCssClasses; return generatedCssClasses;
} }
generateIconHTML(hashtag) {
return `<span class="hashtag-category-badge hashtag-color--${this.type}-${hashtag.id}"></span>`;
}
} }

View File

@ -1,4 +1,5 @@
import HashtagTypeBase from "./base"; import HashtagTypeBase from "./base";
import { iconHTML } from "discourse-common/lib/icon-library";
export default class TagHashtagType extends HashtagTypeBase { export default class TagHashtagType extends HashtagTypeBase {
get type() { get type() {
@ -12,4 +13,10 @@ export default class TagHashtagType extends HashtagTypeBase {
generateColorCssClasses() { generateColorCssClasses() {
return []; return [];
} }
generateIconHTML(hashtag) {
return iconHTML(hashtag.icon, {
class: `hashtag-color--${this.type}-${hashtag.id}`,
});
}
} }

View File

@ -2226,10 +2226,11 @@ class PluginApi {
* This is used when generating CSS classes in the hashtag-css-generator. * This is used when generating CSS classes in the hashtag-css-generator.
* *
* @param {string} type - The type of the hashtag. * @param {string} type - The type of the hashtag.
* @param {Class} typeClass - The class of the hashtag type. * @param {Class} typeClassInstance - The initialized class of the hashtag type, which
* needs the `container`.
*/ */
registerHashtagType(type, typeClass) { registerHashtagType(type, typeClassInstance) {
registerHashtagType(type, typeClass); registerHashtagType(type, typeClassInstance);
} }
} }

View File

@ -699,7 +699,7 @@ export default {
], ],
displayed_about_plugin_stat_groups: ["chat_messages"], displayed_about_plugin_stat_groups: ["chat_messages"],
hashtag_configurations: { "topic-composer": ["category", "tag"] }, hashtag_configurations: { "topic-composer": ["category", "tag"] },
hashtag_icons: ["folder", "tag"], hashtag_icons: { "category": "folder", "tag": "tag" },
anonymous_sidebar_sections: [ anonymous_sidebar_sections: [
{ {
id: 111, id: 111,

View File

@ -29,6 +29,7 @@ function addHashtag(buffer, matches, state) {
["href", result.relative_url], ["href", result.relative_url],
["data-type", result.type], ["data-type", result.type],
["data-slug", result.slug], ["data-slug", result.slug],
["data-id", result.id],
]; ];
// Most cases these will be the exact same, one standout is categories // Most cases these will be the exact same, one standout is categories
@ -40,20 +41,7 @@ function addHashtag(buffer, matches, state) {
token.block = false; token.block = false;
buffer.push(token); buffer.push(token);
token = new state.Token("svg_open", "svg", 1); addIconPlaceholder(buffer, state);
token.block = false;
token.attrs = [
["class", `fa d-icon d-icon-${result.icon} svg-icon svg-node`],
];
buffer.push(token);
token = new state.Token("use_open", "use", 1);
token.block = false;
token.attrs = [["href", `#${result.icon}`]];
buffer.push(token);
buffer.push(new state.Token("use_close", "use", -1));
buffer.push(new state.Token("svg_close", "svg", -1));
token = new state.Token("span_open", "span", 1); token = new state.Token("span_open", "span", 1);
token.block = false; token.block = false;
@ -82,24 +70,22 @@ function addHashtag(buffer, matches, state) {
} }
} }
// The svg icon is not baked into the HTML because we want
// to be able to use icon replacement via renderIcon, and
// because different hashtag types may render icons/CSS
// classes differently.
//
// Instead, the UI will dynamically replace these where hashtags
// are rendered, like within posts, using decorateCooked* APIs.
function addIconPlaceholder(buffer, state) {
const token = new state.Token("span_open", "span", 1);
token.block = false;
token.attrs = [["class", "hashtag-icon-placeholder"]];
buffer.push(token);
buffer.push(new state.Token("span_close", "span", -1));
}
export function setup(helper) { export function setup(helper) {
const opts = helper.getOptions();
// we do this because plugins can register their own hashtag data
// sources which specify an icon, and each icon must be allowlisted
// or it will not render in the markdown pipeline
const hashtagIconAllowList = opts.hashtagIcons
? opts.hashtagIcons
.concat(["hashtag"])
.map((icon) => {
return [
`svg[class=fa d-icon d-icon-${icon} svg-icon svg-node]`,
`use[href=#${icon}]`,
];
})
.flat()
: [];
helper.registerPlugin((md) => { helper.registerPlugin((md) => {
if ( if (
md.options.discourse.limitedSiteSettings md.options.discourse.limitedSiteSettings
@ -114,13 +100,13 @@ export function setup(helper) {
} }
}); });
helper.allowList( helper.allowList([
hashtagIconAllowList.concat([ "a.hashtag-cooked",
"a.hashtag-cooked", "span.hashtag-raw",
"span.hashtag-raw", "span.hashtag-icon-placeholder",
"a[data-type]", "a[data-type]",
"a[data-slug]", "a[data-slug]",
"a[data-ref]", "a[data-ref]",
]) "a[data-id]",
); ]);
} }

View File

@ -43,6 +43,7 @@ const NONE = 0;
const MENTION = 1; const MENTION = 1;
const HASHTAG_LINK = 2; const HASHTAG_LINK = 2;
const HASHTAG_SPAN = 3; const HASHTAG_SPAN = 3;
const HASHTAG_ICON_SPAN = 4;
export function setup(helper) { export function setup(helper) {
const opts = helper.getOptions(); const opts = helper.getOptions();
@ -97,6 +98,7 @@ export function setup(helper) {
// We scan once to mark tokens that must be skipped because they are // We scan once to mark tokens that must be skipped because they are
// mentions or hashtags // mentions or hashtags
let lastType = NONE; let lastType = NONE;
let currentType = NONE;
for (let i = 0; i < tokens.length; ++i) { for (let i = 0; i < tokens.length; ++i) {
const currentToken = tokens[i]; const currentToken = tokens[i];
@ -109,14 +111,26 @@ export function setup(helper) {
currentToken.attrs.some( currentToken.attrs.some(
(attr) => (attr) =>
attr[0] === "class" && attr[0] === "class" &&
(attr[1].includes("hashtag") || (attr[1] === "hashtag" ||
attr[1].includes("hashtag-cooked")) attr[1] === "hashtag-cooked" ||
attr[1] === "hashtag-raw")
) )
) { ) {
lastType = lastType =
currentToken.type === "link_open" ? HASHTAG_LINK : HASHTAG_SPAN; currentToken.type === "link_open" ? HASHTAG_LINK : HASHTAG_SPAN;
} }
if (
currentToken.type === "span_open" &&
currentToken.attrs &&
currentToken.attrs.some(
(attr) =>
attr[0] === "class" && attr[1] === "hashtag-icon-placeholder"
)
) {
currentType = HASHTAG_ICON_SPAN;
}
if (lastType !== NONE) { if (lastType !== NONE) {
currentToken.skipReplace = true; currentToken.skipReplace = true;
} }
@ -124,7 +138,9 @@ export function setup(helper) {
if ( if (
(lastType === MENTION && currentToken.type === "mention_close") || (lastType === MENTION && currentToken.type === "mention_close") ||
(lastType === HASHTAG_LINK && currentToken.type === "link_close") || (lastType === HASHTAG_LINK && currentToken.type === "link_close") ||
(lastType === HASHTAG_SPAN && currentToken.type === "span_close") (lastType === HASHTAG_SPAN &&
currentToken.type === "span_close" &&
currentType !== HASHTAG_ICON_SPAN)
) { ) {
lastType = NONE; lastType = NONE;
} }

View File

@ -37,6 +37,14 @@ a.hashtag-cooked {
svg { svg {
display: inline; display: inline;
} }
.hashtag-category-badge {
flex: 0 0 auto;
width: 9px;
height: 9px;
margin-right: 5px;
display: inline-block;
}
} }
.hashtag-autocomplete { .hashtag-autocomplete {

View File

@ -241,7 +241,7 @@ class SiteSerializer < ApplicationSerializer
end end
def hashtag_icons def hashtag_icons
HashtagAutocompleteService.data_source_icons HashtagAutocompleteService.data_source_icon_map
end end
def displayed_about_plugin_stat_groups def displayed_about_plugin_stat_groups

View File

@ -19,6 +19,7 @@ class CategoryHashtagDataSource
item.description = category.description_text item.description = category.description_text
item.icon = icon item.icon = icon
item.relative_url = category.url item.relative_url = category.url
item.id = category.id
# Single-level category heirarchy should be enough to distinguish between # Single-level category heirarchy should be enough to distinguish between
# categories here. # categories here.

View File

@ -34,8 +34,8 @@ class HashtagAutocompleteService
data_sources.map(&:type) data_sources.map(&:type)
end end
def self.data_source_icons def self.data_source_icon_map
data_sources.map(&:icon) data_sources.map { |ds| [ds.type, ds.icon] }.to_h
end end
def self.data_source_from_type(type) def self.data_source_from_type(type)
@ -88,6 +88,10 @@ class HashtagAutocompleteService
# item, used for the cooked hashtags, e.g. /c/2/staff # item, used for the cooked hashtags, e.g. /c/2/staff
attr_accessor :relative_url attr_accessor :relative_url
# The ID of the resource that is represented by the autocomplete item,
# e.g. category.id, tag.id
attr_accessor :id
def initialize(params = {}) def initialize(params = {})
@relative_url = params[:relative_url] @relative_url = params[:relative_url]
@text = params[:text] @text = params[:text]
@ -96,6 +100,7 @@ class HashtagAutocompleteService
@type = params[:type] @type = params[:type]
@ref = params[:ref] @ref = params[:ref]
@slug = params[:slug] @slug = params[:slug]
@id = params[:id]
end end
def to_h def to_h
@ -107,6 +112,7 @@ class HashtagAutocompleteService
type: self.type, type: self.type,
ref: self.ref, ref: self.ref,
slug: self.slug, slug: self.slug,
id: self.id,
} }
end end
end end

View File

@ -27,6 +27,7 @@ class TagHashtagDataSource
item.slug = tag.name item.slug = tag.name
item.relative_url = tag.url item.relative_url = tag.url
item.icon = icon item.icon = icon
item.id = tag.id
end end
end end
private_class_method :tag_to_hashtag_item private_class_method :tag_to_hashtag_item
@ -66,7 +67,11 @@ class TagHashtagDataSource
TagsController TagsController
.tag_counts_json(tags_with_counts, guardian) .tag_counts_json(tags_with_counts, guardian)
.take(limit) .take(limit)
.map { |tag| tag_to_hashtag_item(tag, guardian) } .map do |tag|
# We want the actual ID here not the `name` as tag_counts_json gives us.
tag[:id] = tags_with_counts.find { |t| t.name == tag[:name] }.id
tag_to_hashtag_item(tag, guardian)
end
end end
def self.search_sort(search_results, _) def self.search_sort(search_results, _)

View File

@ -22,6 +22,7 @@ class ExcerptParser < Nokogiri::XML::SAX::Document
@keep_svg = options[:keep_svg] == true @keep_svg = options[:keep_svg] == true
@remap_emoji = options[:remap_emoji] == true @remap_emoji = options[:remap_emoji] == true
@start_excerpt = false @start_excerpt = false
@start_hashtag_icon = false
@in_details_depth = 0 @in_details_depth = 0
@summary_contents = +"" @summary_contents = +""
@detail_contents = +"" @detail_contents = +""
@ -112,10 +113,14 @@ class ExcerptParser < Nokogiri::XML::SAX::Document
when "header" when "header"
@in_quote = !@keep_onebox_source if attributes.include?(%w[class source]) @in_quote = !@keep_onebox_source if attributes.include?(%w[class source])
when "div", "span" when "div", "span"
if attributes.include?(%w[class excerpt]) attributes = Hash[*attributes.flatten]
if attributes["class"]&.include?("excerpt")
@excerpt = +"" @excerpt = +""
@current_length = 0 @current_length = 0
@start_excerpt = true @start_excerpt = true
elsif attributes["class"]&.include?("hashtag-icon-placeholder")
@start_hashtag_icon = true
include_tag(name, attributes)
end end
when "details" when "details"
@detail_contents = +"" if @in_details_depth == 0 @detail_contents = +"" if @in_details_depth == 0
@ -180,6 +185,7 @@ class ExcerptParser < Nokogiri::XML::SAX::Document
@in_summary = false if @in_details_depth == 1 @in_summary = false if @in_details_depth == 1
when "div", "span" when "div", "span"
throw :done if @start_excerpt throw :done if @start_excerpt
characters("</span>", truncate: false, count_it: false, encode: false) if @start_hashtag_icon
when "svg" when "svg"
characters("</svg>", truncate: false, count_it: false, encode: false) if @keep_svg characters("</svg>", truncate: false, count_it: false, encode: false) if @keep_svg
@in_svg = false @in_svg = false

View File

@ -224,10 +224,8 @@ module PrettyText
.ordered_types_for_context(opts[:hashtag_context]) .ordered_types_for_context(opts[:hashtag_context])
.map { |t| "'#{t}'" } .map { |t| "'#{t}'" }
.join(",") .join(",")
hashtag_icons_as_js =
HashtagAutocompleteService.data_source_icons.map { |i| "'#{i}'" }.join(",")
buffer << "__optInput.hashtagTypesInPriorityOrder = [#{hashtag_types_as_js}];\n" buffer << "__optInput.hashtagTypesInPriorityOrder = [#{hashtag_types_as_js}];\n"
buffer << "__optInput.hashtagIcons = [#{hashtag_icons_as_js}];\n" buffer << "__optInput.hashtagIcons = #{HashtagAutocompleteService.data_source_icon_map.to_json};\n"
buffer << "__textOptions = __buildOptions(__optInput);\n" buffer << "__textOptions = __buildOptions(__optInput);\n"
buffer << ("__pt = new __PrettyText(__textOptions);") buffer << ("__pt = new __PrettyText(__textOptions);")

View File

@ -1,4 +1,5 @@
import { decorateGithubOneboxBody } from "discourse/initializers/onebox-decorators"; import { decorateGithubOneboxBody } from "discourse/initializers/onebox-decorators";
import { replaceHashtagIconPlaceholder } from "discourse/lib/hashtag-autocomplete";
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import highlightSyntax from "discourse/lib/highlight-syntax"; import highlightSyntax from "discourse/lib/highlight-syntax";
import I18n from "I18n"; import I18n from "I18n";
@ -12,6 +13,7 @@ export default {
initializeWithPluginApi(api, container) { initializeWithPluginApi(api, container) {
const siteSettings = container.lookup("service:site-settings"); const siteSettings = container.lookup("service:site-settings");
const site = container.lookup("service:site");
api.decorateChatMessage((element) => decorateGithubOneboxBody(element), { api.decorateChatMessage((element) => decorateGithubOneboxBody(element), {
id: "onebox-github-body", id: "onebox-github-body",
}); });
@ -70,6 +72,11 @@ export default {
id: "lightbox", id: "lightbox",
} }
); );
api.decorateChatMessage(
(element) => replaceHashtagIconPlaceholder(element, site),
{ id: "hashtagIcons" }
);
}, },
_getScrollParent(node, maxParentSelector) { _getScrollParent(node, maxParentSelector) {

View File

@ -28,7 +28,7 @@ export default {
} }
withPluginApi("0.12.1", (api) => { withPluginApi("0.12.1", (api) => {
api.registerHashtagType("channel", ChannelHashtagType); api.registerHashtagType("channel", new ChannelHashtagType(container));
api.registerChatComposerButton({ api.registerChatComposerButton({
id: "chat-upload-btn", id: "chat-upload-btn",

View File

@ -1,4 +1,5 @@
import HashtagTypeBase from "discourse/lib/hashtag-types/base"; import HashtagTypeBase from "discourse/lib/hashtag-types/base";
import { iconHTML } from "discourse-common/lib/icon-library";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
export default class ChannelHashtagType extends HashtagTypeBase { export default class ChannelHashtagType extends HashtagTypeBase {
@ -17,9 +18,15 @@ export default class ChannelHashtagType extends HashtagTypeBase {
} }
} }
generateColorCssClasses(model) { generateColorCssClasses(channel) {
return [ return [
`.hashtag-color--${this.type}-${model.id} { color: var(--category-${model.chatable.id}-color); }`, `.hashtag-cooked .d-icon.hashtag-color--${this.type}-${channel.id} { color: var(--category-${channel.chatable.id}-color); }`,
]; ];
} }
generateIconHTML(hashtag) {
return iconHTML(hashtag.icon, {
class: `hashtag-color--${this.type}-${hashtag.id}`,
});
}
} }

View File

@ -18,6 +18,7 @@ module Chat
item.icon = icon item.icon = icon
item.relative_url = channel.relative_url item.relative_url = channel.relative_url
item.type = "channel" item.type = "channel"
item.id = channel.id
end end
end end

View File

@ -195,7 +195,7 @@ describe Chat::ChannelArchiveService do
expect(@channel_archive.reload.complete?).to eq(true) expect(@channel_archive.reload.complete?).to eq(true)
pm_topic = Topic.private_messages.last pm_topic = Topic.private_messages.last
expect(pm_topic.first_post.cooked).to include( expect(pm_topic.first_post.cooked).to include(
"<a class=\"hashtag-cooked\" href=\"#{channel.relative_url}\" data-type=\"channel\" data-slug=\"#{channel.slug}\" data-ref=\"#{channel.slug}::channel\"><svg class=\"fa d-icon d-icon-comment svg-icon svg-node\"><use href=\"#comment\"></use></svg><span>#{channel.title(user)}</span></a>", "<a class=\"hashtag-cooked\" href=\"#{channel.relative_url}\" data-type=\"channel\" data-slug=\"#{channel.slug}\" data-id=\"#{channel.id}\" data-ref=\"#{channel.slug}::channel\"><span class=\"hashtag-icon-placeholder\"></span><span>#{channel.title(user)}</span></a>",
) )
end end
end end

View File

@ -41,6 +41,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
text: "Zany Things", text: "Zany Things",
description: "Just weird stuff", description: "Just weird stuff",
icon: "comment", icon: "comment",
id: channel1.id,
type: "channel", type: "channel",
ref: nil, ref: nil,
slug: "random", slug: "random",
@ -60,6 +61,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
text: "Secret Stuff", text: "Secret Stuff",
description: nil, description: nil,
icon: "comment", icon: "comment",
id: channel2.id,
type: "channel", type: "channel",
ref: nil, ref: nil,
slug: "secret", slug: "secret",
@ -94,6 +96,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
text: "Zany Things", text: "Zany Things",
description: "Just weird stuff", description: "Just weird stuff",
icon: "comment", icon: "comment",
id: channel1.id,
type: "channel", type: "channel",
ref: nil, ref: nil,
slug: "random", slug: "random",
@ -109,6 +112,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
text: "Zany Things", text: "Zany Things",
description: "Just weird stuff", description: "Just weird stuff",
icon: "comment", icon: "comment",
id: channel1.id,
type: "channel", type: "channel",
ref: nil, ref: nil,
slug: "random", slug: "random",
@ -127,6 +131,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
text: "Secret Stuff", text: "Secret Stuff",
description: nil, description: nil,
icon: "comment", icon: "comment",
id: channel2.id,
type: "channel", type: "channel",
ref: nil, ref: nil,
slug: "secret", slug: "secret",

View File

@ -259,7 +259,7 @@ describe Chat::Message do
cooked = described_class.cook("##{category.slug}", user_id: user.id) cooked = described_class.cook("##{category.slug}", user_id: user.id)
expect(cooked).to eq( expect(cooked).to eq(
"<p><a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"#{category.slug}\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>#{category.name}</span></a></p>", "<p><a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"#{category.slug}\" data-id=\"#{category.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>#{category.name}</span></a></p>",
) )
end end

View File

@ -71,13 +71,13 @@ describe "Using #hashtag autocompletion to search for and lookup channels",
cooked_hashtags = page.all(".hashtag-cooked", count: 3) cooked_hashtags = page.all(".hashtag-cooked", count: 3)
expect(cooked_hashtags[0]["outerHTML"]).to eq(<<~HTML.chomp) expect(cooked_hashtags[0]["outerHTML"]).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{channel2.relative_url}\" data-type=\"channel\" data-slug=\"random\"><svg class=\"fa d-icon d-icon-comment svg-icon svg-node\"><use href=\"#comment\"></use></svg><span>Random</span></a> <a class=\"hashtag-cooked\" href=\"#{channel2.relative_url}\" data-type=\"channel\" data-slug=\"random\" data-id=\"#{channel2.id}\"><svg class=\"fa d-icon d-icon-comment svg-icon hashtag-color--channel-#{channel2.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#comment\"></use></svg><span>Random</span></a>
HTML HTML
expect(cooked_hashtags[1]["outerHTML"]).to eq(<<~HTML.chomp) expect(cooked_hashtags[1]["outerHTML"]).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"raspberry-beret\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>Raspberry</span></a> <a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"raspberry-beret\" data-id="#{category.id}"><span class=\"hashtag-category-badge hashtag-color--category-#{category.id}\"></span><span>Raspberry</span></a>
HTML HTML
expect(cooked_hashtags[2]["outerHTML"]).to eq(<<~HTML.chomp) expect(cooked_hashtags[2]["outerHTML"]).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"razed\"><svg class=\"fa d-icon d-icon-tag svg-icon svg-node\"><use href=\"#tag\"></use></svg><span>razed</span></a> <a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"razed\" data-id="#{tag.id}"><svg class=\"fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#tag\"></use></svg><span>razed</span></a>
HTML HTML
end end
end end

View File

@ -59,7 +59,7 @@ acceptance("Chat | Hashtag CSS Generator", function (needs) {
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}\n.hashtag-color--channel-44 { color: var(--category-1-color); }\n.hashtag-color--channel-74 { color: var(--category-2-color); }\n.hashtag-color--channel-88 { color: var(--category-4-color); }" ".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.hashtag-cooked .d-icon.hashtag-color--channel-44 { color: var(--category-1-color); }\n.hashtag-cooked .d-icon.hashtag-color--channel-74 { color: var(--category-2-color); }\n.hashtag-cooked .d-icon.hashtag-color--channel-88 { color: var(--category-4-color); }"
); );
}); });
}); });

View File

@ -572,7 +572,7 @@ RSpec.describe Email::Sender do
reply.rebake! reply.rebake!
Email::Sender.new(message, :valid_type).send Email::Sender.new(message, :valid_type).send
expected = <<~HTML expected = <<~HTML
<a href=\"#{Discourse.base_url}#{category.url}\" data-type=\"category\" data-slug=\"dev\" style=\"text-decoration: none; font-weight: bold; color: #006699;\"><span>#dev</span> <a href=\"#{Discourse.base_url}#{category.url}\" data-type=\"category\" data-slug=\"dev\" data-id=\"#{category.id}\" style=\"text-decoration: none; font-weight: bold; color: #006699;\"><span>#dev</span>
HTML HTML
expect(message.html_part.body.to_s).to include(expected.chomp) expect(message.html_part.body.to_s).to include(expected.chomp)
end end

View File

@ -178,13 +178,20 @@ RSpec.describe Oneboxer do
expect(preview("/u/#{user.username}")).to include("Thunderland") expect(preview("/u/#{user.username}")).to include("Thunderland")
end end
it "includes hashtag HTML and icons" do it "includes hashtag HTML" do
SiteSetting.enable_experimental_hashtag_autocomplete = true SiteSetting.enable_experimental_hashtag_autocomplete = true
category = Fabricate(:category, slug: "random") category = Fabricate(:category, slug: "random")
Fabricate(:tag, name: "bug") tag = Fabricate(:tag, name: "bug")
public_post = Fabricate(:post, raw: "This post has some hashtags, #random and #bug") public_post = Fabricate(:post, raw: "This post has some hashtags, #random and #bug")
expect(preview(public_post.url).chomp).to include(<<~HTML.chomp) preview =
<a class="hashtag-cooked" href="#{category.url}" data-type="category" data-slug="random"><svg class="fa d-icon d-icon-folder svg-icon svg-node"><use href="#folder"></use></svg>#{category.name}</a> and <a class="hashtag-cooked" href="/tag/bug" data-type="tag" data-slug="bug"><svg class="fa d-icon d-icon-tag svg-icon svg-node"><use href="#tag"></use></svg>bug</a> Nokogiri::HTML5
.fragment(preview(public_post.url).chomp)
.css("blockquote")
.inner_html
.chomp
.strip
expect(preview).to eq(<<~HTML.chomp.strip)
This post has some hashtags, <a class="hashtag-cooked" href="#{category.url}" data-type="category" data-slug="random" data-id="#{category.id}"><span class="hashtag-icon-placeholder"></span>#{category.name}</a> and <a class="hashtag-cooked" href="#{tag.url}" data-type="tag" data-slug="bug" data-id="#{tag.id}"><span class="hashtag-icon-placeholder"></span>#{tag.name}</a>
HTML HTML
end end
end end

View File

@ -68,6 +68,7 @@ RSpec.describe PrettyText::Helpers do
text: "somecooltag", text: "somecooltag",
description: "Coolest things ever", description: "Coolest things ever",
icon: "tag", icon: "tag",
id: tag.id,
slug: "somecooltag", slug: "somecooltag",
ref: "somecooltag::tag", ref: "somecooltag::tag",
type: "tag", type: "tag",
@ -85,6 +86,7 @@ RSpec.describe PrettyText::Helpers do
text: "Some Awesome Category", text: "Some Awesome Category",
description: "Really great stuff here", description: "Really great stuff here",
icon: "folder", icon: "folder",
id: category.id,
slug: "someawesomecategory", slug: "someawesomecategory",
ref: "someawesomecategory::category", ref: "someawesomecategory::category",
type: "category", type: "category",
@ -101,6 +103,7 @@ RSpec.describe PrettyText::Helpers do
text: "Some Awesome Category", text: "Some Awesome Category",
description: "Really great stuff here", description: "Really great stuff here",
icon: "folder", icon: "folder",
id: category.id,
slug: "someawesomecategory", slug: "someawesomecategory",
ref: "someawesomecategory", ref: "someawesomecategory",
type: "category", type: "category",
@ -115,6 +118,7 @@ RSpec.describe PrettyText::Helpers do
text: "somecooltag", text: "somecooltag",
description: "Coolest things ever", description: "Coolest things ever",
icon: "tag", icon: "tag",
id: tag.id,
slug: "somecooltag", slug: "somecooltag",
ref: "somecooltag", ref: "somecooltag",
type: "tag", type: "tag",
@ -128,6 +132,7 @@ RSpec.describe PrettyText::Helpers do
text: "Some Awesome Category", text: "Some Awesome Category",
description: "Really great stuff here", description: "Really great stuff here",
icon: "folder", icon: "folder",
id: category.id,
slug: "someawesomecategory", slug: "someawesomecategory",
ref: "someawesomecategory", ref: "someawesomecategory",
type: "category", type: "category",
@ -150,6 +155,7 @@ RSpec.describe PrettyText::Helpers do
text: "Manager Hideout", text: "Manager Hideout",
description: nil, description: nil,
icon: "folder", icon: "folder",
id: private_category.id,
slug: "secretcategory", slug: "secretcategory",
ref: "secretcategory", ref: "secretcategory",
type: "category", type: "category",

View File

@ -1715,19 +1715,20 @@ RSpec.describe PrettyText do
category2 = Fabricate(:category, name: "known", slug: "known") category2 = Fabricate(:category, name: "known", slug: "known")
group = Fabricate(:group) group = Fabricate(:group)
private_category = Fabricate(:private_category, name: "secret", group: group, slug: "secret") private_category = Fabricate(:private_category, name: "secret", group: group, slug: "secret")
Fabricate(:topic, tags: [Fabricate(:tag, name: "known")]) tag = Fabricate(:tag, name: "known")
Fabricate(:topic, tags: [tag])
cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id) cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id)
expect(cooked).to include("<span class=\"hashtag-raw\">#unknown::tag</span>") expect(cooked).to include("<span class=\"hashtag-raw\">#unknown::tag</span>")
expect(cooked).to include( expect(cooked).to include(
"<a class=\"hashtag-cooked\" href=\"#{category2.url}\" data-type=\"category\" data-slug=\"known\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>known</span></a>", "<a class=\"hashtag-cooked\" href=\"#{category2.url}\" data-type=\"category\" data-slug=\"known\" data-id=\"#{category2.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>known</span></a>",
) )
expect(cooked).to include( expect(cooked).to include(
"<a class=\"hashtag-cooked\" href=\"/tag/known\" data-type=\"tag\" data-slug=\"known\" data-ref=\"known::tag\"><svg class=\"fa d-icon d-icon-tag svg-icon svg-node\"><use href=\"#tag\"></use></svg><span>known</span></a>", "<a class=\"hashtag-cooked\" href=\"/tag/known\" data-type=\"tag\" data-slug=\"known\" data-id=\"#{tag.id}\" data-ref=\"known::tag\"><span class=\"hashtag-icon-placeholder\"></span><span>known</span></a>",
) )
expect(cooked).to include( expect(cooked).to include(
"<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"testing\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>testing</span></a>", "<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"testing\" data-id=\"#{category.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>testing</span></a>",
) )
expect(cooked).to include("<span class=\"hashtag-raw\">#secret</span>") expect(cooked).to include("<span class=\"hashtag-raw\">#secret</span>")
@ -1735,7 +1736,7 @@ RSpec.describe PrettyText do
group.add(user) group.add(user)
cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id) cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id)
expect(cooked).to include( expect(cooked).to include(
"<a class=\"hashtag-cooked\" href=\"#{private_category.url}\" data-type=\"category\" data-slug=\"secret\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>secret</span></a>", "<a class=\"hashtag-cooked\" href=\"#{private_category.url}\" data-type=\"category\" data-slug=\"secret\" data-id=\"#{private_category.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>secret</span></a>",
) )
cooked = PrettyText.cook("[`a` #known::tag here](http://example.com)", user_id: user.id) cooked = PrettyText.cook("[`a` #known::tag here](http://example.com)", user_id: user.id)
@ -1753,7 +1754,7 @@ RSpec.describe PrettyText do
cooked = PrettyText.cook("<A href='/a'>test</A> #known::tag", user_id: user.id) cooked = PrettyText.cook("<A href='/a'>test</A> #known::tag", user_id: user.id)
html = <<~HTML html = <<~HTML
<p><a href="/a">test</a> <a class="hashtag-cooked" href="/tag/known" data-type="tag" data-slug="known" data-ref="known::tag"><svg class="fa d-icon d-icon-tag svg-icon svg-node"><use href="#tag"></use></svg><span>known</span></a></p> <p><a href="/a">test</a> <a class="hashtag-cooked" href="/tag/known" data-type="tag" data-slug="known" data-id=\"#{tag.id}\" data-ref="known::tag"><span class=\"hashtag-icon-placeholder\"></span><span>known</span></a></p>
HTML HTML
expect(cooked).to eq(html.strip) expect(cooked).to eq(html.strip)
@ -2006,7 +2007,7 @@ HTML
expect(PrettyText.cook("@test #test test")).to match_html(<<~HTML) expect(PrettyText.cook("@test #test test")).to match_html(<<~HTML)
<p> <p>
<a class="mention" href="/u/test">@test</a> <a class="mention" href="/u/test">@test</a>
<a class="hashtag-cooked" href="#{category.url}" data-type="category" data-slug="test"><svg class="fa d-icon d-icon-folder svg-icon svg-node"><use href="#folder"></use></svg><span>test</span></a> <a class="hashtag-cooked" href="#{category.url}" data-type="category" data-slug="test" data-id="#{category.id}"><span class="hashtag-icon-placeholder"></span><span>test</span></a>
tdiscourset tdiscourset
</p> </p>
HTML HTML

View File

@ -524,7 +524,7 @@
"type": "object" "type": "object"
}, },
"hashtag_icons" : { "hashtag_icons" : {
"type": "array" "type": "object"
}, },
"displayed_about_plugin_stat_groups" : { "displayed_about_plugin_stat_groups" : {
"type": "array" "type": "array"

View File

@ -163,6 +163,7 @@ RSpec.describe HashtagsController do
"type" => "category", "type" => "category",
"ref" => category.slug, "ref" => category.slug,
"slug" => category.slug, "slug" => category.slug,
"id" => category.id,
}, },
], ],
"tag" => [ "tag" => [
@ -175,6 +176,7 @@ RSpec.describe HashtagsController do
"ref" => tag.name, "ref" => tag.name,
"slug" => tag.name, "slug" => tag.name,
"secondary_text" => "x0", "secondary_text" => "x0",
"id" => tag.id,
}, },
], ],
}, },
@ -198,6 +200,7 @@ RSpec.describe HashtagsController do
"ref" => "#{tag.name}::tag", "ref" => "#{tag.name}::tag",
"slug" => tag.name, "slug" => tag.name,
"secondary_text" => "x0", "secondary_text" => "x0",
"id" => tag.id,
}, },
], ],
}, },
@ -242,6 +245,7 @@ RSpec.describe HashtagsController do
"type" => "category", "type" => "category",
"ref" => private_category.slug, "ref" => private_category.slug,
"slug" => private_category.slug, "slug" => private_category.slug,
"id" => private_category.id,
}, },
], ],
"tag" => [ "tag" => [
@ -254,6 +258,7 @@ RSpec.describe HashtagsController do
"ref" => hidden_tag.name, "ref" => hidden_tag.name,
"slug" => hidden_tag.name, "slug" => hidden_tag.name,
"secondary_text" => "x0", "secondary_text" => "x0",
"id" => hidden_tag.id,
}, },
], ],
}, },
@ -337,6 +342,7 @@ RSpec.describe HashtagsController do
"type" => "category", "type" => "category",
"ref" => category.slug, "ref" => category.slug,
"slug" => category.slug, "slug" => category.slug,
"id" => category.id,
}, },
{ {
"relative_url" => tag_2.url, "relative_url" => tag_2.url,
@ -347,6 +353,7 @@ RSpec.describe HashtagsController do
"ref" => "#{tag_2.name}::tag", "ref" => "#{tag_2.name}::tag",
"slug" => tag_2.name, "slug" => tag_2.name,
"secondary_text" => "x0", "secondary_text" => "x0",
"id" => tag_2.id,
}, },
], ],
) )
@ -379,6 +386,7 @@ RSpec.describe HashtagsController do
"type" => "category", "type" => "category",
"ref" => private_category.slug, "ref" => private_category.slug,
"slug" => private_category.slug, "slug" => private_category.slug,
"id" => private_category.id,
}, },
], ],
) )
@ -396,6 +404,7 @@ RSpec.describe HashtagsController do
"ref" => "#{hidden_tag.name}", "ref" => "#{hidden_tag.name}",
"slug" => hidden_tag.name, "slug" => hidden_tag.name,
"secondary_text" => "x0", "secondary_text" => "x0",
"id" => hidden_tag.id,
}, },
], ],
) )

View File

@ -32,9 +32,11 @@ RSpec.describe HashtagAutocompleteService do
end end
end end
describe ".data_source_icons" do describe ".data_source_icon_map" do
it "gets an array for all icons defined by data sources so they can be used for markdown allowlisting" do it "gets an array for all icons defined by data sources so they can be used for markdown allowlisting" do
expect(HashtagAutocompleteService.data_source_icons).to eq(%w[folder tag]) expect(HashtagAutocompleteService.data_source_icon_map).to eq(
{ "category" => "folder", "tag" => "tag" },
)
end end
end end

View File

@ -61,7 +61,7 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
expect(page).to have_css(".hashtag-cooked") expect(page).to have_css(".hashtag-cooked")
cooked_hashtag = page.find(".hashtag-cooked") cooked_hashtag = page.find(".hashtag-cooked")
expected = <<~HTML.chomp expected = <<~HTML.chomp
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"cool-cat\" tabindex=\"-1\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>Cool Category</span></a> <a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-id=\"#{category.id}\" data-slug=\"cool-cat\" tabindex=\"-1\"><span class="hashtag-category-badge hashtag-color--category-#{category.id}"></span><span>Cool Category</span></a>
HTML HTML
expect(cooked_hashtag["outerHTML"].squish).to eq(expected) expect(cooked_hashtag["outerHTML"].squish).to eq(expected)
@ -71,7 +71,7 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
expect(page).to have_css(".hashtag-cooked") expect(page).to have_css(".hashtag-cooked")
cooked_hashtag = page.find(".hashtag-cooked") cooked_hashtag = page.find(".hashtag-cooked")
expect(cooked_hashtag["outerHTML"].squish).to eq(<<~HTML.chomp) expect(cooked_hashtag["outerHTML"].squish).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"cooltag\" tabindex=\"-1\"><svg class=\"fa d-icon d-icon-tag svg-icon svg-node\"><use href=\"#tag\"></use></svg><span>cooltag</span></a> <a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-id=\"#{tag.id}\" data-slug=\"cooltag\" tabindex=\"-1\"><svg class=\"fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#tag\"></use></svg><span>cooltag</span></a>
HTML HTML
end end
@ -84,10 +84,10 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
cooked_hashtags = page.all(".hashtag-cooked", count: 2) cooked_hashtags = page.all(".hashtag-cooked", count: 2)
expect(cooked_hashtags[0]["outerHTML"]).to eq(<<~HTML.chomp) expect(cooked_hashtags[0]["outerHTML"]).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"cool-cat\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>Cool Category</span></a> <a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"cool-cat\" data-id=\"#{category.id}\"><span class=\"hashtag-category-badge hashtag-color--category-#{category.id}\"></span><span>Cool Category</span></a>
HTML HTML
expect(cooked_hashtags[1]["outerHTML"]).to eq(<<~HTML.chomp) expect(cooked_hashtags[1]["outerHTML"]).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"cooltag\"><svg class=\"fa d-icon d-icon-tag svg-icon svg-node\"><use href=\"#tag\"></use></svg><span>cooltag</span></a> <a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"cooltag\" data-id=\"#{tag.id}\"><svg class=\"fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#tag\"></use></svg><span>cooltag</span></a>
HTML HTML
end end
end end