diff options
Diffstat (limited to 'app/assets/javascripts/lib')
-rw-r--r-- | app/assets/javascripts/lib/utils/animate.js | 49 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/common_utils.js | 861 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/csrf.js | 58 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/datetime_utility.js | 4 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/image_utility.js | 5 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/number_utils.js | 2 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/poll.js | 3 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/pretty_time.js | 107 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/sticky.js | 30 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/url_utility.js | 20 |
10 files changed, 592 insertions, 547 deletions
diff --git a/app/assets/javascripts/lib/utils/animate.js b/app/assets/javascripts/lib/utils/animate.js deleted file mode 100644 index d93c1d0da59..00000000000 --- a/app/assets/javascripts/lib/utils/animate.js +++ /dev/null @@ -1,49 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, no-void, prefer-template, no-var, new-cap, prefer-arrow-callback, consistent-return, max-len */ -(function() { - (function(w) { - if (w.gl == null) { - w.gl = {}; - } - if (gl.animate == null) { - gl.animate = {}; - } - gl.animate.animate = function($el, animation, options, done) { - if ((options != null ? options.cssStart : void 0) != null) { - $el.css(options.cssStart); - } - $el.removeClass(animation + ' animated').addClass(animation + ' animated').one('webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', function() { - $(this).removeClass(animation + ' animated'); - if (done != null) { - done(); - } - if ((options != null ? options.cssEnd : void 0) != null) { - $el.css(options.cssEnd); - } - }); - }; - gl.animate.animateEach = function($els, animation, time, options, done) { - var dfd; - dfd = $.Deferred(); - if (!$els.length) { - dfd.resolve(); - } - $els.each(function(i) { - setTimeout((function(_this) { - return function() { - var $this; - $this = $(_this); - return gl.animate.animate($this, animation, options, function() { - if (i === $els.length - 1) { - dfd.resolve(); - if (done != null) { - return done(); - } - } - }); - }; - })(this), time * i); - }); - return dfd.promise(); - }; - })(window); -}).call(window); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index b8bebe1894f..423a25fbdfa 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -1,437 +1,438 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-unused-expressions, no-param-reassign, no-else-return, quotes, object-shorthand, comma-dangle, camelcase, one-var, vars-on-top, one-var-declaration-per-line, no-return-assign, consistent-return, max-len, prefer-template */ -(function() { - (function(w) { - var base; - const faviconEl = document.getElementById('favicon'); - const originalFavicon = faviconEl ? faviconEl.getAttribute('href') : null; - w.gl || (w.gl = {}); - (base = w.gl).utils || (base.utils = {}); - w.gl.utils.isInGroupsPage = function() { - return gl.utils.getPagePath() === 'groups'; - }; - w.gl.utils.isInProjectPage = function() { - return gl.utils.getPagePath() === 'projects'; - }; - w.gl.utils.getProjectSlug = function() { - if (this.isInProjectPage()) { - return $('body').data('project'); - } else { - return null; - } - }; - w.gl.utils.getGroupSlug = function() { - if (this.isInGroupsPage()) { - return $('body').data('group'); - } else { - return null; - } - }; - - w.gl.utils.isInIssuePage = () => { - const page = gl.utils.getPagePath(1); - const action = gl.utils.getPagePath(2); - - return page === 'issues' && action === 'show'; - }; - w.gl.utils.ajaxGet = function(url) { - return $.ajax({ - type: "GET", - url: url, - dataType: "script" - }); - }; - - w.gl.utils.ajaxPost = function(url, data) { - return $.ajax({ - type: 'POST', - url: url, - data: data, - }); - }; - - w.gl.utils.extractLast = function(term) { - return this.split(term).pop(); - }; - - w.gl.utils.rstrip = function rstrip(val) { - if (val) { - return val.replace(/\s+$/, ''); +export const getPagePath = (index = 0) => $('body').data('page').split(':')[index]; + +export const isInGroupsPage = () => getPagePath() === 'groups'; + +export const isInProjectPage = () => getPagePath() === 'projects'; + +export const getProjectSlug = () => { + if (isInProjectPage()) { + return $('body').data('project'); + } + return null; +}; + +export const getGroupSlug = () => { + if (isInGroupsPage()) { + return $('body').data('group'); + } + return null; +}; + +export const isInIssuePage = () => { + const page = getPagePath(1); + const action = getPagePath(2); + + return page === 'issues' && action === 'show'; +}; + +export const ajaxGet = url => $.ajax({ + type: 'GET', + url, + dataType: 'script', +}); + +export const ajaxPost = (url, data) => $.ajax({ + type: 'POST', + url, + data, +}); + +export const rstrip = (val) => { + if (val) { + return val.replace(/\s+$/, ''); + } + return val; +}; + +export const updateTooltipTitle = ($tooltipEl, newTitle) => $tooltipEl.attr('title', newTitle).tooltip('fixTitle'); + +export const disableButtonIfEmptyField = (fieldSelector, buttonSelector, eventName = 'input') => { + const field = $(fieldSelector); + const closestSubmit = field.closest('form').find(buttonSelector); + if (rstrip(field.val()) === '') { + closestSubmit.disable(); + } + // eslint-disable-next-line func-names + return field.on(eventName, function () { + if (rstrip($(this).val()) === '') { + return closestSubmit.disable(); + } + return closestSubmit.enable(); + }); +}; + +// automatically adjust scroll position for hash urls taking the height of the navbar into account +// https://github.com/twitter/bootstrap/issues/1768 +export const handleLocationHash = () => { + let hash = window.gl.utils.getLocationHash(); + if (!hash) return; + + // This is required to handle non-unicode characters in hash + hash = decodeURIComponent(hash); + + const target = document.getElementById(hash) || document.getElementById(`user-content-${hash}`); + const fixedTabs = document.querySelector('.js-tabs-affix'); + const fixedDiffStats = document.querySelector('.js-diff-files-changed.is-stuck'); + const fixedNav = document.querySelector('.navbar-gitlab'); + + let adjustment = 0; + if (fixedNav) adjustment -= fixedNav.offsetHeight; + + if (target && target.scrollIntoView) { + target.scrollIntoView(true); + } + + if (fixedTabs) { + adjustment -= fixedTabs.offsetHeight; + } + + if (fixedDiffStats) { + adjustment -= fixedDiffStats.offsetHeight; + } + + window.scrollBy(0, adjustment); +}; + +// Check if element scrolled into viewport from above or below +// Courtesy http://stackoverflow.com/a/7557433/414749 +export const isInViewport = (el) => { + const rect = el.getBoundingClientRect(); + + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= window.innerHeight && + rect.right <= window.innerWidth + ); +}; + +export const parseUrl = (url) => { + const parser = document.createElement('a'); + parser.href = url; + return parser; +}; + +export const parseUrlPathname = (url) => { + const parsedUrl = parseUrl(url); + // parsedUrl.pathname will return an absolute path for Firefox and a relative path for IE11 + // We have to make sure we always have an absolute path. + return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : `/${parsedUrl.pathname}`; +}; + +// We can trust that each param has one & since values containing & will be encoded +// Remove the first character of search as it is always ? +export const getUrlParamsArray = () => window.location.search.slice(1).split('&').map((param) => { + const split = param.split('='); + return [decodeURI(split[0]), split[1]].join('='); +}); + +export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; + +// Identify following special clicks +// 1) Cmd + Click on Mac (e.metaKey) +// 2) Ctrl + Click on PC (e.ctrlKey) +// 3) Middle-click or Mouse Wheel Click (e.which is 2) +export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2; + +export const scrollToElement = ($el) => { + const top = $el.offset().top; + const mrTabsHeight = $('.merge-request-tabs').height() || 0; + const headerHeight = $('.navbar-gitlab').height() || 0; + + return $('body, html').animate({ + scrollTop: top - mrTabsHeight - headerHeight, + }, 200); +}; + +/** + this will take in the `name` of the param you want to parse in the url + if the name does not exist this function will return `null` + otherwise it will return the value of the param key provided +*/ +export const getParameterByName = (name, urlToParse) => { + const url = urlToParse || window.location.href; + const parsedName = name.replace(/[[\]]/g, '\\$&'); + const regex = new RegExp(`[?&]${parsedName}(=([^&#]*)|&|#|$)`); + const results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ''; + return decodeURIComponent(results[2].replace(/\+/g, ' ')); +}; + +export const getSelectedFragment = () => { + const selection = window.getSelection(); + if (selection.rangeCount === 0) return null; + const documentFragment = document.createDocumentFragment(); + for (let i = 0; i < selection.rangeCount; i += 1) { + documentFragment.appendChild(selection.getRangeAt(i).cloneContents()); + } + if (documentFragment.textContent.length === 0) return null; + + return documentFragment; +}; + +// TODO: Update this name, there is a gl.text.insertText function. +export const insertText = (target, text) => { + // Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas + const selectionStart = target.selectionStart; + const selectionEnd = target.selectionEnd; + const value = target.value; + + const textBefore = value.substring(0, selectionStart); + const textAfter = value.substring(selectionEnd, value.length); + + const insertedText = text instanceof Function ? text(textBefore, textAfter) : text; + const newText = textBefore + insertedText + textAfter; + + // eslint-disable-next-line no-param-reassign + target.value = newText; + // eslint-disable-next-line no-param-reassign + target.selectionStart = target.selectionEnd = selectionStart + insertedText.length; + + // Trigger autosave + $(target).trigger('input'); + + // Trigger autosize + const event = document.createEvent('Event'); + event.initEvent('autosize:update', true, false); + target.dispatchEvent(event); +}; + +export const nodeMatchesSelector = (node, selector) => { + const matches = Element.prototype.matches || + Element.prototype.matchesSelector || + Element.prototype.mozMatchesSelector || + Element.prototype.msMatchesSelector || + Element.prototype.oMatchesSelector || + Element.prototype.webkitMatchesSelector; + + if (matches) { + return matches.call(node, selector); + } + + // IE11 doesn't support `node.matches(selector)` + + let parentNode = node.parentNode; + if (!parentNode) { + parentNode = document.createElement('div'); + // eslint-disable-next-line no-param-reassign + node = node.cloneNode(true); + parentNode.appendChild(node); + } + + const matchingNodes = parentNode.querySelectorAll(selector); + return Array.prototype.indexOf.call(matchingNodes, node) !== -1; +}; + +/** + this will take in the headers from an API response and normalize them + this way we don't run into production issues when nginx gives us lowercased header keys +*/ +export const normalizeHeaders = (headers) => { + const upperCaseHeaders = {}; + + Object.keys(headers).forEach((e) => { + upperCaseHeaders[e.toUpperCase()] = headers[e]; + }); + + return upperCaseHeaders; +}; + +/** + this will take in the getAllResponseHeaders result and normalize them + this way we don't run into production issues when nginx gives us lowercased header keys +*/ +export const normalizeCRLFHeaders = (headers) => { + const headersObject = {}; + const headersArray = headers.split('\n'); + + headersArray.forEach((header) => { + const keyValue = header.split(': '); + headersObject[keyValue[0]] = keyValue[1]; + }); + + return normalizeHeaders(headersObject); +}; + +/** + * Parses pagination object string values into numbers. + * + * @param {Object} paginationInformation + * @returns {Object} + */ +export const parseIntPagination = paginationInformation => ({ + perPage: parseInt(paginationInformation['X-PER-PAGE'], 10), + page: parseInt(paginationInformation['X-PAGE'], 10), + total: parseInt(paginationInformation['X-TOTAL'], 10), + totalPages: parseInt(paginationInformation['X-TOTAL-PAGES'], 10), + nextPage: parseInt(paginationInformation['X-NEXT-PAGE'], 10), + previousPage: parseInt(paginationInformation['X-PREV-PAGE'], 10), +}); + +/** + * Updates the search parameter of a URL given the parameter and value provided. + * + * If no search params are present we'll add it. + * If param for page is already present, we'll update it + * If there are params but not for the given one, we'll add it at the end. + * Returns the new search parameters. + * + * @param {String} param + * @param {Number|String|Undefined|Null} value + * @return {String} + */ +export const setParamInURL = (param, value) => { + let search; + const locationSearch = window.location.search; + + if (locationSearch.length) { + const parameters = locationSearch.substring(1, locationSearch.length) + .split('&') + .reduce((acc, element) => { + const val = element.split('='); + // eslint-disable-next-line no-param-reassign + acc[val[0]] = decodeURIComponent(val[1]); + return acc; + }, {}); + + parameters[param] = value; + + const toString = Object.keys(parameters) + .map(val => `${val}=${encodeURIComponent(parameters[val])}`) + .join('&'); + + search = `?${toString}`; + } else { + search = `?${param}=${value}`; + } + + return search; +}; + +/** + * Converts permission provided as strings to booleans. + * + * @param {String} string + * @returns {Boolean} + */ +export const convertPermissionToBoolean = permission => permission === 'true'; + +/** + * Back Off exponential algorithm + * backOff :: (Function<next, stop>, Number) -> Promise<Any, Error> + * + * @param {Function<next, stop>} fn function to be called + * @param {Number} timeout + * @return {Promise<Any, Error>} + * @example + * ``` + * backOff(function (next, stop) { + * // Let's perform this function repeatedly for 60s or for the timeout provided. + * + * ourFunction() + * .then(function (result) { + * // continue if result is not what we need + * next(); + * + * // when result is what we need let's stop with the repetions and jump out of the cycle + * stop(result); + * }) + * .catch(function (error) { + * // if there is an error, we need to stop this with an error. + * stop(error); + * }) + * }, 60000) + * .then(function (result) {}) + * .catch(function (error) { + * // deal with errors passed to stop() + * }) + * ``` + */ +export const backOff = (fn, timeout = 60000) => { + const maxInterval = 32000; + let nextInterval = 2000; + let timeElapsed = 0; + + return new Promise((resolve, reject) => { + const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg)); + + const next = () => { + if (timeElapsed < timeout) { + setTimeout(() => fn(next, stop), nextInterval); + timeElapsed += nextInterval; + nextInterval = Math.min(nextInterval + nextInterval, maxInterval); } else { - return val; - } - }; - - gl.utils.updateTooltipTitle = function($tooltipEl, newTitle) { - return $tooltipEl.attr('title', newTitle).tooltip('fixTitle'); - }; - - w.gl.utils.disableButtonIfEmptyField = function(field_selector, button_selector, event_name) { - event_name = event_name || 'input'; - var closest_submit, field, that; - that = this; - field = $(field_selector); - closest_submit = field.closest('form').find(button_selector); - if (this.rstrip(field.val()) === "") { - closest_submit.disable(); + reject(new Error('BACKOFF_TIMEOUT')); } - return field.on(event_name, function() { - if (that.rstrip($(this).val()) === "") { - return closest_submit.disable(); - } else { - return closest_submit.enable(); - } - }); }; - // automatically adjust scroll position for hash urls taking the height of the navbar into account - // https://github.com/twitter/bootstrap/issues/1768 - w.gl.utils.handleLocationHash = function() { - var hash = w.gl.utils.getLocationHash(); - if (!hash) return; - - // This is required to handle non-unicode characters in hash - hash = decodeURIComponent(hash); - - const fixedTabs = document.querySelector('.js-tabs-affix'); - const fixedDiffStats = document.querySelector('.js-diff-files-changed.is-stuck'); - const fixedNav = document.querySelector('.navbar-gitlab'); - - var adjustment = 0; - if (fixedNav) adjustment -= fixedNav.offsetHeight; - - // scroll to user-generated markdown anchor if we cannot find a match - if (document.getElementById(hash) === null) { - var target = document.getElementById('user-content-' + hash); - if (target && target.scrollIntoView) { - target.scrollIntoView(true); - window.scrollBy(0, adjustment); - } + fn(next, stop); + }); +}; + +export const setFavicon = (faviconPath) => { + const faviconEl = document.getElementById('favicon'); + if (faviconEl && faviconPath) { + faviconEl.setAttribute('href', faviconPath); + } +}; + +export const resetFavicon = () => { + const faviconEl = document.getElementById('favicon'); + const originalFavicon = faviconEl ? faviconEl.getAttribute('href') : null; + if (faviconEl) { + faviconEl.setAttribute('href', originalFavicon); + } +}; + +export const setCiStatusFavicon = (pageUrl) => { + $.ajax({ + url: pageUrl, + dataType: 'json', + success: (data) => { + if (data && data.favicon) { + setFavicon(data.favicon); } else { - // only adjust for fixedTabs when not targeting user-generated content - if (fixedTabs) { - adjustment -= fixedTabs.offsetHeight; - } - - if (fixedDiffStats) { - adjustment -= fixedDiffStats.offsetHeight; - } - - window.scrollBy(0, adjustment); + resetFavicon(); } - }; - - // Check if element scrolled into viewport from above or below - // Courtesy http://stackoverflow.com/a/7557433/414749 - w.gl.utils.isInViewport = function(el) { - var rect = el.getBoundingClientRect(); - - return ( - rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= window.innerHeight && - rect.right <= window.innerWidth - ); - }; - - gl.utils.getPagePath = function(index) { - index = index || 0; - return $('body').data('page').split(':')[index]; - }; - - gl.utils.parseUrl = function (url) { - var parser = document.createElement('a'); - parser.href = url; - return parser; - }; - - gl.utils.parseUrlPathname = function (url) { - var parsedUrl = gl.utils.parseUrl(url); - // parsedUrl.pathname will return an absolute path for Firefox and a relative path for IE11 - // We have to make sure we always have an absolute path. - return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : '/' + parsedUrl.pathname; - }; - - gl.utils.getUrlParamsArray = function () { - // We can trust that each param has one & since values containing & will be encoded - // Remove the first character of search as it is always ? - return window.location.search.slice(1).split('&').map((param) => { - const split = param.split('='); - return [decodeURI(split[0]), split[1]].join('='); - }); - }; - - gl.utils.isMetaKey = function(e) { - return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; - }; - - gl.utils.isMetaClick = function(e) { - // Identify following special clicks - // 1) Cmd + Click on Mac (e.metaKey) - // 2) Ctrl + Click on PC (e.ctrlKey) - // 3) Middle-click or Mouse Wheel Click (e.which is 2) - return e.metaKey || e.ctrlKey || e.which === 2; - }; - - gl.utils.scrollToElement = function($el) { - const top = $el.offset().top; - const mrTabsHeight = $('.merge-request-tabs').height() || 0; - const headerHeight = $('.navbar-gitlab').height() || 0; - - return $('body, html').animate({ - scrollTop: top - mrTabsHeight - headerHeight, - }, 200); - }; - - /** - this will take in the `name` of the param you want to parse in the url - if the name does not exist this function will return `null` - otherwise it will return the value of the param key provided - */ - w.gl.utils.getParameterByName = (name, parseUrl) => { - const url = parseUrl || window.location.href; - name = name.replace(/[[\]]/g, '\\$&'); - const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`); - const results = regex.exec(url); - if (!results) return null; - if (!results[2]) return ''; - return decodeURIComponent(results[2].replace(/\+/g, ' ')); - }; - - w.gl.utils.getSelectedFragment = () => { - const selection = window.getSelection(); - if (selection.rangeCount === 0) return null; - const documentFragment = document.createDocumentFragment(); - for (let i = 0; i < selection.rangeCount; i += 1) { - documentFragment.appendChild(selection.getRangeAt(i).cloneContents()); - } - if (documentFragment.textContent.length === 0) return null; - - return documentFragment; - }; - - w.gl.utils.insertText = (target, text) => { - // Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas - - const selectionStart = target.selectionStart; - const selectionEnd = target.selectionEnd; - const value = target.value; - - const textBefore = value.substring(0, selectionStart); - const textAfter = value.substring(selectionEnd, value.length); - - const insertedText = text instanceof Function ? text(textBefore, textAfter) : text; - const newText = textBefore + insertedText + textAfter; - - target.value = newText; - target.selectionStart = target.selectionEnd = selectionStart + insertedText.length; - - // Trigger autosave - $(target).trigger('input'); - - // Trigger autosize - var event = document.createEvent('Event'); - event.initEvent('autosize:update', true, false); - target.dispatchEvent(event); - }; - - w.gl.utils.nodeMatchesSelector = (node, selector) => { - const matches = Element.prototype.matches || - Element.prototype.matchesSelector || - Element.prototype.mozMatchesSelector || - Element.prototype.msMatchesSelector || - Element.prototype.oMatchesSelector || - Element.prototype.webkitMatchesSelector; - - if (matches) { - return matches.call(node, selector); - } - - // IE11 doesn't support `node.matches(selector)` - - let parentNode = node.parentNode; - if (!parentNode) { - parentNode = document.createElement('div'); - node = node.cloneNode(true); - parentNode.appendChild(node); - } - - const matchingNodes = parentNode.querySelectorAll(selector); - return Array.prototype.indexOf.call(matchingNodes, node) !== -1; - }; - - /** - this will take in the headers from an API response and normalize them - this way we don't run into production issues when nginx gives us lowercased header keys - */ - w.gl.utils.normalizeHeaders = (headers) => { - const upperCaseHeaders = {}; - - Object.keys(headers).forEach((e) => { - upperCaseHeaders[e.toUpperCase()] = headers[e]; - }); - - return upperCaseHeaders; - }; - - /** - this will take in the getAllResponseHeaders result and normalize them - this way we don't run into production issues when nginx gives us lowercased header keys - */ - w.gl.utils.normalizeCRLFHeaders = (headers) => { - const headersObject = {}; - const headersArray = headers.split('\n'); - - headersArray.forEach((header) => { - const keyValue = header.split(': '); - headersObject[keyValue[0]] = keyValue[1]; - }); - - return w.gl.utils.normalizeHeaders(headersObject); - }; - - /** - * Parses pagination object string values into numbers. - * - * @param {Object} paginationInformation - * @returns {Object} - */ - w.gl.utils.parseIntPagination = paginationInformation => ({ - perPage: parseInt(paginationInformation['X-PER-PAGE'], 10), - page: parseInt(paginationInformation['X-PAGE'], 10), - total: parseInt(paginationInformation['X-TOTAL'], 10), - totalPages: parseInt(paginationInformation['X-TOTAL-PAGES'], 10), - nextPage: parseInt(paginationInformation['X-NEXT-PAGE'], 10), - previousPage: parseInt(paginationInformation['X-PREV-PAGE'], 10), - }); - - /** - * Updates the search parameter of a URL given the parameter and value provided. - * - * If no search params are present we'll add it. - * If param for page is already present, we'll update it - * If there are params but not for the given one, we'll add it at the end. - * Returns the new search parameters. - * - * @param {String} param - * @param {Number|String|Undefined|Null} value - * @return {String} - */ - w.gl.utils.setParamInURL = (param, value) => { - let search; - const locationSearch = window.location.search; - - if (locationSearch.length) { - const parameters = locationSearch.substring(1, locationSearch.length) - .split('&') - .reduce((acc, element) => { - const val = element.split('='); - acc[val[0]] = decodeURIComponent(val[1]); - return acc; - }, {}); - - parameters[param] = value; - - const toString = Object.keys(parameters) - .map(val => `${val}=${encodeURIComponent(parameters[val])}`) - .join('&'); - - search = `?${toString}`; - } else { - search = `?${param}=${value}`; - } - - return search; - }; - - /** - * Converts permission provided as strings to booleans. - * - * @param {String} string - * @returns {Boolean} - */ - w.gl.utils.convertPermissionToBoolean = permission => permission === 'true'; - - /** - * Back Off exponential algorithm - * backOff :: (Function<next, stop>, Number) -> Promise<Any, Error> - * - * @param {Function<next, stop>} fn function to be called - * @param {Number} timeout - * @return {Promise<Any, Error>} - * @example - * ``` - * backOff(function (next, stop) { - * // Let's perform this function repeatedly for 60s or for the timeout provided. - * - * ourFunction() - * .then(function (result) { - * // continue if result is not what we need - * next(); - * - * // when result is what we need let's stop with the repetions and jump out of the cycle - * stop(result); - * }) - * .catch(function (error) { - * // if there is an error, we need to stop this with an error. - * stop(error); - * }) - * }, 60000) - * .then(function (result) {}) - * .catch(function (error) { - * // deal with errors passed to stop() - * }) - * ``` - */ - w.gl.utils.backOff = (fn, timeout = 60000) => { - const maxInterval = 32000; - let nextInterval = 2000; - let timeElapsed = 0; - - return new Promise((resolve, reject) => { - const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg)); - - const next = () => { - if (timeElapsed < timeout) { - setTimeout(() => fn(next, stop), nextInterval); - timeElapsed += nextInterval; - nextInterval = Math.min(nextInterval + nextInterval, maxInterval); - } else { - reject(new Error('BACKOFF_TIMEOUT')); - } - }; - - fn(next, stop); - }); - }; - - w.gl.utils.setFavicon = (faviconPath) => { - if (faviconEl && faviconPath) { - faviconEl.setAttribute('href', faviconPath); - } - }; - - w.gl.utils.resetFavicon = () => { - if (faviconEl) { - faviconEl.setAttribute('href', originalFavicon); - } - }; - - w.gl.utils.setCiStatusFavicon = (pageUrl) => { - $.ajax({ - url: pageUrl, - dataType: 'json', - success: function(data) { - if (data && data.favicon) { - gl.utils.setFavicon(data.favicon); - } else { - gl.utils.resetFavicon(); - } - }, - error: function() { - gl.utils.resetFavicon(); - } - }); - }; - })(window); -}).call(window); + }, + error: () => { + resetFavicon(); + }, + }); +}; + +export const spriteIcon = icon => `<svg><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`; + +export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`; + +window.gl = window.gl || {}; +window.gl.utils = { + ...(window.gl.utils || {}), + getPagePath, + isInGroupsPage, + isInProjectPage, + getProjectSlug, + getGroupSlug, + isInIssuePage, + ajaxGet, + ajaxPost, + rstrip, + updateTooltipTitle, + disableButtonIfEmptyField, + handleLocationHash, + isInViewport, + parseUrl, + parseUrlPathname, + getUrlParamsArray, + isMetaKey, + isMetaClick, + scrollToElement, + getParameterByName, + getSelectedFragment, + insertText, + nodeMatchesSelector, + spriteIcon, + imagePath, +}; diff --git a/app/assets/javascripts/lib/utils/csrf.js b/app/assets/javascripts/lib/utils/csrf.js new file mode 100644 index 00000000000..0bdb547d31a --- /dev/null +++ b/app/assets/javascripts/lib/utils/csrf.js @@ -0,0 +1,58 @@ +/* +This module provides easy access to the CSRF token and caches +it for re-use. It also exposes some values commonly used in relation +to the CSRF token (header key and headers object). + +If you need to refresh the csrfToken for some reason, just call `init` and +then use the accessors as you would normally. + +If you need to compose a headers object, use the spread operator: + +``` + headers: { + ...csrf.headers, + someOtherHeader: '12345', + } +``` + +see also http://guides.rubyonrails.org/security.html#cross-site-request-forgery-csrf +and https://github.com/rails/jquery-rails/blob/v4.3.1/vendor/assets/javascripts/jquery_ujs.js#L59-L62 + */ + +const csrf = { + init() { + const tokenEl = document.querySelector('meta[name=csrf-token]'); + + if (tokenEl !== null) { + this.csrfToken = tokenEl.getAttribute('content'); + } else { + this.csrfToken = null; + } + }, + + get token() { + return this.csrfToken; + }, + + get headerKey() { + return 'X-CSRF-Token'; + }, + + get headers() { + if (this.csrfToken !== null) { + return { + [this.headerKey]: this.token, + }; + } + return {}; + }, +}; + +csrf.init(); + +// use our cached token for any $.rails-generated AJAX requests +if ($.rails) { + $.rails.csrfToken = () => csrf.token; +} + +export default csrf; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 1d1763c3963..29fc91733b3 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -55,7 +55,7 @@ window.dateFormat = dateFormat; if (!timeagoInstance) { const localeRemaining = function(number, index) { return [ - [s__('Timeago|less than a minute ago'), s__('Timeago|a while')], + [s__('Timeago|less than a minute ago'), s__('Timeago|in a while')], [s__('Timeago|less than a minute ago'), s__('Timeago|%s seconds remaining')], [s__('Timeago|about a minute ago'), s__('Timeago|1 minute remaining')], [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')], @@ -73,7 +73,7 @@ window.dateFormat = dateFormat; }; locale = function(number, index) { return [ - [s__('Timeago|less than a minute ago'), s__('Timeago|a while')], + [s__('Timeago|less than a minute ago'), s__('Timeago|in a while')], [s__('Timeago|less than a minute ago'), s__('Timeago|in %s seconds')], [s__('Timeago|about a minute ago'), s__('Timeago|in 1 minute')], [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')], diff --git a/app/assets/javascripts/lib/utils/image_utility.js b/app/assets/javascripts/lib/utils/image_utility.js new file mode 100644 index 00000000000..2977ec821cb --- /dev/null +++ b/app/assets/javascripts/lib/utils/image_utility.js @@ -0,0 +1,5 @@ +/* eslint-disable import/prefer-default-export */ + +export function isImageLoaded(element) { + return element.complete && element.naturalHeight !== 0; +} diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 57394097944..917a45eb06b 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -13,7 +13,7 @@ export function formatRelevantDigits(number) { let relevantDigits = 0; let formattedNumber = ''; if (!isNaN(Number(number))) { - digitsLeft = number.split('.')[0]; + digitsLeft = number.toString().split('.')[0]; switch (digitsLeft.length) { case 1: relevantDigits = 3; diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js index 97666e13ebe..1485e900945 100644 --- a/app/assets/javascripts/lib/utils/poll.js +++ b/app/assets/javascripts/lib/utils/poll.js @@ -1,4 +1,5 @@ import httpStatusCodes from './http_status'; +import { normalizeHeaders } from './common_utils'; /** * Polling utility for handling realtime updates. @@ -57,7 +58,7 @@ export default class Poll { } checkConditions(response) { - const headers = gl.utils.normalizeHeaders(response.headers); + const headers = normalizeHeaders(response.headers); const pollInterval = parseInt(headers[this.intervalHeader], 10); if (pollInterval > 0 && response.status === httpStatusCodes.OK && this.canPoll) { diff --git a/app/assets/javascripts/lib/utils/pretty_time.js b/app/assets/javascripts/lib/utils/pretty_time.js index 227bf65b560..b1ffd797f7e 100644 --- a/app/assets/javascripts/lib/utils/pretty_time.js +++ b/app/assets/javascripts/lib/utils/pretty_time.js @@ -1,68 +1,61 @@ import _ from 'underscore'; -(() => { - /* - * TODO: Make these methods more configurable (e.g. stringifyTime condensed or - * non-condensed, abbreviateTimelengths) - * */ - - const utils = window.gl.utils = gl.utils || {}; - const prettyTime = utils.prettyTime = { - /* - * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # } - * Seconds can be negative or positive, zero or non-zero. Can be configured for any day - * or week length. - */ - parseSeconds(seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) { - const DAYS_PER_WEEK = daysPerWeek; - const HOURS_PER_DAY = hoursPerDay; - const MINUTES_PER_HOUR = 60; - const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR; - const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR; - - const timePeriodConstraints = { - weeks: MINUTES_PER_WEEK, - days: MINUTES_PER_DAY, - hours: MINUTES_PER_HOUR, - minutes: 1, - }; +/* + * TODO: Make these methods more configurable (e.g. stringifyTime condensed or + * non-condensed, abbreviateTimelengths) + * */ + +/* + * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # } + * Seconds can be negative or positive, zero or non-zero. Can be configured for any day + * or week length. +*/ + +export function parseSeconds(seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) { + const DAYS_PER_WEEK = daysPerWeek; + const HOURS_PER_DAY = hoursPerDay; + const MINUTES_PER_HOUR = 60; + const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR; + const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR; + + const timePeriodConstraints = { + weeks: MINUTES_PER_WEEK, + days: MINUTES_PER_DAY, + hours: MINUTES_PER_HOUR, + minutes: 1, + }; - let unorderedMinutes = prettyTime.secondsToMinutes(seconds); + let unorderedMinutes = Math.abs(seconds / MINUTES_PER_HOUR); - return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => { - const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod); + return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => { + const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod); - unorderedMinutes -= (periodCount * minutesPerPeriod); + unorderedMinutes -= (periodCount * minutesPerPeriod); - return periodCount; - }); - }, + return periodCount; + }); +} - /* - * Accepts a timeObject and returns a condensed string representation of it - * (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included. - */ +/* +* Accepts a timeObject (see parseSeconds) and returns a condensed string representation of it +* (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included. +*/ - stringifyTime(timeObject) { - const reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => { - const isNonZero = !!unitValue; - return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo; - }, '').trim(); - return reducedTime.length ? reducedTime : '0m'; - }, +export function stringifyTime(timeObject) { + const reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => { + const isNonZero = !!unitValue; + return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo; + }, '').trim(); + return reducedTime.length ? reducedTime : '0m'; +} - /* - * Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns - * the first non-zero unit/value pair. - */ +/* +* Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns +* the first non-zero unit/value pair. +*/ - abbreviateTime(timeStr) { - return timeStr.split(' ') - .filter(unitStr => unitStr.charAt(0) !== '0')[0]; - }, +export function abbreviateTime(timeStr) { + return timeStr.split(' ') + .filter(unitStr => unitStr.charAt(0) !== '0')[0]; +} - secondsToMinutes(seconds) { - return Math.abs(seconds / 60); - }, - }; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/lib/utils/sticky.js b/app/assets/javascripts/lib/utils/sticky.js index 283c0ec0410..64db42701ce 100644 --- a/app/assets/javascripts/lib/utils/sticky.js +++ b/app/assets/javascripts/lib/utils/sticky.js @@ -1,14 +1,34 @@ -export const isSticky = (el, scrollY, stickyTop) => { +export const createPlaceholder = () => { + const placeholder = document.createElement('div'); + placeholder.classList.add('sticky-placeholder'); + + return placeholder; +}; + +export const isSticky = (el, scrollY, stickyTop, insertPlaceholder) => { const top = Math.floor(el.offsetTop - scrollY); - if (top <= stickyTop) { + if (top <= stickyTop && !el.classList.contains('is-stuck')) { + const placeholder = insertPlaceholder ? createPlaceholder() : null; + const heightBefore = el.offsetHeight; + el.classList.add('is-stuck'); - } else { + + if (insertPlaceholder) { + el.parentNode.insertBefore(placeholder, el.nextElementSibling); + + placeholder.style.height = `${heightBefore - el.offsetHeight}px`; + } + } else if (top > stickyTop && el.classList.contains('is-stuck')) { el.classList.remove('is-stuck'); + + if (insertPlaceholder && el.nextElementSibling && el.nextElementSibling.classList.contains('sticky-placeholder')) { + el.nextElementSibling.remove(); + } } }; -export default (el) => { +export default (el, insertPlaceholder = true) => { if (!el) return; const computedStyle = window.getComputedStyle(el); @@ -17,7 +37,7 @@ export default (el) => { const stickyTop = parseInt(computedStyle.top, 10); - document.addEventListener('scroll', () => isSticky(el, window.scrollY, stickyTop), { + document.addEventListener('scroll', () => isSticky(el, window.scrollY, stickyTop, insertPlaceholder), { passive: true, }); }; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 3328ff9cc23..78c7a094127 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, one-var, one-var-declaration-per-line, no-void, guard-for-in, no-restricted-syntax, prefer-template, quotes, max-len */ + var base; var w = window; if (w.gl == null) { @@ -86,6 +87,21 @@ w.gl.utils.getLocationHash = function(url) { w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(document.location.href); -w.gl.utils.visitUrl = (url) => { - document.location.href = url; +// eslint-disable-next-line import/prefer-default-export +export function visitUrl(url, external = false) { + if (external) { + // Simulate `target="blank" rel="noopener noreferrer"` + // See https://mathiasbynens.github.io/rel-noopener/ + const otherWindow = window.open(); + otherWindow.opener = null; + otherWindow.location = url; + } else { + document.location.href = url; + } +} + +window.gl = window.gl || {}; +window.gl.utils = { + ...(window.gl.utils || {}), + visitUrl, }; |