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'; ...@@ -10,14 +10,14 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import GLForm from '~/gl_form'; import GLForm from '~/gl_form';
import MarkdownHeader from './header.vue'; import MarkdownHeader from './header.vue';
import MarkdownToolbar from './toolbar.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 Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
export default { export default {
components: { components: {
GlMentions, GfmAutocomplete,
MarkdownHeader, MarkdownHeader,
MarkdownToolbar, MarkdownToolbar,
GlIcon, GlIcon,
...@@ -246,9 +246,9 @@ export default { ...@@ -246,9 +246,9 @@ export default {
/> />
<div v-show="!previewMarkdown" class="md-write-holder"> <div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop"> <div class="zen-backdrop">
<gl-mentions v-if="glFeatures.tributeAutocomplete"> <gfm-autocomplete v-if="glFeatures.tributeAutocomplete">
<slot name="textarea"></slot> <slot name="textarea"></slot>
</gl-mentions> </gfm-autocomplete>
<slot v-else name="textarea"></slot> <slot v-else name="textarea"></slot>
<a <a
class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" class="zen-control zen-control-leave js-zen-leave gl-text-gray-500"
......
...@@ -3093,6 +3093,9 @@ msgstr "" ...@@ -3093,6 +3093,9 @@ msgstr ""
msgid "An error occurred while generating a username. Please try again." msgid "An error occurred while generating a username. Please try again."
msgstr "" 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}" msgid "An error occurred while getting files for - %{branchId}"
msgstr "" 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 { shallowMount } from '@vue/test-utils';
import Tribute from 'tributejs'; 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; let wrapper;
describe('Tribute', () => { describe('tribute', () => {
const mentions = '/gitlab-org/gitlab-test/-/autocomplete_sources/members?type=Issue&type_id=1'; const mentions = '/gitlab-org/gitlab-test/-/autocomplete_sources/members?type=Issue&type_id=1';
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(GlMentions, { wrapper = shallowMount(GfmAutocomplete, {
propsData: { propsData: {
dataSources: { dataSources: {
mentions, 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