diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-07-20 09:55:51 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-07-20 09:55:51 +0000 |
commit | e8d2c2579383897a1dd7f9debd359abe8ae8373d (patch) | |
tree | c42be41678c2586d49a75cabce89322082698334 /app/assets/javascripts/lib | |
parent | fc845b37ec3a90aaa719975f607740c22ba6a113 (diff) | |
download | gitlab-ce-e8d2c2579383897a1dd7f9debd359abe8ae8373d.tar.gz |
Add latest changes from gitlab-org/gitlab@14-1-stable-eev14.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/lib')
-rw-r--r-- | app/assets/javascripts/lib/dompurify.js | 11 | ||||
-rw-r--r-- | app/assets/javascripts/lib/graphql.js | 52 | ||||
-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 |
9 files changed, 339 insertions, 149 deletions
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 76624c81ed5..4357918672d 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -7,6 +7,8 @@ const defaultConfig = { ADD_TAGS: ['use'], }; +const forbiddenDataAttrs = ['data-remote', 'data-url', 'data-type', 'data-method']; + // Only icons urls from `gon` are allowed const getAllowedIconUrls = (gon = window.gon) => [gon.sprite_file_icons, gon.sprite_icons].filter(Boolean); @@ -44,10 +46,19 @@ const sanitizeSvgIcon = (node) => { removeUnsafeHref(node, 'xlink:href'); }; +const sanitizeHTMLAttributes = (node) => { + forbiddenDataAttrs.forEach((attr) => { + if (node.hasAttribute(attr)) { + node.removeAttribute(attr); + } + }); +}; + addHook('afterSanitizeAttributes', (node) => { if (node.tagName.toLowerCase() === 'use') { sanitizeSvgIcon(node); } + sanitizeHTMLAttributes(node); }); export const sanitize = (val, config = defaultConfig) => dompurifySanitize(val, config); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index cec689a44ca..0804213cafa 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -2,12 +2,13 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { ApolloClient } from 'apollo-client'; import { ApolloLink } from 'apollo-link'; import { BatchHttpLink } from 'apollo-link-batch-http'; -import { createHttpLink } from 'apollo-link-http'; +import { HttpLink } from 'apollo-link-http'; import { createUploadLink } from 'apollo-upload-client'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import csrf from '~/lib/utils/csrf'; +import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; export const fetchPolicies = { @@ -18,6 +19,31 @@ export const fetchPolicies = { CACHE_ONLY: 'cache-only', }; +export const stripWhitespaceFromQuery = (url, path) => { + /* eslint-disable-next-line no-unused-vars */ + const [_, params] = url.split(path); + + if (!params) { + return url; + } + + const decoded = decodeURIComponent(params); + const paramsObj = queryToObject(decoded); + + if (!paramsObj.query) { + return url; + } + + const stripped = paramsObj.query + .split(/\s+|\n/) + .join(' ') + .trim(); + paramsObj.query = stripped; + + const reassembled = objectToQuery(paramsObj); + return `${path}?${reassembled}`; +}; + export default (resolvers = {}, config = {}) => { const { assumeImmutableResults, @@ -58,10 +84,31 @@ export default (resolvers = {}, config = {}) => { }); }); + /* + This custom fetcher intervention is to deal with an issue where we are using GET to access + eTag polling, but Apollo Client adds excessive whitespace, which causes the + request to fail on certain self-hosted stacks. When we can move + to subscriptions entirely or can land an upstream PR, this can be removed. + + Related links + Bug report: https://gitlab.com/gitlab-org/gitlab/-/issues/329895 + Moving to subscriptions: https://gitlab.com/gitlab-org/gitlab/-/issues/332485 + Apollo Client issue: https://github.com/apollographql/apollo-feature-requests/issues/182 + */ + + const fetchIntervention = (url, options) => { + return fetch(stripWhitespaceFromQuery(url, uri), options); + }; + + const requestLink = ApolloLink.split( + () => useGet, + new HttpLink({ ...httpOptions, fetch: fetchIntervention }), + new BatchHttpLink(httpOptions), + ); + const uploadsLink = ApolloLink.split( (operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest, createUploadLink(httpOptions), - useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions), ); const performanceBarLink = new ApolloLink((operation, forward) => { @@ -99,6 +146,7 @@ export default (resolvers = {}, config = {}) => { new StartupJSLink(), apolloCaptchaLink, uploadsLink, + requestLink, ]), ); 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('&'); } |