Commit d372b7c2 authored by Toon Claes's avatar Toon Claes

Merge branch 'toon-custom-award-emoji' into 'master'

Allow custom award emoji through the API

See merge request gitlab-org/gitlab!77478
parents 2537a682 8f8eb1a8
import {
initEmojiMap,
getEmojiInfo,
emojiFallbackImageSrc,
emojiImageTag,
FALLBACK_EMOJI_KEY,
} from '../emoji';
import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji';
import isEmojiUnicodeSupported from '../emoji/support';
class GlEmoji extends HTMLElement {
......@@ -22,10 +16,6 @@ class GlEmoji extends HTMLElement {
if (emojiInfo) {
if (name !== emojiInfo.name) {
if (emojiInfo.name === FALLBACK_EMOJI_KEY && this.innerHTML) {
return; // When fallback emoji is used, but there is a <img> provided, use the <img> instead
}
({ name } = emojiInfo);
this.dataset.name = emojiInfo.name;
}
......@@ -43,16 +33,12 @@ class GlEmoji extends HTMLElement {
this.childNodes &&
Array.prototype.every.call(this.childNodes, (childNode) => childNode.nodeType === 3);
if (
emojiUnicode &&
isEmojiUnicode &&
!isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)
) {
const hasImageFallback = fallbackSrc && fallbackSrc.length > 0;
const hasCssSpriteFallback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
const hasImageFallback = fallbackSrc?.length > 0;
const hasCssSpriteFallback = fallbackSpriteClass?.length > 0;
// CSS sprite fallback takes precedence over image fallback
if (hasCssSpriteFallback) {
if (emojiUnicode && isEmojiUnicode && isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)) {
// noop
} else if (hasCssSpriteFallback) {
if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) {
const emojiSpriteLinkTag = document.createElement('link');
emojiSpriteLinkTag.setAttribute('rel', 'stylesheet');
......@@ -71,7 +57,6 @@ class GlEmoji extends HTMLElement {
this.innerHTML = '';
this.appendChild(emojiImageTag(name, src));
}
}
});
}
}
......
......@@ -245,5 +245,12 @@ export function glEmojiTag(inputName, options) {
? `data-fallback-sprite-class="${escape(fallbackSpriteClass)}" `
: '';
return `<gl-emoji ${fallbackSpriteAttribute}data-name="${escape(name)}"></gl-emoji>`;
const fallbackUrl = opts.url;
const fallbackSrcAttribute = fallbackUrl
? `data-fallback-src="${fallbackUrl}" data-unicode-version="custom"`
: '';
return `<gl-emoji ${fallbackSrcAttribute}${fallbackSpriteAttribute}data-name="${escape(
name,
)}"></gl-emoji>`;
}
......@@ -93,12 +93,14 @@ export default {
return awardList.some((award) => award.user.id === this.currentUserId);
},
createAwardList(name, list) {
const url = list.length ? list[0].url : null;
return {
name,
list,
title: this.getAwardListTitle(list, name),
classes: this.getAwardClassBindings(list),
html: glEmojiTag(name),
html: glEmojiTag(name, { url }),
};
},
getAwardListTitle(awardsList, name) {
......
......@@ -244,9 +244,13 @@
// above will be deprecated once all instances of "award emoji" are
// migrated to Vue.
.gl-button .award-emoji-block gl-emoji {
.gl-button .award-emoji-block {
display: contents;
gl-emoji {
margin-top: -1px;
margin-bottom: -1px;
}
}
.add-reaction-button {
......
......@@ -254,10 +254,9 @@ li.note {
}
img.emoji {
height: 20px;
height: 16px;
vertical-align: top;
width: 20px;
margin-top: 1px;
}
.chart {
......
......@@ -60,6 +60,10 @@ class AwardEmoji < ApplicationRecord
self.name == UPVOTE_NAME
end
def url
awardable.try(:namespace)&.custom_emoji&.by_name(name)&.first&.url
end
def expire_cache
awardable.try(:bump_updated_at)
awardable.try(:expire_etag_cache)
......
......@@ -11,9 +11,24 @@
module Gitlab
class EmojiNameValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless TanukiEmoji.find_by_alpha_code(value.to_s)
return if valid_tanuki_emoji?(value)
return if valid_custom_emoji?(record, value)
record.errors.add(attribute, (options[:message] || 'is not a valid emoji name'))
end
private
def valid_tanuki_emoji?(value)
TanukiEmoji.find_by_alpha_code(value.to_s)
end
def valid_custom_emoji?(record, value)
namespace = record.try(:awardable).try(:namespace)
return unless namespace
namespace.custom_emoji&.by_name(value.to_s)&.any?
end
end
end
......@@ -8,6 +8,7 @@ module API
expose :user, using: Entities::UserBasic
expose :created_at, :updated_at
expose :awardable_id, :awardable_type
expose :url
end
end
end
......@@ -46,12 +46,13 @@ module Gitlab
def custom_emoji_tag(name, image_source)
data = {
name: name
name: name,
fallback_src: image_source,
unicode_version: 'custom' # Prevents frontend to check for Unicode support
}
options = { title: name, data: data }
ActionController::Base.helpers.content_tag('gl-emoji', title: name, data: data) do
emoji_image_tag(name, image_source).html_safe
end
ActionController::Base.helpers.content_tag('gl-emoji', "", options)
end
end
end
......@@ -3,9 +3,8 @@
FactoryBot.define do
factory :custom_emoji, class: 'CustomEmoji' do
sequence(:name) { |n| "custom_emoji#{n}" }
namespace
group
file { 'https://gitlab.com/images/partyparrot.png' }
creator { namespace.owner }
creator factory: :user
end
end
......@@ -77,6 +77,12 @@ describe('gl_emoji', () => {
'<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>',
`<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>`,
],
[
'custom emoji with image fallback',
'<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"></gl-emoji>',
'<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>',
'<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>',
],
])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => {
it(`renders correctly with emoji support`, async () => {
jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true);
......
......@@ -18,31 +18,30 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do
doc = filter('<p>:tanuki:</p>', project: project)
expect(doc.css('gl-emoji').first.attributes['title'].value).to eq('tanuki')
expect(doc.css('gl-emoji img').size).to eq 1
end
it 'correctly uses the custom emoji URL' do
doc = filter('<p>:tanuki:</p>')
expect(doc.css('img').first.attributes['src'].value).to eq(custom_emoji.file)
expect(doc.css('gl-emoji').first.attributes['data-fallback-src'].value).to eq(custom_emoji.file)
end
it 'matches multiple same custom emoji' do
doc = filter(':tanuki: :tanuki:')
expect(doc.css('img').size).to eq 2
expect(doc.css('gl-emoji').size).to eq 2
end
it 'matches multiple custom emoji' do
doc = filter(':tanuki: (:happy_tanuki:)')
expect(doc.css('img').size).to eq 2
expect(doc.css('gl-emoji').size).to eq 2
end
it 'does not match enclosed colons' do
doc = filter('tanuki:tanuki:')
expect(doc.css('img').size).to be 0
expect(doc.css('gl-emoji').size).to be 0
end
it 'does not do N+1 query' do
......
......@@ -58,6 +58,19 @@ RSpec.describe AwardEmoji do
end
end
end
it 'accepts custom emoji' do
user = create(:user)
group = create(:group)
group.add_maintainer(user)
project = create(:project, namespace: group)
issue = create(:issue, project: project)
emoji = create(:custom_emoji, name: 'partyparrot', namespace: group)
new_award = build(:award_emoji, user: user, awardable: issue, name: emoji.name)
expect(new_award).to be_valid
end
end
describe 'scopes' do
......
......@@ -38,7 +38,7 @@ RSpec.describe CustomEmoji do
new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace, group: group)
expect(new_emoji).not_to be_valid
expect(new_emoji.errors.messages).to eq(creator: ["can't be blank"], name: ["has already been taken"])
expect(new_emoji.errors.messages).to eq(name: ["has already been taken"])
end
it 'disallows non http and https file value' do
......
......@@ -26,6 +26,23 @@ RSpec.describe API::AwardEmoji do
expect(json_response.first['name']).to eq(award_emoji.name)
end
it "includes custom emoji attributes" do
group = create(:group)
group.add_maintainer(user)
project = create(:project, namespace: group)
custom_emoji = create(:custom_emoji, name: 'partyparrot', namespace: group)
issue = create(:issue, project: project)
create(:award_emoji, awardable: issue, user: user, name: custom_emoji.name)
get api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.first['name']).to eq(custom_emoji.name)
expect(json_response.first['url']).to eq(custom_emoji.file)
end
it "returns a 404 error when issue id not found" do
get api("/projects/#{project.id}/issues/#{non_existing_record_iid}/award_emoji", user)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment