206 lines
7 KiB
Vue
206 lines
7 KiB
Vue
<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',
|
|
};
|
|
|
|
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)}`;
|
|
},
|
|
},
|
|
};
|
|
|
|
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)
|
|
? `~"${original.title}"`
|
|
: `~${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),
|
|
},
|
|
],
|
|
});
|
|
|
|
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>
|