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 { ...@@ -560,7 +560,7 @@ export class AwardsHandler {
} }
findMatchingEmojiElements(query) { 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 $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 'document-register-element'; import 'document-register-element';
import isEmojiUnicodeSupported from '../emoji/support'; 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 { class GlEmoji extends HTMLElement {
connectedCallback() { connectedCallback() {
...@@ -17,7 +23,7 @@ class GlEmoji extends HTMLElement { ...@@ -17,7 +23,7 @@ class GlEmoji extends HTMLElement {
if (emojiInfo) { if (emojiInfo) {
if (name !== emojiInfo.name) { 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 return; // When fallback emoji is used, but there is a <img> provided, use the <img> instead
} }
......
This diff is collapsed.
...@@ -190,59 +190,43 @@ class GfmAutoComplete { ...@@ -190,59 +190,43 @@ class GfmAutoComplete {
} }
setupEmoji($input) { setupEmoji($input) {
const self = this; const fetchData = this.fetchData.bind(this);
const { filter, ...defaults } = this.getDefaultCallbacks();
// Emoji // Emoji
$input.atwho({ $input.atwho({
at: ':', at: ':',
displayTpl(value) { displayTpl: GfmAutoComplete.Emoji.templateFunction,
let tmpl = GfmAutoComplete.Loading.template;
if (value && value.name) {
tmpl = GfmAutoComplete.Emoji.templateFunction(value.name);
}
return tmpl;
},
insertTpl: GfmAutoComplete.Emoji.insertTemplateFunction, insertTpl: GfmAutoComplete.Emoji.insertTemplateFunction,
skipSpecialCharacterTest: true, skipSpecialCharacterTest: true,
data: GfmAutoComplete.defaultLoadingData, data: GfmAutoComplete.defaultLoadingData,
callbacks: { callbacks: {
...defaults, ...this.getDefaultCallbacks(),
matcher(flag, subtext) { matcher(flag, subtext) {
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi'); const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
const match = regexp.exec(subtext); const match = regexp.exec(subtext);
return match && match.length ? match[1] : null; return match && match.length ? match[1] : null;
}, },
filter(query, items, searchKey) { filter(query, items) {
const filtered = filter.call(this, query, items, searchKey); if (GfmAutoComplete.isLoading(items)) {
if (query.length === 0 || GfmAutoComplete.isLoading(items)) { fetchData(this.$inputor, this.at);
return filtered; return items;
} }
// map from value to "<value> is <field> of <emoji>", arranged by emoji return GfmAutoComplete.Emoji.filter(query);
const emojis = {}; },
filtered.forEach(({ name: value }) => { sorter(query, items) {
self.emojiLookup[value].forEach(({ emoji: { name }, kind }) => { this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
let entry = emojis[name]; if (GfmAutoComplete.isLoading(items)) {
if (!entry) { this.setting.highlightFirst = false;
entry = {}; return items;
emojis[name] = entry;
}
if (!(kind in entry) || value.localeCompare(entry[kind]) < 0) {
entry[kind] = value;
} }
});
});
// collate results to list, prefering name > unicode > alias > description if (query.length === 0) {
const results = []; return items;
Object.values(emojis).forEach(({ name, unicode, alias, description }) => { }
results.push(name || unicode || alias || description);
});
// return to the form atwho wants return GfmAutoComplete.Emoji.sorter(items);
return results.map((name) => ({ name }));
}, },
}, },
}); });
...@@ -674,32 +658,7 @@ class GfmAutoComplete { ...@@ -674,32 +658,7 @@ class GfmAutoComplete {
async loadEmojiData($input, at) { async loadEmojiData($input, at) {
await Emoji.initEmojiMap(); await Emoji.initEmojiMap();
// All the emoji this.loadData($input, at, ['loaded']);
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;
GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag; GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
} }
...@@ -772,36 +731,38 @@ GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities']; ...@@ -772,36 +731,38 @@ GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
GfmAutoComplete.isTypeWithBackendFiltering = (type) => GfmAutoComplete.isTypeWithBackendFiltering = (type) =>
GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[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 // Emoji
GfmAutoComplete.glEmojiTag = null; GfmAutoComplete.glEmojiTag = null;
GfmAutoComplete.Emoji = { GfmAutoComplete.Emoji = {
insertTemplateFunction(value) { insertTemplateFunction(value) {
const results = findEmoji(value.name); return `:${value.emoji.name}:`;
if (results.length) {
return `:${results[0].emoji.name}:`;
}
return `:${value.name}:`;
}, },
templateFunction(name) { templateFunction(item) {
// glEmojiTag helper is loaded on-demand in fetchData() if (GfmAutoComplete.isLoading(item)) {
if (!GfmAutoComplete.glEmojiTag) return `<li>${name}</li>`; return GfmAutoComplete.Loading.template;
}
const results = findEmoji(name); const escapedFieldValue = escape(item.fieldValue);
if (!results.length) { if (!GfmAutoComplete.glEmojiTag) {
return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`; return `<li>${escapedFieldValue}</li>`;
} }
const { field, emoji } = results[0]; return `<li>${escapedFieldValue} ${GfmAutoComplete.glEmojiTag(item.emoji.name)}</li>`;
return `<li>${field} ${GfmAutoComplete.glEmojiTag(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 // 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 = { ...@@ -29,10 +29,6 @@ export const emojiFixtureMap = {
unicodeVersion: '6.0', unicodeVersion: '6.0',
description: 'white question mark ornament', description: 'white question mark ornament',
}, },
// used for regression tests
// black_heart MUST come before heart
// custard MUST come before star
black_heart: { black_heart: {
moji: '🖤', moji: '🖤',
unicodeVersion: '1.1', unicodeVersion: '1.1',
...@@ -55,34 +51,18 @@ export const emojiFixtureMap = { ...@@ -55,34 +51,18 @@ export const emojiFixtureMap = {
}, },
}; };
Object.keys(emojiFixtureMap).forEach((k) => { export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => {
emojiFixtureMap[k].name = k; const { moji: e, unicodeVersion: u, category: c, description: d } = emojiFixtureMap[k];
if (!emojiFixtureMap[k].aliases) { acc[k] = { name: k, e, u, c, d };
emojiFixtureMap[k].aliases = [];
}
});
export async function initEmojiMock() { return acc;
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 }];
}),
);
export async function initEmojiMock(mockData = mockEmojiData) {
const mock = new MockAdapter(axios); 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(); await initEmojiMap();
return mock; 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', () => { ...@@ -53,6 +53,12 @@ describe('AwardsHandler', () => {
d: 'smiling face with sunglasses', d: 'smiling face with sunglasses',
u: '6.0', u: '6.0',
}, },
grey_question: {
c: 'symbols',
e: '',
d: 'white question mark ornament',
u: '6.0',
},
}; };
preloadFixtures('snippets/show.html'); preloadFixtures('snippets/show.html');
...@@ -285,16 +291,6 @@ describe('AwardsHandler', () => { ...@@ -285,16 +291,6 @@ describe('AwardsHandler', () => {
expect($('.js-emoji-menu-search').val()).toBe(''); 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 () => { it('should filter by emoji description', async () => {
await openAndWaitForEmojiMenu(); await openAndWaitForEmojiMenu();
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import $ from 'jquery'; import $ from 'jquery';
import MockAdapter from 'axios-mock-adapter'; 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 '~/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';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
...@@ -714,16 +714,20 @@ describe('GfmAutoComplete', () => { ...@@ -714,16 +714,20 @@ describe('GfmAutoComplete', () => {
}); });
describe('emoji', () => { 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; let mock;
const mockItem = {
'atwho-at': ':',
emoji: {
c: 'symbols',
d: 'negative squared ab',
e: '🆎',
name: 'ab',
u: '6.0',
},
fieldValue: 'ab',
};
beforeEach(async () => { beforeEach(async () => {
mock = await initEmojiMock(); mock = await initEmojiMock();
...@@ -735,90 +739,22 @@ describe('GfmAutoComplete', () => { ...@@ -735,90 +739,22 @@ describe('GfmAutoComplete', () => {
mock.restore(); mock.restore();
}); });
describe.each` describe('Emoji.templateFunction', () => {
name | inputFormat | assert it('should return a correct template', () => {
${'insertTemplateFunction'} | ${(name) => ({ name })} | ${assertInserted} const actual = GfmAutoComplete.Emoji.templateFunction(mockItem);
${'templateFunction'} | ${(name) => name} | ${assertTemplated} const glEmojiTag = `<gl-emoji data-name="${mockItem.emoji.name}"></gl-emoji>`;
`('Emoji.$name', ({ name, inputFormat, assert }) => { const expected = `<li>${mockItem.fieldValue} ${glEmojiTag}</li>`;
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);
});
it('should pass through unknown value', () => { expect(actual).toBe(expected);
execute(null, 'foo bar baz');
}); });
}); });
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', () => { describe('Emoji.insertTemplateFunction', () => {
it('should map ":heart" to :heart: [regression]', () => { it('should return a correct template', () => {
// the bug mapped heart to black_heart because the latter sorted first const actual = GfmAutoComplete.Emoji.insertTemplateFunction(mockItem);
expectEmojiOrder('black_heart', 'heart'); const expected = `:${mockItem.emoji.name}:`;
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');
const item = GfmAutoComplete.Emoji.templateFunction('star') expect(actual).toBe(expected);
.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>`);
}); });
}); });
}); });
......
...@@ -18,13 +18,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` ...@@ -18,13 +18,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block" class="award-emoji-block"
data-testid="award-html" data-testid="award-html"
> >
<gl-emoji <gl-emoji
data-name="thumbsup" data-name="thumbsup"
/> />
</span> </span>
<span <span
...@@ -52,13 +48,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` ...@@ -52,13 +48,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block" class="award-emoji-block"
data-testid="award-html" data-testid="award-html"
> >
<gl-emoji <gl-emoji
data-name="thumbsdown" data-name="thumbsdown"
/> />
</span> </span>
<span <span
...@@ -86,13 +78,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` ...@@ -86,13 +78,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block" class="award-emoji-block"
data-testid="award-html" data-testid="award-html"
> >
<gl-emoji <gl-emoji
data-name="smile" data-name="smile"
/> />
</span> </span>
<span <span
...@@ -120,13 +108,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` ...@@ -120,13 +108,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block" class="award-emoji-block"
data-testid="award-html" data-testid="award-html"
> >
<gl-emoji <gl-emoji
data-name="ok_hand" data-name="ok_hand"
/> />
</span> </span>
<span <span
...@@ -154,13 +138,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` ...@@ -154,13 +138,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block" class="award-emoji-block"
data-testid="award-html" data-testid="award-html"
> >
<gl-emoji <gl-emoji
data-name="cactus" data-name="cactus"
/> />
</span> </span>
<span <span
...@@ -188,13 +168,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` ...@@ -188,13 +168,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block" class="award-emoji-block"
data-testid="award-html" data-testid="award-html"
> >
<gl-emoji <gl-emoji
data-name="a" data-name="a"
/> />
</span> </span>
<span <span
...@@ -222,13 +198,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` ...@@ -222,13 +198,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
class="award-emoji-block" class="award-emoji-block"
data-testid="award-html" data-testid="award-html"
> >
<gl-emoji <gl-emoji
data-name="b" data-name="b"
/> />
</span> </span>
<span <span
......
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`gfm_autocomplete/utils emojis config shows the emoji name and icon in the menu item 1`] = ` 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>"`;
"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;"`; 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