gitlab-org--gitlab-foss/app/assets/javascripts/awards_handler.js
Phil Hughes fa2af5e0f5
Flash is now a ES6 module
Reduced the technical debt around our JS flash function by making it a
module that is imported rather than relying on the global function.
The global function still exists mainly for technical debt with how
some requests are being completed, but new JS should import the module
directly.

Also reduces some tech debt in the file by removing the need for jQuery.
Instead Flash is now 100% vanilla JS.
2017-10-10 10:14:22 +01:00

579 lines
19 KiB
JavaScript

/* eslint-disable class-methods-use-this */
import _ from 'underscore';
import Cookies from 'js-cookie';
import { isInIssuePage, updateTooltipTitle } from './lib/utils/common_utils';
import Flash from './flash';
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
const requestAnimationFrame = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.setTimeout;
const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; // For separating lists produced by ruby's Array#toSentence
const categoryLabelMap = {
activity: 'Activity',
people: 'People',
nature: 'Nature',
food: 'Food',
travel: 'Travel',
objects: 'Objects',
symbols: 'Symbols',
flags: 'Flags',
};
const IS_VISIBLE = 'is-visible';
const IS_RENDERED = 'is-rendered';
class AwardsHandler {
constructor(emoji) {
this.emoji = emoji;
this.eventListeners = [];
// If the user shows intent let's pre-build the menu
this.registerEventListener('one', $(document), 'mouseenter focus', '.js-add-award', 'mouseenter focus', () => {
const $menu = $('.emoji-menu');
if ($menu.length === 0) {
requestAnimationFrame(() => {
this.createEmojiMenu();
});
}
});
this.registerEventListener('on', $(document), 'click', '.js-add-award', (e) => {
e.stopPropagation();
e.preventDefault();
this.showEmojiMenu($(e.currentTarget));
});
this.registerEventListener('on', $('html'), 'click', (e) => {
const $target = $(e.target);
if (!$target.closest('.emoji-menu-content').length) {
$('.js-awards-block.current').removeClass('current');
}
if (!$target.closest('.emoji-menu').length) {
if ($('.emoji-menu').is(':visible')) {
$('.js-add-award.is-active').removeClass('is-active');
this.hideMenuElement($('.emoji-menu'));
}
}
});
this.registerEventListener('on', $(document), 'click', '.js-emoji-btn', (e) => {
e.preventDefault();
const $target = $(e.currentTarget);
const $glEmojiElement = $target.find('gl-emoji');
const $spriteIconElement = $target.find('.icon');
const emojiName = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name');
$target.closest('.js-awards-block').addClass('current');
this.addAward(this.getVotesBlock(), this.getAwardUrl(), emojiName);
});
}
registerEventListener(method = 'on', element, ...args) {
element[method].call(element, ...args);
this.eventListeners.push({
element,
args,
});
}
showEmojiMenu($addBtn) {
if ($addBtn.hasClass('js-note-emoji')) {
$addBtn.closest('.note').find('.js-awards-block').addClass('current');
} else {
$addBtn.closest('.js-awards-block').addClass('current');
}
const $menu = $('.emoji-menu');
const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent();
const $userAuthored = this.isUserAuthored($addBtn);
if ($menu.length) {
if ($menu.is('.is-visible')) {
$addBtn.removeClass('is-active');
this.hideMenuElement($menu);
$('.js-emoji-menu-search').blur();
} else {
$addBtn.addClass('is-active');
this.positionMenu($menu, $addBtn);
this.showMenuElement($menu);
$('.js-emoji-menu-search').focus();
}
} else {
$addBtn.addClass('is-loading is-active');
this.createEmojiMenu(() => {
const $createdMenu = $('.emoji-menu');
$addBtn.removeClass('is-loading');
this.positionMenu($createdMenu, $addBtn);
return setTimeout(() => {
this.showMenuElement($createdMenu);
$('.js-emoji-menu-search').focus();
}, 200);
});
}
$thumbsBtn.toggleClass('disabled', $userAuthored);
$thumbsBtn.prop('disabled', $userAuthored);
}
// Create the emoji menu with the first category of emojis.
// Then render the remaining categories of emojis one by one to avoid jank.
createEmojiMenu(callback) {
if (this.isCreatingEmojiMenu) {
return;
}
this.isCreatingEmojiMenu = true;
// Render the first category
const categoryMap = this.emoji.getEmojiCategoryMap();
const categoryNameKey = Object.keys(categoryMap)[0];
const emojisInCategory = categoryMap[categoryNameKey];
const firstCategory = this.renderCategory(categoryLabelMap[categoryNameKey], emojisInCategory);
// Render the frequently used
const frequentlyUsedEmojis = this.getFrequentlyUsedEmojis();
let frequentlyUsedCatgegory = '';
if (frequentlyUsedEmojis.length > 0) {
frequentlyUsedCatgegory = this.renderCategory('Frequently used', frequentlyUsedEmojis, {
menuListClass: 'frequent-emojis',
});
}
const emojiMenuMarkup = `
<div class="emoji-menu">
<input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" />
<div class="emoji-menu-content">
${frequentlyUsedCatgegory}
${firstCategory}
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', emojiMenuMarkup);
this.addRemainingEmojiMenuCategories();
this.setupSearch();
if (callback) {
callback();
}
}
addRemainingEmojiMenuCategories() {
if (this.isAddingRemainingEmojiMenuCategories) {
return;
}
this.isAddingRemainingEmojiMenuCategories = true;
const categoryMap = this.emoji.getEmojiCategoryMap();
// Avoid the jank and render the remaining categories separately
// This will take more time, but makes UI more responsive
const menu = document.querySelector('.emoji-menu');
const emojiContentElement = menu.querySelector('.emoji-menu-content');
const remainingCategories = Object.keys(categoryMap).slice(1);
const allCategoriesAddedPromise = remainingCategories.reduce(
(promiseChain, categoryNameKey) =>
promiseChain.then(() =>
new Promise((resolve) => {
const emojisInCategory = categoryMap[categoryNameKey];
const categoryMarkup = this.renderCategory(
categoryLabelMap[categoryNameKey],
emojisInCategory,
);
requestAnimationFrame(() => {
emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup);
resolve();
});
}),
),
Promise.resolve(),
);
allCategoriesAddedPromise.then(() => {
// Used for tests
// We check for the menu in case it was destroyed in the meantime
if (menu) {
menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish'));
}
}).catch((err) => {
emojiContentElement.insertAdjacentHTML('beforeend', '<p>We encountered an error while adding the remaining categories</p>');
throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`);
});
}
renderCategory(name, emojiList, opts = {}) {
return `
<h5 class="emoji-menu-title">
${name}
</h5>
<ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}">
${emojiList.map(emojiName => `
<li class="emoji-menu-list-item">
<button class="emoji-menu-btn text-center js-emoji-btn" type="button">
${this.emoji.glEmojiTag(emojiName, {
sprite: true,
})}
</button>
</li>
`).join('\n')}
</ul>
`;
}
positionMenu($menu, $addBtn) {
const position = $addBtn.data('position');
// The menu could potentially be off-screen or in a hidden overflow element
// So we position the element absolute in the body
const css = {
top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`,
};
if (position === 'right') {
css.left = `${($addBtn.offset().left - $menu.outerWidth()) + 20}px`;
$menu.addClass('is-aligned-right');
} else {
css.left = `${$addBtn.offset().left}px`;
$menu.removeClass('is-aligned-right');
}
return $menu.css(css);
}
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length;
if (isInIssuePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', '');
this.hideMenuElement($('.emoji-menu'));
$('.js-add-award.is-active').removeClass('is-active');
const toggleAwardEvent = new CustomEvent('toggleAward', {
detail: {
awardName: emoji,
noteId: id,
},
});
document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent);
}
const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined;
});
this.hideMenuElement($('.emoji-menu'));
return $('.js-add-award.is-active').removeClass('is-active');
}
addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) {
if (checkForMutuality || checkForMutuality === null) {
this.checkMutuality(votesBlock, emoji);
}
this.addEmojiToFrequentlyUsedList(emoji);
const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
if ($emojiButton.length > 0) {
if (this.isActive($emojiButton)) {
this.decrementCounter($emojiButton, normalizedEmoji);
} else {
const counter = $emojiButton.find('.js-counter');
counter.text(parseInt(counter.text(), 10) + 1);
$emojiButton.addClass('active');
this.addYouToUserList(votesBlock, normalizedEmoji);
this.animateEmoji($emojiButton);
}
} else {
votesBlock.removeClass('hidden');
this.createEmoji(votesBlock, normalizedEmoji);
}
}
getVotesBlock() {
if (isInIssuePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) {
return $el;
}
}
const currentBlock = $('.js-awards-block.current');
let resultantVotesBlock = currentBlock;
if (currentBlock.length === 0) {
resultantVotesBlock = $('.js-awards-block').eq(0);
}
return resultantVotesBlock;
}
getAwardUrl() {
return this.getVotesBlock().data('award-url');
}
checkMutuality(votesBlock, emoji) {
const awardUrl = this.getAwardUrl();
if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
const mutualVote = emoji === 'thumbsup' ? 'thumbsdown' : 'thumbsup';
const $emojiButton = votesBlock.find(`[data-name="${mutualVote}"]`).parent();
const isAlreadyVoted = $emojiButton.hasClass('active');
if (isAlreadyVoted) {
this.addAward(votesBlock, awardUrl, mutualVote, false);
}
}
}
isActive($emojiButton) {
return $emojiButton.hasClass('active');
}
isUserAuthored($button) {
return $button.hasClass('js-user-authored');
}
decrementCounter($emojiButton, emoji) {
const counter = $('.js-counter', $emojiButton);
const counterNumber = parseInt(counter.text(), 10);
if (counterNumber > 1) {
counter.text(counterNumber - 1);
this.removeYouFromUserList($emojiButton);
} else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
$emojiButton.tooltip('destroy');
counter.text('0');
this.removeYouFromUserList($emojiButton);
if ($emojiButton.parents('.note').length) {
this.removeEmoji($emojiButton);
}
} else {
this.removeEmoji($emojiButton);
}
return $emojiButton.removeClass('active');
}
removeEmoji($emojiButton) {
$emojiButton.tooltip('destroy');
$emojiButton.remove();
const $votesBlock = this.getVotesBlock();
if ($votesBlock.find('.js-emoji-btn').length === 0) {
$votesBlock.addClass('hidden');
}
}
getAwardTooltip($awardBlock) {
return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
}
toSentence(list) {
let sentence;
if (list.length <= 2) {
sentence = list.join(' and ');
} else {
sentence = `${list.slice(0, -1).join(', ')}, and ${list[list.length - 1]}`;
}
return sentence;
}
removeYouFromUserList($emojiButton) {
const awardBlock = $emojiButton;
const originalTitle = this.getAwardTooltip(awardBlock);
const authors = originalTitle.split(FROM_SENTENCE_REGEX);
authors.splice(authors.indexOf('You'), 1);
return awardBlock
.closest('.js-emoji-btn')
.removeData('title')
.removeAttr('data-title')
.removeAttr('data-original-title')
.attr('title', this.toSentence(authors))
.tooltip('fixTitle');
}
addYouToUserList(votesBlock, emoji) {
const awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
const origTitle = this.getAwardTooltip(awardBlock);
let users = [];
if (origTitle) {
users = origTitle.trim().split(FROM_SENTENCE_REGEX);
}
users.unshift('You');
return awardBlock
.attr('title', this.toSentence(users))
.tooltip('fixTitle');
}
createAwardButtonForVotesBlock(votesBlock, emojiName) {
const buttonHtml = `
<button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom">
${this.emoji.glEmojiTag(emojiName)}
<span class="award-control-text js-counter">1</span>
</button>
`;
const $emojiButton = $(buttonHtml);
$emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('name', emojiName);
this.animateEmoji($emojiButton);
$('.award-control').tooltip();
votesBlock.removeClass('current');
}
animateEmoji($emoji) {
const className = 'pulse animated once short';
$emoji.addClass(className);
this.registerEventListener('on', $emoji, animationEndEventString, (e) => {
$(e.currentTarget).removeClass(className);
});
}
createEmoji(votesBlock, emoji) {
if ($('.emoji-menu').length) {
this.createAwardButtonForVotesBlock(votesBlock, emoji);
}
this.createEmojiMenu(() => {
this.createAwardButtonForVotesBlock(votesBlock, emoji);
});
}
postEmoji($emojiButton, awardUrl, emoji, callback) {
if (this.isUserAuthored($emojiButton)) {
this.userAuthored($emojiButton);
} else {
$.post(awardUrl, {
name: emoji,
}, (data) => {
if (data.ok) {
callback();
}
}).fail(() => new Flash('Something went wrong on our end.'));
}
}
findEmojiIcon(votesBlock, emoji) {
return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`);
}
userAuthored($emojiButton) {
const oldTitle = this.getAwardTooltip($emojiButton);
const newTitle = 'You cannot vote on your own issue, MR and note';
updateTooltipTitle($emojiButton, newTitle).tooltip('show');
// Restore tooltip back to award list
return setTimeout(() => {
$emojiButton.tooltip('hide');
updateTooltipTitle($emojiButton, oldTitle);
}, 2800);
}
scrollToAwards() {
const options = {
scrollTop: $('.awards').offset().top - 110,
};
return $('body, html').animate(options, 200);
}
addEmojiToFrequentlyUsedList(emoji) {
if (this.emoji.isEmojiNameValid(emoji)) {
this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji));
Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
}
}
getFrequentlyUsedEmojis() {
return this.frequentlyUsedEmojis || (() => {
const frequentlyUsedEmojis = _.uniq((Cookies.get('frequently_used_emojis') || '').split(','));
this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(
inputName => this.emoji.isEmojiNameValid(inputName),
);
return this.frequentlyUsedEmojis;
})();
}
setupSearch() {
const $search = $('.js-emoji-menu-search');
this.registerEventListener('on', $search, 'input', (e) => {
const term = $(e.target).val().trim();
this.searchEmojis(term);
});
const $menu = $('.emoji-menu');
this.registerEventListener('on', $menu, transitionEndEventString, (e) => {
if (e.target === e.currentTarget) {
// Clear the search
this.searchEmojis('');
}
});
}
searchEmojis(term) {
const $search = $('.js-emoji-menu-search');
$search.val(term);
// Clean previous search results
$('ul.emoji-menu-search, h5.emoji-search-title').remove();
if (term.length > 0) {
// Generate a search result block
const h5 = $('<h5 class="emoji-search-title"/>').text('Search results');
const foundEmojis = this.findMatchingEmojiElements(term).show();
const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis);
$('.emoji-menu-content ul, .emoji-menu-content h5').hide();
$('.emoji-menu-content').append(h5).append(ul);
} else {
$('.emoji-menu-content').children().show();
}
}
findMatchingEmojiElements(query) {
const emojiMatches = this.emoji.filterEmojiNamesByAlias(query);
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements
.filter((i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0);
return $matchingElements.closest('li').clone();
}
/* showMenuElement and hideMenuElement are performance optimizations. We use
* opacity to show/hide the emoji menu, because we can animate it. But opacity
* leaves hidden elements in the render tree, which is unacceptable given the number
* of emoji elements in the emoji menu (5k+). To get the best of both worlds, we separately
* apply IS_RENDERED to add/remove the menu from the render tree and IS_VISIBLE to animate
* the menu being opened and closed. */
showMenuElement($emojiMenu) {
$emojiMenu.addClass(IS_RENDERED);
// enqueues animation as a microtask, so it begins ASAP once IS_RENDERED added
return Promise.resolve()
.then(() => $emojiMenu.addClass(IS_VISIBLE));
}
hideMenuElement($emojiMenu) {
$emojiMenu.on(transitionEndEventString, (e) => {
if (e.currentTarget === e.target) {
$emojiMenu
.removeClass(IS_RENDERED)
.off(transitionEndEventString);
}
});
$emojiMenu.removeClass(IS_VISIBLE);
}
destroy() {
this.eventListeners.forEach((entry) => {
entry.element.off.call(entry.element, ...entry.args);
});
$('.emoji-menu').remove();
}
}
let awardsHandlerPromise = null;
export default function loadAwardsHandler(reload = false) {
if (!awardsHandlerPromise || reload) {
awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji')
.then(Emoji => new AwardsHandler(Emoji));
}
return awardsHandlerPromise;
}