Commit 4f1babeb authored by Ethan Reesor's avatar Ethan Reesor

Improve award/reaction emoji search

parent 3a3e135e
......@@ -572,7 +572,7 @@ export class AwardsHandler {
}
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 $matchingElements = $emojiElements.filter(
(i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0,
......
......@@ -2,53 +2,57 @@ import { uniq } from 'lodash';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import emojiAliases from 'emojis/aliases.json';
import axios from '../lib/utils/axios_utils';
import AccessorUtilities from '../lib/utils/accessor';
let emojiMap = null;
let emojiPromise = null;
let validEmojiNames = null;
export const EMOJI_VERSION = '1';
const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
export function initEmojiMap() {
emojiPromise =
emojiPromise ||
new Promise((resolve, reject) => {
if (emojiMap) {
resolve(emojiMap);
} else if (
isLocalStorageAvailable &&
window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
window.localStorage.getItem('gl-emoji-map')
) {
emojiMap = JSON.parse(window.localStorage.getItem('gl-emoji-map'));
validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
resolve(emojiMap);
} else {
// We load the JSON file direct from the server
// because it can't be loaded from a CDN due to
// cross domain problems with JSON
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('gl-emoji-map-version', EMOJI_VERSION);
window.localStorage.setItem('gl-emoji-map', JSON.stringify(emojiMap));
}
})
.catch(err => {
reject(err);
});
}
});
async function loadEmoji() {
if (
isLocalStorageAvailable &&
window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION &&
window.localStorage.getItem('gl-emoji-map')
) {
return JSON.parse(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
// cross domain problems with JSON
const { data } = await axios.get(
`${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`,
);
window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION);
window.localStorage.setItem('gl-emoji-map', JSON.stringify(data));
return data;
}
async function prepareEmojiMap() {
emojiMap = await loadEmoji();
validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
Object.keys(emojiMap).forEach(name => {
emojiMap[name].aliases = [];
emojiMap[name].name = name;
});
Object.entries(emojiAliases).forEach(([alias, name]) => {
// 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) {
......@@ -77,6 +81,37 @@ export function queryEmojiNames(filter) {
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;
export function getEmojiCategoryMap() {
if (!emojiCategoryMap) {
......
---
title: Improve issuable reaction search
merge_request: 42321
author: Ethan Reesor (@firelizzard)
type: added
......@@ -319,6 +319,20 @@ describe('AwardsHandler', () => {
expect($('[data-name=anger]').is(':visible')).toBe(false);
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', () => {
......
import MockAdapter from 'axios-mock-adapter';
import { trimText } from 'helpers/text_helper';
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, {
isFlagEmoji,
isRainbowFlagEmoji,
......@@ -31,25 +31,35 @@ const emptySupportMap = {
};
const emojiFixtureMap = {
atom: {
name: 'atom',
moji: '',
description: 'atom symbol',
unicodeVersion: '4.1',
},
bomb: {
name: 'bomb',
moji: '💣',
unicodeVersion: '6.0',
description: 'bomb',
},
construction_worker_tone5: {
name: 'construction_worker_tone5',
moji: '👷🏿',
unicodeVersion: '8.0',
description: 'construction worker tone 5',
},
five: {
name: 'five',
moji: '5️⃣',
unicodeVersion: '3.0',
description: 'keycap digit five',
},
grey_question: {
name: 'grey_question',
moji: '',
unicodeVersion: '6.0',
description: 'white question mark ornament',
},
};
......@@ -386,14 +396,23 @@ describe('gl_emoji', () => {
});
});
describe('queryEmojiNames', () => {
const contains = (e, term) => {
const names = queryEmojiNames(term);
expect(names.indexOf(e.name) >= 0).toBe(true);
};
describe('searchEmoji', () => {
const { atom, grey_question } = emojiFixtureMap;
const contains = (e, term) =>
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 partial name', () => contains(emojiFixtureMap.grey_question, 'question'));
it('should fuzzy match by name', () => contains(emojiFixtureMap.grey_question, 'grqtn'));
it('should match by character', () => contains(grey_question, ''));
});
});
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