Commit c46d482b authored by Tim Zallmann's avatar Tim Zallmann

Dynamic loading of emojis from json file

Updated Specs + Fixtures for Emoji Loading
Externalises the emojis json loading
Fixed Frontend Fixtures creation for emojis
Fixed Emoji Specs working with Jest
parent e99e3f2d
......@@ -619,10 +619,19 @@ export class AwardsHandler {
let awardsHandlerPromise = null;
export default function loadAwardsHandler(reload = false) {
if (!awardsHandlerPromise || reload) {
awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then(Emoji => {
const awardsHandler = new AwardsHandler(Emoji);
awardsHandler.bindEvents();
return awardsHandler;
awardsHandlerPromise = new Promise((resolve, reject) => {
let emojiModule;
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(Emoji => {
emojiModule = Emoji;
return Emoji.initEmojiMap();
})
.then(() => {
const awardsHandler = new AwardsHandler(emojiModule);
awardsHandler.bindEvents();
resolve(awardsHandler);
})
.catch(() => reject);
});
}
return awardsHandlerPromise;
......
import 'document-register-element';
import isEmojiUnicodeSupported from '../emoji/support';
import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji';
class GlEmoji extends HTMLElement {
constructor() {
super();
const emojiUnicode = this.textContent.trim();
const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset;
const isEmojiUnicode =
this.childNodes &&
Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3);
const hasImageFallback = fallbackSrc && fallbackSrc.length > 0;
const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
if (emojiUnicode && isEmojiUnicode && !isEmojiUnicodeSupported(emojiUnicode, unicodeVersion)) {
// CSS sprite fallback takes precedence over image fallback
if (hasCssSpriteFalback) {
if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) {
const emojiSpriteLinkTag = document.createElement('link');
emojiSpriteLinkTag.setAttribute('rel', 'stylesheet');
emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path);
document.head.appendChild(emojiSpriteLinkTag);
gon.emoji_sprites_css_added = true;
}
// IE 11 doesn't like adding multiple at once :(
this.classList.add('emoji-icon');
this.classList.add(fallbackSpriteClass);
} else {
import(/* webpackChunkName: 'emoji' */ '../emoji')
.then(({ emojiImageTag, emojiFallbackImageSrc }) => {
if (hasImageFallback) {
this.innerHTML = emojiImageTag(name, fallbackSrc);
this.initialize();
}
initialize(enforceUnicodeRedering) {
let emojiUnicode = this.textContent.trim();
const { fallbackSpriteClass, fallbackSrc, forceFallback } = this.dataset;
let { name, unicodeVersion } = this.dataset;
initEmojiMap()
.then(() => {
if (!unicodeVersion) {
const emojiInfo = getEmojiInfo(name);
if (emojiInfo) {
if (name !== emojiInfo.name) {
({ name } = emojiInfo);
this.dataset.name = emojiInfo.name;
}
unicodeVersion = emojiInfo.u;
this.dataset.uni = unicodeVersion;
if (forceFallback === 'true' && !fallbackSpriteClass) {
this.innerHTML = emojiImageTag(name, emojiFallbackImageSrc(name));
} else {
const src = emojiFallbackImageSrc(name);
this.innerHTML = emojiImageTag(name, src);
emojiUnicode = emojiInfo.e;
this.innerHTML = emojiInfo.e;
}
})
.catch(() => {
// do nothing
});
}
}
this.title = emojiInfo.d;
}
}
const isEmojiUnicode =
this.childNodes &&
Array.prototype.every.call(this.childNodes, childNode => childNode.nodeType === 3);
const hasImageFallback = fallbackSrc && fallbackSrc.length > 0;
const hasCssSpriteFalback = fallbackSpriteClass && fallbackSpriteClass.length > 0;
if (
emojiUnicode &&
isEmojiUnicode &&
(!isEmojiUnicodeSupported(emojiUnicode, unicodeVersion) && !enforceUnicodeRedering)
) {
// CSS sprite fallback takes precedence over image fallback
if (hasCssSpriteFalback) {
if (!gon.emoji_sprites_css_added && gon.emoji_sprites_css_path) {
const emojiSpriteLinkTag = document.createElement('link');
emojiSpriteLinkTag.setAttribute('rel', 'stylesheet');
emojiSpriteLinkTag.setAttribute('href', gon.emoji_sprites_css_path);
document.head.appendChild(emojiSpriteLinkTag);
gon.emoji_sprites_css_added = true;
}
// IE 11 doesn't like adding multiple at once :(
this.classList.add('emoji-icon');
this.classList.add(fallbackSpriteClass);
} else if (hasImageFallback) {
this.innerHTML = emojiImageTag(name, fallbackSrc);
} else {
const src = emojiFallbackImageSrc(name);
this.innerHTML = emojiImageTag(name, src);
}
}
})
.catch(error => {
// Only reject is already handled in initEmojiMap
throw error;
});
}
}
......
import { uniq } from 'lodash';
import emojiMap from 'emojis/digests.json';
import emojiAliases from 'emojis/aliases.json';
import axios from '../lib/utils/axios_utils';
export const validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
import AccessorUtilities from '../lib/utils/accessor';
let emojiMap = null;
let validEmojiNames = null;
export const EMOJI_VERSION = '1';
const EMOJI_VERSION_LOCALSTORAGE = `EMOJIS_${EMOJI_VERSION}`;
const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
export function initEmojiMap() {
return new Promise((resolve, reject) => {
if (emojiMap) {
resolve(emojiMap);
} else if (isLocalStorageAvailable && window.localStorage.getItem(EMOJI_VERSION_LOCALSTORAGE)) {
emojiMap = JSON.parse(window.localStorage.getItem(EMOJI_VERSION_LOCALSTORAGE));
validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
resolve(emojiMap);
} else {
// We load the JSON from server
// Can't be loaded from CDN due to cross domain problems with JSON files
axios
.get(`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`)
.then(({ data }) => {
emojiMap = data;
validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
resolve(emojiMap);
if (isLocalStorageAvailable) {
window.localStorage.setItem(EMOJI_VERSION_LOCALSTORAGE, JSON.stringify(emojiMap));
}
})
.catch(err => {
reject(err);
});
}
});
}
export function normalizeEmojiName(name) {
return Object.prototype.hasOwnProperty.call(emojiAliases, name) ? emojiAliases[name] : name;
}
export function getValidEmojiNames() {
return validEmojiNames;
}
export function isEmojiNameValid(name) {
return validEmojiNames.indexOf(name) >= 0;
}
......@@ -36,8 +76,8 @@ export function getEmojiCategoryMap() {
};
Object.keys(emojiMap).forEach(name => {
const emoji = emojiMap[name];
if (emojiCategoryMap[emoji.category]) {
emojiCategoryMap[emoji.category].push(name);
if (emojiCategoryMap[emoji.c]) {
emojiCategoryMap[emoji.c].push(name);
}
});
}
......@@ -58,8 +98,9 @@ export function getEmojiInfo(query) {
}
export function emojiFallbackImageSrc(inputName) {
const { name, digest } = getEmojiInfo(inputName);
return `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/emoji/${name}-${digest}.png`;
const { name } = getEmojiInfo(inputName);
return `${gon.asset_host || ''}${gon.relative_url_root ||
''}/-/emojis/${EMOJI_VERSION}/${name}.png`;
}
export function emojiImageTag(name, src) {
......@@ -68,9 +109,7 @@ export function emojiImageTag(name, src) {
export function glEmojiTag(inputName, options) {
const opts = { sprite: false, forceFallback: false, ...options };
const { name, ...emojiInfo } = getEmojiInfo(inputName);
const fallbackImageSrc = emojiFallbackImageSrc(name);
const name = normalizeEmojiName(inputName);
const fallbackSpriteClass = `emoji-${name}`;
const classList = [];
......@@ -82,21 +121,15 @@ export function glEmojiTag(inputName, options) {
const fallbackSpriteAttribute = opts.sprite
? `data-fallback-sprite-class="${fallbackSpriteClass}"`
: '';
let contents = emojiInfo.moji;
if (opts.forceFallback && !opts.sprite) {
contents = emojiImageTag(name, fallbackImageSrc);
}
const forceFallbackAttribute = opts.forceFallback ? 'data-force-fallback="true"' : '';
return `
<gl-emoji
${classAttribute}
data-name="${name}"
data-fallback-src="${fallbackImageSrc}"
${fallbackSpriteAttribute}
data-unicode-version="${emojiInfo.unicodeVersion}"
title="${emojiInfo.description}"
${forceFallbackAttribute}
>
${contents}
</gl-emoji>
`;
}
......@@ -5,6 +5,9 @@ import getUnicodeSupportMap from './unicode_support_map';
let browserUnicodeSupportMap;
export default function isEmojiUnicodeSupportedByBrowser(emojiUnicode, unicodeVersion) {
// Our Spec browser would fail producing emoji maps
if (process.env.JEST_WORKER_ID) return true;
browserUnicodeSupportMap = browserUnicodeSupportMap || getUnicodeSupportMap();
return isEmojiUnicodeSupported(browserUnicodeSupportMap, emojiUnicode, unicodeVersion);
}
......@@ -136,16 +136,21 @@ export default class VisualTokenValue {
const container = tokenValueContainer;
const element = tokenValueElement;
const value = this.tokenValue;
let emojiModule;
return (
import(/* webpackChunkName: 'emoji' */ '../emoji')
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(Emoji => {
if (!Emoji.isEmojiNameValid(value)) {
emojiModule = Emoji;
return Emoji.initEmojiMap();
})
.then(() => {
if (!emojiModule.isEmojiNameValid(value)) {
return;
}
container.dataset.originalValue = value;
element.innerHTML = Emoji.glEmojiTag(value);
element.innerHTML = emojiModule.glEmojiTag(value);
})
// ignore error and leave emoji name in the search bar
.catch(() => {})
......
......@@ -586,10 +586,15 @@ class GfmAutoComplete {
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
import(/* webpackChunkName: 'emoji' */ './emoji')
.then(({ validEmojiNames, glEmojiTag }) => {
this.loadData($input, at, validEmojiNames);
GfmAutoComplete.glEmojiTag = glEmojiTag;
let emojiModule;
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(Emoji => {
emojiModule = Emoji;
return Emoji.initEmojiMap();
})
.then(() => {
this.loadData($input, at, emojiModule.getValidEmojiNames());
GfmAutoComplete.glEmojiTag = emojiModule.glEmojiTag;
})
.catch(() => {
this.isLoadingData[at] = false;
......
......@@ -55,17 +55,22 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
let emojiModule;
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(Emoji => {
emojiModule = Emoji;
return Emoji.initEmojiMap();
})
.then(() => {
const emojiMenu = new EmojiMenu(
Emoji,
emojiModule,
toggleEmojiMenuButtonSelector,
'js-status-emoji-menu',
selectEmojiCallback,
);
emojiMenu.bindEvents();
const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji);
const defaultEmojiTag = emojiModule.glEmojiTag(defaultStatusEmoji);
statusMessageField.addEventListener('input', () => {
const hasStatusMessage = statusMessageField.value.trim() !== '';
const statusEmoji = findStatusEmoji();
......
......@@ -64,16 +64,21 @@ export default {
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true });
let emojiModule;
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(Emoji => {
emojiModule = Emoji;
return Emoji.initEmojiMap();
})
.then(() => {
if (this.emoji) {
this.emojiTag = Emoji.glEmojiTag(this.emoji);
this.emojiTag = emojiModule.glEmojiTag(this.emoji);
}
this.noEmoji = this.emoji === '';
this.defaultEmojiTag = Emoji.glEmojiTag('speech_balloon');
this.defaultEmojiTag = emojiModule.glEmojiTag('speech_balloon');
this.emojiMenu = new EmojiMenuInModal(
Emoji,
emojiModule,
toggleEmojiMenuButtonSelector,
emojiMenuClass,
this.setEmoji,
......
import $ from 'jquery';
import Cookies from 'js-cookie';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import loadAwardsHandler from '~/awards_handler';
import '~/lib/utils/common_utils';
import waitForPromises from './helpers/wait_for_promises';
import { EMOJI_VERSION } from '~/emoji';
window.gl = window.gl || {};
window.gon = window.gon || {};
let openAndWaitForEmojiMenu;
let mock;
let awardsHandler = null;
const urlRoot = gon.relative_url_root;
......@@ -24,8 +28,13 @@ const lazyAssert = (done, assertFn) => {
};
describe('AwardsHandler', () => {
const emojiData = getJSONFixture('emojis/emojis.json');
preloadFixtures('snippets/show.html');
beforeEach(done => {
mock = new MockAdapter(axios);
mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData);
loadFixtures('snippets/show.html');
loadAwardsHandler(true)
.then(obj => {
......@@ -58,6 +67,8 @@ describe('AwardsHandler', () => {
// restore original url root value
gon.relative_url_root = urlRoot;
mock.restore();
// Undo what we did to the shared <body>
$('body').removeAttr('data-page');
......
import { glEmojiTag } from '~/emoji';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { initEmojiMap, glEmojiTag, EMOJI_VERSION } from '~/emoji';
import isEmojiUnicodeSupported, {
isFlagEmoji,
isRainbowFlagEmoji,
......@@ -7,6 +9,7 @@ import isEmojiUnicodeSupported, {
isHorceRacingSkinToneComboEmoji,
isPersonZwjEmoji,
} from '~/emoji/support/is_emoji_unicode_supported';
import installGlEmojiElement from '~/behaviors/gl_emoji';
const emptySupportMap = {
personZwj: false,
......@@ -50,15 +53,27 @@ const emojiFixtureMap = {
},
};
function markupToDomElement(markup) {
async function markupToDomElement(markup, enforceUnicodeRedering) {
const div = document.createElement('div');
div.innerHTML = markup;
return div.firstElementChild;
document.body.appendChild(div);
const glEmojiElementFromMarkup = div.firstElementChild;
const glEmojiElement = document.createElement('gl-emoji');
glEmojiElementFromMarkup.getAttributeNames().forEach(name => {
glEmojiElement.setAttribute(name, glEmojiElementFromMarkup.getAttribute(name));
});
// We need to call the function directly as the normal setup in Jest doesn't work
glEmojiElement.initialize(enforceUnicodeRedering);
await glEmojiElement.updateComplete;
return glEmojiElement;
}
function testGlEmojiImageFallback(element, name, src) {
function testGlEmojiImageFallback(element, name) {
expect(element.tagName.toLowerCase()).toBe('img');
expect(element.getAttribute('src')).toBe(src);
expect(element.getAttribute('src')).toBe(`/-/emojis/${EMOJI_VERSION}/${name}.png`);
expect(element.getAttribute('title')).toBe(`:${name}:`);
expect(element.getAttribute('alt')).toBe(`:${name}:`);
}
......@@ -72,8 +87,7 @@ function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options
const opts = { ...defaults, ...options };
expect(element.tagName.toLowerCase()).toBe('gl-emoji');
expect(element.dataset.name).toBe(name);
expect(element.dataset.fallbackSrc.length).toBeGreaterThan(0);
expect(element.dataset.unicodeVersion).toBe(unicodeVersion);
expect(element.dataset.uni).toBe(unicodeVersion);
const fallbackSpriteClass = `emoji-${name}`;
if (opts.sprite) {
......@@ -86,7 +100,7 @@ function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options
if (opts.forceFallback && !opts.sprite) {
// Check for image fallback
testGlEmojiImageFallback(element.firstElementChild, name, element.dataset.fallbackSrc);
testGlEmojiImageFallback(element.firstElementChild, name);
} else {
// Otherwise make sure things are still unicode text
expect(element.textContent.trim()).toBe(unicodeMoji);
......@@ -94,11 +108,38 @@ function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options
}
describe('gl_emoji', () => {
beforeAll(() => {
installGlEmojiElement();
});
let mock;
const emojiData = getJSONFixture('emojis/emojis.json');
beforeEach(done => {
mock = new MockAdapter(axios);
mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData);
initEmojiMap()
.then(() => {
done();
})
.catch(e => {
done();
});
});
afterEach(() => {
mock.restore();
document.body.innerHTML = '';
});
describe('glEmojiTag', () => {
it('bomb emoji', () => {
it('bomb emoji', async () => {
const emojiKey = 'bomb';
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name);
const glEmojiElement = markupToDomElement(markup);
const glEmojiElement = await markupToDomElement(markup, true);
testGlEmojiElement(
glEmojiElement,
emojiFixtureMap[emojiKey].name,
......@@ -107,12 +148,12 @@ describe('gl_emoji', () => {
);
});
it('bomb emoji with image fallback', () => {
it('bomb emoji with image fallback', async () => {
const emojiKey = 'bomb';
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
forceFallback: true,
});
const glEmojiElement = markupToDomElement(markup);
const glEmojiElement = await markupToDomElement(markup);
testGlEmojiElement(
glEmojiElement,
emojiFixtureMap[emojiKey].name,
......@@ -124,12 +165,12 @@ describe('gl_emoji', () => {
);
});
it('bomb emoji with sprite fallback readiness', () => {
it('bomb emoji with sprite fallback readiness', async () => {
const emojiKey = 'bomb';
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
sprite: true,
});
const glEmojiElement = markupToDomElement(markup);
const glEmojiElement = await markupToDomElement(markup);
testGlEmojiElement(
glEmojiElement,
emojiFixtureMap[emojiKey].name,
......@@ -141,13 +182,13 @@ describe('gl_emoji', () => {
);
});
it('bomb emoji with sprite fallback', () => {
it('bomb emoji with sprite fallback', async () => {
const emojiKey = 'bomb';
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
forceFallback: true,
sprite: true,
});
const glEmojiElement = markupToDomElement(markup);
const glEmojiElement = await markupToDomElement(markup);
testGlEmojiElement(
glEmojiElement,
emojiFixtureMap[emojiKey].name,
......@@ -160,11 +201,12 @@ describe('gl_emoji', () => {
);
});
it('question mark when invalid emoji name given', () => {
it('question mark when invalid emoji name given', async () => {
const name = 'invalid_emoji';
const emojiKey = 'grey_question';
const markup = glEmojiTag(name);
const glEmojiElement = markupToDomElement(markup);
const glEmojiElement = await markupToDomElement(markup, true);
testGlEmojiElement(
glEmojiElement,
emojiFixtureMap[emojiKey].name,
......@@ -173,13 +215,13 @@ describe('gl_emoji', () => {
);
});
it('question mark with image fallback when invalid emoji name given', () => {
it('question mark with image fallback when invalid emoji name given', async () => {
const name = 'invalid_emoji';
const emojiKey = 'grey_question';
const markup = glEmojiTag(name, {
forceFallback: true,
});
const glEmojiElement = markupToDomElement(markup);
const glEmojiElement = await markupToDomElement(markup);
testGlEmojiElement(
glEmojiElement,
emojiFixtureMap[emojiKey].name,
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Emojis (JavaScript fixtures)', type: :request do
include JavaScriptFixturesHelpers
before(:all) do
clean_frontend_fixtures('emojis/')
end
it 'emojis/emojis.json' do |example|
get '/-/emojis/1/emojis.json'
expect(response).to be_successful
end
end
......@@ -18,13 +18,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
<gl-emoji
data-fallback-src="/assets/emoji/thumbsup-59ec2457ab33e8897261d01a495f6cf5c668d0004807dc541c3b1be5294b1e61.png"
data-name="thumbsup"
data-unicode-version="6.0"
title="thumbs up sign"
>
👍
</gl-emoji>
......@@ -51,13 +47,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
<gl-emoji
data-fallback-src="/assets/emoji/thumbsdown-5954334e2dae5357312b3d629f10a496c728029e02216f8c8b887f9b51561c61.png"
data-name="thumbsdown"
data-unicode-version="6.0"
title="thumbs down sign"
>
👎
</gl-emoji>
......@@ -84,13 +76,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
<gl-emoji
data-fallback-src="/assets/emoji/smile-14905c372d5bf7719bd727c9efae31a03291acec79801652a23710c6848c5d14.png"
data-name="smile"
data-unicode-version="6.0"
title="smiling face with open mouth and smiling eyes"
>
😄
</gl-emoji>
......@@ -117,13 +105,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
<gl-emoji
data-fallback-src="/assets/emoji/ok_hand-d63002dce3cc3655b67b8765b7c28d370edba0e3758b2329b60e0e61c4d8e78d.png"
data-name="ok_hand"
data-unicode-version="6.0"
title="ok hand sign"
>
👌
</gl-emoji>
......@@ -150,13 +134,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
<gl-emoji
data-fallback-src="/assets/emoji/cactus-2c5c4c35f26c7046fdc002b337e0d939729b33a26980e675950f9934c91e40fd.png"
data-name="cactus"
data-unicode-version="6.0"
title="cactus"
>
🌵
</gl-emoji>
......@@ -183,13 +163,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
<gl-emoji
data-fallback-src="/assets/emoji/a-bddbb39e8a1d35d42b7c08e7d47f63988cb4d8614b79f74e70b9c67c221896cc.png"
data-name="a"
data-unicode-version="6.0"
title="negative squared latin capital letter a"
>
🅰
</gl-emoji>
......@@ -216,13 +192,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
<gl-emoji
data-fallback-src="/assets/emoji/b-722f9db9442e7c0fc0d0ac0f5291fbf47c6a0ac4d8abd42e97957da705fb82bf.png"
data-name="b"
data-unicode-version="6.0"
title="negative squared latin capital letter b"
>
🅱
</gl-emoji>
......
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