Commit 0645b01a authored by Nathan Friend's avatar Nathan Friend

Merge branch 'improve-emoji-support' into 'master'

Improve issue/MR reaction search

Closes #17405 and #24640

See merge request gitlab-org/gitlab!42321
parents c624404d 4f1babeb
...@@ -572,7 +572,7 @@ export class AwardsHandler { ...@@ -572,7 +572,7 @@ export class AwardsHandler {
} }
findMatchingEmojiElements(query) { findMatchingEmojiElements(query) {
const emojiMatches = this.emoji.queryEmojiNames(query); const emojiMatches = this.emoji.searchEmoji(query).map(({ name }) => name);
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]'); const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements.filter( const $matchingElements = $emojiElements.filter(
(i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0, (i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0,
......
...@@ -2,53 +2,57 @@ import { uniq } from 'lodash'; ...@@ -2,53 +2,57 @@ import { uniq } from 'lodash';
import fuzzaldrinPlus from 'fuzzaldrin-plus'; import fuzzaldrinPlus from 'fuzzaldrin-plus';
import emojiAliases from 'emojis/aliases.json'; import emojiAliases from 'emojis/aliases.json';
import axios from '../lib/utils/axios_utils'; import axios from '../lib/utils/axios_utils';
import AccessorUtilities from '../lib/utils/accessor'; import AccessorUtilities from '../lib/utils/accessor';
let emojiMap = null; let emojiMap = null;
let emojiPromise = null;
let validEmojiNames = null; let validEmojiNames = null;
export const EMOJI_VERSION = '1'; export const EMOJI_VERSION = '1';
const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
export function initEmojiMap() { async function loadEmoji() {
emojiPromise = if (
emojiPromise || isLocalStorageAvailable &&
new Promise((resolve, reject) => { window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
if (emojiMap) { window.localStorage.getItem('gl-emoji-map')
resolve(emojiMap); ) {
} else if ( return JSON.parse(window.localStorage.getItem('gl-emoji-map'));
isLocalStorageAvailable && }
window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
window.localStorage.getItem('gl-emoji-map') // We load the JSON file direct from the server
) { // because it can't be loaded from a CDN due to
emojiMap = JSON.parse(window.localStorage.getItem('gl-emoji-map')); // cross domain problems with JSON
validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; const { data } = await axios.get(
resolve(emojiMap); `${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`,
} else { );
// We load the JSON file direct from the server window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION);
// because it can't be loaded from a CDN due to window.localStorage.setItem('gl-emoji-map', JSON.stringify(data));
// cross domain problems with JSON return data;
axios }
.get(`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`)
.then(({ data }) => { async function prepareEmojiMap() {
emojiMap = data; emojiMap = await loadEmoji();
validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
resolve(emojiMap); validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
if (isLocalStorageAvailable) {
window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION); Object.keys(emojiMap).forEach(name => {
window.localStorage.setItem('gl-emoji-map', JSON.stringify(emojiMap)); emojiMap[name].aliases = [];
} emojiMap[name].name = name;
}) });
.catch(err => { Object.entries(emojiAliases).forEach(([alias, name]) => {
reject(err); // This check, `if (name in emojiMap)` is necessary during testing. In
}); // production, it shouldn't be necessary, because at no point should there
} // be an entry in aliases.json with no corresponding entry in emojis.json.
}); // However, during testing, the endpoint for emojis.json is mocked with a
// small dataset, whereas aliases.json is always `import`ed directly.
if (name in emojiMap) emojiMap[name].aliases.push(alias);
});
}
return emojiPromise; export function initEmojiMap() {
initEmojiMap.promise = initEmojiMap.promise || prepareEmojiMap();
return initEmojiMap.promise;
} }
export function normalizeEmojiName(name) { export function normalizeEmojiName(name) {
...@@ -77,6 +81,37 @@ export function queryEmojiNames(filter) { ...@@ -77,6 +81,37 @@ export function queryEmojiNames(filter) {
return uniq(matches.map(name => normalizeEmojiName(name))); return uniq(matches.map(name => normalizeEmojiName(name)));
} }
/**
* Searches emoji by name, alias, description, and unicode value and returns an
* array of matches.
*
* Note: `initEmojiMap` must have been called and completed before this method
* can safely be called.
*
* @param {String} query The search query
* @returns {Object[]} A list of emoji that match the query
*/
export function searchEmoji(query) {
if (!emojiMap)
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('The emoji map is uninitialized or initialization has not completed');
const matches = s => fuzzaldrinPlus.score(s, query) > 0;
// Search emoji
return Object.values(emojiMap).filter(
emoji =>
// by name
matches(emoji.name) ||
// by alias
emoji.aliases.some(matches) ||
// by description
matches(emoji.d) ||
// by unicode value
query === emoji.e,
);
}
let emojiCategoryMap; let emojiCategoryMap;
export function getEmojiCategoryMap() { export function getEmojiCategoryMap() {
if (!emojiCategoryMap) { if (!emojiCategoryMap) {
......
---
title: Improve issuable reaction search
merge_request: 42321
author: Ethan Reesor (@firelizzard)
type: added
...@@ -319,6 +319,20 @@ describe('AwardsHandler', () => { ...@@ -319,6 +319,20 @@ describe('AwardsHandler', () => {
expect($('[data-name=anger]').is(':visible')).toBe(false); expect($('[data-name=anger]').is(':visible')).toBe(false);
expect($('[data-name=sunglasses]').is(':visible')).toBe(true); expect($('[data-name=sunglasses]').is(':visible')).toBe(true);
}); });
it('should filter by emoji description', async () => {
await openAndWaitForEmojiMenu();
awardsHandler.searchEmojis('baby');
expect($('[data-name=angel]').is(':visible')).toBe(true);
});
it('should filter by emoji unicode value', async () => {
await openAndWaitForEmojiMenu();
awardsHandler.searchEmojis('👼');
expect($('[data-name=angel]').is(':visible')).toBe(true);
});
}); });
describe('emoji menu', () => { describe('emoji menu', () => {
......
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { trimText } from 'helpers/text_helper'; import { trimText } from 'helpers/text_helper';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { initEmojiMap, glEmojiTag, queryEmojiNames, EMOJI_VERSION } from '~/emoji'; import { initEmojiMap, glEmojiTag, searchEmoji, EMOJI_VERSION } from '~/emoji';
import isEmojiUnicodeSupported, { import isEmojiUnicodeSupported, {
isFlagEmoji, isFlagEmoji,
isRainbowFlagEmoji, isRainbowFlagEmoji,
...@@ -31,25 +31,35 @@ const emptySupportMap = { ...@@ -31,25 +31,35 @@ const emptySupportMap = {
}; };
const emojiFixtureMap = { const emojiFixtureMap = {
atom: {
name: 'atom',
moji: '',
description: 'atom symbol',
unicodeVersion: '4.1',
},
bomb: { bomb: {
name: 'bomb', name: 'bomb',
moji: '💣', moji: '💣',
unicodeVersion: '6.0', unicodeVersion: '6.0',
description: 'bomb',
}, },
construction_worker_tone5: { construction_worker_tone5: {
name: 'construction_worker_tone5', name: 'construction_worker_tone5',
moji: '👷🏿', moji: '👷🏿',
unicodeVersion: '8.0', unicodeVersion: '8.0',
description: 'construction worker tone 5',
}, },
five: { five: {
name: 'five', name: 'five',
moji: '5️⃣', moji: '5️⃣',
unicodeVersion: '3.0', unicodeVersion: '3.0',
description: 'keycap digit five',
}, },
grey_question: { grey_question: {
name: 'grey_question', name: 'grey_question',
moji: '', moji: '',
unicodeVersion: '6.0', unicodeVersion: '6.0',
description: 'white question mark ornament',
}, },
}; };
...@@ -386,14 +396,23 @@ describe('gl_emoji', () => { ...@@ -386,14 +396,23 @@ describe('gl_emoji', () => {
}); });
}); });
describe('queryEmojiNames', () => { describe('searchEmoji', () => {
const contains = (e, term) => { const { atom, grey_question } = emojiFixtureMap;
const names = queryEmojiNames(term); const contains = (e, term) =>
expect(names.indexOf(e.name) >= 0).toBe(true); expect(searchEmoji(term).map(({ name }) => name)).toContain(e.name);
};
it('should match by full name', () => contains(grey_question, 'grey_question'));
it('should match by full alias', () => contains(atom, 'atom_symbol'));
it('should match by full description', () => contains(grey_question, 'ornament'));
it('should match by partial name', () => contains(grey_question, 'question'));
it('should match by partial alias', () => contains(atom, '_symbol'));
it('should match by partial description', () => contains(grey_question, 'ment'));
it('should fuzzy match by name', () => contains(grey_question, 'greion'));
it('should fuzzy match by alias', () => contains(atom, 'atobol'));
it('should fuzzy match by description', () => contains(grey_question, 'ornt'));
it('should match by name', () => contains(emojiFixtureMap.grey_question, 'grey_question')); it('should match by character', () => contains(grey_question, ''));
it('should match by partial name', () => contains(emojiFixtureMap.grey_question, 'question'));
it('should fuzzy match by name', () => contains(emojiFixtureMap.grey_question, 'grqtn'));
}); });
}); });
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