diff options
Diffstat (limited to 'app/assets/javascripts/lib/utils')
-rw-r--r-- | app/assets/javascripts/lib/utils/axios_utils.js | 3 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/common_utils.js | 139 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/constants.js | 1 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/datetime/timeago_utility.js | 47 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/finite_state_machine.js | 101 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/text_utility.js | 58 | ||||
-rw-r--r-- | app/assets/javascripts/lib/utils/url_utility.js | 76 |
7 files changed, 278 insertions, 147 deletions
diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js index 204c84b879e..0a26f78e253 100644 --- a/app/assets/javascripts/lib/utils/axios_utils.js +++ b/app/assets/javascripts/lib/utils/axios_utils.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor'; import setupAxiosStartupCalls from './axios_startup_calls'; import csrf from './csrf'; import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation'; @@ -41,6 +42,8 @@ axios.interceptors.response.use( (err) => suppressAjaxErrorsDuringNavigation(err, isUserNavigating), ); +registerCaptchaModalInterceptor(axios); + export default axios; /** diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 8666d325c1b..8a051041fbe 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -11,31 +11,10 @@ import { isObject } from './type_utility'; import { getLocationHash } from './url_utility'; export const getPagePath = (index = 0) => { - const page = $('body').attr('data-page') || ''; - + const { page = '' } = document?.body?.dataset; 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); @@ -49,6 +28,8 @@ export const isInDesignPage = () => checkPageAndAction('issues', 'designs'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInEpicPage = () => checkPageAndAction('epics', 'show'); +export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null; + export const getCspNonceValue = () => { const metaTag = document.querySelector('meta[name=csp-nonce]'); return metaTag && metaTag.content; @@ -162,53 +143,6 @@ export const parseUrlPathname = (url) => { 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 @@ -301,21 +235,6 @@ export const debounceByAnimationFrame = (fn) => { }; }; -/** - 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; @@ -390,8 +309,8 @@ export const insertText = (target, text) => { }; /** - 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 + 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 = {}; @@ -418,39 +337,6 @@ export const parseIntPagination = (paginationInformation) => ({ 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}`; @@ -789,7 +675,18 @@ export const searchBy = (query = '', searchSpace = {}) => { * @param {Object} label * @returns Boolean */ -export const isScopedLabel = ({ title = '' }) => title.indexOf('::') !== -1; +export const isScopedLabel = ({ title = '' } = {}) => title.indexOf('::') !== -1; + +/** + * Returns the base value of the scoped label + * + * Expected Label to be an Object with `title` as a key: + * { title: 'LabelTitle', ...otherProperties }; + * + * @param {Object} label + * @returns String + */ +export const scopedLabelKey = ({ title = '' }) => isScopedLabel({ title }) && title.split('::')[0]; // Methods to set and get Cookie export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 }); @@ -821,3 +718,5 @@ export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag]; * @returns {Array[String]} Converted array */ export const convertArrayToCamelCase = (array) => array.map((i) => convertToCamelCase(i)); + +export const isLoggedIn = () => Boolean(window.gon?.current_user_id); diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 2d4765f54b9..e41de72ded4 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,4 +1,5 @@ export const BYTES_IN_KIB = 1024; +export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250; export const HIDDEN_CLASS = 'hidden'; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js index 512b1f079a1..d68682ebed1 100644 --- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js @@ -1,10 +1,7 @@ -import $ from 'jquery'; import * as timeago from 'timeago.js'; -import { languageCode, s__ } from '../../../locale'; +import { languageCode, s__, createDateTimeFormat } from '../../../locale'; import { formatDate } from './date_format_utility'; -window.timeago = timeago; - /** * Timeago uses underscores instead of dashes to separate language from country code. * @@ -76,24 +73,44 @@ const memoizedLocale = () => { timeago.register(timeagoLanguageCode, memoizedLocale()); timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); -export const getTimeago = () => timeago; +let memoizedFormatter = null; + +function setupAbsoluteFormatter() { + if (memoizedFormatter === null) { + const formatter = createDateTimeFormat({ + dateStyle: 'medium', + timeStyle: 'short', + }); + + memoizedFormatter = { + format(date) { + return formatter.format(date instanceof Date ? date : new Date(date)); + }, + }; + } + return memoizedFormatter; +} + +export const getTimeago = () => + window.gon?.time_display_relative === false ? setupAbsoluteFormatter() : timeago; /** * For the given elements, sets a tooltip with a formatted date. - * @param {JQuery} $timeagoEls - * @param {Boolean} setTimeago + * @param {Array<Node>|NodeList} elements + * @param {Boolean} updateTooltip */ -export const localTimeAgo = ($timeagoEls, setTimeago = true) => { - $timeagoEls.each((i, el) => { - $(el).text(timeago.format($(el).attr('datetime'), timeagoLanguageCode)); +export const localTimeAgo = (elements, updateTooltip = true) => { + const { format } = getTimeago(); + elements.forEach((el) => { + el.innerText = format(el.dateTime, timeagoLanguageCode); }); - if (!setTimeago) { + if (!updateTooltip) { return; } function addTimeAgoTooltip() { - $timeagoEls.each((i, el) => { + elements.forEach((el) => { // Recreate with custom template el.setAttribute('title', formatDate(el.dateTime)); }); @@ -116,9 +133,3 @@ export const timeFor = (time, expiredLabel) => { } return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim(); }; - -window.gl = window.gl || {}; -window.gl.utils = { - ...(window.gl.utils || {}), - localTimeAgo, -}; diff --git a/app/assets/javascripts/lib/utils/finite_state_machine.js b/app/assets/javascripts/lib/utils/finite_state_machine.js new file mode 100644 index 00000000000..99eeb7cb947 --- /dev/null +++ b/app/assets/javascripts/lib/utils/finite_state_machine.js @@ -0,0 +1,101 @@ +/** + * @module finite_state_machine + */ + +/** + * The states to be used with state machine definitions + * @typedef {Object} FiniteStateMachineStates + * @property {!Object} ANY_KEY - Any key that maps to a known state + * @property {!Object} ANY_KEY.on - A dictionary of transition events for the ANY_KEY state that map to a different state + * @property {!String} ANY_KEY.on.ANY_EVENT - The resulting state that the machine should end at + */ + +/** + * An object whose minimum definition defined here can be used to guard UI state transitions + * @typedef {Object} StatelessFiniteStateMachineDefinition + * @property {FiniteStateMachineStates} states + */ + +/** + * An object whose minimum definition defined here can be used to create a live finite state machine + * @typedef {Object} LiveFiniteStateMachineDefinition + * @property {String} initial - The initial state for this machine + * @property {FiniteStateMachineStates} states + */ + +/** + * An object that allows interacting with a stateful, live finite state machine + * @typedef {Object} LiveStateMachine + * @property {String} value - The current state of this machine + * @property {Object} states - The states from when the machine definition was constructed + * @property {Function} is - {@link module:finite_state_machine~is LiveStateMachine.is} + * @property {Function} send - {@link module:finite_state_machine~send LiveStatemachine.send} + */ + +// This is not user-facing functionality +/* eslint-disable @gitlab/require-i18n-strings */ + +function hasKeys(object, keys) { + return keys.every((key) => Object.keys(object).includes(key)); +} + +/** + * Get an updated state given a machine definition, a starting state, and a transition event + * @param {StatelessFiniteStateMachineDefinition} definition + * @param {String} current - The current known state + * @param {String} event - A transition event + * @returns {String} A state value + */ +export function transition(definition, current, event) { + return definition?.states?.[current]?.on[event] || current; +} + +function startMachine({ states, initial } = {}) { + let current = initial; + + return { + /** + * A convenience function to test arbitrary input against the machine's current state + * @param {String} testState - The value to test against the machine's current state + */ + is(testState) { + return current === testState; + }, + /** + * A function to transition the live state machine using an arbitrary event + * @param {String} event - The event to send to the machine + * @returns {String} A string representing the current state. Note this may not have changed if the current state + transition event combination are not valid. + */ + send(event) { + current = transition({ states }, current, event); + + return current; + }, + get value() { + return current; + }, + set value(forcedState) { + current = forcedState; + }, + states, + }; +} + +/** + * Create a live state machine + * @param {LiveFiniteStateMachineDefinition} definition + * @returns {LiveStateMachine} A live state machine + */ +export function machine(definition) { + if (!hasKeys(definition, ['initial', 'states'])) { + throw new Error( + 'A state machine must have an initial state (`.initial`) and a dictionary of possible states (`.states`)', + ); + } else if (!hasKeys(definition.states, [definition.initial])) { + throw new Error( + `Cannot initialize the state machine to state '${definition.initial}'. Is that one of the machine's defined states?`, + ); + } else { + return startMachine(definition); + } +} diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index eaf396a7a59..5ee00464a8b 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -421,3 +421,61 @@ export const isValidSha1Hash = (str) => { export function insertFinalNewline(content, endOfLine = '\n') { return content.slice(-endOfLine.length) !== endOfLine ? `${content}${endOfLine}` : content; } + +export const markdownConfig = { + // allowedTags from GitLab's inline HTML guidelines + // https://docs.gitlab.com/ee/user/markdown.html#inline-html + ALLOWED_TAGS: [ + 'a', + 'abbr', + 'b', + 'blockquote', + 'br', + 'code', + 'dd', + 'del', + 'div', + 'dl', + 'dt', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'i', + 'img', + 'ins', + 'kbd', + 'li', + 'ol', + 'p', + 'pre', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'span', + 'strike', + 'strong', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'tr', + 'tt', + 'ul', + 'var', + ], + ALLOWED_ATTR: ['class', 'style', 'href', 'src'], + ALLOW_DATA_ATTR: false, +}; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index d68b41b7f7a..7922ff22a70 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -209,11 +209,7 @@ export function removeParams(params, url = window.location.href, skipEncoding = return `${root}${writableQuery}${writableFragment}`; } -export function getLocationHash(url = window.location.href) { - const hashIndex = url.indexOf('#'); - - return hashIndex === -1 ? null : url.substring(hashIndex + 1); -} +export const getLocationHash = (hash = window.location.hash) => hash.split('#')[1]; /** * Returns a boolean indicating whether the URL hash contains the given string value @@ -409,6 +405,55 @@ export function getWebSocketUrl(path) { return `${getWebSocketProtocol()}//${joinPaths(window.location.host, path)}`; } +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. + * + * @deprecated Please use `queryToObject(query, { gatherArrays: true });` instead. See https://gitlab.com/gitlab-org/gitlab/-/issues/328845 + */ +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; + }, {}); + /** * Convert search query into an object * @@ -450,17 +495,30 @@ export function queryToObject(query, { gatherArrays = false, legacySpacesDecode } /** + * This function accepts the `name` of the param 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 + * + * @param {String} name + * @param {String?} urlToParse + * @returns value of the parameter as string + */ +export const getParameterByName = (name, query = window.location.search) => { + return queryToObject(query)[name] || null; +}; + +/** * Convert search query object back into a search query * - * @param {Object} obj that needs to be converted + * @param {Object?} params that needs to be converted * @returns {String} * * ex: {one: 1, two: 2} into "one=1&two=2" * */ -export function objectToQuery(obj) { - return Object.keys(obj) - .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(obj[k])}`) +export function objectToQuery(params = {}) { + return Object.keys(params) + .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) .join('&'); } |