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,
......
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