2018-03-09 15:18:59 -05:00
|
|
|
import $ from 'jquery';
|
2019-06-03 10:36:34 -04:00
|
|
|
import 'at.js';
|
2017-08-03 16:31:53 -04:00
|
|
|
import _ from 'underscore';
|
2017-06-23 15:08:06 -04:00
|
|
|
import glRegexp from './lib/utils/regexp';
|
|
|
|
import AjaxCache from './lib/utils/ajax_cache';
|
2017-02-27 23:44:34 -05:00
|
|
|
|
2017-03-11 02:30:44 -05:00
|
|
|
function sanitize(str) {
|
|
|
|
return str.replace(/<(?:.|\n)*?>/gm, '');
|
|
|
|
}
|
2016-11-06 19:16:39 -05:00
|
|
|
|
2018-06-21 07:52:43 -04:00
|
|
|
export const defaultAutocompleteConfig = {
|
|
|
|
emojis: true,
|
|
|
|
members: true,
|
|
|
|
issues: true,
|
|
|
|
mergeRequests: true,
|
2018-06-28 02:41:56 -04:00
|
|
|
epics: true,
|
2018-06-21 07:52:43 -04:00
|
|
|
milestones: true,
|
|
|
|
labels: true,
|
2018-10-05 05:42:38 -04:00
|
|
|
snippets: true,
|
2018-06-21 07:52:43 -04:00
|
|
|
};
|
|
|
|
|
2017-05-16 14:42:06 -04:00
|
|
|
class GfmAutoComplete {
|
|
|
|
constructor(dataSources) {
|
|
|
|
this.dataSources = dataSources || {};
|
|
|
|
this.cachedData = {};
|
|
|
|
this.isLoadingData = {};
|
|
|
|
}
|
2016-08-30 08:49:31 -04:00
|
|
|
|
2018-06-21 07:52:43 -04:00
|
|
|
setup(input, enableMap = defaultAutocompleteConfig) {
|
2017-03-11 02:30:44 -05:00
|
|
|
// Add GFM auto-completion to all input fields, that accept GFM input.
|
|
|
|
this.input = input || $('.js-gfm-input');
|
2017-05-03 11:47:59 -04:00
|
|
|
this.enableMap = enableMap;
|
2017-03-11 02:30:44 -05:00
|
|
|
this.setupLifecycle();
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
|
|
|
|
2017-03-11 02:30:44 -05:00
|
|
|
setupLifecycle() {
|
|
|
|
this.input.each((i, input) => {
|
|
|
|
const $input = $(input);
|
|
|
|
$input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input));
|
2017-07-07 13:08:39 -04:00
|
|
|
$input.on('change.atwho', () => input.dispatchEvent(new Event('input')));
|
2017-03-11 02:30:44 -05:00
|
|
|
// This triggers at.js again
|
2017-05-31 01:50:53 -04:00
|
|
|
// Needed for quick actions with suffixes (ex: /label ~)
|
2017-03-11 02:30:44 -05:00
|
|
|
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
|
2017-06-05 05:12:15 -04:00
|
|
|
$input.on('clear-commands-cache.atwho', () => this.clearCache());
|
2017-03-11 02:30:44 -05:00
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
2017-05-03 11:47:59 -04:00
|
|
|
|
2017-05-16 14:42:06 -04:00
|
|
|
setupAtWho($input) {
|
2017-05-03 11:47:59 -04:00
|
|
|
if (this.enableMap.emojis) this.setupEmoji($input);
|
|
|
|
if (this.enableMap.members) this.setupMembers($input);
|
|
|
|
if (this.enableMap.issues) this.setupIssues($input);
|
|
|
|
if (this.enableMap.milestones) this.setupMilestones($input);
|
|
|
|
if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
|
|
|
|
if (this.enableMap.labels) this.setupLabels($input);
|
2018-10-05 05:42:38 -04:00
|
|
|
if (this.enableMap.snippets) this.setupSnippets($input);
|
2017-05-03 11:47:59 -04:00
|
|
|
|
2017-05-31 01:50:53 -04:00
|
|
|
// We don't instantiate the quick actions autocomplete for note and issue/MR edit forms
|
|
|
|
$input.filter('[data-supports-quick-actions="true"]').atwho({
|
2017-05-03 11:47:59 -04:00
|
|
|
at: '/',
|
|
|
|
alias: 'commands',
|
|
|
|
searchKey: 'search',
|
|
|
|
skipSpecialCharacterTest: true,
|
2018-03-28 10:45:16 -04:00
|
|
|
skipMarkdownCharacterTest: true,
|
2017-05-16 14:42:06 -04:00
|
|
|
data: GfmAutoComplete.defaultLoadingData,
|
|
|
|
displayTpl(value) {
|
2018-11-21 09:22:41 -05:00
|
|
|
const cssClasses = [];
|
|
|
|
|
2017-05-16 14:42:06 -04:00
|
|
|
if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template;
|
|
|
|
// eslint-disable-next-line no-template-curly-in-string
|
2018-11-21 09:22:41 -05:00
|
|
|
let tpl = '<li class="<%- className %>"><span class="name">/${name}</span>';
|
2017-05-03 11:47:59 -04:00
|
|
|
if (value.aliases.length > 0) {
|
2018-01-08 04:27:50 -05:00
|
|
|
tpl += ' <small class="aliases">(or /<%- aliases.join(", /") %>)</small>';
|
2017-05-03 11:47:59 -04:00
|
|
|
}
|
|
|
|
if (value.params.length > 0) {
|
2018-01-08 04:27:50 -05:00
|
|
|
tpl += ' <small class="params"><%- params.join(" ") %></small>';
|
2017-05-03 11:47:59 -04:00
|
|
|
}
|
|
|
|
if (value.description !== '') {
|
2018-11-21 09:22:41 -05:00
|
|
|
tpl += '<small class="description"><i><%- description %> <%- warningText %></i></small>';
|
2017-05-03 11:47:59 -04:00
|
|
|
}
|
|
|
|
tpl += '</li>';
|
2018-11-21 09:22:41 -05:00
|
|
|
|
|
|
|
if (value.warning) {
|
|
|
|
cssClasses.push('has-warning');
|
|
|
|
}
|
|
|
|
|
|
|
|
return _.template(tpl)({
|
|
|
|
...value,
|
|
|
|
className: cssClasses.join(' '),
|
|
|
|
warningText: value.warning ? `(${value.warning})` : '',
|
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
},
|
|
|
|
insertTpl(value) {
|
|
|
|
// eslint-disable-next-line no-template-curly-in-string
|
|
|
|
let tpl = '/${name} ';
|
|
|
|
let referencePrefix = null;
|
2017-05-03 11:47:59 -04:00
|
|
|
if (value.params.length > 0) {
|
2018-06-16 17:50:13 -04:00
|
|
|
[[referencePrefix]] = value.params;
|
2017-05-16 14:42:06 -04:00
|
|
|
if (/^[@%~]/.test(referencePrefix)) {
|
|
|
|
tpl += '<%- referencePrefix %>';
|
2017-05-03 11:47:59 -04:00
|
|
|
}
|
|
|
|
}
|
2017-05-16 14:42:06 -04:00
|
|
|
return _.template(tpl)({ referencePrefix });
|
2017-05-03 11:47:59 -04:00
|
|
|
},
|
|
|
|
suffix: '',
|
|
|
|
callbacks: {
|
2017-05-16 14:42:06 -04:00
|
|
|
...this.getDefaultCallbacks(),
|
|
|
|
beforeSave(commands) {
|
|
|
|
if (GfmAutoComplete.isLoading(commands)) return commands;
|
2018-10-30 16:28:31 -04:00
|
|
|
return $.map(commands, c => {
|
2017-05-16 14:42:06 -04:00
|
|
|
let search = c.name;
|
2017-05-03 11:47:59 -04:00
|
|
|
if (c.aliases.length > 0) {
|
2017-05-16 14:42:06 -04:00
|
|
|
search = `${search} ${c.aliases.join(' ')}`;
|
2017-05-03 11:47:59 -04:00
|
|
|
}
|
|
|
|
return {
|
|
|
|
name: c.name,
|
|
|
|
aliases: c.aliases,
|
|
|
|
params: c.params,
|
|
|
|
description: c.description,
|
2018-11-21 09:22:41 -05:00
|
|
|
warning: c.warning,
|
2017-05-16 14:42:06 -04:00
|
|
|
search,
|
2017-05-03 11:47:59 -04:00
|
|
|
};
|
|
|
|
});
|
|
|
|
},
|
2017-05-16 14:42:06 -04:00
|
|
|
matcher(flag, subtext) {
|
|
|
|
const regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
|
|
|
|
const match = regexp.exec(subtext);
|
2017-05-03 11:47:59 -04:00
|
|
|
if (match) {
|
|
|
|
return match[1];
|
|
|
|
}
|
2017-05-16 14:42:06 -04:00
|
|
|
return null;
|
|
|
|
},
|
|
|
|
},
|
2017-05-03 11:47:59 -04:00
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
2017-05-03 11:47:59 -04:00
|
|
|
|
|
|
|
setupEmoji($input) {
|
2017-03-11 02:30:44 -05:00
|
|
|
// Emoji
|
|
|
|
$input.atwho({
|
|
|
|
at: ':',
|
2017-05-16 14:42:06 -04:00
|
|
|
displayTpl(value) {
|
|
|
|
let tmpl = GfmAutoComplete.Loading.template;
|
|
|
|
if (value && value.name) {
|
|
|
|
tmpl = GfmAutoComplete.Emoji.templateFunction(value.name);
|
|
|
|
}
|
|
|
|
return tmpl;
|
|
|
|
},
|
|
|
|
// eslint-disable-next-line no-template-curly-in-string
|
2017-03-11 02:30:44 -05:00
|
|
|
insertTpl: ':${name}:',
|
|
|
|
skipSpecialCharacterTest: true,
|
2017-05-16 14:42:06 -04:00
|
|
|
data: GfmAutoComplete.defaultLoadingData,
|
2017-03-11 02:30:44 -05:00
|
|
|
callbacks: {
|
2017-05-16 14:42:06 -04:00
|
|
|
...this.getDefaultCallbacks(),
|
|
|
|
matcher(flag, subtext) {
|
2017-04-25 06:06:24 -04:00
|
|
|
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
|
2018-03-11 15:02:55 -04:00
|
|
|
const match = regexp.exec(subtext);
|
2017-04-25 06:06:24 -04:00
|
|
|
|
|
|
|
return match && match.length ? match[1] : null;
|
2017-05-16 14:42:06 -04:00
|
|
|
},
|
|
|
|
},
|
2017-03-11 02:30:44 -05:00
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
2017-05-03 11:47:59 -04:00
|
|
|
|
|
|
|
setupMembers($input) {
|
2017-03-11 02:30:44 -05:00
|
|
|
// Team Members
|
|
|
|
$input.atwho({
|
|
|
|
at: '@',
|
2018-11-12 04:21:25 -05:00
|
|
|
alias: 'users',
|
2017-05-16 14:42:06 -04:00
|
|
|
displayTpl(value) {
|
|
|
|
let tmpl = GfmAutoComplete.Loading.template;
|
2018-11-12 04:21:25 -05:00
|
|
|
const { avatarTag, username, title } = value;
|
|
|
|
if (username != null) {
|
|
|
|
tmpl = GfmAutoComplete.Members.templateFunction({
|
|
|
|
avatarTag,
|
|
|
|
username,
|
|
|
|
title,
|
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
|
|
|
return tmpl;
|
|
|
|
},
|
|
|
|
// eslint-disable-next-line no-template-curly-in-string
|
2017-03-11 02:30:44 -05:00
|
|
|
insertTpl: '${atwho-at}${username}',
|
|
|
|
searchKey: 'search',
|
|
|
|
alwaysHighlightFirst: true,
|
|
|
|
skipSpecialCharacterTest: true,
|
2017-05-16 14:42:06 -04:00
|
|
|
data: GfmAutoComplete.defaultLoadingData,
|
2017-03-11 02:30:44 -05:00
|
|
|
callbacks: {
|
2017-05-16 14:42:06 -04:00
|
|
|
...this.getDefaultCallbacks(),
|
|
|
|
beforeSave(members) {
|
2018-10-30 16:28:31 -04:00
|
|
|
return $.map(members, m => {
|
2017-03-11 02:30:44 -05:00
|
|
|
let title = '';
|
|
|
|
if (m.username == null) {
|
|
|
|
return m;
|
|
|
|
}
|
|
|
|
title = m.name;
|
|
|
|
if (m.count) {
|
2017-05-16 14:42:06 -04:00
|
|
|
title += ` (${m.count})`;
|
2017-03-11 02:30:44 -05:00
|
|
|
}
|
2016-10-13 13:40:06 -04:00
|
|
|
|
2019-02-25 08:00:05 -05:00
|
|
|
const GROUP_TYPE = 'Group';
|
|
|
|
|
2017-03-11 02:30:44 -05:00
|
|
|
const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
|
2019-02-25 08:00:05 -05:00
|
|
|
|
|
|
|
const rectAvatarClass = m.type === GROUP_TYPE ? 'rect-avatar' : '';
|
2019-06-21 13:07:35 -04:00
|
|
|
const imgAvatar = `<img src="${m.avatar_url}" alt="${m.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`;
|
2019-02-25 08:00:05 -05:00
|
|
|
const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`;
|
2016-10-13 13:40:06 -04:00
|
|
|
|
2017-03-11 02:30:44 -05:00
|
|
|
return {
|
|
|
|
username: m.username,
|
|
|
|
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
|
|
|
|
title: sanitize(title),
|
2017-05-16 14:42:06 -04:00
|
|
|
search: sanitize(`${m.username} ${m.name}`),
|
2017-03-11 02:30:44 -05:00
|
|
|
};
|
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
},
|
|
|
|
},
|
2017-03-11 02:30:44 -05:00
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
2017-05-03 11:47:59 -04:00
|
|
|
|
|
|
|
setupIssues($input) {
|
2017-03-11 02:30:44 -05:00
|
|
|
$input.atwho({
|
|
|
|
at: '#',
|
|
|
|
alias: 'issues',
|
|
|
|
searchKey: 'search',
|
2017-05-16 14:42:06 -04:00
|
|
|
displayTpl(value) {
|
|
|
|
let tmpl = GfmAutoComplete.Loading.template;
|
|
|
|
if (value.title != null) {
|
2018-12-21 03:49:44 -05:00
|
|
|
tmpl = GfmAutoComplete.Issues.templateFunction(value);
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
|
|
|
return tmpl;
|
|
|
|
},
|
|
|
|
data: GfmAutoComplete.defaultLoadingData,
|
2018-12-21 03:49:44 -05:00
|
|
|
insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
|
|
|
|
skipSpecialCharacterTest: true,
|
2017-03-11 02:30:44 -05:00
|
|
|
callbacks: {
|
2017-05-16 14:42:06 -04:00
|
|
|
...this.getDefaultCallbacks(),
|
|
|
|
beforeSave(issues) {
|
2018-10-30 16:28:31 -04:00
|
|
|
return $.map(issues, i => {
|
2017-03-11 02:30:44 -05:00
|
|
|
if (i.title == null) {
|
|
|
|
return i;
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
id: i.iid,
|
|
|
|
title: sanitize(i.title),
|
2018-12-21 03:49:44 -05:00
|
|
|
reference: i.reference,
|
2017-05-16 14:42:06 -04:00
|
|
|
search: `${i.iid} ${i.title}`,
|
2017-03-11 02:30:44 -05:00
|
|
|
};
|
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
},
|
|
|
|
},
|
2017-03-11 02:30:44 -05:00
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
2017-05-03 11:47:59 -04:00
|
|
|
|
|
|
|
setupMilestones($input) {
|
2017-03-11 02:30:44 -05:00
|
|
|
$input.atwho({
|
|
|
|
at: '%',
|
|
|
|
alias: 'milestones',
|
|
|
|
searchKey: 'search',
|
2017-05-16 14:42:06 -04:00
|
|
|
// eslint-disable-next-line no-template-curly-in-string
|
2017-03-11 02:30:44 -05:00
|
|
|
insertTpl: '${atwho-at}${title}',
|
2017-05-16 14:42:06 -04:00
|
|
|
displayTpl(value) {
|
|
|
|
let tmpl = GfmAutoComplete.Loading.template;
|
|
|
|
if (value.title != null) {
|
2018-12-11 07:22:08 -05:00
|
|
|
tmpl = GfmAutoComplete.Milestones.templateFunction(value.title);
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
|
|
|
return tmpl;
|
|
|
|
},
|
|
|
|
data: GfmAutoComplete.defaultLoadingData,
|
2017-03-11 02:30:44 -05:00
|
|
|
callbacks: {
|
2017-05-16 14:42:06 -04:00
|
|
|
...this.getDefaultCallbacks(),
|
|
|
|
beforeSave(milestones) {
|
2018-10-30 16:28:31 -04:00
|
|
|
return $.map(milestones, m => {
|
2017-03-11 02:30:44 -05:00
|
|
|
if (m.title == null) {
|
|
|
|
return m;
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
id: m.iid,
|
|
|
|
title: sanitize(m.title),
|
2017-05-16 14:42:06 -04:00
|
|
|
search: m.title,
|
2017-03-11 02:30:44 -05:00
|
|
|
};
|
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
},
|
|
|
|
},
|
2017-03-11 02:30:44 -05:00
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
2017-05-03 11:47:59 -04:00
|
|
|
|
|
|
|
setupMergeRequests($input) {
|
2017-03-11 02:30:44 -05:00
|
|
|
$input.atwho({
|
|
|
|
at: '!',
|
|
|
|
alias: 'mergerequests',
|
|
|
|
searchKey: 'search',
|
2017-05-16 14:42:06 -04:00
|
|
|
displayTpl(value) {
|
|
|
|
let tmpl = GfmAutoComplete.Loading.template;
|
|
|
|
if (value.title != null) {
|
2018-12-21 03:49:44 -05:00
|
|
|
tmpl = GfmAutoComplete.Issues.templateFunction(value);
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
|
|
|
return tmpl;
|
|
|
|
},
|
|
|
|
data: GfmAutoComplete.defaultLoadingData,
|
2018-12-21 03:49:44 -05:00
|
|
|
insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
|
|
|
|
skipSpecialCharacterTest: true,
|
2017-03-11 02:30:44 -05:00
|
|
|
callbacks: {
|
2017-05-16 14:42:06 -04:00
|
|
|
...this.getDefaultCallbacks(),
|
|
|
|
beforeSave(merges) {
|
2018-10-30 16:28:31 -04:00
|
|
|
return $.map(merges, m => {
|
2017-03-11 02:30:44 -05:00
|
|
|
if (m.title == null) {
|
|
|
|
return m;
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
id: m.iid,
|
|
|
|
title: sanitize(m.title),
|
2018-12-21 03:49:44 -05:00
|
|
|
reference: m.reference,
|
2017-05-16 14:42:06 -04:00
|
|
|
search: `${m.iid} ${m.title}`,
|
2017-03-11 02:30:44 -05:00
|
|
|
};
|
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
},
|
|
|
|
},
|
2017-03-11 02:30:44 -05:00
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
2017-05-03 11:47:59 -04:00
|
|
|
|
|
|
|
setupLabels($input) {
|
2019-06-25 06:19:29 -04:00
|
|
|
const instance = this;
|
2017-11-27 22:51:03 -05:00
|
|
|
const fetchData = this.fetchData.bind(this);
|
|
|
|
const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' };
|
|
|
|
let command = '';
|
|
|
|
|
2017-03-11 02:30:44 -05:00
|
|
|
$input.atwho({
|
|
|
|
at: '~',
|
|
|
|
alias: 'labels',
|
|
|
|
searchKey: 'search',
|
2017-05-16 14:42:06 -04:00
|
|
|
data: GfmAutoComplete.defaultLoadingData,
|
|
|
|
displayTpl(value) {
|
2018-12-11 07:22:08 -05:00
|
|
|
let tmpl = GfmAutoComplete.Labels.templateFunction(value.color, value.title);
|
2017-05-16 14:42:06 -04:00
|
|
|
if (GfmAutoComplete.isLoading(value)) {
|
|
|
|
tmpl = GfmAutoComplete.Loading.template;
|
|
|
|
}
|
|
|
|
return tmpl;
|
|
|
|
},
|
|
|
|
// eslint-disable-next-line no-template-curly-in-string
|
2017-03-11 02:30:44 -05:00
|
|
|
insertTpl: '${atwho-at}${title}',
|
2019-10-04 11:06:38 -04:00
|
|
|
limit: 20,
|
2017-03-11 02:30:44 -05:00
|
|
|
callbacks: {
|
2017-05-16 14:42:06 -04:00
|
|
|
...this.getDefaultCallbacks(),
|
|
|
|
beforeSave(merges) {
|
|
|
|
if (GfmAutoComplete.isLoading(merges)) return merges;
|
|
|
|
return $.map(merges, m => ({
|
|
|
|
title: sanitize(m.title),
|
|
|
|
color: m.color,
|
|
|
|
search: m.title,
|
2017-11-27 22:51:03 -05:00
|
|
|
set: m.set,
|
2017-05-16 14:42:06 -04:00
|
|
|
}));
|
|
|
|
},
|
2017-11-27 22:51:03 -05:00
|
|
|
matcher(flag, subtext) {
|
2018-10-30 16:28:31 -04:00
|
|
|
const subtextNodes = subtext
|
|
|
|
.split(/\n+/g)
|
|
|
|
.pop()
|
|
|
|
.split(GfmAutoComplete.regexSubtext);
|
2017-11-27 22:51:03 -05:00
|
|
|
|
|
|
|
// Check if ~ is followed by '/label', '/relabel' or '/unlabel' commands.
|
2018-10-30 16:28:31 -04:00
|
|
|
command = subtextNodes.find(node => {
|
|
|
|
if (
|
|
|
|
node === LABEL_COMMAND.LABEL ||
|
|
|
|
node === LABEL_COMMAND.RELABEL ||
|
|
|
|
node === LABEL_COMMAND.UNLABEL
|
|
|
|
) {
|
|
|
|
return node;
|
|
|
|
}
|
2017-11-27 22:51:03 -05:00
|
|
|
return null;
|
|
|
|
});
|
|
|
|
|
2019-06-25 06:19:29 -04:00
|
|
|
// If any label matches the inserted text after the last `~`, suggest those labels,
|
|
|
|
// even if any spaces or funky characters were typed.
|
|
|
|
// This allows matching labels like "Accepting merge requests".
|
|
|
|
const labels = instance.cachedData[flag];
|
|
|
|
if (labels) {
|
|
|
|
if (!subtext.includes(flag)) {
|
|
|
|
// Do not match if there is no `~` before the cursor
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
const lastCandidate = subtext.split(flag).pop();
|
|
|
|
if (labels.find(label => label.title.startsWith(lastCandidate))) {
|
|
|
|
return lastCandidate;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// Load all labels into the autocompleter.
|
|
|
|
// This needs to happen if e.g. editing a label in an existing comment, because normally
|
|
|
|
// label data would only be loaded only once you type `~`.
|
|
|
|
fetchData(this.$inputor, this.at);
|
|
|
|
}
|
|
|
|
|
|
|
|
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
|
2017-11-27 22:51:03 -05:00
|
|
|
return match && match.length ? match[1] : null;
|
|
|
|
},
|
|
|
|
filter(query, data, searchKey) {
|
|
|
|
if (GfmAutoComplete.isLoading(data)) {
|
|
|
|
fetchData(this.$inputor, this.at);
|
|
|
|
return data;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (data === GfmAutoComplete.defaultLoadingData) {
|
|
|
|
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
|
|
|
|
}
|
|
|
|
|
|
|
|
// The `LABEL_COMMAND.RELABEL` is intentionally skipped
|
|
|
|
// because we want to return all the labels (unfiltered) for that command.
|
|
|
|
if (command === LABEL_COMMAND.LABEL) {
|
|
|
|
// Return labels with set: undefined.
|
|
|
|
return data.filter(label => !label.set);
|
|
|
|
} else if (command === LABEL_COMMAND.UNLABEL) {
|
|
|
|
// Return labels with set: true.
|
|
|
|
return data.filter(label => label.set);
|
|
|
|
}
|
|
|
|
|
|
|
|
return data;
|
|
|
|
},
|
2017-05-16 14:42:06 -04:00
|
|
|
},
|
2017-03-11 02:30:44 -05:00
|
|
|
});
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
2017-05-03 11:47:59 -04:00
|
|
|
|
2018-10-05 05:42:38 -04:00
|
|
|
setupSnippets($input) {
|
|
|
|
$input.atwho({
|
|
|
|
at: '$',
|
|
|
|
alias: 'snippets',
|
|
|
|
searchKey: 'search',
|
|
|
|
displayTpl(value) {
|
|
|
|
let tmpl = GfmAutoComplete.Loading.template;
|
|
|
|
if (value.title != null) {
|
2018-12-21 03:49:44 -05:00
|
|
|
tmpl = GfmAutoComplete.Issues.templateFunction(value);
|
2018-10-05 05:42:38 -04:00
|
|
|
}
|
|
|
|
return tmpl;
|
|
|
|
},
|
|
|
|
data: GfmAutoComplete.defaultLoadingData,
|
|
|
|
// eslint-disable-next-line no-template-curly-in-string
|
|
|
|
insertTpl: '${atwho-at}${id}',
|
|
|
|
callbacks: {
|
|
|
|
...this.getDefaultCallbacks(),
|
|
|
|
beforeSave(snippets) {
|
2018-10-30 16:28:31 -04:00
|
|
|
return $.map(snippets, m => {
|
2018-10-05 05:42:38 -04:00
|
|
|
if (m.title == null) {
|
|
|
|
return m;
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
id: m.id,
|
|
|
|
title: sanitize(m.title),
|
|
|
|
search: `${m.id} ${m.title}`,
|
|
|
|
};
|
|
|
|
});
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2017-05-16 14:42:06 -04:00
|
|
|
getDefaultCallbacks() {
|
|
|
|
const fetchData = this.fetchData.bind(this);
|
|
|
|
|
|
|
|
return {
|
|
|
|
sorter(query, items, searchKey) {
|
|
|
|
this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
|
|
|
|
if (GfmAutoComplete.isLoading(items)) {
|
|
|
|
this.setting.highlightFirst = false;
|
|
|
|
return items;
|
|
|
|
}
|
|
|
|
return $.fn.atwho.default.callbacks.sorter(query, items, searchKey);
|
|
|
|
},
|
|
|
|
filter(query, data, searchKey) {
|
|
|
|
if (GfmAutoComplete.isLoading(data)) {
|
|
|
|
fetchData(this.$inputor, this.at);
|
|
|
|
return data;
|
|
|
|
}
|
|
|
|
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
|
|
|
|
},
|
|
|
|
beforeInsert(value) {
|
2018-03-28 10:45:16 -04:00
|
|
|
let withoutAt = value.substring(1);
|
|
|
|
const at = value.charAt();
|
|
|
|
|
2017-05-16 14:42:06 -04:00
|
|
|
if (value && !this.setting.skipSpecialCharacterTest) {
|
2018-03-28 10:45:16 -04:00
|
|
|
const regex = at === '~' ? /\W|^\d+$/ : /\W/;
|
2017-11-13 09:30:22 -05:00
|
|
|
if (withoutAt && regex.test(withoutAt)) {
|
2018-03-28 10:45:16 -04:00
|
|
|
withoutAt = `"${withoutAt}"`;
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
|
|
|
}
|
2018-03-28 10:45:16 -04:00
|
|
|
|
|
|
|
// We can ignore this for quick actions because they are processed
|
|
|
|
// before Markdown.
|
|
|
|
if (!this.setting.skipMarkdownCharacterTest) {
|
Only use backslash escapes in autocomplete when needed
Autocompletion for references happens on the frontend. Those references
are turned into actual references on the backend, but only after
Markdown processing has happened. That means that if a reference
contains a character that Markdown might consume, it won't render
correctly. So we need to do some escaping on the frontend.
We have these potential problem characters:
https://docs.gitlab.com/ee/user/markdown.html#emphasis
1. ~ - this is ~~strikethrough~~, but only when doubled.
2. _ - used for _emphasis_, doubled is __bold__.
3. * - also used for *emphasis*, doubled is **bold** also.
4. ` - used for `code spans`, any number works.
We don't need to escape `-` any more. When it comes to being inside a
word:
1. a~~b~~ has strikethrough, so it needs to be escaped everywhere.
2. a_b_ has no emphasis (see [a]) so it only needs to be escaped at the
start and end of words.
3. a*b* has emphasis, so it needs to be escaped everywhere.
4. a`b` has a code span, so it needs to be escaped everywhere.
Or, in code terms:
1. Always escape ~~, *, and ` when being inserted by autocomplete.
2. Escape _ when it's either at the beginning or the end of a word.
[a]: https://docs.gitlab.com/ee/user/markdown.html#multiple-underscores-in-words
2019-04-17 07:52:25 -04:00
|
|
|
withoutAt = withoutAt
|
|
|
|
.replace(/(~~|`|\*)/g, '\\$1')
|
|
|
|
.replace(/(\b)(_+)/g, '$1\\$2') // only escape underscores at the start
|
|
|
|
.replace(/(_+)(\b)/g, '\\$1$2'); // or end of words
|
2018-03-28 10:45:16 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
return `${at}${withoutAt}`;
|
2017-05-16 14:42:06 -04:00
|
|
|
},
|
|
|
|
matcher(flag, subtext) {
|
2017-11-27 22:51:03 -05:00
|
|
|
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
|
2017-05-16 14:42:06 -04:00
|
|
|
|
|
|
|
if (match) {
|
|
|
|
return match[1];
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
},
|
2019-04-25 04:11:20 -04:00
|
|
|
highlighter(li, query) {
|
|
|
|
// override default behaviour to escape dot character
|
|
|
|
// see https://github.com/ichord/At.js/pull/576
|
|
|
|
if (!query) {
|
|
|
|
return li;
|
|
|
|
}
|
|
|
|
const escapedQuery = query.replace(/[.+]/, '\\$&');
|
|
|
|
const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig');
|
|
|
|
return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`);
|
|
|
|
},
|
2017-05-16 14:42:06 -04:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
fetchData($input, at) {
|
2017-03-11 02:30:44 -05:00
|
|
|
if (this.isLoadingData[at]) return;
|
2018-04-26 15:53:13 -04:00
|
|
|
|
2017-03-11 02:30:44 -05:00
|
|
|
this.isLoadingData[at] = true;
|
2018-04-26 15:53:13 -04:00
|
|
|
const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]];
|
|
|
|
|
2017-03-11 02:30:44 -05:00
|
|
|
if (this.cachedData[at]) {
|
|
|
|
this.loadData($input, at, this.cachedData[at]);
|
2017-05-16 14:42:06 -04:00
|
|
|
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
|
2017-06-27 02:26:38 -04:00
|
|
|
import(/* webpackChunkName: 'emoji' */ './emoji')
|
2019-03-14 05:18:18 -04:00
|
|
|
.then(({ validEmojiNames, glEmojiTag }) => {
|
|
|
|
this.loadData($input, at, validEmojiNames);
|
|
|
|
GfmAutoComplete.glEmojiTag = glEmojiTag;
|
2017-06-27 02:26:38 -04:00
|
|
|
})
|
2018-10-30 16:28:31 -04:00
|
|
|
.catch(() => {
|
|
|
|
this.isLoadingData[at] = false;
|
|
|
|
});
|
2018-04-26 15:53:13 -04:00
|
|
|
} else if (dataSource) {
|
|
|
|
AjaxCache.retrieve(dataSource, true)
|
2018-10-30 16:28:31 -04:00
|
|
|
.then(data => {
|
2017-06-05 05:12:15 -04:00
|
|
|
this.loadData($input, at, data);
|
|
|
|
})
|
2018-10-30 16:28:31 -04:00
|
|
|
.catch(() => {
|
|
|
|
this.isLoadingData[at] = false;
|
|
|
|
});
|
2018-04-26 15:53:13 -04:00
|
|
|
} else {
|
|
|
|
this.isLoadingData[at] = false;
|
2017-03-11 02:30:44 -05:00
|
|
|
}
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
2017-06-05 05:12:15 -04:00
|
|
|
|
2017-05-16 14:42:06 -04:00
|
|
|
loadData($input, at, data) {
|
2017-03-11 02:30:44 -05:00
|
|
|
this.isLoadingData[at] = false;
|
|
|
|
this.cachedData[at] = data;
|
|
|
|
$input.atwho('load', at, data);
|
|
|
|
// This trigger at.js again
|
|
|
|
// otherwise we would be stuck with loading until the user types
|
|
|
|
return $input.trigger('keyup');
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
|
|
|
|
2017-06-05 05:12:15 -04:00
|
|
|
clearCache() {
|
|
|
|
this.cachedData = {};
|
|
|
|
}
|
|
|
|
|
2017-06-29 11:28:12 -04:00
|
|
|
destroy() {
|
|
|
|
this.input.each((i, input) => {
|
|
|
|
const $input = $(input);
|
|
|
|
$input.atwho('destroy');
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2017-05-16 14:42:06 -04:00
|
|
|
static isLoading(data) {
|
|
|
|
let dataToInspect = data;
|
2017-03-11 02:30:44 -05:00
|
|
|
if (data && data.length > 0) {
|
2018-06-16 17:50:13 -04:00
|
|
|
[dataToInspect] = data;
|
2016-12-22 06:56:10 -05:00
|
|
|
}
|
2017-03-11 02:30:44 -05:00
|
|
|
|
2017-05-16 14:42:06 -04:00
|
|
|
const loadingState = GfmAutoComplete.defaultLoadingData[0];
|
2018-10-30 16:28:31 -04:00
|
|
|
return dataToInspect && (dataToInspect === loadingState || dataToInspect.name === loadingState);
|
2017-03-11 02:30:44 -05:00
|
|
|
}
|
2017-11-27 22:51:03 -05:00
|
|
|
|
|
|
|
static defaultMatcher(flag, subtext, controllers) {
|
|
|
|
// The below is taken from At.js source
|
|
|
|
// Tweaked to commands to start without a space only if char before is a non-word character
|
|
|
|
// https://github.com/ichord/At.js
|
2018-10-30 16:28:31 -04:00
|
|
|
const atSymbolsWithBar = Object.keys(controllers)
|
|
|
|
.join('|')
|
|
|
|
.replace(/[$]/, '\\$&');
|
2017-11-27 22:51:03 -05:00
|
|
|
const atSymbolsWithoutBar = Object.keys(controllers).join('');
|
|
|
|
const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
|
|
|
|
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
|
|
|
|
|
|
|
|
const accentAChar = decodeURI('%C3%80');
|
|
|
|
const accentYChar = decodeURI('%C3%BF');
|
|
|
|
|
2019-06-25 06:19:29 -04:00
|
|
|
// Holy regex, batman!
|
2018-10-30 16:28:31 -04:00
|
|
|
const regexp = new RegExp(
|
2019-06-25 06:19:29 -04:00
|
|
|
`^(?:\\B|[^a-zA-Z0-9_\`${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-:]|[^\\x00-\\x7a])*)$`,
|
2018-10-30 16:28:31 -04:00
|
|
|
'gi',
|
|
|
|
);
|
2017-11-27 22:51:03 -05:00
|
|
|
|
|
|
|
return regexp.exec(targetSubtext);
|
|
|
|
}
|
2017-05-16 14:42:06 -04:00
|
|
|
}
|
|
|
|
|
2017-11-27 22:51:03 -05:00
|
|
|
GfmAutoComplete.regexSubtext = new RegExp(/\s+/g);
|
|
|
|
|
2017-05-16 14:42:06 -04:00
|
|
|
GfmAutoComplete.defaultLoadingData = ['loading'];
|
|
|
|
|
|
|
|
GfmAutoComplete.atTypeMap = {
|
|
|
|
':': 'emojis',
|
|
|
|
'@': 'members',
|
|
|
|
'#': 'issues',
|
|
|
|
'!': 'mergeRequests',
|
2018-06-28 02:41:56 -04:00
|
|
|
'&': 'epics',
|
2017-05-16 14:42:06 -04:00
|
|
|
'~': 'labels',
|
|
|
|
'%': 'milestones',
|
|
|
|
'/': 'commands',
|
2018-10-05 05:42:38 -04:00
|
|
|
$: 'snippets',
|
2017-05-16 14:42:06 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
// Emoji
|
2017-06-27 02:26:38 -04:00
|
|
|
GfmAutoComplete.glEmojiTag = null;
|
2017-05-16 14:42:06 -04:00
|
|
|
GfmAutoComplete.Emoji = {
|
|
|
|
templateFunction(name) {
|
2017-06-27 02:26:38 -04:00
|
|
|
// glEmojiTag helper is loaded on-demand in fetchData()
|
|
|
|
if (GfmAutoComplete.glEmojiTag) {
|
|
|
|
return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`;
|
|
|
|
}
|
|
|
|
return `<li>${name}</li>`;
|
2017-05-16 14:42:06 -04:00
|
|
|
},
|
2017-03-11 02:30:44 -05:00
|
|
|
};
|
2017-05-16 14:42:06 -04:00
|
|
|
// Team Members
|
|
|
|
GfmAutoComplete.Members = {
|
2018-11-12 04:21:25 -05:00
|
|
|
templateFunction({ avatarTag, username, title }) {
|
|
|
|
return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small></li>`;
|
|
|
|
},
|
2017-05-16 14:42:06 -04:00
|
|
|
};
|
|
|
|
GfmAutoComplete.Labels = {
|
2018-12-11 07:22:08 -05:00
|
|
|
templateFunction(color, title) {
|
|
|
|
return `<li><span class="dropdown-label-box" style="background: ${_.escape(
|
|
|
|
color,
|
|
|
|
)}"></span> ${_.escape(title)}</li>`;
|
|
|
|
},
|
2017-05-16 14:42:06 -04:00
|
|
|
};
|
2018-10-05 05:42:38 -04:00
|
|
|
// Issues, MergeRequests and Snippets
|
2017-05-16 14:42:06 -04:00
|
|
|
GfmAutoComplete.Issues = {
|
2018-12-21 03:49:44 -05:00
|
|
|
insertTemplateFunction(value) {
|
|
|
|
// eslint-disable-next-line no-template-curly-in-string
|
|
|
|
return value.reference || '${atwho-at}${id}';
|
|
|
|
},
|
|
|
|
templateFunction({ id, title, reference }) {
|
|
|
|
return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`;
|
2018-10-19 04:17:56 -04:00
|
|
|
},
|
2017-05-16 14:42:06 -04:00
|
|
|
};
|
|
|
|
// Milestones
|
|
|
|
GfmAutoComplete.Milestones = {
|
2018-12-11 07:22:08 -05:00
|
|
|
templateFunction(title) {
|
|
|
|
return `<li>${_.escape(title)}</li>`;
|
|
|
|
},
|
2017-05-16 14:42:06 -04:00
|
|
|
};
|
|
|
|
GfmAutoComplete.Loading = {
|
2018-10-30 16:28:31 -04:00
|
|
|
template:
|
|
|
|
'<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>',
|
2017-05-16 14:42:06 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
export default GfmAutoComplete;
|