gitlab-org--gitlab-foss/spec/frontend/gfm_auto_complete_spec.js

605 lines
19 KiB
JavaScript
Raw Normal View History

/* eslint no-param-reassign: "off" */
import $ from 'jquery';
import { emojiFixtureMap, initEmojiMock, describeEmojiFields } 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';
2019-03-11 13:47:36 +00:00
import { getJSONFixture } from 'helpers/fixtures';
2019-03-11 13:47:36 +00:00
const labelsFixture = getJSONFixture('autocomplete_sources/labels.json');
2019-02-21 13:26:27 +00:00
describe('GfmAutoComplete', () => {
const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
fetchData: () => {},
});
2019-02-21 13:26:27 +00:00
let atwhoInstance;
let sorterValue;
2017-01-18 12:17:24 +00:00
2019-02-21 13:26:27 +00:00
describe('DefaultOptions.sorter', () => {
describe('assets loading', () => {
let items;
2019-02-21 13:26:27 +00:00
beforeEach(() => {
jest.spyOn(GfmAutoComplete, 'isLoading').mockReturnValue(true);
2017-01-18 12:17:24 +00:00
2019-02-21 13:26:27 +00:00
atwhoInstance = { setting: {} };
items = [];
sorterValue = gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, '', items);
2017-01-18 12:17:24 +00:00
});
2019-02-21 13:26:27 +00:00
it('should disable highlightFirst', () => {
expect(atwhoInstance.setting.highlightFirst).toBe(false);
2017-01-18 12:17:24 +00:00
});
2019-02-21 13:26:27 +00:00
it('should return the passed unfiltered items', () => {
expect(sorterValue).toEqual(items);
2017-01-18 12:17:24 +00:00
});
});
2019-02-21 13:26:27 +00:00
describe('assets finished loading', () => {
beforeEach(() => {
jest.spyOn(GfmAutoComplete, 'isLoading').mockReturnValue(false);
jest.spyOn($.fn.atwho.default.callbacks, 'sorter').mockImplementation(() => {});
2017-01-18 12:17:24 +00:00
});
2019-02-21 13:26:27 +00:00
it('should enable highlightFirst if alwaysHighlightFirst is set', () => {
atwhoInstance = { setting: { alwaysHighlightFirst: true } };
2017-01-18 12:17:24 +00:00
gfmAutoCompleteCallbacks.sorter.call(atwhoInstance);
2017-01-18 12:17:24 +00:00
expect(atwhoInstance.setting.highlightFirst).toBe(true);
});
2019-02-21 13:26:27 +00:00
it('should enable highlightFirst if a query is present', () => {
atwhoInstance = { setting: {} };
2017-01-18 12:17:24 +00:00
gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, 'query');
2017-01-18 12:17:24 +00:00
expect(atwhoInstance.setting.highlightFirst).toBe(true);
});
2019-02-21 13:26:27 +00:00
it('should call the default atwho sorter', () => {
atwhoInstance = { setting: {} };
2017-01-18 12:17:24 +00:00
const query = 'query';
const items = [];
2017-01-18 12:17:24 +00:00
const searchKey = 'searchKey';
gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, query, items, searchKey);
2017-01-18 12:17:24 +00:00
expect($.fn.atwho.default.callbacks.sorter).toHaveBeenCalledWith(query, items, searchKey);
});
});
});
2017-11-13 14:30:22 +00:00
describe('DefaultOptions.beforeInsert', () => {
2018-10-17 07:13:26 +00:00
const beforeInsert = (context, value) =>
gfmAutoCompleteCallbacks.beforeInsert.call(context, value);
2017-11-13 14:30:22 +00:00
2019-02-21 13:26:27 +00:00
beforeEach(() => {
atwhoInstance = { setting: { skipSpecialCharacterTest: false } };
});
2017-11-13 14:30:22 +00:00
it('should not quote if value only contains alphanumeric charecters', () => {
expect(beforeInsert(atwhoInstance, '@user1')).toBe('@user1');
expect(beforeInsert(atwhoInstance, '~label1')).toBe('~label1');
});
it('should quote if value contains any non-alphanumeric characters', () => {
expect(beforeInsert(atwhoInstance, '~label-20')).toBe('~"label-20"');
2017-11-13 14:30:22 +00:00
expect(beforeInsert(atwhoInstance, '~label 20')).toBe('~"label 20"');
});
it('should quote integer labels', () => {
expect(beforeInsert(atwhoInstance, '~1234')).toBe('~"1234"');
});
it('escapes Markdown strikethroughs when needed', () => {
expect(beforeInsert(atwhoInstance, '~a~bug')).toEqual('~"a~bug"');
expect(beforeInsert(atwhoInstance, '~a~~bug~~')).toEqual('~"a\\~~bug\\~~"');
});
it('escapes Markdown emphasis when needed', () => {
expect(beforeInsert(atwhoInstance, '~a_bug_')).toEqual('~a_bug\\_');
expect(beforeInsert(atwhoInstance, '~a _bug_')).toEqual('~"a \\_bug\\_"');
expect(beforeInsert(atwhoInstance, '~a*bug*')).toEqual('~"a\\*bug\\*"');
expect(beforeInsert(atwhoInstance, '~a *bug*')).toEqual('~"a \\*bug\\*"');
});
it('escapes Markdown code spans when needed', () => {
expect(beforeInsert(atwhoInstance, '~a`bug`')).toEqual('~"a\\`bug\\`"');
expect(beforeInsert(atwhoInstance, '~a `bug`')).toEqual('~"a \\`bug\\`"');
});
2017-11-13 14:30:22 +00:00
});
2019-02-21 13:26:27 +00:00
describe('DefaultOptions.matcher', () => {
2018-10-17 07:13:26 +00:00
const defaultMatcher = (context, flag, subtext) =>
gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext);
const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$'];
const otherFlags = ['/', ':'];
const flags = flagsUseDefaultMatcher.concat(otherFlags);
2018-10-17 07:13:26 +00:00
const flagsHash = flags.reduce((hash, el) => {
hash[el] = null;
return hash;
}, {});
2019-02-21 13:26:27 +00:00
beforeEach(() => {
atwhoInstance = { setting: {}, app: { controllers: flagsHash } };
});
const minLen = 1;
const maxLen = 20;
const argumentSize = [minLen, maxLen / 2, maxLen];
2018-10-17 07:13:26 +00:00
const allowedSymbols = [
'',
'a',
'n',
'z',
'A',
'Z',
'N',
'0',
'5',
'9',
'А',
'а',
'Я',
'я',
'.',
"'",
'+',
2018-10-17 07:13:26 +00:00
'-',
'_',
];
const jointAllowedSymbols = allowedSymbols.join('');
describe('should match regular symbols', () => {
2018-10-17 07:13:26 +00:00
flagsUseDefaultMatcher.forEach(flag => {
allowedSymbols.forEach(symbol => {
argumentSize.forEach(size => {
const query = new Array(size + 1).join(symbol);
const subtext = flag + query;
it(`matches argument "${flag}" with query "${subtext}"`, () => {
expect(defaultMatcher(atwhoInstance, flag, subtext)).toBe(query);
});
});
});
it(`matches combination of allowed symbols for flag "${flag}"`, () => {
const subtext = flag + jointAllowedSymbols;
expect(defaultMatcher(atwhoInstance, flag, subtext)).toBe(jointAllowedSymbols);
});
});
});
describe('should not match special sequences', () => {
2018-02-01 10:35:03 +00:00
const shouldNotBeFollowedBy = flags.concat(['\x00', '\x10', '\x3f', '\n', ' ']);
const shouldNotBePrependedBy = ['`'];
2018-10-17 07:13:26 +00:00
flagsUseDefaultMatcher.forEach(atSign => {
shouldNotBeFollowedBy.forEach(followedSymbol => {
const seq = atSign + followedSymbol;
2017-12-20 11:39:40 +00:00
2018-09-11 21:03:05 +00:00
it(`should not match ${JSON.stringify(seq)}`, () => {
2017-12-20 11:39:40 +00:00
expect(defaultMatcher(atwhoInstance, atSign, seq)).toBe(null);
});
});
2018-10-17 07:13:26 +00:00
shouldNotBePrependedBy.forEach(prependedSymbol => {
2017-12-20 11:39:40 +00:00
const seq = prependedSymbol + atSign;
it(`should not match "${seq}"`, () => {
expect(defaultMatcher(atwhoInstance, atSign, seq)).toBe(null);
});
});
});
});
});
describe('DefaultOptions.highlighter', () => {
beforeEach(() => {
atwhoInstance = { setting: {} };
});
it('should return li if no query is given', () => {
const liTag = '<li></li>';
const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag);
expect(highlightedTag).toEqual(liTag);
});
it('should highlight search query in li element', () => {
const liTag = '<li><img src="" />string</li>';
const query = 's';
const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag, query);
expect(highlightedTag).toEqual('<li><img src="" /> <strong>s</strong>tring </li>');
});
it('should highlight search query with special char in li element', () => {
const liTag = '<li><img src="" />te.st</li>';
const query = '.';
const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag, query);
expect(highlightedTag).toEqual('<li><img src="" /> te<strong>.</strong>st </li>');
});
});
2019-02-21 13:26:27 +00:00
describe('isLoading', () => {
it('should be true with loading data object item', () => {
expect(GfmAutoComplete.isLoading({ name: 'loading' })).toBe(true);
});
2019-02-21 13:26:27 +00:00
it('should be true with loading data array', () => {
expect(GfmAutoComplete.isLoading(['loading'])).toBe(true);
});
2019-02-21 13:26:27 +00:00
it('should be true with loading data object array', () => {
expect(GfmAutoComplete.isLoading([{ name: 'loading' }])).toBe(true);
});
2019-02-21 13:26:27 +00:00
it('should be false with actual array data', () => {
2018-10-17 07:13:26 +00:00
expect(
GfmAutoComplete.isLoading([{ title: 'Foo' }, { title: 'Bar' }, { title: 'Qux' }]),
).toBe(false);
});
2019-02-21 13:26:27 +00:00
it('should be false with actual data item', () => {
expect(GfmAutoComplete.isLoading({ title: 'Foo' })).toBe(false);
});
});
describe('membersBeforeSave', () => {
const mockGroup = {
username: 'my-group',
name: 'My Group',
count: 2,
avatar_url: './group.jpg',
type: 'Group',
mentionsDisabled: false,
};
it('should return the original object when username is null', () => {
expect(membersBeforeSave([{ ...mockGroup, username: null }])).toEqual([
{ ...mockGroup, username: null },
]);
});
it('should set the text avatar if avatar_url is null', () => {
expect(membersBeforeSave([{ ...mockGroup, avatar_url: null }])).toEqual([
{
username: 'my-group',
avatarTag: '<div class="avatar rect-avatar center avatar-inline s26">M</div>',
title: 'My Group (2)',
search: 'my-group My Group',
icon: '',
},
]);
});
it('should set the image avatar if avatar_url is given', () => {
expect(membersBeforeSave([mockGroup])).toEqual([
{
username: 'my-group',
avatarTag:
'<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>',
title: 'My Group (2)',
search: 'my-group My Group',
icon: '',
},
]);
});
it('should set mentions disabled icon if mentionsDisabled is set', () => {
expect(membersBeforeSave([{ ...mockGroup, mentionsDisabled: true }])).toEqual([
{
username: 'my-group',
avatarTag:
'<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>',
title: 'My Group',
search: 'my-group My Group',
icon:
'<svg class="s16 vertical-align-middle gl-ml-2"><use xlink:href="undefined#notifications-off" /></svg>',
},
]);
});
it('should set the right image classes for User type members', () => {
expect(
membersBeforeSave([
{ username: 'my-user', name: 'My User', avatar_url: './users.jpg', type: 'User' },
]),
).toEqual([
{
username: 'my-user',
avatarTag:
'<img src="./users.jpg" alt="my-user" class="avatar avatar-inline center s26"/>',
title: 'My User',
search: 'my-user My User',
icon: '',
},
]);
});
});
2019-02-21 13:26:27 +00:00
describe('Issues.insertTemplateFunction', () => {
it('should return default template', () => {
expect(GfmAutoComplete.Issues.insertTemplateFunction({ id: 5, title: 'Some Issue' })).toBe(
'${atwho-at}${id}', // eslint-disable-line no-template-curly-in-string
);
});
2019-02-21 13:26:27 +00:00
it('should return reference when reference is set', () => {
expect(
GfmAutoComplete.Issues.insertTemplateFunction({
id: 5,
title: 'Some Issue',
reference: 'grp/proj#5',
}),
).toBe('grp/proj#5');
});
});
2019-02-21 13:26:27 +00:00
describe('Issues.templateFunction', () => {
it('should return html with id and title', () => {
expect(GfmAutoComplete.Issues.templateFunction({ id: 5, title: 'Some Issue' })).toBe(
'<li><small>5</small> Some Issue</li>',
);
});
2019-02-21 13:26:27 +00:00
it('should replace id with reference if reference is set', () => {
expect(
GfmAutoComplete.Issues.templateFunction({
id: 5,
title: 'Some Issue',
reference: 'grp/proj#5',
}),
).toBe('<li><small>grp/proj#5</small> Some Issue</li>');
});
});
describe('Members.templateFunction', () => {
it('should return html with avatarTag and username', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: '',
icon: '',
}),
).toBe('<li>IMG my-group <small></small> </li>');
});
it('should add icon if icon is set', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: '',
icon: '<i class="icon"/>',
}),
).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>');
});
it('should add escaped title if title is set', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: 'MyGroup+',
icon: '<i class="icon"/>',
}),
).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>');
});
});
describe('labels', () => {
const dataSources = {
labels: `${TEST_HOST}/autocomplete_sources/labels`,
};
const allLabels = labelsFixture;
const assignedLabels = allLabels.filter(label => label.set);
const unassignedLabels = allLabels.filter(label => !label.set);
let autocomplete;
let $textarea;
beforeEach(() => {
setFixtures('<textarea></textarea>');
autocomplete = new GfmAutoComplete(dataSources);
$textarea = $('textarea');
autocomplete.setup($textarea, { labels: true });
});
afterEach(() => {
autocomplete.destroy();
});
const triggerDropdown = text => {
$textarea
.trigger('focus')
.val(text)
.caret('pos', -1);
$textarea.trigger('keyup');
return new Promise(window.requestAnimationFrame);
};
const getDropdownItems = () => {
const dropdown = document.getElementById('at-view-labels');
const items = dropdown.getElementsByTagName('li');
return [].map.call(items, item => item.textContent.trim());
};
const expectLabels = ({ input, output }) =>
triggerDropdown(input).then(() => {
expect(getDropdownItems()).toEqual(output.map(label => label.title));
});
describe('with no labels assigned', () => {
beforeEach(() => {
autocomplete.cachedData['~'] = [...unassignedLabels];
});
it.each`
input | output
${'~'} | ${unassignedLabels}
${'/label ~'} | ${unassignedLabels}
${'/relabel ~'} | ${unassignedLabels}
${'/unlabel ~'} | ${[]}
`('$input shows $output.length labels', expectLabels);
});
describe('with some labels assigned', () => {
beforeEach(() => {
autocomplete.cachedData['~'] = allLabels;
});
it.each`
input | output
${'~'} | ${allLabels}
${'/label ~'} | ${unassignedLabels}
${'/relabel ~'} | ${allLabels}
${'/unlabel ~'} | ${assignedLabels}
`('$input shows $output.length labels', expectLabels);
});
describe('with all labels assigned', () => {
beforeEach(() => {
autocomplete.cachedData['~'] = [...assignedLabels];
});
it.each`
input | output
${'~'} | ${assignedLabels}
${'/label ~'} | ${[]}
${'/relabel ~'} | ${assignedLabels}
${'/unlabel ~'} | ${assignedLabels}
`('$input shows $output.length labels', expectLabels);
});
});
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;
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 = (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', () => {
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', () => {
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');
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>`);
});
});
});
2017-01-18 12:17:24 +00:00
});