Commit b943a28f authored by Phil Hughes's avatar Phil Hughes

Merge branch 'improve-gfm-ac-emoji' into 'master'

Improve GFM emoji auto-complete

See merge request gitlab-org/gitlab!42669
parents 6dd3d97f e8b1c089
...@@ -572,7 +572,7 @@ export class AwardsHandler { ...@@ -572,7 +572,7 @@ export class AwardsHandler {
} }
findMatchingEmojiElements(query) { findMatchingEmojiElements(query) {
const emojiMatches = this.emoji.searchEmoji(query).map(({ name }) => name); const emojiMatches = this.emoji.searchEmoji(query, { match: 'fuzzy' }).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,
......
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';
...@@ -67,49 +66,111 @@ export function isEmojiNameValid(name) { ...@@ -67,49 +66,111 @@ export function isEmojiNameValid(name) {
return validEmojiNames.indexOf(name) >= 0; return validEmojiNames.indexOf(name) >= 0;
} }
export function getValidEmojiUnicodeValues() {
return Object.values(emojiMap).map(({ e }) => e);
}
export function getValidEmojiDescriptions() {
return Object.values(emojiMap).map(({ d }) => d);
}
/** /**
* Search emoji by name or alias. Returns a normalized, deduplicated list of * Retrieves an emoji by name or alias.
* names.
* *
* Calling with an empty filter returns an empty array. * Note: `initEmojiMap` must have been called and completed before this method
* can safely be called.
* *
* @param {String} * @param {String} query The emoji name
* @returns {Array} * @param {Boolean} fallback If true, a fallback emoji will be returned if the
* named emoji does not exist. Defaults to false.
* @returns {Object} The matching emoji.
*/ */
export function queryEmojiNames(filter) { export function getEmoji(query, fallback = false) {
const matches = fuzzaldrinPlus.filter(validEmojiNames, filter); if (!emojiMap) {
return uniq(matches.map(name => normalizeEmojiName(name))); // eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('The emoji map is uninitialized or initialization has not completed');
}
const lowercaseQuery = query.toLowerCase();
const name = normalizeEmojiName(lowercaseQuery);
if (name in emojiMap) {
return emojiMap[name];
}
if (fallback) {
return emojiMap.grey_question;
}
return null;
} }
const searchMatchers = {
fuzzy: (value, query) => fuzzaldrinPlus.score(value, query) > 0, // Fuzzy matching compares using a fuzzy matching library
contains: (value, query) => value.indexOf(query.toLowerCase()) >= 0, // Contains matching compares by indexOf
exact: (value, query) => value === query.toLowerCase(), // Exact matching compares by equality
};
const searchPredicates = {
name: (matcher, query) => emoji => matcher(emoji.name, query), // Search by name
alias: (matcher, query) => emoji => emoji.aliases.some(v => matcher(v, query)), // Search by alias
description: (matcher, query) => emoji => matcher(emoji.d, query), // Search by description
unicode: (matcher, query) => emoji => emoji.e === query, // Search by unicode value (always exact)
};
/** /**
* Searches emoji by name, alias, description, and unicode value and returns an * Searches emoji by name, aliases, description, and unicode value and returns
* array of matches. * an array of matches.
*
* Behavior is undefined if `opts.fields` is empty or if `opts.match` is fuzzy
* and the query is empty.
* *
* Note: `initEmojiMap` must have been called and completed before this method * Note: `initEmojiMap` must have been called and completed before this method
* can safely be called. * can safely be called.
* *
* @param {String} query The search query * @param {String} query Search query.
* @returns {Object[]} A list of emoji that match the query * @param {Object} opts Search options (optional).
* @param {String[]} opts.fields Fields to search. Choices are 'name', 'alias',
* 'description', and 'unicode' (value). Default is all (four) fields.
* @param {String} opts.match Search method to use. Choices are 'exact',
* 'contains', or 'fuzzy'. All methods are case-insensitive. Exact matching (the
* default) compares by equality. Contains matching compares by indexOf. Fuzzy
* matching compares using a fuzzy matching library.
* @param {Boolean} opts.fallback If true, a fallback emoji will be returned if
* the result set is empty. Defaults to false.
* @returns {Object[]} A list of emoji that match the query.
*/ */
export function searchEmoji(query) { export function searchEmoji(query, opts) {
if (!emojiMap) if (!emojiMap) {
// eslint-disable-next-line @gitlab/require-i18n-strings // eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('The emoji map is uninitialized or initialization has not completed'); throw new Error('The emoji map is uninitialized or initialization has not completed');
}
const {
fields = ['name', 'alias', 'description', 'unicode'],
match = 'exact',
fallback = false,
} = opts || {};
const matches = s => fuzzaldrinPlus.score(s, query) > 0; // optimization for an exact match in name and alias
if (match === 'exact' && new Set([...fields, 'name', 'alias']).size === 2) {
// Search emoji const emoji = getEmoji(query, fallback);
return Object.values(emojiMap).filter( return emoji ? [emoji] : [];
emoji => }
// by name
matches(emoji.name) || const matcher = searchMatchers[match] || searchMatchers.exact;
// by alias const predicates = fields.map(f => searchPredicates[f](matcher, query));
emoji.aliases.some(matches) ||
// by description const results = Object.values(emojiMap).filter(emoji =>
matches(emoji.d) || predicates.some(predicate => predicate(emoji)),
// by unicode value
query === emoji.e,
); );
// Fallback to question mark for unknown emojis
if (fallback && results.length === 0) {
return [emojiMap.grey_question];
}
return results;
} }
let emojiCategoryMap; let emojiCategoryMap;
...@@ -136,16 +197,10 @@ export function getEmojiCategoryMap() { ...@@ -136,16 +197,10 @@ export function getEmojiCategoryMap() {
} }
export function getEmojiInfo(query) { export function getEmojiInfo(query) {
let name = normalizeEmojiName(query); return searchEmoji(query, {
let emojiInfo = emojiMap[name]; fields: ['name', 'alias'],
fallback: true,
// Fallback to question mark for unknown emojis })[0];
if (!emojiInfo) {
name = 'grey_question';
emojiInfo = emojiMap[name];
}
return { ...emojiInfo, name };
} }
export function emojiFallbackImageSrc(inputName) { export function emojiFallbackImageSrc(inputName) {
......
...@@ -191,8 +191,7 @@ class GfmAutoComplete { ...@@ -191,8 +191,7 @@ class GfmAutoComplete {
} }
return tmpl; return tmpl;
}, },
// eslint-disable-next-line no-template-curly-in-string insertTpl: GfmAutoComplete.Emoji.insertTemplateFunction,
insertTpl: ':${name}:',
skipSpecialCharacterTest: true, skipSpecialCharacterTest: true,
data: GfmAutoComplete.defaultLoadingData, data: GfmAutoComplete.defaultLoadingData,
callbacks: { callbacks: {
...@@ -612,12 +611,7 @@ class GfmAutoComplete { ...@@ -612,12 +611,7 @@ class GfmAutoComplete {
} else if (this.cachedData[at]) { } else if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]); this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
Emoji.initEmojiMap() this.loadEmojiData($input, at).catch(() => {});
.then(() => {
this.loadData($input, at, Emoji.getValidEmojiNames());
GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
})
.catch(() => {});
} else if (dataSource) { } else if (dataSource) {
AjaxCache.retrieve(dataSource, true) AjaxCache.retrieve(dataSource, true)
.then(data => { .then(data => {
...@@ -640,6 +634,18 @@ class GfmAutoComplete { ...@@ -640,6 +634,18 @@ class GfmAutoComplete {
return $input.trigger('keyup'); return $input.trigger('keyup');
} }
async loadEmojiData($input, at) {
await Emoji.initEmojiMap();
this.loadData($input, at, [
...Emoji.getValidEmojiNames(),
...Emoji.getValidEmojiDescriptions(),
...Emoji.getValidEmojiUnicodeValues(),
]);
GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
}
clearCache() { clearCache() {
this.cachedData = {}; this.cachedData = {};
} }
...@@ -708,12 +714,16 @@ GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities']; ...@@ -708,12 +714,16 @@ GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
// Emoji // Emoji
GfmAutoComplete.glEmojiTag = null; GfmAutoComplete.glEmojiTag = null;
GfmAutoComplete.Emoji = { GfmAutoComplete.Emoji = {
insertTemplateFunction(value) {
const { name = value.name } = Emoji.searchEmoji(value.name, { match: 'contains' })[0] || {};
return `:${name}:`;
},
templateFunction(name) { templateFunction(name) {
// glEmojiTag helper is loaded on-demand in fetchData() // glEmojiTag helper is loaded on-demand in fetchData()
if (GfmAutoComplete.glEmojiTag) { if (!GfmAutoComplete.glEmojiTag) return `<li>${name}</li>`;
return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`;
} const emoji = Emoji.searchEmoji(name, { match: 'contains' })[0];
return `<li>${name}</li>`; return `<li>${name} ${GfmAutoComplete.glEmojiTag(emoji?.name || name)}</li>`;
}, },
}; };
// Team Members // Team Members
......
---
title: Match against description and unicode character when autocompleting GFM emoji
merge_request: 42669
author: Ethan Reesor (@firelizzard)
type: added
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 { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji';
import { initEmojiMap, glEmojiTag, searchEmoji, EMOJI_VERSION } from '~/emoji'; import { glEmojiTag, searchEmoji } from '~/emoji';
import isEmojiUnicodeSupported, { import isEmojiUnicodeSupported, {
isFlagEmoji, isFlagEmoji,
isRainbowFlagEmoji, isRainbowFlagEmoji,
...@@ -30,54 +29,11 @@ const emptySupportMap = { ...@@ -30,54 +29,11 @@ const emptySupportMap = {
1.1: false, 1.1: false,
}; };
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',
},
};
describe('gl_emoji', () => { describe('gl_emoji', () => {
let mock; let mock;
beforeEach(() => { beforeEach(async () => {
const emojiData = Object.fromEntries( mock = await initEmojiMock();
Object.values(emojiFixtureMap).map(m => {
const { name: n, moji: e, unicodeVersion: u, category: c, description: d } = m;
return [n, { c, e, d, u }];
}),
);
mock = new MockAdapter(axios);
mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData));
return initEmojiMap().catch(() => {});
}); });
afterEach(() => { afterEach(() => {
...@@ -398,21 +354,101 @@ describe('gl_emoji', () => { ...@@ -398,21 +354,101 @@ describe('gl_emoji', () => {
describe('searchEmoji', () => { describe('searchEmoji', () => {
const { atom, grey_question } = emojiFixtureMap; const { atom, grey_question } = emojiFixtureMap;
const contains = (e, term) => const search = (query, opts) => searchEmoji(query, opts).map(({ name }) => name);
expect(searchEmoji(term).map(({ name }) => name)).toContain(e.name); const mangle = str => str.slice(0, 1) + str.slice(-1);
const partial = str => str.slice(0, 2);
describe('with default options', () => {
const subject = query => search(query);
describeEmojiFields('with $field', ({ accessor }) => {
it(`should match by lower case: ${accessor(atom)}`, () => {
expect(subject(accessor(atom))).toContain(atom.name);
});
it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => {
expect(subject(accessor(atom).toUpperCase())).toContain(atom.name);
});
it(`should not match by partial: ${mangle(accessor(atom))}`, () => {
expect(subject(mangle(accessor(atom)))).not.toContain(atom.name);
});
});
it(`should match by unicode value: ${atom.moji}`, () => {
expect(subject(atom.moji)).toContain(atom.name);
});
it('should not return a fallback value', () => {
expect(subject('foo bar baz')).toHaveLength(0);
});
});
describe('with fuzzy match', () => {
const subject = query => search(query, { match: 'fuzzy' });
describeEmojiFields('with $field', ({ accessor }) => {
it(`should match by lower case: ${accessor(atom)}`, () => {
expect(subject(accessor(atom))).toContain(atom.name);
});
it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => {
expect(subject(accessor(atom).toUpperCase())).toContain(atom.name);
});
it(`should match by partial: ${mangle(accessor(atom))}`, () => {
expect(subject(mangle(accessor(atom)))).toContain(atom.name);
});
});
});
describe('with contains match', () => {
const subject = query => search(query, { match: 'contains' });
describeEmojiFields('with $field', ({ accessor }) => {
it(`should match by lower case: ${accessor(atom)}`, () => {
expect(subject(accessor(atom))).toContain(atom.name);
});
it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => {
expect(subject(accessor(atom).toUpperCase())).toContain(atom.name);
});
it(`should match by partial: ${partial(accessor(atom))}`, () => {
expect(subject(partial(accessor(atom)))).toContain(atom.name);
});
it(`should not match by mangled: ${mangle(accessor(atom))}`, () => {
expect(subject(mangle(accessor(atom)))).not.toContain(atom.name);
});
});
});
it('should match by full name', () => contains(grey_question, 'grey_question')); describe('with fallback', () => {
it('should match by full alias', () => contains(atom, 'atom_symbol')); const subject = query => search(query, { fallback: true });
it('should match by full description', () => contains(grey_question, 'ornament'));
it('should match by partial name', () => contains(grey_question, 'question')); it('should return a fallback value', () =>
it('should match by partial alias', () => contains(atom, '_symbol')); expect(subject('foo bar baz')).toContain(grey_question.name));
it('should match by partial description', () => contains(grey_question, 'ment')); });
describe('with name and alias fields', () => {
const subject = query => search(query, { fields: ['name', 'alias'] });
it('should fuzzy match by name', () => contains(grey_question, 'greion')); it(`should match by name: ${atom.name}`, () => {
it('should fuzzy match by alias', () => contains(atom, 'atobol')); expect(subject(atom.name)).toContain(atom.name);
it('should fuzzy match by description', () => contains(grey_question, 'ornt')); });
it(`should match by alias: ${atom.aliases[0]}`, () => {
expect(subject(atom.aliases[0])).toContain(atom.name);
});
it('should match by character', () => contains(grey_question, '')); it(`should not match by description: ${atom.description}`, () => {
expect(subject(atom.description)).not.toContain(atom.name);
});
it(`should not match by unicode value: ${atom.moji}`, () => {
expect(subject(atom.moji)).not.toContain(atom.name);
});
});
}); });
}); });
/* eslint no-param-reassign: "off" */ /* eslint no-param-reassign: "off" */
import $ from 'jquery'; import $ from 'jquery';
import { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji';
import '~/lib/utils/jquery_at_who'; import '~/lib/utils/jquery_at_who';
import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete'; import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete';
...@@ -702,4 +703,62 @@ describe('GfmAutoComplete', () => { ...@@ -702,4 +703,62 @@ describe('GfmAutoComplete', () => {
`('$input shows $output.length labels', expectLabels); `('$input shows $output.length labels', expectLabels);
}); });
}); });
describe('emoji', () => {
const { atom } = emojiFixtureMap;
const assertInserted = ({ input, subject, emoji }) =>
expect(subject).toBe(`:${emoji?.name || input}:`);
const assertTemplated = ({ input, subject, emoji }) =>
expect(subject.replace(/\s+/g, ' ')).toBe(
`<li>${input} <gl-emoji data-name="${emoji?.name || input}"></gl-emoji> </li>`,
);
let mock;
beforeEach(async () => {
mock = await initEmojiMock();
await new GfmAutoComplete({}).loadEmojiData({ atwho() {}, trigger() {} }, ':');
if (!GfmAutoComplete.glEmojiTag) throw new Error('emoji not loaded');
});
afterEach(() => {
mock.restore();
});
describe.each`
name | inputFormat | assert
${'insertTemplateFunction'} | ${name => ({ name })} | ${assertInserted}
${'templateFunction'} | ${name => name} | ${assertTemplated}
`('Emoji.$name', ({ name, inputFormat, assert }) => {
const execute = (input, emoji) =>
assert({
input,
emoji,
subject: GfmAutoComplete.Emoji[name](inputFormat(input)),
});
describeEmojiFields('for $field', ({ accessor }) => {
it('should work with lowercase', () => {
execute(accessor(atom), atom);
});
it('should work with uppercase', () => {
execute(accessor(atom).toUpperCase(), atom);
});
it('should work with partial value', () => {
execute(accessor(atom).slice(1), atom);
});
});
it('should work with unicode value', () => {
execute(atom.moji, atom);
});
it('should pass through unknown value', () => {
execute('foo bar baz');
});
});
});
}); });
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { initEmojiMap, EMOJI_VERSION } from '~/emoji';
export const emojiFixtureMap = {
atom: {
name: 'atom',
moji: '',
description: 'atom symbol',
unicodeVersion: '4.1',
aliases: ['atom_symbol'],
},
bomb: {
name: 'bomb',
moji: '💣',
unicodeVersion: '6.0',
description: 'bomb',
aliases: [],
},
construction_worker_tone5: {
name: 'construction_worker_tone5',
moji: '👷🏿',
unicodeVersion: '8.0',
description: 'construction worker tone 5',
aliases: [],
},
five: {
name: 'five',
moji: '5️⃣',
unicodeVersion: '3.0',
description: 'keycap digit five',
aliases: [],
},
grey_question: {
name: 'grey_question',
moji: '',
unicodeVersion: '6.0',
description: 'white question mark ornament',
aliases: [],
},
};
export async function initEmojiMock() {
const emojiData = Object.fromEntries(
Object.values(emojiFixtureMap).map(m => {
const { name: n, moji: e, unicodeVersion: u, category: c, description: d } = m;
return [n, { c, e, d, u }];
}),
);
const mock = new MockAdapter(axios);
mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData));
await initEmojiMap();
return mock;
}
export function describeEmojiFields(label, tests) {
describe.each`
field | accessor
${'name'} | ${e => e.name}
${'alias'} | ${e => e.aliases[0]}
${'description'} | ${e => e.description}
`(label, tests);
}
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