Commit b18eca24 authored by Eulyeon Ko's avatar Eulyeon Ko Committed by Paul Slaughter

Remove fuzzy search for emoji

This MR refactors and partially reverts the changes made in
https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42669
parent 948e03df
......@@ -560,7 +560,7 @@ export class AwardsHandler {
}
findMatchingEmojiElements(query) {
const emojiMatches = this.emoji.searchEmoji(query, { match: 'fuzzy' }).map(({ name }) => name);
const emojiMatches = this.emoji.searchEmoji(query).map((x) => x.emoji.name);
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements.filter(
(i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0,
......
import 'document-register-element';
import isEmojiUnicodeSupported from '../emoji/support';
import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji';
import {
initEmojiMap,
getEmojiInfo,
emojiFallbackImageSrc,
emojiImageTag,
FALLBACK_EMOJI_KEY,
} from '../emoji';
class GlEmoji extends HTMLElement {
connectedCallback() {
......@@ -17,7 +23,7 @@ class GlEmoji extends HTMLElement {
if (emojiInfo) {
if (name !== emojiInfo.name) {
if (emojiInfo.fallback && this.innerHTML) {
if (emojiInfo.name === FALLBACK_EMOJI_KEY && this.innerHTML) {
return; // When fallback emoji is used, but there is a <img> provided, use the <img> instead
}
......
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { escape, minBy } from 'lodash';
import emojiAliases from 'emojis/aliases.json';
import axios from '../lib/utils/axios_utils';
import AccessorUtilities from '../lib/utils/accessor';
let emojiMap = null;
let validEmojiNames = null;
export const FALLBACK_EMOJI_KEY = 'grey_question';
export const EMOJI_VERSION = '1';
......@@ -30,23 +31,17 @@ async function loadEmoji() {
return data;
}
async function prepareEmojiMap() {
emojiMap = await loadEmoji();
async function loadEmojiWithNames() {
return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => {
acc[key] = { ...value, name: key };
validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
return acc;
}, {});
}
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);
});
async function prepareEmojiMap() {
emojiMap = await loadEmojiWithNames();
validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
}
export function initEmojiMap() {
......@@ -63,156 +58,101 @@ export function getValidEmojiNames() {
}
export function isEmojiNameValid(name) {
return validEmojiNames.indexOf(name) >= 0;
if (!emojiMap) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('The emoji map is uninitialized or initialization has not completed');
}
return name in emojiMap || name in emojiAliases;
}
export function getAllEmoji() {
return emojiMap;
}
/**
* Retrieves an emoji by name or alias.
*
* Note: `initEmojiMap` must have been called and completed before this method
* can safely be called.
*
* @param {String} query The emoji name
* @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 getEmoji(query, fallback = false) {
// TODO https://gitlab.com/gitlab-org/gitlab/-/issues/268208
const fallbackEmoji = emojiMap.grey_question;
if (!query) {
return fallback ? fallbackEmoji : null;
}
if (!emojiMap) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('The emoji map is uninitialized or initialization has not completed');
function getAliasesMatchingQuery(query) {
return Object.keys(emojiAliases)
.filter((alias) => alias.includes(query))
.reduce((map, alias) => {
const emojiName = emojiAliases[alias];
const score = alias.indexOf(query);
const prev = map.get(emojiName);
// overwrite if we beat the previous score or we're more alphabetical
const shouldSet =
!prev ||
prev.score > score ||
(prev.score === score && prev.alias.localeCompare(alias) > 0);
if (shouldSet) {
map.set(emojiName, { score, alias });
}
const lowercaseQuery = query.toLowerCase();
const name = normalizeEmojiName(lowercaseQuery);
return map;
}, new Map());
}
if (name in emojiMap) {
return emojiMap[name];
function getUnicodeMatch(emoji, query) {
if (emoji.e === query) {
return { score: 0, field: 'e', fieldValue: emoji.name, emoji };
}
return fallback ? fallbackEmoji : null;
return null;
}
const searchMatchers = {
// Fuzzy matching compares using a fuzzy matching library
fuzzy: (value, query) => {
const score = fuzzaldrinPlus.score(value, query) > 0;
return { score, success: score > 0 };
},
// Contains matching compares by indexOf
contains: (value, query) => {
const index = value.indexOf(query.toLowerCase());
return { index, success: index >= 0 };
},
// Exact matching compares by equality
exact: (value, query) => {
return { success: value === query.toLowerCase() };
},
};
const searchPredicates = {
// Search by name
name: (matcher, query) => (emoji) => {
const m = matcher(emoji.name, query);
return [{ ...m, emoji, field: emoji.name }];
},
// Search by alias
alias: (matcher, query) => (emoji) =>
emoji.aliases.map((alias) => {
const m = matcher(alias, query);
return { ...m, emoji, field: alias };
}),
// Search by description
description: (matcher, query) => (emoji) => {
const m = matcher(emoji.d, query);
return [{ ...m, emoji, field: emoji.d }];
},
// Search by unicode value (always exact)
unicode: (matcher, query) => (emoji) => {
return [{ emoji, field: emoji.e, success: emoji.e === query }];
},
};
/**
* Searches emoji by name, aliases, description, and unicode value and returns
* 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
* can safely be called.
*
* @param {String} query Search 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.
* @param {Boolean} opts.raw Returns the raw match data instead of just the
* matching emoji.
* @returns {Object[]} A list of emoji that match the query.
*/
export function searchEmoji(query, opts) {
if (!emojiMap) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('The emoji map is uninitialized or initialization has not completed');
function getDescriptionMatch(emoji, query) {
if (emoji.d.includes(query)) {
return { score: emoji.d.indexOf(query), field: 'd', fieldValue: emoji.d, emoji };
}
const {
fields = ['name', 'alias', 'description', 'unicode'],
match = 'exact',
fallback = false,
raw = false,
} = opts || {};
return null;
}
const fallbackEmoji = emojiMap.grey_question;
function getAliasMatch(emoji, matchingAliases) {
if (matchingAliases.has(emoji.name)) {
const { score, alias } = matchingAliases.get(emoji.name);
if (fallbackEmoji) {
fallbackEmoji.fallback = true;
return { score, field: 'alias', fieldValue: alias, emoji };
}
if (!query) {
if (fallback) {
return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji];
}
return null;
}
return [];
function getNameMatch(emoji, query) {
if (emoji.name.includes(query)) {
return {
score: emoji.name.indexOf(query),
field: 'name',
fieldValue: emoji.name,
emoji,
};
}
// optimization for an exact match in name and alias
if (match === 'exact' && new Set([...fields, 'name', 'alias']).size === 2) {
const emoji = getEmoji(query, fallback);
return emoji ? [emoji] : [];
}
return null;
}
const matcher = searchMatchers[match] || searchMatchers.exact;
const predicates = fields.map((f) => searchPredicates[f](matcher, query));
export function searchEmoji(query) {
const lowercaseQuery = query ? `${query}`.toLowerCase() : '';
const results = Object.values(emojiMap)
.flatMap((emoji) => predicates.flatMap((predicate) => predicate(emoji)))
.filter((r) => r.success);
const matchingAliases = getAliasesMatchingQuery(lowercaseQuery);
// Fallback to question mark for unknown emojis
if (fallback && results.length === 0) {
return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji];
}
return Object.values(emojiMap)
.map((emoji) => {
const matches = [
getUnicodeMatch(emoji, query),
getDescriptionMatch(emoji, lowercaseQuery),
getAliasMatch(emoji, matchingAliases),
getNameMatch(emoji, lowercaseQuery),
].filter(Boolean);
return minBy(matches, (x) => x.score);
})
.filter(Boolean);
}
return raw ? results : results.map((r) => r.emoji);
export function sortEmoji(items) {
// Sort results by index of and string comparison
return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue));
}
let emojiCategoryMap;
......@@ -238,11 +178,28 @@ export function getEmojiCategoryMap() {
return emojiCategoryMap;
}
export function getEmojiInfo(query) {
return searchEmoji(query, {
fields: ['name', 'alias'],
fallback: true,
})[0];
/**
* Retrieves an emoji by name
*
* @param {String} query The emoji name
* @param {Boolean} fallback If true, a fallback emoji will be returned if the
* named emoji does not exist.
* @returns {Object} The matching emoji.
*/
export function getEmojiInfo(query, fallback = true) {
if (!emojiMap) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('The emoji map is uninitialized or initialization has not completed');
}
const lowercaseQuery = query ? `${query}`.toLowerCase() : '';
const name = normalizeEmojiName(lowercaseQuery);
if (name in emojiMap) {
return emojiMap[name];
}
return fallback ? emojiMap[FALLBACK_EMOJI_KEY] : null;
}
export function emojiFallbackImageSrc(inputName) {
......@@ -262,12 +219,8 @@ export function glEmojiTag(inputName, options) {
const fallbackSpriteClass = `emoji-${name}`;
const fallbackSpriteAttribute = opts.sprite
? `data-fallback-sprite-class="${fallbackSpriteClass}"`
? `data-fallback-sprite-class="${escape(fallbackSpriteClass)}" `
: '';
return `
<gl-emoji
${fallbackSpriteAttribute}
data-name="${name}"></gl-emoji>
`;
return `<gl-emoji ${fallbackSpriteAttribute}data-name="${escape(name)}"></gl-emoji>`;
}
......@@ -190,59 +190,43 @@ class GfmAutoComplete {
}
setupEmoji($input) {
const self = this;
const { filter, ...defaults } = this.getDefaultCallbacks();
const fetchData = this.fetchData.bind(this);
// Emoji
$input.atwho({
at: ':',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value && value.name) {
tmpl = GfmAutoComplete.Emoji.templateFunction(value.name);
}
return tmpl;
},
displayTpl: GfmAutoComplete.Emoji.templateFunction,
insertTpl: GfmAutoComplete.Emoji.insertTemplateFunction,
skipSpecialCharacterTest: true,
data: GfmAutoComplete.defaultLoadingData,
callbacks: {
...defaults,
...this.getDefaultCallbacks(),
matcher(flag, subtext) {
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
const match = regexp.exec(subtext);
return match && match.length ? match[1] : null;
},
filter(query, items, searchKey) {
const filtered = filter.call(this, query, items, searchKey);
if (query.length === 0 || GfmAutoComplete.isLoading(items)) {
return filtered;
filter(query, items) {
if (GfmAutoComplete.isLoading(items)) {
fetchData(this.$inputor, this.at);
return items;
}
// map from value to "<value> is <field> of <emoji>", arranged by emoji
const emojis = {};
filtered.forEach(({ name: value }) => {
self.emojiLookup[value].forEach(({ emoji: { name }, kind }) => {
let entry = emojis[name];
if (!entry) {
entry = {};
emojis[name] = entry;
}
if (!(kind in entry) || value.localeCompare(entry[kind]) < 0) {
entry[kind] = value;
return GfmAutoComplete.Emoji.filter(query);
},
sorter(query, items) {
this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
if (GfmAutoComplete.isLoading(items)) {
this.setting.highlightFirst = false;
return items;
}
});
});
// collate results to list, prefering name > unicode > alias > description
const results = [];
Object.values(emojis).forEach(({ name, unicode, alias, description }) => {
results.push(name || unicode || alias || description);
});
if (query.length === 0) {
return items;
}
// return to the form atwho wants
return results.map((name) => ({ name }));
return GfmAutoComplete.Emoji.sorter(items);
},
},
});
......@@ -674,32 +658,7 @@ class GfmAutoComplete {
async loadEmojiData($input, at) {
await Emoji.initEmojiMap();
// All the emoji
const emojis = Emoji.getAllEmoji();
// Add all of the fields to atwho's database
this.loadData($input, at, [
...Object.keys(emojis), // Names
...Object.values(emojis).flatMap(({ aliases }) => aliases), // Aliases
...Object.values(emojis).map(({ e }) => e), // Unicode values
...Object.values(emojis).map(({ d }) => d), // Descriptions
]);
// Construct a lookup that can correlate a value to "<value> is the <field> of <emoji>"
const lookup = {};
const add = (key, kind, emoji) => {
if (!(key in lookup)) {
lookup[key] = [];
}
lookup[key].push({ kind, emoji });
};
Object.values(emojis).forEach((emoji) => {
add(emoji.name, 'name', emoji);
add(emoji.d, 'description', emoji);
add(emoji.e, 'unicode', emoji);
emoji.aliases.forEach((a) => add(a, 'alias', emoji));
});
this.emojiLookup = lookup;
this.loadData($input, at, ['loaded']);
GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
}
......@@ -772,36 +731,38 @@ GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
GfmAutoComplete.isTypeWithBackendFiltering = (type) =>
GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[type]);
function findEmoji(name) {
return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => {
if (a.index !== b.index) {
return a.index - b.index;
}
return a.field.localeCompare(b.field);
});
}
// Emoji
GfmAutoComplete.glEmojiTag = null;
GfmAutoComplete.Emoji = {
insertTemplateFunction(value) {
const results = findEmoji(value.name);
if (results.length) {
return `:${results[0].emoji.name}:`;
}
return `:${value.name}:`;
return `:${value.emoji.name}:`;
},
templateFunction(name) {
// glEmojiTag helper is loaded on-demand in fetchData()
if (!GfmAutoComplete.glEmojiTag) return `<li>${name}</li>`;
templateFunction(item) {
if (GfmAutoComplete.isLoading(item)) {
return GfmAutoComplete.Loading.template;
}
const results = findEmoji(name);
if (!results.length) {
return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`;
const escapedFieldValue = escape(item.fieldValue);
if (!GfmAutoComplete.glEmojiTag) {
return `<li>${escapedFieldValue}</li>`;
}
const { field, emoji } = results[0];
return `<li>${field} ${GfmAutoComplete.glEmojiTag(emoji.name)}</li>`;
return `<li>${escapedFieldValue} ${GfmAutoComplete.glEmojiTag(item.emoji.name)}</li>`;
},
filter(query) {
if (query.length === 0) {
return Object.values(Emoji.getAllEmoji())
.map((emoji) => ({
emoji,
fieldValue: emoji.name,
}))
.slice(0, 20);
}
return Emoji.searchEmoji(query);
},
sorter(items) {
return Emoji.sortEmoji(items);
},
};
// Team Members
......
---
title: Remove fuzzy search for awards emoji and refactor GFM autocomplete emoji support
merge_request: 51972
author: Ethan Reesor (@firelizzard)
type: other
......@@ -29,10 +29,6 @@ export const emojiFixtureMap = {
unicodeVersion: '6.0',
description: 'white question mark ornament',
},
// used for regression tests
// black_heart MUST come before heart
// custard MUST come before star
black_heart: {
moji: '🖤',
unicodeVersion: '1.1',
......@@ -55,34 +51,18 @@ export const emojiFixtureMap = {
},
};
Object.keys(emojiFixtureMap).forEach((k) => {
emojiFixtureMap[k].name = k;
if (!emojiFixtureMap[k].aliases) {
emojiFixtureMap[k].aliases = [];
}
});
export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => {
const { moji: e, unicodeVersion: u, category: c, description: d } = emojiFixtureMap[k];
acc[k] = { name: k, e, u, c, d };
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 }];
}),
);
return acc;
}, {});
export async function initEmojiMock(mockData = mockEmojiData) {
const mock = new MockAdapter(axios);
mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData));
mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(mockData));
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);
}
......@@ -53,6 +53,12 @@ describe('AwardsHandler', () => {
d: 'smiling face with sunglasses',
u: '6.0',
},
grey_question: {
c: 'symbols',
e: '',
d: 'white question mark ornament',
u: '6.0',
},
};
preloadFixtures('snippets/show.html');
......@@ -285,16 +291,6 @@ describe('AwardsHandler', () => {
expect($('.js-emoji-menu-search').val()).toBe('');
});
it('should fuzzy filter the emoji', async () => {
await openAndWaitForEmojiMenu();
awardsHandler.searchEmojis('sgls');
expect($('[data-name=angel]').is(':visible')).toBe(false);
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();
......
import { trimText } from 'helpers/text_helper';
import { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji';
import { glEmojiTag, searchEmoji, getEmoji } from '~/emoji';
import { emojiFixtureMap, mockEmojiData, initEmojiMock } from 'helpers/emoji';
import { glEmojiTag, searchEmoji, getEmojiInfo, sortEmoji } from '~/emoji';
import isEmojiUnicodeSupported, {
isFlagEmoji,
isRainbowFlagEmoji,
......@@ -29,7 +29,7 @@ const emptySupportMap = {
1.1: false,
};
describe('gl_emoji', () => {
describe('emoji', () => {
let mock;
beforeEach(async () => {
......@@ -43,7 +43,7 @@ describe('gl_emoji', () => {
describe('glEmojiTag', () => {
it('bomb emoji', () => {
const emojiKey = 'bomb';
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name);
const markup = glEmojiTag(emojiKey);
expect(trimText(markup)).toMatchInlineSnapshot(
`"<gl-emoji data-name=\\"bomb\\"></gl-emoji>"`,
......@@ -52,7 +52,7 @@ describe('gl_emoji', () => {
it('bomb emoji with sprite fallback readiness', () => {
const emojiKey = 'bomb';
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
const markup = glEmojiTag(emojiKey, {
sprite: true,
});
expect(trimText(markup)).toMatchInlineSnapshot(
......@@ -352,125 +352,272 @@ describe('gl_emoji', () => {
});
});
describe('getEmoji', () => {
const { grey_question } = emojiFixtureMap;
describe('when query is undefined', () => {
it('should return null by default', () => {
expect(getEmoji()).toBe(null);
});
it('should return fallback emoji when fallback is true', () => {
expect(getEmoji(undefined, true).name).toEqual(grey_question.name);
});
});
});
describe('searchEmoji', () => {
const { atom, grey_question } = emojiFixtureMap;
const search = (query, opts) => searchEmoji(query, opts).map(({ name }) => 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);
describe('getEmojiInfo', () => {
it.each(['atom', 'five', 'black_heart'])("should return a correct emoji for '%s'", (name) => {
expect(getEmojiInfo(name)).toEqual(mockEmojiData[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 return fallback emoji by default', () => {
expect(getEmojiInfo('atjs')).toEqual(mockEmojiData.grey_question);
});
it(`should match by unicode value: ${atom.moji}`, () => {
expect(subject(atom.moji)).toContain(atom.name);
it('should return null when fallback is false', () => {
expect(getEmojiInfo('atjs', false)).toBe(null);
});
it('should not return a fallback value', () => {
expect(subject('foo bar baz')).toHaveLength(0);
});
it('should not return a fallback value when query is falsey', () => {
expect(subject()).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);
describe('when query is undefined', () => {
it('should return fallback emoji by default', () => {
expect(getEmojiInfo()).toEqual(mockEmojiData.grey_question);
});
it(`should match by partial: ${mangle(accessor(atom))}`, () => {
expect(subject(mangle(accessor(atom)))).toContain(atom.name);
});
it('should return null when fallback is false', () => {
expect(getEmojiInfo(undefined, false)).toBe(null);
});
});
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);
describe('searchEmoji', () => {
const emojiFixture = Object.keys(mockEmojiData).reduce((acc, k) => {
const { name, e, u, d } = mockEmojiData[k];
acc[k] = { name, e, u, d };
return acc;
}, {});
it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => {
const search = searchEmoji(input);
const expected = [
'atom',
'bomb',
'construction_worker_tone5',
'five',
'grey_question',
'black_heart',
'heart',
'custard',
'star',
].map((name) => {
return {
emoji: emojiFixture[name],
field: 'd',
fieldValue: emojiFixture[name].d,
score: 0,
};
});
it(`should not match by mangled: ${mangle(accessor(atom))}`, () => {
expect(subject(mangle(accessor(atom)))).not.toContain(atom.name);
});
expect(search).toEqual(expected);
});
});
describe('with fallback', () => {
const subject = (query) => search(query, { fallback: true });
it.each`
query
${'foo bar baz'} | ${undefined}
`('should return a fallback value when given $query', ({ query }) => {
expect(subject(query)).toContain(grey_question.name);
});
});
describe('with name and alias fields', () => {
const subject = (query) => search(query, { fields: ['name', 'alias'] });
it(`should match by name: ${atom.name}`, () => {
expect(subject(atom.name)).toContain(atom.name);
it.each([
[
'searching by unicode value',
'',
[
{
name: 'atom',
field: 'e',
fieldValue: 'atom',
score: 0,
},
],
],
[
'searching by partial alias',
'_symbol',
[
{
name: 'atom',
field: 'alias',
fieldValue: 'atom_symbol',
score: 4,
},
],
],
[
'searching by full alias',
'atom_symbol',
[
{
name: 'atom',
field: 'alias',
fieldValue: 'atom_symbol',
score: 0,
},
],
],
])('should return a correct result when %s', (_, query, searchResult) => {
const expected = searchResult.map((item) => {
const { field, score, fieldValue, name } = item;
return {
emoji: emojiFixture[name],
field,
fieldValue,
score,
};
});
it(`should match by alias: ${atom.aliases[0]}`, () => {
expect(subject(atom.aliases[0])).toContain(atom.name);
expect(searchEmoji(query)).toEqual(expected);
});
it(`should not match by description: ${atom.description}`, () => {
expect(subject(atom.description)).not.toContain(atom.name);
it.each([
['searching with a non-existing emoji name', 'asdf', []],
[
'searching by full name',
'atom',
[
{
name: 'atom',
field: 'd',
score: 0,
},
],
],
[
'searching by full description',
'atom symbol',
[
{
name: 'atom',
field: 'd',
score: 0,
},
],
],
[
'searching by partial name',
'question',
[
{
name: 'grey_question',
field: 'name',
score: 5,
},
],
],
[
'searching by partial description',
'ment',
[
{
name: 'grey_question',
field: 'd',
score: 24,
},
],
],
[
'searching with query "heart"',
'heart',
[
{
name: 'black_heart',
field: 'd',
score: 6,
},
{
name: 'heart',
field: 'name',
score: 0,
},
],
],
[
'searching with query "HEART"',
'HEART',
[
{
name: 'black_heart',
field: 'd',
score: 6,
},
{
name: 'heart',
field: 'name',
score: 0,
},
],
],
[
'searching with query "star"',
'star',
[
{
name: 'custard',
field: 'd',
score: 2,
},
{
name: 'star',
field: 'name',
score: 0,
},
],
],
])('should return a correct result when %s', (_, query, searchResult) => {
const expected = searchResult.map((item) => {
const { field, score, name } = item;
return {
emoji: emojiFixture[name],
field,
fieldValue: emojiFixture[name][field],
score,
};
});
it(`should not match by unicode value: ${atom.moji}`, () => {
expect(subject(atom.moji)).not.toContain(atom.name);
});
expect(searchEmoji(query)).toEqual(expected);
});
});
describe('sortEmoji', () => {
const testCases = [
[
'should correctly sort by score',
[
{ score: 10, fieldValue: '', emoji: { name: 'a' } },
{ score: 5, fieldValue: '', emoji: { name: 'b' } },
{ score: 0, fieldValue: '', emoji: { name: 'c' } },
],
[
{ score: 0, fieldValue: '', emoji: { name: 'c' } },
{ score: 5, fieldValue: '', emoji: { name: 'b' } },
{ score: 10, fieldValue: '', emoji: { name: 'a' } },
],
],
[
'should correctly sort by fieldValue',
[
{ score: 0, fieldValue: 'y', emoji: { name: 'b' } },
{ score: 0, fieldValue: 'x', emoji: { name: 'a' } },
{ score: 0, fieldValue: 'z', emoji: { name: 'c' } },
],
[
{ score: 0, fieldValue: 'x', emoji: { name: 'a' } },
{ score: 0, fieldValue: 'y', emoji: { name: 'b' } },
{ score: 0, fieldValue: 'z', emoji: { name: 'c' } },
],
],
[
'should correctly sort by score and then by fieldValue (in order)',
[
{ score: 5, fieldValue: 'y', emoji: { name: 'c' } },
{ score: 0, fieldValue: 'z', emoji: { name: 'a' } },
{ score: 5, fieldValue: 'x', emoji: { name: 'b' } },
],
[
{ score: 0, fieldValue: 'z', emoji: { name: 'a' } },
{ score: 5, fieldValue: 'x', emoji: { name: 'b' } },
{ score: 5, fieldValue: 'y', emoji: { name: 'c' } },
],
],
];
it.each(testCases)('%s', (_, scoredItems, expected) => {
expect(sortEmoji(scoredItems)).toEqual(expected);
});
});
});
......@@ -2,7 +2,7 @@
import $ from 'jquery';
import MockAdapter from 'axios-mock-adapter';
import { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji';
import { initEmojiMock } from 'helpers/emoji';
import '~/lib/utils/jquery_at_who';
import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete';
import { TEST_HOST } from 'helpers/test_constants';
......@@ -714,16 +714,20 @@ describe('GfmAutoComplete', () => {
});
describe('emoji', () => {
const { atom, heart, star } = emojiFixtureMap;
const assertInserted = ({ input, subject, emoji }) =>
expect(subject).toBe(`:${emoji?.name || input}:`);
const assertTemplated = ({ input, subject, emoji, field }) =>
expect(subject.replace(/\s+/g, ' ')).toBe(
`<li>${field || input} <gl-emoji data-name="${emoji?.name || input}"></gl-emoji> </li>`,
);
let mock;
const mockItem = {
'atwho-at': ':',
emoji: {
c: 'symbols',
d: 'negative squared ab',
e: '🆎',
name: 'ab',
u: '6.0',
},
fieldValue: 'ab',
};
beforeEach(async () => {
mock = await initEmojiMock();
......@@ -735,90 +739,22 @@ describe('GfmAutoComplete', () => {
mock.restore();
});
describe.each`
name | inputFormat | assert
${'insertTemplateFunction'} | ${(name) => ({ name })} | ${assertInserted}
${'templateFunction'} | ${(name) => name} | ${assertTemplated}
`('Emoji.$name', ({ name, inputFormat, assert }) => {
const execute = (accessor, input, emoji) =>
assert({
input,
emoji,
field: accessor && accessor(emoji),
subject: GfmAutoComplete.Emoji[name](inputFormat(input)),
});
describeEmojiFields('for $field', ({ accessor }) => {
it('should work with lowercase', () => {
execute(accessor, accessor(atom), atom);
});
it('should work with uppercase', () => {
execute(accessor, accessor(atom).toUpperCase(), atom);
});
it('should work with partial value', () => {
execute(accessor, accessor(atom).slice(1), atom);
});
});
it('should work with unicode value', () => {
execute(null, atom.moji, atom);
});
describe('Emoji.templateFunction', () => {
it('should return a correct template', () => {
const actual = GfmAutoComplete.Emoji.templateFunction(mockItem);
const glEmojiTag = `<gl-emoji data-name="${mockItem.emoji.name}"></gl-emoji>`;
const expected = `<li>${mockItem.fieldValue} ${glEmojiTag}</li>`;
it('should pass through unknown value', () => {
execute(null, 'foo bar baz');
expect(actual).toBe(expected);
});
});
const expectEmojiOrder = (first, second) => {
const keys = Object.keys(emojiFixtureMap);
const firstIndex = keys.indexOf(first);
const secondIndex = keys.indexOf(second);
expect(firstIndex).toBeGreaterThanOrEqual(0);
expect(secondIndex).toBeGreaterThanOrEqual(0);
expect(firstIndex).toBeLessThan(secondIndex);
};
describe('Emoji.insertTemplateFunction', () => {
it('should map ":heart" to :heart: [regression]', () => {
// the bug mapped heart to black_heart because the latter sorted first
expectEmojiOrder('black_heart', 'heart');
const item = GfmAutoComplete.Emoji.insertTemplateFunction({ name: 'heart' });
expect(item).toEqual(`:${heart.name}:`);
});
it('should map ":star" to :star: [regression]', () => {
// the bug mapped star to custard because the latter sorted first
expectEmojiOrder('custard', 'star');
const item = GfmAutoComplete.Emoji.insertTemplateFunction({ name: 'star' });
expect(item).toEqual(`:${star.name}:`);
});
});
describe('Emoji.templateFunction', () => {
it('should map ":heart" to ❤ [regression]', () => {
// the bug mapped heart to black_heart because the latter sorted first
expectEmojiOrder('black_heart', 'heart');
const item = GfmAutoComplete.Emoji.templateFunction('heart')
.replace(/(<gl-emoji)\s+(data-name)/, '$1 $2')
.replace(/>\s+|\s+</g, (s) => s.trim());
expect(item).toEqual(
`<li>${heart.name}<gl-emoji data-name="${heart.name}"></gl-emoji></li>`,
);
});
it('should map ":star" to ⭐ [regression]', () => {
// the bug mapped star to custard because the latter sorted first
expectEmojiOrder('custard', 'star');
it('should return a correct template', () => {
const actual = GfmAutoComplete.Emoji.insertTemplateFunction(mockItem);
const expected = `:${mockItem.emoji.name}:`;
const item = GfmAutoComplete.Emoji.templateFunction('star')
.replace(/(<gl-emoji)\s+(data-name)/, '$1 $2')
.replace(/>\s+|\s+</g, (s) => s.trim());
expect(item).toEqual(`<li>${star.name}<gl-emoji data-name="${star.name}"></gl-emoji></li>`);
expect(actual).toBe(expected);
});
});
});
......
......@@ -18,13 +18,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block"
data-testid="award-html"
>
<gl-emoji
data-name="thumbsup"
/>
</span>
<span
......@@ -52,13 +48,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block"
data-testid="award-html"
>
<gl-emoji
data-name="thumbsdown"
/>
</span>
<span
......@@ -86,13 +78,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block"
data-testid="award-html"
>
<gl-emoji
data-name="smile"
/>
</span>
<span
......@@ -120,13 +108,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block"
data-testid="award-html"
>
<gl-emoji
data-name="ok_hand"
/>
</span>
<span
......@@ -154,13 +138,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block"
data-testid="award-html"
>
<gl-emoji
data-name="cactus"
/>
</span>
<span
......@@ -188,13 +168,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block"
data-testid="award-html"
>
<gl-emoji
data-name="a"
/>
</span>
<span
......@@ -222,13 +198,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block"
data-testid="award-html"
>
<gl-emoji
data-name="b"
/>
</span>
<span
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`gfm_autocomplete/utils emojis config shows the emoji name and icon in the menu item 1`] = `
"raised_hands
<gl-emoji
data-name=\\"raised_hands\\"></gl-emoji>
"
`;
exports[`gfm_autocomplete/utils emojis config shows the emoji name and icon in the menu item 1`] = `"raised_hands <gl-emoji data-name=\\"raised_hands\\"></gl-emoji>"`;
exports[`gfm_autocomplete/utils issues config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context issue title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
......
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