debian-mirror-gitlab/app/assets/javascripts/gfm_auto_complete.js

962 lines
30 KiB
JavaScript
Raw Normal View History

2018-05-09 12:01:36 +05:30
import $ from 'jquery';
2020-11-24 15:15:51 +05:30
import '~/lib/utils/jquery_at_who';
2021-12-11 22:18:48 +05:30
import { escape as lodashEscape, sortBy, template, escapeRegExp } from 'lodash';
2021-03-11 19:13:27 +05:30
import * as Emoji from '~/emoji';
import axios from '~/lib/utils/axios_utils';
2021-04-17 20:07:23 +05:30
import { s__, __, sprintf } from '~/locale';
2021-01-29 00:20:46 +05:30
import { isUserBusy } from '~/set_status_modal/utils';
2021-03-11 19:13:27 +05:30
import SidebarMediator from '~/sidebar/sidebar_mediator';
2017-09-10 17:25:29 +05:30
import AjaxCache from './lib/utils/ajax_cache';
2020-01-01 13:55:28 +05:30
import { spriteIcon } from './lib/utils/common_utils';
2021-04-17 20:07:23 +05:30
import { parsePikadayDate } from './lib/utils/datetime_utility';
2021-03-11 19:13:27 +05:30
import glRegexp from './lib/utils/regexp';
2017-08-17 22:00:37 +05:30
2021-09-30 23:02:18 +05:30
/**
* Escapes user input before we pass it to at.js, which
* renders it as HTML in the autocomplete dropdown.
*
* at.js allows you to reference data using `${}` syntax
* (e.g. ${search}) which it replaces with the actual data
* before rendering it in the autocomplete dropdown.
* To prevent user input from executing this `${}` syntax,
* we also need to escape the $ character.
*
* @param string user input
* @return {string} escaped user input
*/
function escape(string) {
return lodashEscape(string).replace(/\$/g, '$');
2017-08-17 22:00:37 +05:30
}
2021-03-11 19:13:27 +05:30
function createMemberSearchString(member) {
return `${member.name.replace(/ /g, '')} ${member.username}`;
}
2020-01-01 13:55:28 +05:30
export function membersBeforeSave(members) {
2021-03-08 18:12:59 +05:30
return members.map((member) => {
2020-01-01 13:55:28 +05:30
const GROUP_TYPE = 'Group';
let title = '';
if (member.username == null) {
return member;
}
title = member.name;
if (member.count && !member.mentionsDisabled) {
title += ` (${member.count})`;
}
const autoCompleteAvatar = member.avatar_url || member.username.charAt(0).toUpperCase();
const rectAvatarClass = member.type === GROUP_TYPE ? 'rect-avatar' : '';
const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`;
const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`;
const avatarIcon = member.mentionsDisabled
2020-07-28 23:09:34 +05:30
? spriteIcon('notifications-off', 's16 vertical-align-middle gl-ml-2')
2020-01-01 13:55:28 +05:30
: '';
return {
username: member.username,
avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
2021-09-30 23:02:18 +05:30
title,
search: createMemberSearchString(member),
2020-01-01 13:55:28 +05:30
icon: avatarIcon,
2021-01-29 00:20:46 +05:30
availability: member?.availability,
2020-01-01 13:55:28 +05:30
};
});
}
2021-12-11 22:18:48 +05:30
export const 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 = escapeRegExp(query);
const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig');
return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`);
};
2018-11-08 19:23:39 +05:30
export const defaultAutocompleteConfig = {
emojis: true,
members: true,
issues: true,
mergeRequests: true,
epics: true,
milestones: true,
labels: true,
2018-12-05 23:21:45 +05:30
snippets: true,
2021-01-29 00:20:46 +05:30
vulnerabilities: true,
2022-04-04 11:22:00 +05:30
contacts: true,
2018-11-08 19:23:39 +05:30
};
2017-09-10 17:25:29 +05:30
class GfmAutoComplete {
2020-03-13 15:44:24 +05:30
constructor(dataSources = {}) {
this.dataSources = dataSources;
2017-09-10 17:25:29 +05:30
this.cachedData = {};
this.isLoadingData = {};
2021-01-29 00:20:46 +05:30
this.previousQuery = '';
2017-09-10 17:25:29 +05:30
}
2017-08-17 22:00:37 +05:30
2018-11-08 19:23:39 +05:30
setup(input, enableMap = defaultAutocompleteConfig) {
2017-08-17 22:00:37 +05:30
// Add GFM auto-completion to all input fields, that accept GFM input.
this.input = input || $('.js-gfm-input');
this.enableMap = enableMap;
this.setupLifecycle();
2017-09-10 17:25:29 +05:30
}
2017-08-17 22:00:37 +05:30
setupLifecycle() {
this.input.each((i, input) => {
const $input = $(input);
2020-11-24 15:15:51 +05:30
if (!$input.hasClass('js-gfm-input-initialized')) {
2021-02-22 17:27:13 +05:30
// eslint-disable-next-line @gitlab/no-global-event-off
2020-11-24 15:15:51 +05:30
$input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input));
$input.on('change.atwho', () => input.dispatchEvent(new Event('input')));
// This triggers at.js again
// Needed for quick actions with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
$input.on('clear-commands-cache.atwho', () => this.clearCache());
$input.addClass('js-gfm-input-initialized');
}
2017-08-17 22:00:37 +05:30
});
2017-09-10 17:25:29 +05:30
}
2017-08-17 22:00:37 +05:30
2017-09-10 17:25:29 +05:30
setupAtWho($input) {
2017-08-17 22:00:37 +05:30
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-12-05 23:21:45 +05:30
if (this.enableMap.snippets) this.setupSnippets($input);
2022-04-04 11:22:00 +05:30
if (this.enableMap.contacts) this.setupContacts($input);
2017-08-17 22:00:37 +05:30
2017-09-10 17:25:29 +05:30
$input.filter('[data-supports-quick-actions="true"]').atwho({
2017-08-17 22:00:37 +05:30
at: '/',
alias: 'commands',
searchKey: 'search',
2021-03-08 18:12:59 +05:30
limit: 100,
2017-08-17 22:00:37 +05:30
skipSpecialCharacterTest: true,
2018-05-09 12:01:36 +05:30
skipMarkdownCharacterTest: true,
2017-09-10 17:25:29 +05:30
data: GfmAutoComplete.defaultLoadingData,
displayTpl(value) {
2019-02-15 15:39:39 +05:30
const cssClasses = [];
2017-09-10 17:25:29 +05:30
if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template;
// eslint-disable-next-line no-template-curly-in-string
2019-02-15 15:39:39 +05:30
let tpl = '<li class="<%- className %>"><span class="name">/${name}</span>';
2017-08-17 22:00:37 +05:30
if (value.aliases.length > 0) {
2018-03-17 18:26:18 +05:30
tpl += ' <small class="aliases">(or /<%- aliases.join(", /") %>)</small>';
2017-08-17 22:00:37 +05:30
}
if (value.params.length > 0) {
2018-03-17 18:26:18 +05:30
tpl += ' <small class="params"><%- params.join(" ") %></small>';
2017-08-17 22:00:37 +05:30
}
2020-03-13 15:44:24 +05:30
if (value.warning && value.icon && value.icon === 'confidential') {
2020-07-28 23:09:34 +05:30
tpl += `<small class="description gl-display-flex gl-align-items-center">${spriteIcon(
'eye-slash',
's16 gl-mr-2',
)}<em><%- warning %></em></small>`;
2020-03-13 15:44:24 +05:30
} else if (value.warning) {
tpl += '<small class="description"><em><%- warning %></em></small>';
} else if (value.description !== '') {
tpl += '<small class="description"><em><%- description %></em></small>';
2017-08-17 22:00:37 +05:30
}
tpl += '</li>';
2019-02-15 15:39:39 +05:30
if (value.warning) {
cssClasses.push('has-warning');
}
2020-05-24 23:13:21 +05:30
return template(tpl)({
2019-02-15 15:39:39 +05:30
...value,
className: cssClasses.join(' '),
});
2017-09-10 17:25:29 +05:30
},
insertTpl(value) {
// eslint-disable-next-line no-template-curly-in-string
let tpl = '/${name} ';
let referencePrefix = null;
2017-08-17 22:00:37 +05:30
if (value.params.length > 0) {
2022-04-04 11:22:00 +05:30
const regexp = /\[[a-z]+:/;
const match = regexp.exec(value.params);
if (match) {
[referencePrefix] = match;
2017-09-10 17:25:29 +05:30
tpl += '<%- referencePrefix %>';
2022-04-04 11:22:00 +05:30
} else {
[[referencePrefix]] = value.params;
if (/^[@%~]/.test(referencePrefix)) {
tpl += '<%- referencePrefix %>';
}
2017-08-17 22:00:37 +05:30
}
}
2020-05-24 23:13:21 +05:30
return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix });
2017-08-17 22:00:37 +05:30
},
suffix: '',
callbacks: {
2017-09-10 17:25:29 +05:30
...this.getDefaultCallbacks(),
beforeSave(commands) {
if (GfmAutoComplete.isLoading(commands)) return commands;
2021-03-08 18:12:59 +05:30
return $.map(commands, (c) => {
2017-09-10 17:25:29 +05:30
let search = c.name;
2017-08-17 22:00:37 +05:30
if (c.aliases.length > 0) {
2017-09-10 17:25:29 +05:30
search = `${search} ${c.aliases.join(' ')}`;
2017-08-17 22:00:37 +05:30
}
return {
name: c.name,
aliases: c.aliases,
params: c.params,
description: c.description,
2019-02-15 15:39:39 +05:30
warning: c.warning,
2020-03-13 15:44:24 +05:30
icon: c.icon,
2017-09-10 17:25:29 +05:30
search,
2017-08-17 22:00:37 +05:30
};
});
},
2017-09-10 17:25:29 +05:30
matcher(flag, subtext) {
const regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
const match = regexp.exec(subtext);
2017-08-17 22:00:37 +05:30
if (match) {
return match[1];
}
2017-09-10 17:25:29 +05:30
return null;
},
},
2017-08-17 22:00:37 +05:30
});
2017-09-10 17:25:29 +05:30
}
2017-08-17 22:00:37 +05:30
setupEmoji($input) {
2021-03-11 19:13:27 +05:30
const fetchData = this.fetchData.bind(this);
2021-01-03 14:25:43 +05:30
2017-08-17 22:00:37 +05:30
// Emoji
$input.atwho({
at: ':',
2021-03-11 19:13:27 +05:30
displayTpl: GfmAutoComplete.Emoji.templateFunction,
2021-01-03 14:25:43 +05:30
insertTpl: GfmAutoComplete.Emoji.insertTemplateFunction,
2017-08-17 22:00:37 +05:30
skipSpecialCharacterTest: true,
2017-09-10 17:25:29 +05:30
data: GfmAutoComplete.defaultLoadingData,
2017-08-17 22:00:37 +05:30
callbacks: {
2021-03-11 19:13:27 +05:30
...this.getDefaultCallbacks(),
2017-09-10 17:25:29 +05:30
matcher(flag, subtext) {
2017-08-17 22:00:37 +05:30
const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
2018-05-09 12:01:36 +05:30
const match = regexp.exec(subtext);
2017-08-17 22:00:37 +05:30
return match && match.length ? match[1] : null;
2017-09-10 17:25:29 +05:30
},
2021-03-11 19:13:27 +05:30
filter(query, items) {
if (GfmAutoComplete.isLoading(items)) {
fetchData(this.$inputor, this.at);
return items;
2021-01-03 14:25:43 +05:30
}
2021-03-11 19:13:27 +05:30
return GfmAutoComplete.Emoji.filter(query);
},
sorter(query, items) {
this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
if (GfmAutoComplete.isLoading(items)) {
this.setting.highlightFirst = false;
return items;
}
2021-01-03 14:25:43 +05:30
2021-03-11 19:13:27 +05:30
if (query.length === 0) {
return items;
}
2021-01-03 14:25:43 +05:30
2021-03-11 19:13:27 +05:30
return GfmAutoComplete.Emoji.sorter(items);
2021-01-03 14:25:43 +05:30
},
2017-09-10 17:25:29 +05:30
},
2017-08-17 22:00:37 +05:30
});
2017-09-10 17:25:29 +05:30
}
2017-08-17 22:00:37 +05:30
setupMembers($input) {
2020-03-13 15:44:24 +05:30
const fetchData = this.fetchData.bind(this);
const MEMBER_COMMAND = {
ASSIGN: '/assign',
UNASSIGN: '/unassign',
2021-06-08 01:23:25 +05:30
ASSIGN_REVIEWER: '/assign_reviewer',
UNASSIGN_REVIEWER: '/unassign_reviewer',
2020-03-13 15:44:24 +05:30
REASSIGN: '/reassign',
CC: '/cc',
2022-04-04 11:22:00 +05:30
ATTENTION: '/attention',
REMOVE_ATTENTION: '/remove_attention',
2020-03-13 15:44:24 +05:30
};
let assignees = [];
2021-06-08 01:23:25 +05:30
let reviewers = [];
2020-03-13 15:44:24 +05:30
let command = '';
2017-08-17 22:00:37 +05:30
// Team Members
$input.atwho({
at: '@',
2018-11-20 20:47:30 +05:30
alias: 'users',
2017-09-10 17:25:29 +05:30
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
2021-01-29 00:20:46 +05:30
const { avatarTag, username, title, icon, availability } = value;
2018-11-20 20:47:30 +05:30
if (username != null) {
tmpl = GfmAutoComplete.Members.templateFunction({
avatarTag,
username,
title,
2020-01-01 13:55:28 +05:30
icon,
2021-01-29 00:20:46 +05:30
availabilityStatus:
availability && isUserBusy(availability)
? `<span class="gl-text-gray-500"> ${s__('UserAvailability|(Busy)')}</span>`
: '',
2018-11-20 20:47:30 +05:30
});
2017-09-10 17:25:29 +05:30
}
return tmpl;
},
// eslint-disable-next-line no-template-curly-in-string
2017-08-17 22:00:37 +05:30
insertTpl: '${atwho-at}${username}',
2021-04-17 20:07:23 +05:30
limit: 10,
2017-08-17 22:00:37 +05:30
searchKey: 'search',
alwaysHighlightFirst: true,
skipSpecialCharacterTest: true,
2017-09-10 17:25:29 +05:30
data: GfmAutoComplete.defaultLoadingData,
2017-08-17 22:00:37 +05:30
callbacks: {
2017-09-10 17:25:29 +05:30
...this.getDefaultCallbacks(),
2020-01-01 13:55:28 +05:30
beforeSave: membersBeforeSave,
2020-03-13 15:44:24 +05:30
matcher(flag, subtext) {
2021-03-08 18:12:59 +05:30
const subtextNodes = subtext.split(/\n+/g).pop().split(GfmAutoComplete.regexSubtext);
2020-03-13 15:44:24 +05:30
// Check if @ is followed by '/assign', '/reassign', '/unassign' or '/cc' commands.
2021-03-08 18:12:59 +05:30
command = subtextNodes.find((node) => {
2020-03-13 15:44:24 +05:30
if (Object.values(MEMBER_COMMAND).includes(node)) {
return node;
}
return null;
});
2021-06-08 01:23:25 +05:30
// Cache assignees & reviewers list for easier filtering later
2020-04-22 19:07:51 +05:30
assignees =
2021-03-11 19:13:27 +05:30
SidebarMediator.singleton?.store?.assignees?.map(createMemberSearchString) || [];
2021-06-08 01:23:25 +05:30
reviewers =
SidebarMediator.singleton?.store?.reviewers?.map(createMemberSearchString) || [];
2020-03-13 15:44:24 +05:30
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
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);
}
if (command === MEMBER_COMMAND.ASSIGN) {
// Only include members which are not assigned to Issuable currently
2021-03-08 18:12:59 +05:30
return data.filter((member) => !assignees.includes(member.search));
2020-03-13 15:44:24 +05:30
} else if (command === MEMBER_COMMAND.UNASSIGN) {
// Only include members which are assigned to Issuable currently
2021-03-08 18:12:59 +05:30
return data.filter((member) => assignees.includes(member.search));
2021-06-08 01:23:25 +05:30
} else if (command === MEMBER_COMMAND.ASSIGN_REVIEWER) {
// Only include members which are not assigned as a reviewer to Issuable currently
return data.filter((member) => !reviewers.includes(member.search));
} else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) {
// Only include members which are not assigned as a reviewer to Issuable currently
return data.filter((member) => reviewers.includes(member.search));
2022-04-04 11:22:00 +05:30
} else if (
command === MEMBER_COMMAND.ATTENTION ||
command === MEMBER_COMMAND.REMOVE_ATTENTION
) {
const attentionUsers = [
...(SidebarMediator.singleton?.store?.assignees || []),
...(SidebarMediator.singleton?.store?.reviewers || []),
];
const attentionRequested = command === MEMBER_COMMAND.REMOVE_ATTENTION;
return data.filter((member) =>
attentionUsers.find(
(u) =>
createMemberSearchString(u).includes(member.search) &&
u.attention_requested === attentionRequested,
),
);
2020-03-13 15:44:24 +05:30
}
return data;
},
2021-04-17 20:07:23 +05:30
sorter(query, items) {
// Disable auto-selecting the loading icon
this.setting.highlightFirst = this.setting.alwaysHighlightFirst;
if (GfmAutoComplete.isLoading(items)) {
this.setting.highlightFirst = false;
return items;
}
if (!query) {
return items;
}
2021-04-29 21:17:54 +05:30
return GfmAutoComplete.Members.sort(query, items);
2021-04-17 20:07:23 +05:30
},
2017-09-10 17:25:29 +05:30
},
2017-08-17 22:00:37 +05:30
});
2017-09-10 17:25:29 +05:30
}
2017-08-17 22:00:37 +05:30
setupIssues($input) {
$input.atwho({
at: '#',
alias: 'issues',
searchKey: 'search',
2017-09-10 17:25:29 +05:30
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
2019-02-15 15:39:39 +05:30
tmpl = GfmAutoComplete.Issues.templateFunction(value);
2017-09-10 17:25:29 +05:30
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
2019-02-15 15:39:39 +05:30
insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
skipSpecialCharacterTest: true,
2017-08-17 22:00:37 +05:30
callbacks: {
2017-09-10 17:25:29 +05:30
...this.getDefaultCallbacks(),
beforeSave(issues) {
2021-03-08 18:12:59 +05:30
return $.map(issues, (i) => {
2017-08-17 22:00:37 +05:30
if (i.title == null) {
return i;
}
return {
id: i.iid,
2021-09-30 23:02:18 +05:30
title: i.title,
2019-02-15 15:39:39 +05:30
reference: i.reference,
2017-09-10 17:25:29 +05:30
search: `${i.iid} ${i.title}`,
2017-08-17 22:00:37 +05:30
};
});
2017-09-10 17:25:29 +05:30
},
},
2017-08-17 22:00:37 +05:30
});
2017-09-10 17:25:29 +05:30
}
2017-08-17 22:00:37 +05:30
setupMilestones($input) {
$input.atwho({
at: '%',
alias: 'milestones',
searchKey: 'search',
2017-09-10 17:25:29 +05:30
// eslint-disable-next-line no-template-curly-in-string
2017-08-17 22:00:37 +05:30
insertTpl: '${atwho-at}${title}',
2017-09-10 17:25:29 +05:30
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
2021-04-17 20:07:23 +05:30
tmpl = GfmAutoComplete.Milestones.templateFunction(value.title, value.expired);
2017-09-10 17:25:29 +05:30
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
2017-08-17 22:00:37 +05:30
callbacks: {
2017-09-10 17:25:29 +05:30
...this.getDefaultCallbacks(),
beforeSave(milestones) {
2021-04-17 20:07:23 +05:30
const parsedMilestones = $.map(milestones, (m) => {
2017-08-17 22:00:37 +05:30
if (m.title == null) {
return m;
}
2021-04-17 20:07:23 +05:30
const dueDate = m.due_date ? parsePikadayDate(m.due_date) : null;
const expired = dueDate ? Date.now() > dueDate.getTime() : false;
2017-08-17 22:00:37 +05:30
return {
id: m.iid,
2021-09-30 23:02:18 +05:30
title: m.title,
2017-09-10 17:25:29 +05:30
search: m.title,
2021-04-17 20:07:23 +05:30
expired,
dueDate,
2017-08-17 22:00:37 +05:30
};
});
2021-04-17 20:07:23 +05:30
// Sort milestones by due date when present.
if (typeof parsedMilestones[0] === 'object') {
return parsedMilestones.sort((mA, mB) => {
// Move all expired milestones to the bottom.
if (mA.expired) return 1;
if (mB.expired) return -1;
// Move milestones without due dates just above expired milestones.
if (!mA.dueDate) return 1;
if (!mB.dueDate) return -1;
// Sort by due date in ascending order.
return mA.dueDate - mB.dueDate;
});
}
return parsedMilestones;
2017-09-10 17:25:29 +05:30
},
},
2017-08-17 22:00:37 +05:30
});
2017-09-10 17:25:29 +05:30
}
2017-08-17 22:00:37 +05:30
setupMergeRequests($input) {
$input.atwho({
at: '!',
alias: 'mergerequests',
searchKey: 'search',
2017-09-10 17:25:29 +05:30
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
2019-02-15 15:39:39 +05:30
tmpl = GfmAutoComplete.Issues.templateFunction(value);
2017-09-10 17:25:29 +05:30
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
2019-02-15 15:39:39 +05:30
insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
skipSpecialCharacterTest: true,
2017-08-17 22:00:37 +05:30
callbacks: {
2017-09-10 17:25:29 +05:30
...this.getDefaultCallbacks(),
beforeSave(merges) {
2021-03-08 18:12:59 +05:30
return $.map(merges, (m) => {
2017-08-17 22:00:37 +05:30
if (m.title == null) {
return m;
}
return {
id: m.iid,
2021-09-30 23:02:18 +05:30
title: m.title,
2019-02-15 15:39:39 +05:30
reference: m.reference,
2017-09-10 17:25:29 +05:30
search: `${m.iid} ${m.title}`,
2017-08-17 22:00:37 +05:30
};
});
2017-09-10 17:25:29 +05:30
},
},
2017-08-17 22:00:37 +05:30
});
2017-09-10 17:25:29 +05:30
}
2017-08-17 22:00:37 +05:30
setupLabels($input) {
2019-09-30 21:07:59 +05:30
const instance = this;
2018-03-17 18:26:18 +05:30
const fetchData = this.fetchData.bind(this);
const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' };
let command = '';
2017-08-17 22:00:37 +05:30
$input.atwho({
at: '~',
alias: 'labels',
searchKey: 'search',
2017-09-10 17:25:29 +05:30
data: GfmAutoComplete.defaultLoadingData,
displayTpl(value) {
2019-01-03 12:48:30 +05:30
let tmpl = GfmAutoComplete.Labels.templateFunction(value.color, value.title);
2017-09-10 17:25:29 +05:30
if (GfmAutoComplete.isLoading(value)) {
tmpl = GfmAutoComplete.Loading.template;
}
return tmpl;
},
// eslint-disable-next-line no-template-curly-in-string
2017-08-17 22:00:37 +05:30
insertTpl: '${atwho-at}${title}',
2019-12-21 20:55:43 +05:30
limit: 20,
2017-08-17 22:00:37 +05:30
callbacks: {
2017-09-10 17:25:29 +05:30
...this.getDefaultCallbacks(),
beforeSave(merges) {
if (GfmAutoComplete.isLoading(merges)) return merges;
2021-03-08 18:12:59 +05:30
return $.map(merges, (m) => ({
2021-09-30 23:02:18 +05:30
title: m.title,
2017-09-10 17:25:29 +05:30
color: m.color,
search: m.title,
2018-03-17 18:26:18 +05:30
set: m.set,
2017-09-10 17:25:29 +05:30
}));
},
2018-03-17 18:26:18 +05:30
matcher(flag, subtext) {
2021-03-08 18:12:59 +05:30
const subtextNodes = subtext.split(/\n+/g).pop().split(GfmAutoComplete.regexSubtext);
2018-03-17 18:26:18 +05:30
// Check if ~ is followed by '/label', '/relabel' or '/unlabel' commands.
2021-03-08 18:12:59 +05:30
command = subtextNodes.find((node) => {
2018-12-13 13:39:08 +05:30
if (
node === LABEL_COMMAND.LABEL ||
node === LABEL_COMMAND.RELABEL ||
node === LABEL_COMMAND.UNLABEL
) {
return node;
}
2018-03-17 18:26:18 +05:30
return null;
});
2019-09-30 21:07:59 +05:30
// 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();
2021-03-08 18:12:59 +05:30
if (labels.find((label) => label.title.startsWith(lastCandidate))) {
2019-09-30 21:07:59 +05:30
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);
2018-03-17 18:26:18 +05:30
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.
2021-03-08 18:12:59 +05:30
return data.filter((label) => !label.set);
2018-03-17 18:26:18 +05:30
} else if (command === LABEL_COMMAND.UNLABEL) {
// Return labels with set: true.
2021-03-08 18:12:59 +05:30
return data.filter((label) => label.set);
2018-03-17 18:26:18 +05:30
}
return data;
},
2017-09-10 17:25:29 +05:30
},
2017-08-17 22:00:37 +05:30
});
2017-09-10 17:25:29 +05:30
}
2018-12-05 23:21:45 +05:30
setupSnippets($input) {
$input.atwho({
at: '$',
alias: 'snippets',
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
2019-02-15 15:39:39 +05:30
tmpl = GfmAutoComplete.Issues.templateFunction(value);
2018-12-05 23:21:45 +05:30
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${id}',
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(snippets) {
2021-03-08 18:12:59 +05:30
return $.map(snippets, (m) => {
2018-12-05 23:21:45 +05:30
if (m.title == null) {
return m;
}
return {
id: m.id,
2021-09-30 23:02:18 +05:30
title: m.title,
2018-12-05 23:21:45 +05:30
search: `${m.id} ${m.title}`,
};
});
},
},
});
}
2022-04-04 11:22:00 +05:30
setupContacts($input) {
$input.atwho({
at: '[contact:',
suffix: ']',
alias: 'contacts',
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.email != null) {
tmpl = GfmAutoComplete.Contacts.templateFunction(value);
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${email}',
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(contacts) {
return $.map(contacts, (m) => {
if (m.email == null) {
return m;
}
return {
id: m.id,
email: m.email,
firstName: m.first_name,
lastName: m.last_name,
search: `${m.email}`,
};
});
},
},
});
}
2017-09-10 17:25:29 +05:30
getDefaultCallbacks() {
2021-01-29 00:20:46 +05:30
const self = this;
2017-08-17 22:00:37 +05:30
2017-09-10 17:25:29 +05:30
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)) {
2021-01-29 00:20:46 +05:30
self.fetchData(this.$inputor, this.at);
return data;
} else if (
GfmAutoComplete.isTypeWithBackendFiltering(this.at) &&
self.previousQuery !== query
) {
self.fetchData(this.$inputor, this.at, query);
self.previousQuery = query;
2017-09-10 17:25:29 +05:30
return data;
}
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
},
beforeInsert(value) {
2018-05-09 12:01:36 +05:30
let withoutAt = value.substring(1);
const at = value.charAt();
2017-09-10 17:25:29 +05:30
if (value && !this.setting.skipSpecialCharacterTest) {
2018-05-09 12:01:36 +05:30
const regex = at === '~' ? /\W|^\d+$/ : /\W/;
2018-03-17 18:26:18 +05:30
if (withoutAt && regex.test(withoutAt)) {
2018-05-09 12:01:36 +05:30
withoutAt = `"${withoutAt}"`;
2017-09-10 17:25:29 +05:30
}
}
2018-05-09 12:01:36 +05:30
// We can ignore this for quick actions because they are processed
// before Markdown.
if (!this.setting.skipMarkdownCharacterTest) {
2019-07-31 22:56:46 +05:30
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-05-09 12:01:36 +05:30
}
return `${at}${withoutAt}`;
2017-09-10 17:25:29 +05:30
},
matcher(flag, subtext) {
2018-03-17 18:26:18 +05:30
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
2017-09-10 17:25:29 +05:30
if (match) {
return match[1];
}
return null;
},
2021-12-11 22:18:48 +05:30
highlighter,
2017-09-10 17:25:29 +05:30
};
}
2021-01-29 00:20:46 +05:30
fetchData($input, at, search) {
2017-08-17 22:00:37 +05:30
if (this.isLoadingData[at]) return;
2018-10-15 14:42:47 +05:30
2017-08-17 22:00:37 +05:30
this.isLoadingData[at] = true;
2018-10-15 14:42:47 +05:30
const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]];
2021-01-29 00:20:46 +05:30
if (GfmAutoComplete.isTypeWithBackendFiltering(at)) {
axios
.get(dataSource, { params: { search } })
.then(({ data }) => {
this.loadData($input, at, data);
})
.catch(() => {
this.isLoadingData[at] = false;
});
} else if (this.cachedData[at]) {
2017-08-17 22:00:37 +05:30
this.loadData($input, at, this.cachedData[at]);
2017-09-10 17:25:29 +05:30
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
2021-01-03 14:25:43 +05:30
this.loadEmojiData($input, at).catch(() => {});
2018-10-15 14:42:47 +05:30
} else if (dataSource) {
AjaxCache.retrieve(dataSource, true)
2021-03-08 18:12:59 +05:30
.then((data) => {
2017-09-10 17:25:29 +05:30
this.loadData($input, at, data);
})
2018-12-13 13:39:08 +05:30
.catch(() => {
this.isLoadingData[at] = false;
});
2018-10-15 14:42:47 +05:30
} else {
this.isLoadingData[at] = false;
2017-08-17 22:00:37 +05:30
}
2017-09-10 17:25:29 +05:30
}
loadData($input, at, data) {
2017-08-17 22:00:37 +05:30
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-09-10 17:25:29 +05:30
}
2021-01-03 14:25:43 +05:30
async loadEmojiData($input, at) {
await Emoji.initEmojiMap();
2021-03-11 19:13:27 +05:30
this.loadData($input, at, ['loaded']);
2021-01-03 14:25:43 +05:30
GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
}
2017-09-10 17:25:29 +05:30
clearCache() {
this.cachedData = {};
}
destroy() {
this.input.each((i, input) => {
const $input = $(input);
$input.atwho('destroy');
});
}
static isLoading(data) {
let dataToInspect = data;
2017-08-17 22:00:37 +05:30
if (data && data.length > 0) {
2018-11-08 19:23:39 +05:30
[dataToInspect] = data;
2017-08-17 22:00:37 +05:30
}
2017-09-10 17:25:29 +05:30
const loadingState = GfmAutoComplete.defaultLoadingData[0];
2018-12-13 13:39:08 +05:30
return dataToInspect && (dataToInspect === loadingState || dataToInspect.name === loadingState);
2017-08-17 22:00:37 +05:30
}
2018-03-17 18:26:18 +05:30
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-12-13 13:39:08 +05:30
const atSymbolsWithBar = Object.keys(controllers)
.join('|')
2021-01-29 00:20:46 +05:30
.replace(/[$]/, '\\$&')
.replace(/([[\]:])/g, '\\$1');
2018-03-17 18:26:18 +05:30
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-09-30 21:07:59 +05:30
// Holy regex, batman!
2018-12-13 13:39:08 +05:30
const regexp = new RegExp(
2019-09-30 21:07:59 +05:30
`^(?:\\B|[^a-zA-Z0-9_\`${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-:]|[^\\x00-\\x7a])*)$`,
2018-12-13 13:39:08 +05:30
'gi',
);
2018-03-17 18:26:18 +05:30
return regexp.exec(targetSubtext);
}
2017-09-10 17:25:29 +05:30
}
2018-03-17 18:26:18 +05:30
GfmAutoComplete.regexSubtext = new RegExp(/\s+/g);
2017-09-10 17:25:29 +05:30
GfmAutoComplete.defaultLoadingData = ['loading'];
GfmAutoComplete.atTypeMap = {
':': 'emojis',
'@': 'members',
'#': 'issues',
'!': 'mergeRequests',
2018-11-08 19:23:39 +05:30
'&': 'epics',
2017-09-10 17:25:29 +05:30
'~': 'labels',
'%': 'milestones',
'/': 'commands',
2021-01-29 00:20:46 +05:30
'[vulnerability:': 'vulnerabilities',
2018-12-05 23:21:45 +05:30
$: 'snippets',
2022-04-04 11:22:00 +05:30
'[contact:': 'contacts',
2017-09-10 17:25:29 +05:30
};
2021-01-29 00:20:46 +05:30
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
2021-03-08 18:12:59 +05:30
GfmAutoComplete.isTypeWithBackendFiltering = (type) =>
2021-01-29 00:20:46 +05:30
GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[type]);
2017-09-10 17:25:29 +05:30
// Emoji
GfmAutoComplete.glEmojiTag = null;
GfmAutoComplete.Emoji = {
2021-01-03 14:25:43 +05:30
insertTemplateFunction(value) {
2021-03-11 19:13:27 +05:30
return `:${value.emoji.name}:`;
2021-01-03 14:25:43 +05:30
},
2021-03-11 19:13:27 +05:30
templateFunction(item) {
if (GfmAutoComplete.isLoading(item)) {
return GfmAutoComplete.Loading.template;
}
2021-01-03 14:25:43 +05:30
2021-03-11 19:13:27 +05:30
const escapedFieldValue = escape(item.fieldValue);
if (!GfmAutoComplete.glEmojiTag) {
return `<li>${escapedFieldValue}</li>`;
2017-09-10 17:25:29 +05:30
}
2021-01-03 14:25:43 +05:30
2021-03-11 19:13:27 +05:30
return `<li>${escapedFieldValue} ${GfmAutoComplete.glEmojiTag(item.emoji.name)}</li>`;
},
filter(query) {
if (query.length === 0) {
return Object.values(Emoji.getAllEmoji())
.map((emoji) => ({
emoji,
fieldValue: emoji.name,
}))
.slice(0, 20);
}
return Emoji.searchEmoji(query);
},
sorter(items) {
return Emoji.sortEmoji(items);
2017-09-10 17:25:29 +05:30
},
};
// Team Members
GfmAutoComplete.Members = {
2021-01-29 00:20:46 +05:30
templateFunction({ avatarTag, username, title, icon, availabilityStatus }) {
return `<li>${avatarTag} ${username} <small>${escape(
title,
)}${availabilityStatus}</small> ${icon}</li>`;
2018-11-20 20:47:30 +05:30
},
2021-04-17 20:07:23 +05:30
nameOrUsernameStartsWith(member, query) {
// `member.search` is a name:username string like `MargeSimpson msimpson`
return member.search.split(' ').some((name) => name.toLowerCase().startsWith(query));
},
nameOrUsernameIncludes(member, query) {
// `member.search` is a name:username string like `MargeSimpson msimpson`
return member.search.toLowerCase().includes(query);
},
2021-04-29 21:17:54 +05:30
sort(query, members) {
const lowercaseQuery = query.toLowerCase();
const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members;
2021-06-08 01:23:25 +05:30
return sortBy(
members.filter((member) => nameOrUsernameIncludes(member, lowercaseQuery)),
2021-04-29 21:17:54 +05:30
(member) => (nameOrUsernameStartsWith(member, lowercaseQuery) ? -1 : 0),
2021-06-08 01:23:25 +05:30
);
2021-04-29 21:17:54 +05:30
},
2017-09-10 17:25:29 +05:30
};
GfmAutoComplete.Labels = {
2019-01-03 12:48:30 +05:30
templateFunction(color, title) {
2020-05-24 23:13:21 +05:30
return `<li><span class="dropdown-label-box" style="background: ${escape(
2019-01-03 12:48:30 +05:30
color,
2020-05-24 23:13:21 +05:30
)}"></span> ${escape(title)}</li>`;
2019-01-03 12:48:30 +05:30
},
2017-09-10 17:25:29 +05:30
};
2018-12-05 23:21:45 +05:30
// Issues, MergeRequests and Snippets
2017-09-10 17:25:29 +05:30
GfmAutoComplete.Issues = {
2019-02-15 15:39:39 +05:30
insertTemplateFunction(value) {
// eslint-disable-next-line no-template-curly-in-string
return value.reference || '${atwho-at}${id}';
},
templateFunction({ id, title, reference }) {
2020-05-24 23:13:21 +05:30
return `<li><small>${reference || id}</small> ${escape(title)}</li>`;
2018-11-18 11:00:15 +05:30
},
2017-08-17 22:00:37 +05:30
};
2017-09-10 17:25:29 +05:30
// Milestones
GfmAutoComplete.Milestones = {
2021-04-17 20:07:23 +05:30
templateFunction(title, expired) {
if (expired) {
return `<li>${sprintf(__('%{milestone} (expired)'), {
milestone: escape(title),
})}</li>`;
}
2020-05-24 23:13:21 +05:30
return `<li>${escape(title)}</li>`;
2019-01-03 12:48:30 +05:30
},
2017-09-10 17:25:29 +05:30
};
2022-04-04 11:22:00 +05:30
GfmAutoComplete.Contacts = {
templateFunction({ email, firstName, lastName }) {
return `<li><small>${firstName} ${lastName}</small> ${escape(email)}</li>`;
},
};
2017-09-10 17:25:29 +05:30
GfmAutoComplete.Loading = {
2018-12-13 13:39:08 +05:30
template:
2020-03-13 15:44:24 +05:30
'<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>',
2017-09-10 17:25:29 +05:30
};
export default GfmAutoComplete;