888 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
			
		
		
	
	
			888 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
| /**
 | |
|  * @module common-utils
 | |
|  */
 | |
| 
 | |
| import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils';
 | |
| import $ from 'jquery';
 | |
| import { isFunction } from 'lodash';
 | |
| import Cookies from 'js-cookie';
 | |
| import axios from './axios_utils';
 | |
| import { getLocationHash } from './url_utility';
 | |
| import { convertToCamelCase, convertToSnakeCase } from './text_utility';
 | |
| import { isObject } from './type_utility';
 | |
| 
 | |
| export const getPagePath = (index = 0) => {
 | |
|   const page = $('body').attr('data-page') || '';
 | |
| 
 | |
|   return page.split(':')[index];
 | |
| };
 | |
| 
 | |
| export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null;
 | |
| 
 | |
| 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 (isInProjectPage() || isInGroupsPage()) {
 | |
|     return $('body').data('group');
 | |
|   }
 | |
|   return null;
 | |
| };
 | |
| 
 | |
| export const checkPageAndAction = (page, action) => {
 | |
|   const pagePath = getPagePath(1);
 | |
|   const actionPath = getPagePath(2);
 | |
| 
 | |
|   return pagePath === page && actionPath === action;
 | |
| };
 | |
| 
 | |
| export const isInIncidentPage = () => checkPageAndAction('incidents', 'show');
 | |
| export const isInIssuePage = () => checkPageAndAction('issues', 'show');
 | |
| export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
 | |
| export const isInEpicPage = () => checkPageAndAction('epics', 'show');
 | |
| 
 | |
| export const getCspNonceValue = () => {
 | |
|   const metaTag = document.querySelector('meta[name=csp-nonce]');
 | |
|   return metaTag && metaTag.content;
 | |
| };
 | |
| 
 | |
| export const rstrip = val => {
 | |
|   if (val) {
 | |
|     return val.replace(/\s+$/, '');
 | |
|   }
 | |
|   return val;
 | |
| };
 | |
| 
 | |
| 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 = 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');
 | |
|   const fixedNav = document.querySelector('.navbar-gitlab');
 | |
|   const performanceBar = document.querySelector('#js-peek');
 | |
|   const topPadding = 8;
 | |
|   const diffFileHeader = document.querySelector('.js-file-title');
 | |
|   const versionMenusContainer = document.querySelector('.mr-version-menus-container');
 | |
|   const fixedIssuableTitle = document.querySelector('.issue-sticky-header');
 | |
| 
 | |
|   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;
 | |
|   }
 | |
| 
 | |
|   if (performanceBar) {
 | |
|     adjustment -= performanceBar.offsetHeight;
 | |
|   }
 | |
| 
 | |
|   if (diffFileHeader) {
 | |
|     adjustment -= diffFileHeader.offsetHeight;
 | |
|   }
 | |
| 
 | |
|   if (versionMenusContainer) {
 | |
|     adjustment -= versionMenusContainer.offsetHeight;
 | |
|   }
 | |
| 
 | |
|   if (isInIssuePage()) {
 | |
|     adjustment -= fixedIssuableTitle.offsetHeight;
 | |
|   }
 | |
| 
 | |
|   if (isInMRPage()) {
 | |
|     adjustment -= topPadding;
 | |
|   }
 | |
| 
 | |
|   setTimeout(() => {
 | |
|     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, offset = {}) => {
 | |
|   const rect = el.getBoundingClientRect();
 | |
|   const { top, left } = offset;
 | |
| 
 | |
|   return (
 | |
|     rect.top >= (top || 0) &&
 | |
|     rect.left >= (left || 0) &&
 | |
|     rect.bottom <= window.innerHeight &&
 | |
|     parseInt(rect.right, 10) <= 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}`;
 | |
| };
 | |
| 
 | |
| const splitPath = (path = '') => path.replace(/^\?/, '').split('&');
 | |
| 
 | |
| export const urlParamsToArray = (path = '') =>
 | |
|   splitPath(path)
 | |
|     .filter(param => param.length > 0)
 | |
|     .map(param => {
 | |
|       const split = param.split('=');
 | |
|       return [decodeURI(split[0]), split[1]].join('=');
 | |
|     });
 | |
| 
 | |
| export const getUrlParamsArray = () => urlParamsToArray(window.location.search);
 | |
| 
 | |
| /**
 | |
|  * Accepts encoding string which includes query params being
 | |
|  * sent to URL.
 | |
|  *
 | |
|  * @param {string} path Query param string
 | |
|  *
 | |
|  * @returns {object} Query params object containing key-value pairs
 | |
|  *                   with both key and values decoded into plain string.
 | |
|  */
 | |
| export const urlParamsToObject = (path = '') =>
 | |
|   splitPath(path).reduce((dataParam, filterParam) => {
 | |
|     if (filterParam === '') {
 | |
|       return dataParam;
 | |
|     }
 | |
| 
 | |
|     const data = dataParam;
 | |
|     let [key, value] = filterParam.split('=');
 | |
|     key = /%\w+/g.test(key) ? decodeURIComponent(key) : key;
 | |
|     const isArray = key.includes('[]');
 | |
|     key = key.replace('[]', '');
 | |
|     value = decodeURIComponent(value.replace(/\+/g, ' '));
 | |
| 
 | |
|     if (isArray) {
 | |
|       if (!data[key]) {
 | |
|         data[key] = [];
 | |
|       }
 | |
| 
 | |
|       data[key].push(value);
 | |
|     } else {
 | |
|       data[key] = value;
 | |
|     }
 | |
| 
 | |
|     return data;
 | |
|   }, {});
 | |
| 
 | |
| 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 contentTop = () => {
 | |
|   const isDesktop = breakpointInstance.isDesktop();
 | |
|   const heightCalculators = [
 | |
|     () => $('#js-peek').outerHeight(),
 | |
|     () => $('.navbar-gitlab').outerHeight(),
 | |
|     ({ desktop }) => {
 | |
|       const container = document.querySelector('.line-resolve-all-container');
 | |
|       let size = 0;
 | |
| 
 | |
|       if (!desktop && container) {
 | |
|         size = container.offsetHeight;
 | |
|       }
 | |
| 
 | |
|       return size;
 | |
|     },
 | |
|     () => $('.merge-request-tabs').outerHeight(),
 | |
|     () => $('.js-diff-files-changed').outerHeight(),
 | |
|     ({ desktop }) => {
 | |
|       const diffsTabIsActive = window.mrTabs?.currentAction === 'diffs';
 | |
|       let size;
 | |
| 
 | |
|       if (desktop && diffsTabIsActive) {
 | |
|         size = $('.diff-file .file-title-flex-parent:visible').outerHeight();
 | |
|       }
 | |
| 
 | |
|       return size;
 | |
|     },
 | |
|     ({ desktop }) => {
 | |
|       let size;
 | |
| 
 | |
|       if (desktop) {
 | |
|         size = $('.mr-version-controls').outerHeight();
 | |
|       }
 | |
| 
 | |
|       return size;
 | |
|     },
 | |
|   ];
 | |
| 
 | |
|   return heightCalculators.reduce((totalHeight, calculator) => {
 | |
|     return totalHeight + (calculator({ desktop: isDesktop }) || 0);
 | |
|   }, 0);
 | |
| };
 | |
| 
 | |
| export const scrollToElement = (element, options = {}) => {
 | |
|   let $el = element;
 | |
|   if (!(element instanceof $)) {
 | |
|     $el = $(element);
 | |
|   }
 | |
|   const { top } = $el.offset();
 | |
|   const { offset = 0 } = options;
 | |
| 
 | |
|   // eslint-disable-next-line no-jquery/no-animate
 | |
|   return $('body, html').animate(
 | |
|     {
 | |
|       scrollTop: top - contentTop() + offset,
 | |
|     },
 | |
|     200,
 | |
|   );
 | |
| };
 | |
| 
 | |
| export const scrollToElementWithContext = element => {
 | |
|   const offsetMultiplier = -0.1;
 | |
|   return scrollToElement(element, { offset: window.innerHeight * offsetMultiplier });
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Returns a function that can only be invoked once between
 | |
|  * each browser screen repaint.
 | |
|  * @param {Function} fn
 | |
|  */
 | |
| export const debounceByAnimationFrame = fn => {
 | |
|   let requestId;
 | |
| 
 | |
|   return function debounced(...args) {
 | |
|     if (requestId) {
 | |
|       window.cancelAnimationFrame(requestId);
 | |
|     }
 | |
|     requestId = window.requestAnimationFrame(() => fn.apply(this, args));
 | |
|   };
 | |
| };
 | |
| 
 | |
| /**
 | |
|   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, ' '));
 | |
| };
 | |
| 
 | |
| const handleSelectedRange = (range, restrictToNode) => {
 | |
|   // Make sure this range is within the restricting container
 | |
|   if (restrictToNode && !range.intersectsNode(restrictToNode)) return null;
 | |
| 
 | |
|   // If only a part of the range is within the wanted container, we need to restrict the range to it
 | |
|   if (restrictToNode && !restrictToNode.contains(range.commonAncestorContainer)) {
 | |
|     if (!restrictToNode.contains(range.startContainer)) range.setStart(restrictToNode, 0);
 | |
|     if (!restrictToNode.contains(range.endContainer))
 | |
|       range.setEnd(restrictToNode, restrictToNode.childNodes.length);
 | |
|   }
 | |
| 
 | |
|   const container = range.commonAncestorContainer;
 | |
|   // add context to fragment if needed
 | |
|   if (container.tagName === 'OL') {
 | |
|     const parentContainer = document.createElement(container.tagName);
 | |
|     parentContainer.appendChild(range.cloneContents());
 | |
|     return parentContainer;
 | |
|   }
 | |
|   return range.cloneContents();
 | |
| };
 | |
| 
 | |
| export const getSelectedFragment = restrictToNode => {
 | |
|   const selection = window.getSelection();
 | |
|   if (selection.rangeCount === 0) return null;
 | |
|   // Most usages of the selection only want text from a part of the page (e.g. discussion)
 | |
|   if (restrictToNode && !selection.containsNode(restrictToNode, true)) return null;
 | |
| 
 | |
|   const documentFragment = document.createDocumentFragment();
 | |
|   documentFragment.originalNodes = [];
 | |
| 
 | |
|   for (let i = 0; i < selection.rangeCount; i += 1) {
 | |
|     const range = selection.getRangeAt(i);
 | |
|     const handledRange = handleSelectedRange(range, restrictToNode);
 | |
|     if (handledRange) {
 | |
|       documentFragment.appendChild(handledRange);
 | |
|       documentFragment.originalNodes.push(range.commonAncestorContainer);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   if (documentFragment.textContent.length === 0 && documentFragment.children.length === 0) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   return documentFragment;
 | |
| };
 | |
| 
 | |
| export const insertText = (target, text) => {
 | |
|   // Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas
 | |
|   const { selectionStart, selectionEnd, value } = target;
 | |
| 
 | |
|   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 = selectionStart + insertedText.length;
 | |
| 
 | |
|   // eslint-disable-next-line no-param-reassign
 | |
|   target.selectionEnd = selectionStart + insertedText.length;
 | |
| 
 | |
|   // Trigger autosave
 | |
|   target.dispatchEvent(new Event('input'));
 | |
| 
 | |
|   // Trigger autosize
 | |
|   const event = document.createEvent('Event');
 | |
|   event.initEvent('autosize:update', true, false);
 | |
|   target.dispatchEvent(event);
 | |
| };
 | |
| 
 | |
| /**
 | |
|   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;
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * 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),
 | |
| });
 | |
| 
 | |
| /**
 | |
|  * Given a string of query parameters creates an object.
 | |
|  *
 | |
|  * @example
 | |
|  * `scope=all&page=2` -> { scope: 'all', page: '2'}
 | |
|  * `scope=all` -> { scope: 'all' }
 | |
|  * ``-> {}
 | |
|  * @param {String} query
 | |
|  * @returns {Object}
 | |
|  */
 | |
| export const parseQueryStringIntoObject = (query = '') => {
 | |
|   if (query === '') return {};
 | |
| 
 | |
|   return query.split('&').reduce((acc, element) => {
 | |
|     const val = element.split('=');
 | |
|     Object.assign(acc, {
 | |
|       [val[0]]: decodeURIComponent(val[1]),
 | |
|     });
 | |
|     return acc;
 | |
|   }, {});
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Converts object with key-value pairs
 | |
|  * into query-param string
 | |
|  *
 | |
|  * @param {Object} params
 | |
|  */
 | |
| export const objectToQueryString = (params = {}) =>
 | |
|   Object.keys(params)
 | |
|     .map(param => `${param}=${params[param]}`)
 | |
|     .join('&');
 | |
| 
 | |
| export const buildUrlWithCurrentLocation = param => {
 | |
|   if (param) return `${window.location.pathname}${param}`;
 | |
| 
 | |
|   return window.location.pathname;
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Based on the current location and the string parameters provided
 | |
|  * creates a new entry in the history without reloading the page.
 | |
|  *
 | |
|  * @param {String} param
 | |
|  */
 | |
| export const historyPushState = newUrl => {
 | |
|   window.history.pushState({}, document.title, newUrl);
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Based on the current location and the string parameters provided
 | |
|  * overwrites the current entry in the history without reloading the page.
 | |
|  *
 | |
|  * @param {String} param
 | |
|  */
 | |
| export const historyReplaceState = newUrl => {
 | |
|   window.history.replaceState({}, document.title, newUrl);
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Returns true for a String value of "true" and false otherwise.
 | |
|  * This is the opposite of Boolean(...).toString().
 | |
|  * `parseBoolean` is idempotent.
 | |
|  *
 | |
|  * @param  {String} value
 | |
|  * @returns {Boolean}
 | |
|  */
 | |
| export const parseBoolean = value => (value && value.toString()) === 'true';
 | |
| 
 | |
| export const BACKOFF_TIMEOUT = 'BACKOFF_TIMEOUT';
 | |
| 
 | |
| /**
 | |
|  * @callback backOffCallback
 | |
|  * @param {Function} next
 | |
|  * @param {Function} stop
 | |
|  */
 | |
| 
 | |
| /**
 | |
|  * Back Off exponential algorithm
 | |
|  * backOff :: (Function<next, stop>, Number) -> Promise<Any, Error>
 | |
|  *
 | |
|  * @param {backOffCallback} 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 {
 | |
|         reject(new Error(BACKOFF_TIMEOUT));
 | |
|       }
 | |
|     };
 | |
| 
 | |
|     fn(next, stop);
 | |
|   });
 | |
| };
 | |
| 
 | |
| export const createOverlayIcon = (iconPath, overlayPath) => {
 | |
|   const faviconImage = document.createElement('img');
 | |
| 
 | |
|   return new Promise(resolve => {
 | |
|     faviconImage.onload = () => {
 | |
|       const size = 32;
 | |
| 
 | |
|       const canvas = document.createElement('canvas');
 | |
|       canvas.width = size;
 | |
|       canvas.height = size;
 | |
| 
 | |
|       const context = canvas.getContext('2d');
 | |
|       context.clearRect(0, 0, size, size);
 | |
|       context.drawImage(
 | |
|         faviconImage,
 | |
|         0,
 | |
|         0,
 | |
|         faviconImage.width,
 | |
|         faviconImage.height,
 | |
|         0,
 | |
|         0,
 | |
|         size,
 | |
|         size,
 | |
|       );
 | |
| 
 | |
|       const overlayImage = document.createElement('img');
 | |
|       overlayImage.onload = () => {
 | |
|         context.drawImage(
 | |
|           overlayImage,
 | |
|           0,
 | |
|           0,
 | |
|           overlayImage.width,
 | |
|           overlayImage.height,
 | |
|           0,
 | |
|           0,
 | |
|           size,
 | |
|           size,
 | |
|         );
 | |
| 
 | |
|         const faviconWithOverlayUrl = canvas.toDataURL();
 | |
| 
 | |
|         resolve(faviconWithOverlayUrl);
 | |
|       };
 | |
|       overlayImage.src = overlayPath;
 | |
|     };
 | |
|     faviconImage.src = iconPath;
 | |
|   });
 | |
| };
 | |
| 
 | |
| export const setFaviconOverlay = overlayPath => {
 | |
|   const faviconEl = document.getElementById('favicon');
 | |
| 
 | |
|   if (!faviconEl) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   const iconPath = faviconEl.getAttribute('data-original-href');
 | |
| 
 | |
|   return createOverlayIcon(iconPath, overlayPath).then(faviconWithOverlayUrl =>
 | |
|     faviconEl.setAttribute('href', faviconWithOverlayUrl),
 | |
|   );
 | |
| };
 | |
| 
 | |
| export const resetFavicon = () => {
 | |
|   const faviconEl = document.getElementById('favicon');
 | |
| 
 | |
|   if (faviconEl) {
 | |
|     const originalFavicon = faviconEl.getAttribute('data-original-href');
 | |
|     faviconEl.setAttribute('href', originalFavicon);
 | |
|   }
 | |
| };
 | |
| 
 | |
| export const setCiStatusFavicon = pageUrl =>
 | |
|   axios
 | |
|     .get(pageUrl)
 | |
|     .then(({ data }) => {
 | |
|       if (data && data.favicon) {
 | |
|         return setFaviconOverlay(data.favicon);
 | |
|       }
 | |
|       return resetFavicon();
 | |
|     })
 | |
|     .catch(error => {
 | |
|       resetFavicon();
 | |
|       throw error;
 | |
|     });
 | |
| 
 | |
| export const spriteIcon = (icon, className = '') => {
 | |
|   const classAttribute = className.length > 0 ? `class="${className}"` : '';
 | |
| 
 | |
|   return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`;
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * @callback ConversionFunction
 | |
|  * @param {string} prop
 | |
|  */
 | |
| 
 | |
| /**
 | |
|  * This function takes a conversion function as the first parameter
 | |
|  * and applies this function to each prop in the provided object.
 | |
|  *
 | |
|  * This method also supports additional params in `options` object
 | |
|  *
 | |
|  * @param {ConversionFunction} conversionFunction - Function to apply to each prop of the object.
 | |
|  * @param {Object} obj - Object to be converted.
 | |
|  * @param {Object} options - Object containing additional options.
 | |
|  * @param {boolean} options.deep - FLag to allow deep object converting
 | |
|  * @param {Array[]} options.dropKeys - List of properties to discard while building new object
 | |
|  * @param {Array[]} options.ignoreKeyNames - List of properties to leave intact (as snake_case) while building new object
 | |
|  */
 | |
| export const convertObjectProps = (conversionFunction, obj = {}, options = {}) => {
 | |
|   if (!isFunction(conversionFunction) || obj === null) {
 | |
|     return {};
 | |
|   }
 | |
| 
 | |
|   const { deep = false, dropKeys = [], ignoreKeyNames = [] } = options;
 | |
| 
 | |
|   const isObjParameterArray = Array.isArray(obj);
 | |
|   const initialValue = isObjParameterArray ? [] : {};
 | |
| 
 | |
|   return Object.keys(obj).reduce((acc, prop) => {
 | |
|     const val = obj[prop];
 | |
| 
 | |
|     // Drop properties from new object if
 | |
|     // there are any mentioned in options
 | |
|     if (dropKeys.indexOf(prop) > -1) {
 | |
|       return acc;
 | |
|     }
 | |
| 
 | |
|     // Skip converting properties in new object
 | |
|     // if there are any mentioned in options
 | |
|     if (ignoreKeyNames.indexOf(prop) > -1) {
 | |
|       acc[prop] = val;
 | |
|       return acc;
 | |
|     }
 | |
| 
 | |
|     if (deep && (isObject(val) || Array.isArray(val))) {
 | |
|       if (isObjParameterArray) {
 | |
|         acc[prop] = convertObjectProps(conversionFunction, val, options);
 | |
|       } else {
 | |
|         acc[conversionFunction(prop)] = convertObjectProps(conversionFunction, val, options);
 | |
|       }
 | |
|     } else if (isObjParameterArray) {
 | |
|       acc[prop] = val;
 | |
|     } else {
 | |
|       acc[conversionFunction(prop)] = val;
 | |
|     }
 | |
|     return acc;
 | |
|   }, initialValue);
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * This method takes in object with snake_case property names
 | |
|  * and returns a new object with camelCase property names
 | |
|  *
 | |
|  * Reasoning for this method is to ensure consistent property
 | |
|  * naming conventions across JS code.
 | |
|  *
 | |
|  * This method also supports additional params in `options` object
 | |
|  *
 | |
|  * @param {Object} obj - Object to be converted.
 | |
|  * @param {Object} options - Object containing additional options.
 | |
|  * @param {boolean} options.deep - FLag to allow deep object converting
 | |
|  * @param {Array[]} options.dropKeys - List of properties to discard while building new object
 | |
|  * @param {Array[]} options.ignoreKeyNames - List of properties to leave intact (as snake_case) while building new object
 | |
|  */
 | |
| export const convertObjectPropsToCamelCase = (obj = {}, options = {}) =>
 | |
|   convertObjectProps(convertToCamelCase, obj, options);
 | |
| 
 | |
| /**
 | |
|  * Converts all the object keys to snake case
 | |
|  *
 | |
|  * This method also supports additional params in `options` object
 | |
|  *
 | |
|  * @param {Object} obj - Object to be converted.
 | |
|  * @param {Object} options - Object containing additional options.
 | |
|  * @param {boolean} options.deep - FLag to allow deep object converting
 | |
|  * @param {Array[]} options.dropKeys - List of properties to discard while building new object
 | |
|  * @param {Array[]} options.ignoreKeyNames - List of properties to leave intact (as snake_case) while building new object
 | |
|  */
 | |
| export const convertObjectPropsToSnakeCase = (obj = {}, options = {}) =>
 | |
|   convertObjectProps(convertToSnakeCase, obj, options);
 | |
| 
 | |
| export const imagePath = imgUrl =>
 | |
|   `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
 | |
| 
 | |
| export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => {
 | |
|   // Click a .js-select-on-focus field, select the contents
 | |
|   // Prevent a mouseup event from deselecting the input
 | |
|   $(selector).on('focusin', function selectOnFocusCallback() {
 | |
|     $(this)
 | |
|       .select()
 | |
|       .one('mouseup', e => {
 | |
|         e.preventDefault();
 | |
|       });
 | |
|   });
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Method to round of values with decimal places
 | |
|  * with provided precision.
 | |
|  *
 | |
|  * Taken from https://stackoverflow.com/a/7343013/414749
 | |
|  *
 | |
|  * Eg; roundOffFloat(3.141592, 3) = 3.142
 | |
|  *
 | |
|  * Refer to spec/javascripts/lib/utils/common_utils_spec.js for
 | |
|  * more supported examples.
 | |
|  *
 | |
|  * @param {Float} number
 | |
|  * @param {Number} precision
 | |
|  */
 | |
| export const roundOffFloat = (number, precision = 0) => {
 | |
|   // eslint-disable-next-line no-restricted-properties
 | |
|   const multiplier = Math.pow(10, precision);
 | |
|   return Math.round(number * multiplier) / multiplier;
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Method to round down values with decimal places
 | |
|  * with provided precision.
 | |
|  *
 | |
|  * Eg; roundDownFloat(3.141592, 3) = 3.141
 | |
|  *
 | |
|  * Refer to spec/javascripts/lib/utils/common_utils_spec.js for
 | |
|  * more supported examples.
 | |
|  *
 | |
|  * @param {Float} number
 | |
|  * @param {Number} precision
 | |
|  */
 | |
| export const roundDownFloat = (number, precision = 0) => {
 | |
|   // eslint-disable-next-line no-restricted-properties
 | |
|   const multiplier = Math.pow(10, precision);
 | |
|   return Math.floor(number * multiplier) / multiplier;
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Represents navigation type constants of the Performance Navigation API.
 | |
|  * Detailed explanation see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigation.
 | |
|  */
 | |
| export const NavigationType = {
 | |
|   TYPE_NAVIGATE: 0,
 | |
|   TYPE_RELOAD: 1,
 | |
|   TYPE_BACK_FORWARD: 2,
 | |
|   TYPE_RESERVED: 255,
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Method to perform case-insensitive search for a string
 | |
|  * within multiple properties and return object containing
 | |
|  * properties in case there are multiple matches or `null`
 | |
|  * if there's no match.
 | |
|  *
 | |
|  * Eg; Suppose we want to allow user to search using for a string
 | |
|  *     within `iid`, `title`, `url` or `reference` props of a target object;
 | |
|  *
 | |
|  *     const objectToSearch = {
 | |
|  *       "iid": 1,
 | |
|  *       "title": "Error omnis quos consequatur ullam a vitae sed omnis libero cupiditate. &3",
 | |
|  *       "url": "/groups/gitlab-org/-/epics/1",
 | |
|  *       "reference": "&1",
 | |
|  *     };
 | |
|  *
 | |
|  *    Following is how we call searchBy and the return values it will yield;
 | |
|  *
 | |
|  *    -  `searchBy('omnis', objectToSearch);`: This will return `{ title: ... }` as our
 | |
|  *        query was found within title prop we only return that.
 | |
|  *    -  `searchBy('1', objectToSearch);`: This will return `{ "iid": ..., "reference": ..., "url": ... }`.
 | |
|  *    -  `searchBy('https://gitlab.com/groups/gitlab-org/-/epics/1', objectToSearch);`:
 | |
|  *        This will return `{ "url": ... }`.
 | |
|  *    -  `searchBy('foo', objectToSearch);`: This will return `null` as no property value
 | |
|  *        matched with our query.
 | |
|  *
 | |
|  *    You can learn more about behaviour of this method by referring to tests
 | |
|  *    within `spec/javascripts/lib/utils/common_utils_spec.js`.
 | |
|  *
 | |
|  * @param {string} query String to search for
 | |
|  * @param {object} searchSpace Object containing properties to search in for `query`
 | |
|  */
 | |
| export const searchBy = (query = '', searchSpace = {}) => {
 | |
|   const targetKeys = searchSpace !== null ? Object.keys(searchSpace) : [];
 | |
| 
 | |
|   if (!query || !targetKeys.length) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   const normalizedQuery = query.toLowerCase();
 | |
|   const matches = targetKeys
 | |
|     .filter(item => {
 | |
|       const searchItem = `${searchSpace[item]}`.toLowerCase();
 | |
| 
 | |
|       return (
 | |
|         searchItem.indexOf(normalizedQuery) > -1 ||
 | |
|         normalizedQuery.indexOf(searchItem) > -1 ||
 | |
|         normalizedQuery === searchItem
 | |
|       );
 | |
|     })
 | |
|     .reduce((acc, prop) => {
 | |
|       const match = acc;
 | |
|       match[prop] = searchSpace[prop];
 | |
| 
 | |
|       return acc;
 | |
|     }, {});
 | |
| 
 | |
|   return Object.keys(matches).length ? matches : null;
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Checks if the given Label has a special syntax `::` in
 | |
|  * it's title.
 | |
|  *
 | |
|  * Expected Label to be an Object with `title` as a key:
 | |
|  *   { title: 'LabelTitle', ...otherProperties };
 | |
|  *
 | |
|  * @param {Object} label
 | |
|  * @returns Boolean
 | |
|  */
 | |
| export const isScopedLabel = ({ title = '' }) => title.indexOf('::') !== -1;
 | |
| 
 | |
| // Methods to set and get Cookie
 | |
| export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 });
 | |
| 
 | |
| export const getCookie = name => Cookies.get(name);
 | |
| 
 | |
| export const removeCookie = name => Cookies.remove(name);
 | |
| 
 | |
| /**
 | |
|  * Returns the status of a feature flag.
 | |
|  * Currently, there is no way to access feature
 | |
|  * flags in Vuex other than directly tapping into
 | |
|  * window.gon.
 | |
|  *
 | |
|  * This should only be used on Vuex. If feature flags
 | |
|  * need to be accessed in Vue components consider
 | |
|  * using the Vue feature flag mixin.
 | |
|  *
 | |
|  * @param {String} flag Feature flag
 | |
|  * @returns {Boolean} on/off
 | |
|  */
 | |
| export const isFeatureFlagEnabled = flag => window.gon.features?.[flag];
 |