Commit a4bbb806 authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '230972-refactor-gl-mentions-component' into 'master'

Refactor GlMentions autocomplete component

See merge request gitlab-org/gitlab!47438
parents 8ca85a26 9efb0477
<script>
import Tribute from 'tributejs';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import { GfmAutocompleteType, tributeConfig } from '~/vue_shared/components/gfm_autocomplete/utils';
export default {
errorMessage: __(
'An error occurred while getting autocomplete data. Please refresh the page and try again.',
),
props: {
autocompleteTypes: {
type: Array,
required: false,
default: () => Object.values(GfmAutocompleteType),
},
dataSources: {
type: Object,
required: false,
default: () => gl.GfmAutoComplete?.dataSources || {},
},
},
computed: {
config() {
return this.autocompleteTypes.map(type => ({
...tributeConfig[type].config,
values: this.getValues(type),
}));
},
},
mounted() {
this.cache = {};
this.tribute = new Tribute({ collection: this.config });
const input = this.$slots.default?.[0]?.elm;
this.tribute.attach(input);
},
beforeDestroy() {
const input = this.$slots.default?.[0]?.elm;
this.tribute.detach(input);
},
methods: {
cacheAssignees() {
const isAssigneesLengthSame =
this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length;
if (!this.assignees || !isAssigneesLengthSame) {
this.assignees =
SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
}
},
filterValues(type) {
// The assignees AJAX response can come after the user first invokes autocomplete
// so we need to check more than once if we need to update the assignee cache
this.cacheAssignees();
return tributeConfig[type].filterValues
? tributeConfig[type].filterValues({
assignees: this.assignees,
collection: this.cache[type],
fullText: this.$slots.default?.[0]?.elm?.value,
selectionStart: this.$slots.default?.[0]?.elm?.selectionStart,
})
: this.cache[type];
},
getValues(type) {
return (inputText, processValues) => {
if (this.cache[type]) {
processValues(this.filterValues(type));
} else if (this.dataSources[type]) {
axios
.get(this.dataSources[type])
.then(response => {
this.cache[type] = response.data;
processValues(this.filterValues(type));
})
.catch(() => createFlash({ message: this.$options.errorMessage }));
} else {
processValues([]);
}
};
},
},
render(createElement) {
return createElement('div', this.$slots.default);
},
};
</script>
import { escape, last } from 'lodash';
import { spriteIcon } from '~/lib/utils/common_utils';
const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
const nonWordOrInteger = /\W|^\d+$/;
export const GfmAutocompleteType = {
Issues: 'issues',
Labels: 'labels',
Members: 'members',
MergeRequests: 'mergeRequests',
Milestones: 'milestones',
Snippets: 'snippets',
};
function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
const currentLine = fullText.split('\n')[currentLineNumber - 1];
return currentLine.startsWith(searchString);
}
export const tributeConfig = {
[GfmAutocompleteType.Issues]: {
config: {
trigger: '#',
lookup: value => value.iid + value.title,
menuItemTemplate: ({ original }) =>
`<small>${original.reference || original.iid}</small> ${escape(original.title)}`,
selectTemplate: ({ original }) => original.reference || `#${original.iid}`,
},
},
[GfmAutocompleteType.Labels]: {
config: {
trigger: '~',
lookup: 'title',
menuItemTemplate: ({ original }) => `
<span class="dropdown-label-box" style="background: ${escape(original.color)};"></span>
${escape(original.title)}`,
selectTemplate: ({ original }) =>
nonWordOrInteger.test(original.title)
? `~"${escape(original.title)}"`
: `~${escape(original.title)}`,
},
filterValues({ collection, fullText, selectionStart }) {
if (doesCurrentLineStartWith('/label', fullText, selectionStart)) {
return collection.filter(label => !label.set);
}
if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) {
return collection.filter(label => label.set);
}
return collection;
},
},
[GfmAutocompleteType.Members]: {
config: {
trigger: '@',
fillAttr: 'username',
lookup: value =>
value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username,
menuItemTemplate: ({ original }) => {
const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0';
const noAvatarClasses = `${commonClasses} gl-rounded-small
gl-display-flex gl-align-items-center gl-justify-content-center`;
const avatar = original.avatar_url
? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />`
: `<div class="${noAvatarClasses}" aria-hidden="true">
${original.username.charAt(0).toUpperCase()}</div>`;
let displayName = original.name;
let parentGroupOrUsername = `@${original.username}`;
if (original.type === groupType) {
const splitName = original.name.split(' / ');
displayName = splitName.pop();
parentGroupOrUsername = splitName.pop();
}
const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
const disabledMentionsIcon = original.mentionsDisabled
? spriteIcon('notifications-off', 's16 gl-ml-3')
: '';
return `
<div class="gl-display-flex gl-align-items-center">
${avatar}
<div class="gl-font-sm gl-line-height-normal gl-ml-3">
<div>${escape(displayName)}${count}</div>
<div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div>
</div>
${disabledMentionsIcon}
</div>
`;
},
},
filterValues({ assignees, collection, fullText, selectionStart }) {
if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) {
return collection.filter(member => !assignees.includes(member.username));
}
if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) {
return collection.filter(member => assignees.includes(member.username));
}
return collection;
},
},
[GfmAutocompleteType.MergeRequests]: {
config: {
trigger: '!',
lookup: value => value.iid + value.title,
menuItemTemplate: ({ original }) =>
`<small>${original.reference || original.iid}</small> ${escape(original.title)}`,
selectTemplate: ({ original }) => original.reference || `!${original.iid}`,
},
},
[GfmAutocompleteType.Milestones]: {
config: {
trigger: '%',
lookup: 'title',
menuItemTemplate: ({ original }) => escape(original.title),
selectTemplate: ({ original }) => `%"${escape(original.title)}"`,
},
},
[GfmAutocompleteType.Snippets]: {
config: {
trigger: '$',
fillAttr: 'id',
lookup: value => value.id + value.title,
menuItemTemplate: ({ original }) => `<small>${original.id}</small> ${escape(original.title)}`,
},
},
};
<script>
import { escape, last } from 'lodash';
import Tribute from 'tributejs';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
const AutoComplete = {
Issues: 'issues',
Labels: 'labels',
Members: 'members',
MergeRequests: 'mergeRequests',
Milestones: 'milestones',
Snippets: 'snippets',
};
const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
const currentLine = fullText.split('\n')[currentLineNumber - 1];
return currentLine.startsWith(searchString);
}
const autoCompleteMap = {
[AutoComplete.Issues]: {
filterValues() {
return this[AutoComplete.Issues];
},
menuItemTemplate({ original }) {
return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`;
},
},
[AutoComplete.Labels]: {
filterValues() {
const fullText = this.$slots.default?.[0]?.elm?.value;
const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart;
if (doesCurrentLineStartWith('/label', fullText, selectionStart)) {
return this.labels.filter(label => !label.set);
}
if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) {
return this.labels.filter(label => label.set);
}
return this.labels;
},
menuItemTemplate({ original }) {
return `
<span class="dropdown-label-box" style="background: ${escape(original.color)};"></span>
${escape(original.title)}`;
},
},
[AutoComplete.Members]: {
filterValues() {
const fullText = this.$slots.default?.[0]?.elm?.value;
const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart;
// Need to check whether sidebar store assignees has been updated
// in the case where the assignees AJAX response comes after the user does @ autocomplete
const isAssigneesLengthSame =
this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length;
if (!this.assignees || !isAssigneesLengthSame) {
this.assignees =
SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
}
if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) {
return this.members.filter(member => !this.assignees.includes(member.username));
}
if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) {
return this.members.filter(member => this.assignees.includes(member.username));
}
return this.members;
},
menuItemTemplate({ original }) {
const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0';
const noAvatarClasses = `${commonClasses} gl-rounded-small
gl-display-flex gl-align-items-center gl-justify-content-center`;
const avatar = original.avatar_url
? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />`
: `<div class="${noAvatarClasses}" aria-hidden="true">
${original.username.charAt(0).toUpperCase()}</div>`;
let displayName = original.name;
let parentGroupOrUsername = `@${original.username}`;
if (original.type === groupType) {
const splitName = original.name.split(' / ');
displayName = splitName.pop();
parentGroupOrUsername = splitName.pop();
}
const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
const disabledMentionsIcon = original.mentionsDisabled
? spriteIcon('notifications-off', 's16 gl-ml-3')
: '';
return `
<div class="gl-display-flex gl-align-items-center">
${avatar}
<div class="gl-font-sm gl-line-height-normal gl-ml-3">
<div>${escape(displayName)}${count}</div>
<div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div>
</div>
${disabledMentionsIcon}
</div>
`;
},
},
[AutoComplete.MergeRequests]: {
filterValues() {
return this[AutoComplete.MergeRequests];
},
menuItemTemplate({ original }) {
return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`;
},
},
[AutoComplete.Milestones]: {
filterValues() {
return this[AutoComplete.Milestones];
},
menuItemTemplate({ original }) {
return escape(original.title);
},
},
[AutoComplete.Snippets]: {
filterValues() {
return this[AutoComplete.Snippets];
},
menuItemTemplate({ original }) {
return `<small>${original.id}</small> ${escape(original.title)}`;
},
},
};
export default {
name: 'GlMentions',
props: {
dataSources: {
type: Object,
required: false,
default: () => gl.GfmAutoComplete?.dataSources || {},
},
},
mounted() {
const NON_WORD_OR_INTEGER = /\W|^\d+$/;
this.tribute = new Tribute({
collection: [
{
trigger: '#',
lookup: value => value.iid + value.title,
menuItemTemplate: autoCompleteMap[AutoComplete.Issues].menuItemTemplate,
selectTemplate: ({ original }) => original.reference || `#${original.iid}`,
values: this.getValues(AutoComplete.Issues),
},
{
trigger: '@',
fillAttr: 'username',
lookup: value =>
value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username,
menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate,
values: this.getValues(AutoComplete.Members),
},
{
trigger: '~',
lookup: 'title',
menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate,
selectTemplate: ({ original }) =>
NON_WORD_OR_INTEGER.test(original.title)
? `~"${escape(original.title)}"`
: `~${escape(original.title)}`,
values: this.getValues(AutoComplete.Labels),
},
{
trigger: '!',
lookup: value => value.iid + value.title,
menuItemTemplate: autoCompleteMap[AutoComplete.MergeRequests].menuItemTemplate,
selectTemplate: ({ original }) => original.reference || `!${original.iid}`,
values: this.getValues(AutoComplete.MergeRequests),
},
{
trigger: '%',
lookup: 'title',
menuItemTemplate: autoCompleteMap[AutoComplete.Milestones].menuItemTemplate,
selectTemplate: ({ original }) => `%"${escape(original.title)}"`,
values: this.getValues(AutoComplete.Milestones),
},
{
trigger: '$',
fillAttr: 'id',
lookup: value => value.id + value.title,
menuItemTemplate: autoCompleteMap[AutoComplete.Snippets].menuItemTemplate,
values: this.getValues(AutoComplete.Snippets),
},
],
});
const input = this.$slots.default?.[0]?.elm;
this.tribute.attach(input);
},
beforeDestroy() {
const input = this.$slots.default?.[0]?.elm;
this.tribute.detach(input);
},
methods: {
getValues(autoCompleteType) {
return (inputText, processValues) => {
if (this[autoCompleteType]) {
const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this);
processValues(filteredValues);
} else if (this.dataSources[autoCompleteType]) {
axios
.get(this.dataSources[autoCompleteType])
.then(response => {
this[autoCompleteType] = response.data;
const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this);
processValues(filteredValues);
})
.catch(() => {});
} else {
processValues([]);
}
};
},
},
render(createElement) {
return createElement('div', this.$slots.default);
},
};
</script>
......@@ -10,14 +10,14 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import GLForm from '~/gl_form';
import MarkdownHeader from './header.vue';
import MarkdownToolbar from './toolbar.vue';
import GlMentions from '~/vue_shared/components/gl_mentions.vue';
import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import axios from '~/lib/utils/axios_utils';
export default {
components: {
GlMentions,
GfmAutocomplete,
MarkdownHeader,
MarkdownToolbar,
GlIcon,
......@@ -246,9 +246,9 @@ export default {
/>
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
<gl-mentions v-if="glFeatures.tributeAutocomplete">
<gfm-autocomplete v-if="glFeatures.tributeAutocomplete">
<slot name="textarea"></slot>
</gl-mentions>
</gfm-autocomplete>
<slot v-else name="textarea"></slot>
<a
class="zen-control zen-control-leave js-zen-leave gl-text-gray-500"
......
......@@ -3093,6 +3093,9 @@ msgstr ""
msgid "An error occurred while generating a username. Please try again."
msgstr ""
msgid "An error occurred while getting autocomplete data. Please refresh the page and try again."
msgstr ""
msgid "An error occurred while getting files for - %{branchId}"
msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
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 reference and title in the menu item within a group context 1`] = `"<small>gitlab#987654</small> Group context issue title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
exports[`gfm_autocomplete/utils labels config shows the title in the menu item 1`] = `
"
<span class=\\"dropdown-label-box\\" style=\\"background: #123456;\\"></span>
bug &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"
`;
exports[`gfm_autocomplete/utils members config shows an avatar character, name, parent name, and count in the menu item for a group 1`] = `
"
<div class=\\"gl-display-flex gl-align-items-center\\">
<div class=\\"gl-avatar gl-avatar-s24 gl-flex-shrink-0 gl-rounded-small
gl-display-flex gl-align-items-center gl-justify-content-center\\" aria-hidden=\\"true\\">
G</div>
<div class=\\"gl-font-sm gl-line-height-normal gl-ml-3\\">
<div>1-1s &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt; (2)</div>
<div class=\\"gl-text-gray-700\\">GitLab Support Team</div>
</div>
</div>
"
`;
exports[`gfm_autocomplete/utils members config shows the avatar, name and username in the menu item for a user 1`] = `
"
<div class=\\"gl-display-flex gl-align-items-center\\">
<img class=\\"gl-avatar gl-avatar-s24 gl-flex-shrink-0 gl-avatar-circle\\" src=\\"/uploads/-/system/user/avatar/123456/avatar.png\\" alt=\\"\\" />
<div class=\\"gl-font-sm gl-line-height-normal gl-ml-3\\">
<div>My Name &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;</div>
<div class=\\"gl-text-gray-700\\">@myusername</div>
</div>
</div>
"
`;
exports[`gfm_autocomplete/utils merge requests config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context merge request title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
exports[`gfm_autocomplete/utils merge requests config shows the reference and title in the menu item within a group context 1`] = `"<small>gitlab!456789</small> Group context merge request title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
exports[`gfm_autocomplete/utils milestones config shows the title in the menu item 1`] = `"13.2 &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
exports[`gfm_autocomplete/utils snippets config shows the id and title in the menu item 1`] = `"<small>123456</small> Snippet title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
import { shallowMount } from '@vue/test-utils';
import Tribute from 'tributejs';
import GlMentions from '~/vue_shared/components/gl_mentions.vue';
import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue';
describe('GlMentions', () => {
describe('GfmAutocomplete', () => {
let wrapper;
describe('Tribute', () => {
describe('tribute', () => {
const mentions = '/gitlab-org/gitlab-test/-/autocomplete_sources/members?type=Issue&type_id=1';
beforeEach(() => {
wrapper = shallowMount(GlMentions, {
wrapper = shallowMount(GfmAutocomplete, {
propsData: {
dataSources: {
mentions,
......
import { escape, last } from 'lodash';
import { GfmAutocompleteType, tributeConfig } from '~/vue_shared/components/gfm_autocomplete/utils';
describe('gfm_autocomplete/utils', () => {
describe('issues config', () => {
const issuesConfig = tributeConfig[GfmAutocompleteType.Issues].config;
const groupContextIssue = {
iid: 987654,
reference: 'gitlab#987654',
title: "Group context issue title <script>alert('hi')</script>",
};
const projectContextIssue = {
id: null,
iid: 123456,
time_estimate: 0,
title: "Project context issue title <script>alert('hi')</script>",
};
it('uses # as the trigger', () => {
expect(issuesConfig.trigger).toBe('#');
});
it('searches using both the iid and title', () => {
expect(issuesConfig.lookup(projectContextIssue)).toBe(
`${projectContextIssue.iid}${projectContextIssue.title}`,
);
});
it('shows the reference and title in the menu item within a group context', () => {
expect(issuesConfig.menuItemTemplate({ original: groupContextIssue })).toMatchSnapshot();
});
it('shows the iid and title in the menu item within a project context', () => {
expect(issuesConfig.menuItemTemplate({ original: projectContextIssue })).toMatchSnapshot();
});
it('inserts the reference on autocomplete selection within a group context', () => {
expect(issuesConfig.selectTemplate({ original: groupContextIssue })).toBe(
groupContextIssue.reference,
);
});
it('inserts the iid on autocomplete selection within a project context', () => {
expect(issuesConfig.selectTemplate({ original: projectContextIssue })).toBe(
`#${projectContextIssue.iid}`,
);
});
});
describe('labels config', () => {
const labelsConfig = tributeConfig[GfmAutocompleteType.Labels].config;
const labelsFilter = tributeConfig[GfmAutocompleteType.Labels].filterValues;
const label = {
color: '#123456',
textColor: '#FFFFFF',
title: `bug <script>alert('hi')</script>`,
type: 'GroupLabel',
};
const singleWordLabel = {
color: '#456789',
textColor: '#DDD',
title: `bug`,
type: 'GroupLabel',
};
const numericalLabel = {
color: '#abcdef',
textColor: '#AAA',
title: 123456,
type: 'ProjectLabel',
};
it('uses ~ as the trigger', () => {
expect(labelsConfig.trigger).toBe('~');
});
it('searches using `title`', () => {
expect(labelsConfig.lookup).toBe('title');
});
it('shows the title in the menu item', () => {
expect(labelsConfig.menuItemTemplate({ original: label })).toMatchSnapshot();
});
it('inserts the title on autocomplete selection', () => {
expect(labelsConfig.selectTemplate({ original: singleWordLabel })).toBe(
`~${escape(singleWordLabel.title)}`,
);
});
it('inserts the title enclosed with quotes on autocomplete selection when the title is numerical', () => {
expect(labelsConfig.selectTemplate({ original: numericalLabel })).toBe(
`~"${escape(numericalLabel.title)}"`,
);
});
it('inserts the title enclosed with quotes on autocomplete selection when the title contains multiple words', () => {
expect(labelsConfig.selectTemplate({ original: label })).toBe(`~"${escape(label.title)}"`);
});
describe('filter', () => {
const collection = [label, singleWordLabel, { ...numericalLabel, set: true }];
describe('/label quick action', () => {
describe('when the line starts with `/label`', () => {
it('shows labels that are not currently selected', () => {
const fullText = '/label ~';
const selectionStart = 8;
expect(labelsFilter({ collection, fullText, selectionStart })).toEqual([
collection[0],
collection[1],
]);
});
});
describe('when the line does not start with `/label`', () => {
it('shows all labels', () => {
const fullText = '~';
const selectionStart = 1;
expect(labelsFilter({ collection, fullText, selectionStart })).toEqual(collection);
});
});
});
describe('/unlabel quick action', () => {
describe('when the line starts with `/unlabel`', () => {
it('shows labels that are currently selected', () => {
const fullText = '/unlabel ~';
const selectionStart = 10;
expect(labelsFilter({ collection, fullText, selectionStart })).toEqual([collection[2]]);
});
});
describe('when the line does not start with `/unlabel`', () => {
it('shows all labels', () => {
const fullText = '~';
const selectionStart = 1;
expect(labelsFilter({ collection, fullText, selectionStart })).toEqual(collection);
});
});
});
});
});
describe('members config', () => {
const membersConfig = tributeConfig[GfmAutocompleteType.Members].config;
const membersFilter = tributeConfig[GfmAutocompleteType.Members].filterValues;
const userMember = {
type: 'User',
username: 'myusername',
name: "My Name <script>alert('hi')</script>",
avatar_url: '/uploads/-/system/user/avatar/123456/avatar.png',
availability: null,
};
const groupMember = {
type: 'Group',
username: 'gitlab-com/support/1-1s',
name: "GitLab.com / GitLab Support Team / 1-1s <script>alert('hi')</script>",
avatar_url: null,
count: 2,
mentionsDisabled: null,
};
it('uses @ as the trigger', () => {
expect(membersConfig.trigger).toBe('@');
});
it('inserts the username on autocomplete selection', () => {
expect(membersConfig.fillAttr).toBe('username');
});
it('searches using both the name and username for a user', () => {
expect(membersConfig.lookup(userMember)).toBe(`${userMember.name}${userMember.username}`);
});
it('searches using only its own name and not its ancestors for a group', () => {
expect(membersConfig.lookup(groupMember)).toBe(last(groupMember.name.split(' / ')));
});
it('shows the avatar, name and username in the menu item for a user', () => {
expect(membersConfig.menuItemTemplate({ original: userMember })).toMatchSnapshot();
});
it('shows an avatar character, name, parent name, and count in the menu item for a group', () => {
expect(membersConfig.menuItemTemplate({ original: groupMember })).toMatchSnapshot();
});
describe('filter', () => {
const assignees = [userMember.username];
const collection = [userMember, groupMember];
describe('/assign quick action', () => {
describe('when the line starts with `/assign`', () => {
it('shows members that are not currently selected', () => {
const fullText = '/assign @';
const selectionStart = 9;
expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual([
collection[1],
]);
});
});
describe('when the line does not start with `/assign`', () => {
it('shows all labels', () => {
const fullText = '@';
const selectionStart = 1;
expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual(
collection,
);
});
});
});
describe('/unassign quick action', () => {
describe('when the line starts with `/unassign`', () => {
it('shows members that are currently selected', () => {
const fullText = '/unassign @';
const selectionStart = 11;
expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual([
collection[0],
]);
});
});
describe('when the line does not start with `/unassign`', () => {
it('shows all members', () => {
const fullText = '@';
const selectionStart = 1;
expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual(
collection,
);
});
});
});
});
});
describe('merge requests config', () => {
const mergeRequestsConfig = tributeConfig[GfmAutocompleteType.MergeRequests].config;
const groupContextMergeRequest = {
iid: 456789,
reference: 'gitlab!456789',
title: "Group context merge request title <script>alert('hi')</script>",
};
const projectContextMergeRequest = {
id: null,
iid: 123456,
time_estimate: 0,
title: "Project context merge request title <script>alert('hi')</script>",
};
it('uses ! as the trigger', () => {
expect(mergeRequestsConfig.trigger).toBe('!');
});
it('searches using both the iid and title', () => {
expect(mergeRequestsConfig.lookup(projectContextMergeRequest)).toBe(
`${projectContextMergeRequest.iid}${projectContextMergeRequest.title}`,
);
});
it('shows the reference and title in the menu item within a group context', () => {
expect(
mergeRequestsConfig.menuItemTemplate({ original: groupContextMergeRequest }),
).toMatchSnapshot();
});
it('shows the iid and title in the menu item within a project context', () => {
expect(
mergeRequestsConfig.menuItemTemplate({ original: projectContextMergeRequest }),
).toMatchSnapshot();
});
it('inserts the reference on autocomplete selection within a group context', () => {
expect(mergeRequestsConfig.selectTemplate({ original: groupContextMergeRequest })).toBe(
groupContextMergeRequest.reference,
);
});
it('inserts the iid on autocomplete selection within a project context', () => {
expect(mergeRequestsConfig.selectTemplate({ original: projectContextMergeRequest })).toBe(
`!${projectContextMergeRequest.iid}`,
);
});
});
describe('milestones config', () => {
const milestonesConfig = tributeConfig[GfmAutocompleteType.Milestones].config;
const milestone = {
id: null,
iid: 49,
title: "13.2 <script>alert('hi')</script>",
};
it('uses % as the trigger', () => {
expect(milestonesConfig.trigger).toBe('%');
});
it('searches using the title', () => {
expect(milestonesConfig.lookup).toBe('title');
});
it('shows the title in the menu item', () => {
expect(milestonesConfig.menuItemTemplate({ original: milestone })).toMatchSnapshot();
});
it('inserts the title on autocomplete selection', () => {
expect(milestonesConfig.selectTemplate({ original: milestone })).toBe(
`%"${escape(milestone.title)}"`,
);
});
});
describe('snippets config', () => {
const snippetsConfig = tributeConfig[GfmAutocompleteType.Snippets].config;
const snippet = {
id: 123456,
title: "Snippet title <script>alert('hi')</script>",
};
it('uses $ as the trigger', () => {
expect(snippetsConfig.trigger).toBe('$');
});
it('inserts the id on autocomplete selection', () => {
expect(snippetsConfig.fillAttr).toBe('id');
});
it('searches using both the id and title', () => {
expect(snippetsConfig.lookup(snippet)).toBe(`${snippet.id}${snippet.title}`);
});
it('shows the id and title in the menu item', () => {
expect(snippetsConfig.menuItemTemplate({ original: snippet })).toMatchSnapshot();
});
});
});
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