diff options
Diffstat (limited to 'app/assets/javascripts/lib')
38 files changed, 1110 insertions, 182 deletions
diff --git a/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js new file mode 100644 index 00000000000..5d2a002bf85 --- /dev/null +++ b/app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js @@ -0,0 +1,97 @@ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable class-methods-use-this */ +import { db } from './local_db'; + +/** + * IndexedDB implementation of apollo-cache-persist [PersistentStorage][1] + * + * [1]: https://github.com/apollographql/apollo-cache-persist/blob/d536c741d1f2828a0ef9abda343a9186dd8dbff2/src/types/index.ts#L15 + */ +export class IndexedDBPersistentStorage { + static async create() { + await db.open(); + + return new IndexedDBPersistentStorage(); + } + + async getItem(queryId) { + const resultObj = {}; + const selectedQuery = await db.table('queries').get(queryId); + const tableNames = new Set(db.tables.map((table) => table.name)); + + if (selectedQuery) { + resultObj.ROOT_QUERY = selectedQuery; + + const lookupTable = []; + + const parseObjectsForRef = async (selObject) => { + const ops = Object.values(selObject).map(async (child) => { + if (!child) { + return; + } + + if (child.__ref) { + const pathId = child.__ref; + const [refType, ...refKeyParts] = pathId.split(':'); + const refKey = refKeyParts.join(':'); + + if ( + !resultObj[pathId] && + !lookupTable.includes(pathId) && + tableNames.has(refType.toLowerCase()) + ) { + lookupTable.push(pathId); + const selectedEntity = await db.table(refType.toLowerCase()).get(refKey); + if (selectedEntity) { + await parseObjectsForRef(selectedEntity); + resultObj[pathId] = selectedEntity; + } + } + } else if (typeof child === 'object') { + await parseObjectsForRef(child); + } + }); + + return Promise.all(ops); + }; + + await parseObjectsForRef(resultObj.ROOT_QUERY); + } + + return resultObj; + } + + async setItem(key, value) { + await this.#setQueryResults(key, JSON.parse(value)); + } + + async removeItem() { + // apollo-cache-persist only ever calls this when we're removing everything, so let's blow it all away + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/113745#note_1329175993 + + await Promise.all( + db.tables.map((table) => { + return table.clear(); + }), + ); + } + + async #setQueryResults(queryId, results) { + await Promise.all( + Object.keys(results).map((id) => { + const objectType = id.split(':')[0]; + if (objectType === 'ROOT_QUERY') { + return db.table('queries').put(results[id], queryId); + } + const key = objectType.toLowerCase(); + const tableExists = db.tables.some((table) => table.name === key); + if (tableExists) { + return db.table(key).put(results[id], id); + } + return new Promise((resolve) => { + resolve(); + }); + }), + ); + } +} diff --git a/app/assets/javascripts/lib/apollo/local_db.js b/app/assets/javascripts/lib/apollo/local_db.js new file mode 100644 index 00000000000..cda30ff9d42 --- /dev/null +++ b/app/assets/javascripts/lib/apollo/local_db.js @@ -0,0 +1,14 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import Dexie from 'dexie'; + +export const db = new Dexie('GLLocalCache'); +db.version(1).stores({ + pages: 'url, timestamp', + queries: '', + project: 'id', + group: 'id', + usercore: 'id', + issue: 'id, state, title', + label: 'id, title', + milestone: 'id', +}); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index c0e923b2670..a4c13f9e40e 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,7 +1,7 @@ import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core'; import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; -import { persistCacheSync, LocalStorageWrapper } from 'apollo3-cache-persist'; +import { persistCache } from 'apollo3-cache-persist'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import possibleTypes from '~/graphql_shared/possible_types.json'; @@ -53,6 +53,15 @@ export const typePolicies = { TreeEntry: { keyFields: ['webPath'], }, + Subscription: { + fields: { + aiCompletionResponse: { + read(value) { + return value ?? null; + }, + }, + }, + }, }; export const stripWhitespaceFromQuery = (url, path) => { @@ -104,7 +113,7 @@ Object.defineProperty(window, 'pendingApolloRequests', { }, }); -export default (resolvers = {}, config = {}) => { +function createApolloClient(resolvers = {}, config = {}) { const { baseUrl, batchMax = 10, @@ -113,8 +122,10 @@ export default (resolvers = {}, config = {}) => { typeDefs, path = '/api/graphql', useGet = false, - localCacheKey = null, } = config; + + const shouldUnbatch = gon.features?.unbatchGraphqlQueries; + let ac = null; let uri = `${gon.relative_url_root || ''}${path}`; @@ -152,7 +163,7 @@ export default (resolvers = {}, config = {}) => { }; const requestLink = ApolloLink.split( - () => useGet, + () => useGet || shouldUnbatch, new HttpLink({ ...httpOptions, fetch: fetchIntervention }), new BatchHttpLink(httpOptions), ); @@ -171,6 +182,7 @@ export default (resolvers = {}, config = {}) => { config: { url: httpResponse.url, operationName: operation.operationName, + method: operation.getContext()?.fetchOptions?.method || 'POST', // If method is not explicitly set, we default to POST request }, headers: { 'x-request-id': httpResponse.headers.get('x-request-id'), @@ -237,16 +249,6 @@ export default (resolvers = {}, config = {}) => { }, }); - if (localCacheKey) { - persistCacheSync({ - cache: newCache, - // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode - debug: process.env.NODE_ENV === 'development', - storage: new LocalStorageWrapper(window.localStorage), - persistenceMapper, - }); - } - ac = new ApolloClient({ typeDefs, link: appLink, @@ -262,5 +264,42 @@ export default (resolvers = {}, config = {}) => { acs.push(ac); - return ac; + return { client: ac, cache: newCache }; +} + +export async function createApolloClientWithCaching(resolvers = {}, config = {}) { + const { localCacheKey = null } = config; + const { client, cache } = createApolloClient(resolvers, config); + + if (localCacheKey) { + let storage; + + // Test that we can use IndexedDB. If not, no persisting for you! + try { + const { IndexedDBPersistentStorage } = await import( + /* webpackChunkName: 'indexed_db_persistent_storage' */ './apollo/indexed_db_persistent_storage' + ); + + storage = await IndexedDBPersistentStorage.create(); + } catch (error) { + return client; + } + + await persistCache({ + cache, + // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode + debug: process.env.NODE_ENV === 'development', + storage, + key: localCacheKey, + persistenceMapper, + }); + } + + return client; +} + +export default (resolvers = {}, config = {}) => { + const { client } = createApolloClient(resolvers, config); + + return client; }; diff --git a/app/assets/javascripts/lib/mermaid.js b/app/assets/javascripts/lib/mermaid.js index c72561ce69d..bbc1d8ae1e1 100644 --- a/app/assets/javascripts/lib/mermaid.js +++ b/app/assets/javascripts/lib/mermaid.js @@ -6,17 +6,20 @@ const setIframeRenderedSize = (h, w) => { window.parent.postMessage({ h, w }, origin); }; -const drawDiagram = (source) => { +const drawDiagram = async (source) => { const element = document.getElementById('app'); const insertSvg = (svgCode) => { // eslint-disable-next-line no-unsanitized/property element.innerHTML = svgCode; - const height = parseInt(element.firstElementChild.getAttribute('height'), 10); - const width = parseInt(element.firstElementChild.style.maxWidth, 10); + element.firstElementChild.removeAttribute('height'); + const { height, width } = element.firstElementChild.getBoundingClientRect(); + setIframeRenderedSize(height, width); }; - mermaid.mermaidAPI.render('mermaid', source, insertSvg); + + const { svg } = await mermaid.mermaidAPI.render('mermaid', source); + insertSvg(svg); }; const darkModeEnabled = () => getParameterByName('darkMode') === 'true'; diff --git a/app/assets/javascripts/lib/mousetrap.js b/app/assets/javascripts/lib/mousetrap.js new file mode 100644 index 00000000000..ef3f54ec314 --- /dev/null +++ b/app/assets/javascripts/lib/mousetrap.js @@ -0,0 +1,59 @@ +// This is the only file allowed to import directly from the package. +// eslint-disable-next-line no-restricted-imports +import Mousetrap from 'mousetrap'; + +const additionalStopCallbacks = []; +const originalStopCallback = Mousetrap.prototype.stopCallback; + +Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) { + for (const callback of additionalStopCallbacks) { + const returnValue = callback.call(this, e, element, combo); + if (returnValue !== undefined) return returnValue; + } + + return originalStopCallback.call(this, e, element, combo); +}; + +/** + * Add a stop callback to Mousetrap. + * + * This allows overriding the default behaviour of Mousetrap#stopCallback, + * which is to stop the bound key handler/callback from being called if the key + * combo is pressed inside form fields (input, select, textareas, etc). See + * https://craig.is/killing/mice#api.stopCallback. + * + * The stopCallback registered here has the same signature as + * Mousetrap#stopCallback, with the one difference being that the callback + * should return `undefined` if it has no opinion on whether the current key + * combo should be stopped or not, and the next stop callback should be + * consulted instead. If a boolean is returned, no other stop callbacks are + * called. + * + * Note: This approach does not always work as expected when coupled with + * Mousetrap's pause plugin, which is used for enabling/disabling all keyboard + * shortcuts. That plugin assumes it's the first to execute and overwrite + * Mousetrap's `stopCallback` method, whereas to work correctly with this, it + * must execute last. This is not guaranteed or even attempted. + * + * To work correctly, we may need to reimplement the pause plugin here. + * + * @param {(e: Event, element: Element, combo: string) => boolean|undefined} + * stopCallback The additional stop callback function to add to the chain + * of stop callbacks. + * @returns {void} + */ +export const addStopCallback = (stopCallback) => { + // Unshift, since we want to iterate through them in reverse order, so that + // the most recently added handler is called first, and the original + // stopCallback method is called last. + additionalStopCallbacks.unshift(stopCallback); +}; + +/** + * Clear additionalStopCallbacks. Used only for tests. + */ +export const clearStopCallbacksForTests = () => { + additionalStopCallbacks.length = 0; +}; + +export { Mousetrap }; diff --git a/app/assets/javascripts/lib/swagger.js b/app/assets/javascripts/lib/swagger.js index ed646176604..fcdab18c623 100644 --- a/app/assets/javascripts/lib/swagger.js +++ b/app/assets/javascripts/lib/swagger.js @@ -1,6 +1,13 @@ import { SwaggerUIBundle } from 'swagger-ui-dist'; import { safeLoad } from 'js-yaml'; import { isObject } from '~/lib/utils/type_utility'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import { resetServiceWorkersPublicPath } from '~/lib/utils/webpack'; + +const resetWebpackPublicPath = () => { + window.gon = { relative_url_root: getParameterByName('relativeRootPath') }; + resetServiceWorkersPublicPath(); +}; const renderSwaggerUI = (value) => { /* SwaggerUIBundle accepts openapi definition @@ -12,6 +19,8 @@ const renderSwaggerUI = (value) => { spec = safeLoad(spec, { json: true }); } + resetWebpackPublicPath(); + Promise.all([import(/* webpackChunkName: 'openapi' */ 'swagger-ui-dist/swagger-ui.css')]) .then(() => { SwaggerUIBundle({ diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js index 7da3bab0a4b..520d7f627f6 100644 --- a/app/assets/javascripts/lib/utils/chart_utils.js +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -1,3 +1,6 @@ +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { __ } from '~/locale'; + const commonTooltips = () => ({ mode: 'x', intersect: false, @@ -98,3 +101,38 @@ export const firstAndLastY = (data) => { return [firstY, lastY]; }; + +const toolboxIconSvgPath = async (name) => { + return `path://${await getSvgIconPathContent(name)}`; +}; + +export const getToolboxOptions = async () => { + const promises = ['marquee-selection', 'redo', 'repeat', 'download'].map(toolboxIconSvgPath); + + try { + const [marqueeSelectionPath, redoPath, repeatPath, downloadPath] = await Promise.all(promises); + + return { + toolbox: { + feature: { + dataZoom: { + icon: { zoom: marqueeSelectionPath, back: redoPath }, + }, + restore: { + icon: repeatPath, + }, + saveAsImage: { + icon: downloadPath, + }, + }, + }, + }; + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn(__('SVG could not be rendered correctly: '), e); + } + + return {}; + } +}; diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js index 3d8df4fde05..a9f4257e28b 100644 --- a/app/assets/javascripts/lib/utils/color_utils.js +++ b/app/assets/javascripts/lib/utils/color_utils.js @@ -8,7 +8,7 @@ const colorValidatorEl = document.createElement('div'); * element’s color property. If the color expression is valid, * the DOM API will accept the value. * - * @param {String} color color expression rgba, hex, hsla, etc. + * @param {String} colorExpression color expression rgba, hex, hsla, etc. */ export const isValidColorExpression = (colorExpression) => { colorValidatorEl.style.color = ''; @@ -18,32 +18,6 @@ export const isValidColorExpression = (colorExpression) => { }; /** - * Convert hex color to rgb array - * - * @param hex string - * @returns array|null - */ -export const hexToRgb = (hex) => { - // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") - const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; - const fullHex = hex.replace(shorthandRegex, (_m, r, g, b) => r + r + g + g + b + b); - - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(fullHex); - return result - ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] - : null; -}; - -export const textColorForBackground = (backgroundColor) => { - const [r, g, b] = hexToRgb(backgroundColor); - - if (r + g + b > 500) { - return '#333333'; - } - return '#FFFFFF'; -}; - -/** * Check whether a color matches the expected hex format * * This matches any hex (0-9 and A-F) value which is either 3 or 6 characters in length diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js index 3bfbfea7f22..a6081303bf8 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js @@ -12,6 +12,7 @@ export function confirmAction( modalHtmlMessage, title, hideCancel, + size, } = {}, ) { return new Promise((resolve) => { @@ -36,6 +37,7 @@ export function confirmAction( title, modalHtmlMessage, hideCancel, + size, }, on: { confirmed() { diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue index ea91ccec546..24be1485379 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue @@ -56,6 +56,11 @@ export default { required: false, default: false, }, + size: { + type: String, + required: false, + default: 'sm', + }, }, computed: { primaryAction() { @@ -103,9 +108,9 @@ export default { <template> <gl-modal ref="modal" - size="sm" modal-id="confirmationModal" body-class="gl-display-flex" + :size="size" :title="title" :action-primary="primaryAction" :action-cancel="cancelAction" diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 2c8953237cf..fb69a61880a 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -26,3 +26,8 @@ export const DEFAULT_TH_CLASSES = export const DRAWER_Z_INDEX = 252; export const MIN_USERNAME_LENGTH = 2; + +export const BYTES_FORMAT_BYTES = 'Bytes'; +export const BYTES_FORMAT_KIB = 'KiB'; +export const BYTES_FORMAT_MIB = 'MiB'; +export const BYTES_FORMAT_GIB = 'GiB'; diff --git a/app/assets/javascripts/lib/utils/css_utils.js b/app/assets/javascripts/lib/utils/css_utils.js index e4f68dd1b6c..87cc69bad61 100644 --- a/app/assets/javascripts/lib/utils/css_utils.js +++ b/app/assets/javascripts/lib/utils/css_utils.js @@ -23,3 +23,28 @@ export function loadCSSFile(path) { export function getCssVariable(variable) { return getComputedStyle(document.documentElement).getPropertyValue(variable).trim(); } + +/** + * Return the measured width and height of a temporary element with the given + * CSS classes. + * + * Multiple classes can be given by separating them with spaces. + * + * Since this forces a layout calculation, do not call this frequently or in + * loops. + * + * Finally, this assumes the styles for the given classes are loaded. + * + * @param {string} className CSS class(es) to apply to a temporary element and + * measure. + * @returns {{ width: number, height: number }} Measured width and height in + * CSS pixels. + */ +export function getCssClassDimensions(className) { + const el = document.createElement('div'); + el.className = className; + document.body.appendChild(el); + const { width, height } = el.getBoundingClientRect(); + el.remove(); + return { width, height }; +} diff --git a/app/assets/javascripts/lib/utils/datetime/constants.js b/app/assets/javascripts/lib/utils/datetime/constants.js new file mode 100644 index 00000000000..869ade45ebd --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime/constants.js @@ -0,0 +1,7 @@ +// Keys for the memoized Intl dateTime formatters +export const DATE_WITH_TIME_FORMAT = 'DATE_WITH_TIME_FORMAT'; +export const DATE_ONLY_FORMAT = 'DATE_ONLY_FORMAT'; + +export const DEFAULT_DATE_TIME_FORMAT = DATE_WITH_TIME_FORMAT; + +export const DATE_TIME_FORMATS = [DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT]; diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js index 04a82836f69..e1a57bf4589 100644 --- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js @@ -162,16 +162,24 @@ export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z', utc = fals * @returns {string} */ export const formatTime = (milliseconds) => { - const remainingSeconds = Math.floor(milliseconds / 1000) % 60; - const remainingMinutes = Math.floor(milliseconds / 1000 / 60) % 60; - const remainingHours = Math.floor(milliseconds / 1000 / 60 / 60); + const seconds = Math.round(milliseconds / 1000); + const absSeconds = Math.abs(seconds); + + const remainingSeconds = Math.floor(absSeconds) % 60; + const remainingMinutes = Math.floor(absSeconds / 60) % 60; + const hours = Math.floor(absSeconds / 60 / 60); + let formattedTime = ''; - if (remainingHours < 10) formattedTime += '0'; - formattedTime += `${remainingHours}:`; + if (hours < 10) formattedTime += '0'; + formattedTime += `${hours}:`; if (remainingMinutes < 10) formattedTime += '0'; formattedTime += `${remainingMinutes}:`; if (remainingSeconds < 10) formattedTime += '0'; formattedTime += remainingSeconds; + + if (seconds < 0) { + return `-${formattedTime}`; + } return formattedTime; }; @@ -203,7 +211,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => { const isNonZero = Boolean(unitValue); if (fullNameFormat && isNonZero) { - // Remove traling 's' if unit value is singular + // Remove trailing 's' if unit value is singular const formattedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, ''); return `${memo} ${unitValue} ${formattedUnitName}`; } @@ -387,26 +395,6 @@ export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, mont return '-'; }; -export const durationTimeFormatted = (duration) => { - const date = new Date(duration * 1000); - - let hh = date.getUTCHours(); - let mm = date.getUTCMinutes(); - let ss = date.getSeconds(); - - if (hh < 10) { - hh = `0${hh}`; - } - if (mm < 10) { - mm = `0${mm}`; - } - if (ss < 10) { - ss = `0${ss}`; - } - - return `${hh}:${mm}:${ss}`; -}; - /** * Converts a numeric utc offset in seconds to +/- hours * ie -32400 => -9 hours diff --git a/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js new file mode 100644 index 00000000000..64c77bf1080 --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime/time_spent_utility.js @@ -0,0 +1,13 @@ +import { stringifyTime, parseSeconds } from './date_format_utility'; + +/** + * Formats seconds into a human readable value of elapsed time, + * optionally limiting it to hours. + * @param {Number} seconds Seconds to format + * @param {Boolean} limitToHours Whether or not to limit the elapsed time to be expressed in hours + * @return {String} Provided seconds in human readable elapsed time format + */ +export const formatTimeSpent = (seconds, limitToHours) => { + const negative = seconds < 0; + return (negative ? '- ' : '') + stringifyTime(parseSeconds(seconds, { limitToHours })); +}; diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js index 05f34db662a..a973cd890ba 100644 --- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js @@ -1,6 +1,7 @@ import * as timeago from 'timeago.js'; import { languageCode, s__, createDateTimeFormat } from '~/locale'; import { formatDate } from './date_format_utility'; +import { DATE_WITH_TIME_FORMAT, DATE_ONLY_FORMAT, DEFAULT_DATE_TIME_FORMAT } from './constants'; /** * Timeago uses underscores instead of dashes to separate language from country code. @@ -106,26 +107,39 @@ timeago.register(timeagoLanguageCode, memoizedLocale()); timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); timeago.register(`${timeagoLanguageCode}-duration`, memoizedLocaleDuration()); -let memoizedFormatter = null; +const setupAbsoluteFormatters = () => { + const cache = {}; -function setupAbsoluteFormatter() { - if (memoizedFormatter === null) { - const formatter = createDateTimeFormat({ - dateStyle: 'medium', - timeStyle: 'short', - }); + // Intl.DateTimeFormat options (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options) + const formats = { + [DATE_WITH_TIME_FORMAT]: () => ({ dateStyle: 'medium', timeStyle: 'short' }), + [DATE_ONLY_FORMAT]: () => ({ dateStyle: 'medium' }), + }; + + return (formatName = DEFAULT_DATE_TIME_FORMAT) => { + if (cache[formatName]) { + return cache[formatName]; + } + + let format = formats[formatName] && formats[formatName](); + if (!format) { + format = formats[DEFAULT_DATE_TIME_FORMAT](); + } + + const formatter = createDateTimeFormat(format); - memoizedFormatter = { + cache[formatName] = { format(date) { return formatter.format(date instanceof Date ? date : new Date(date)); }, }; - } - return memoizedFormatter; -} + return cache[formatName]; + }; +}; +const memoizedFormatters = setupAbsoluteFormatters(); -export const getTimeago = () => - window.gon?.time_display_relative === false ? setupAbsoluteFormatter() : timeago; +export const getTimeago = (formatName) => + window.gon?.time_display_relative === false ? memoizedFormatters(formatName) : timeago; /** * For the given elements, sets a tooltip with a formatted date. diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index c1081239544..a6331bc6551 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -1,4 +1,6 @@ +export * from './datetime/constants'; export * from './datetime/timeago_utility'; export * from './datetime/date_format_utility'; export * from './datetime/date_calculation_utility'; export * from './datetime/pikaday_utility'; +export * from './datetime/time_spent_utility'; diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 317c401e404..198f2da385c 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -39,7 +39,7 @@ export const toggleContainerClasses = (containerEl, classList) => { * Return a object mapping element dataset names to booleans. * * This is useful for data- attributes whose presense represent - * a truthiness, no matter the value of the attribute. The absense of the + * a truthiness, no matter the value of the attribute. The absence of the * attribute represents falsiness. * * This can be useful when Rails-provided boolean-like values are passed diff --git a/app/assets/javascripts/lib/utils/error_message.js b/app/assets/javascripts/lib/utils/error_message.js new file mode 100644 index 00000000000..febf83a4d38 --- /dev/null +++ b/app/assets/javascripts/lib/utils/error_message.js @@ -0,0 +1,15 @@ +/** + * Utility to parse an error object returned from API. + * + * + * @param { Object } error - An error object directly from API response + * @param { string } error.message - The error message, returned from API. + * @param { string } defaultMessage - Default user-facing error message + * @returns { string } - A transformed user-facing error message, or defaultMessage + */ +export const parseErrorMessage = (error = {}, defaultMessage = '') => { + const messageString = error.message || ''; + return messageString.startsWith(window.gon.uf_error_prefix) + ? messageString.replace(window.gon.uf_error_prefix, '').trim() + : defaultMessage; +}; diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js index f99a4927338..c80d3f24d07 100644 --- a/app/assets/javascripts/lib/utils/file_upload.js +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -29,3 +29,10 @@ export const validateImageName = (file) => { const legalImageRegex = /^[\w.\-+]+\.(png|jpg|jpeg|gif|bmp|tiff|ico|webp)$/; return legalImageRegex.test(fileName) ? fileName : 'image.png'; }; + +export const validateFileFromAllowList = (fileName, allowList) => { + const parts = fileName.split('.'); + const ext = `.${parts[parts.length - 1]}`; + + return allowList.includes(ext); +}; diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js index bd47f10b3ac..7cfcd11ece9 100644 --- a/app/assets/javascripts/lib/utils/keys.js +++ b/app/assets/javascripts/lib/utils/keys.js @@ -1,3 +1,7 @@ export const ESC_KEY = 'Escape'; export const ENTER_KEY = 'Enter'; export const BACKSPACE_KEY = 'Backspace'; +export const ARROW_DOWN_KEY = 'ArrowDown'; +export const ARROW_UP_KEY = 'ArrowUp'; +export const END_KEY = 'End'; +export const HOME_KEY = 'Home'; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index b0e31fe729b..d64f84d2040 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -1,5 +1,12 @@ import { sprintf, __ } from '~/locale'; -import { BYTES_IN_KIB, THOUSAND } from './constants'; +import { + BYTES_IN_KIB, + THOUSAND, + BYTES_FORMAT_BYTES, + BYTES_FORMAT_KIB, + BYTES_FORMAT_MIB, + BYTES_FORMAT_GIB, +} from './constants'; /** * Function that allows a number with an X amount of decimals @@ -64,25 +71,51 @@ export function bytesToGiB(number) { } /** - * Port of rails number_to_human_size * Formats the bytes in number into a more understandable - * representation (e.g., giving it 1500 yields 1.5 KB). + * representation. Returns an array with the first value being the human size + * and the second value being the format (e.g., [1.5, 'KiB']). * * @param {Number} size * @param {Number} digits - The number of digits to appear after the decimal point * @returns {String} */ -export function numberToHumanSize(size, digits = 2) { +export function numberToHumanSizeSplit(size, digits = 2) { const abs = Math.abs(size); if (abs < BYTES_IN_KIB) { - return sprintf(__('%{size} bytes'), { size }); + return [size.toString(), BYTES_FORMAT_BYTES]; } else if (abs < BYTES_IN_KIB ** 2) { - return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(digits) }); + return [bytesToKiB(size).toFixed(digits), BYTES_FORMAT_KIB]; } else if (abs < BYTES_IN_KIB ** 3) { - return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(digits) }); + return [bytesToMiB(size).toFixed(digits), BYTES_FORMAT_MIB]; + } + return [bytesToGiB(size).toFixed(digits), BYTES_FORMAT_GIB]; +} + +/** + * Port of rails number_to_human_size + * Formats the bytes in number into a more understandable + * representation (e.g., giving it 1500 yields 1.5 KB). + * + * @param {Number} size + * @param {Number} digits - The number of digits to appear after the decimal point + * @returns {String} + */ +export function numberToHumanSize(size, digits = 2) { + const [humanSize, format] = numberToHumanSizeSplit(size, digits); + + switch (format) { + case BYTES_FORMAT_BYTES: + return sprintf(__('%{size} bytes'), { size: humanSize }); + case BYTES_FORMAT_KIB: + return sprintf(__('%{size} KiB'), { size: humanSize }); + case BYTES_FORMAT_MIB: + return sprintf(__('%{size} MiB'), { size: humanSize }); + case BYTES_FORMAT_GIB: + return sprintf(__('%{size} GiB'), { size: humanSize }); + default: + return ''; } - return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(digits) }); } /** diff --git a/app/assets/javascripts/lib/utils/ref_validator.js b/app/assets/javascripts/lib/utils/ref_validator.js new file mode 100644 index 00000000000..d679a3b4198 --- /dev/null +++ b/app/assets/javascripts/lib/utils/ref_validator.js @@ -0,0 +1,145 @@ +import { __, sprintf } from '~/locale'; + +// this service validates tagName agains git ref format. +// the git spec can be found here: https://git-scm.com/docs/git-check-ref-format#_description + +// the ruby counterpart of the validator is here: +// lib/gitlab/git_ref_validator.rb + +const EXPANDED_PREFIXES = ['refs/heads/', 'refs/remotes/', 'refs/tags']; +const DISALLOWED_PREFIXES = ['-', '/']; +const DISALLOWED_POSTFIXES = ['/']; +const DISALLOWED_NAMES = ['HEAD', '@']; +const DISALLOWED_SUBSTRINGS = [' ', '\\', '~', ':', '..', '^', '?', '*', '[', '@{']; +const DISALLOWED_SEQUENCE_POSTFIXES = ['.lock', '.']; +const DISALLOWED_SEQUENCE_PREFIXES = ['.']; + +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTERS_REGEX = /[\x00-\x19\x7f]/; + +const toReadableString = (array) => array.map((item) => `"${item}"`).join(', '); + +const DisallowedPrefixesValidationMessage = sprintf( + __('Tag name should not start with %{prefixes}'), + { + prefixes: toReadableString([...EXPANDED_PREFIXES, ...DISALLOWED_PREFIXES]), + }, + false, +); + +const DisallowedPostfixesValidationMessage = sprintf( + __('Tag name should not end with %{postfixes}'), + { postfixes: toReadableString(DISALLOWED_POSTFIXES) }, + false, +); + +const DisallowedNameValidationMessage = sprintf( + __('Tag name cannot be one of the following: %{names}'), + { names: toReadableString(DISALLOWED_NAMES) }, + false, +); + +const EmptyNameValidationMessage = __('Tag name should not be empty'); + +const DisallowedSubstringsValidationMessage = sprintf( + __('Tag name should not contain any of the following: %{substrings}'), + { substrings: toReadableString(DISALLOWED_SUBSTRINGS) }, + false, +); + +const DisallowedSequenceEmptyValidationMessage = __( + `No slash-separated tag name component can be empty`, +); + +const DisallowedSequencePrefixesValidationMessage = sprintf( + __('No slash-separated component can begin with %{sequencePrefixes}'), + { sequencePrefixes: toReadableString(DISALLOWED_SEQUENCE_PREFIXES) }, + false, +); + +const DisallowedSequencePostfixesValidationMessage = sprintf( + __('No slash-separated component can end with %{sequencePostfixes}'), + { sequencePostfixes: toReadableString(DISALLOWED_SEQUENCE_POSTFIXES) }, + false, +); + +const ControlCharactersValidationMessage = __('Tag name should not contain any control characters'); + +export const validationMessages = { + EmptyNameValidationMessage, + DisallowedPrefixesValidationMessage, + DisallowedPostfixesValidationMessage, + DisallowedNameValidationMessage, + DisallowedSubstringsValidationMessage, + DisallowedSequenceEmptyValidationMessage, + DisallowedSequencePrefixesValidationMessage, + DisallowedSequencePostfixesValidationMessage, + ControlCharactersValidationMessage, +}; + +export class ValidationResult { + isValid = true; + validationErrors = []; + + addValidationError = (errorMessage) => { + this.isValid = false; + this.validationErrors.push(errorMessage); + }; +} + +export const validateTag = (refName) => { + if (typeof refName !== 'string') { + throw new Error('refName argument must be a string'); + } + + const validationResult = new ValidationResult(); + + if (!refName || refName.trim() === '') { + validationResult.addValidationError(EmptyNameValidationMessage); + return validationResult; + } + + if (CONTROL_CHARACTERS_REGEX.test(refName)) { + validationResult.addValidationError(ControlCharactersValidationMessage); + } + + if (DISALLOWED_NAMES.some((name) => name === refName)) { + validationResult.addValidationError(DisallowedNameValidationMessage); + } + + if ([...EXPANDED_PREFIXES, ...DISALLOWED_PREFIXES].some((prefix) => refName.startsWith(prefix))) { + validationResult.addValidationError(DisallowedPrefixesValidationMessage); + } + + if (DISALLOWED_POSTFIXES.some((postfix) => refName.endsWith(postfix))) { + validationResult.addValidationError(DisallowedPostfixesValidationMessage); + } + + if (DISALLOWED_SUBSTRINGS.some((substring) => refName.includes(substring))) { + validationResult.addValidationError(DisallowedSubstringsValidationMessage); + } + + const refNameParts = refName.split('/'); + + if (refNameParts.some((part) => part === '')) { + validationResult.addValidationError(DisallowedSequenceEmptyValidationMessage); + } + + if ( + refNameParts.some((part) => + DISALLOWED_SEQUENCE_PREFIXES.some((prefix) => part.startsWith(prefix)), + ) + ) { + validationResult.addValidationError(DisallowedSequencePrefixesValidationMessage); + } + + if ( + refNameParts.some((part) => + DISALLOWED_SEQUENCE_POSTFIXES.some((postfix) => part.endsWith(postfix)), + ) + ) { + validationResult.addValidationError(DisallowedSequencePostfixesValidationMessage); + } + + return validationResult; +}; diff --git a/app/assets/javascripts/lib/utils/resize_observer.js b/app/assets/javascripts/lib/utils/resize_observer.js index 5d194340b9e..1db863294f8 100644 --- a/app/assets/javascripts/lib/utils/resize_observer.js +++ b/app/assets/javascripts/lib/utils/resize_observer.js @@ -19,27 +19,31 @@ export function createResizeObserver() { * @param {Object} options * @param {string} options.targetId - id of element to scroll to * @param {string} options.container - Selector of element containing target + * @param {Element} options.component - Element containing target * * @return {ResizeObserver|null} - ResizeObserver instance if target looks like a note DOM ID */ export function scrollToTargetOnResize({ targetId = window.location.hash.slice(1), container = '#content-body', + containerId, } = {}) { if (!targetId) return null; const ro = createResizeObserver(); - const containerEl = document.querySelector(container); + const containerEl = + document.querySelector(`#${containerId}`) || document.querySelector(container); let interactionListenersAdded = false; - function keepTargetAtTop() { + function keepTargetAtTop(evt) { const anchorEl = document.getElementById(targetId); + const scrollContainer = containerId ? evt.target : document.documentElement; if (!anchorEl) return; const anchorTop = anchorEl.getBoundingClientRect().top + window.scrollY; const top = anchorTop - contentTop(); - document.documentElement.scrollTo({ + scrollContainer.scrollTo({ top, }); diff --git a/app/assets/javascripts/lib/utils/secret_detection.js b/app/assets/javascripts/lib/utils/secret_detection.js new file mode 100644 index 00000000000..2807911c9bb --- /dev/null +++ b/app/assets/javascripts/lib/utils/secret_detection.js @@ -0,0 +1,45 @@ +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { s__, __ } from '~/locale'; + +export const i18n = { + defaultPrompt: s__( + 'SecretDetection|This comment appears to have a token in it. Are you sure you want to add it?', + ), + descriptionPrompt: s__( + 'SecretDetection|This description appears to have a token in it. Are you sure you want to add it?', + ), + primaryBtnText: __('Proceed'), +}; + +const sensitiveDataPatterns = [ + { + name: 'GitLab Personal Access Token', + regex: 'glpat-[0-9a-zA-Z_-]{20}', + }, + { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Feed Token', + regex: 'feed_token=[0-9a-zA-Z_-]{20}', + }, +]; + +export const containsSensitiveToken = (message) => { + for (const rule of sensitiveDataPatterns) { + const regex = new RegExp(rule.regex, 'gi'); + if (regex.test(message)) { + return true; + } + } + return false; +}; + +export async function confirmSensitiveAction(prompt = i18n.defaultPrompt) { + const confirmed = await confirmAction(prompt, { + primaryBtnVariant: 'danger', + primaryBtnText: i18n.primaryBtnText, + }); + if (!confirmed) { + return false; + } + return true; +} diff --git a/app/assets/javascripts/lib/utils/sticky.js b/app/assets/javascripts/lib/utils/sticky.js deleted file mode 100644 index a6d53358cb8..00000000000 --- a/app/assets/javascripts/lib/utils/sticky.js +++ /dev/null @@ -1,60 +0,0 @@ -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 && !el.classList.contains('is-stuck')) { - const placeholder = insertPlaceholder ? createPlaceholder() : null; - const heightBefore = el.offsetHeight; - - el.classList.add('is-stuck'); - - 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(); - } - } -}; - -/** - * Create a listener that will toggle a 'is-stuck' class, based on the current scroll position. - * - * - If the current environment does not support `position: sticky`, do nothing. - * - * @param {HTMLElement} el The `position: sticky` element. - * @param {Number} stickyTop Used to determine when an element is stuck. - * @param {Boolean} insertPlaceholder Should a placeholder element be created when element is stuck? - */ -export const stickyMonitor = (el, stickyTop, insertPlaceholder = true) => { - if (!el) return; - - if ( - typeof CSS === 'undefined' || - !CSS.supports('(position: -webkit-sticky) or (position: sticky)') - ) - return; - - document.addEventListener( - 'scroll', - () => isSticky(el, window.scrollY, stickyTop, insertPlaceholder), - { - passive: true, - }, - ); -}; diff --git a/app/assets/javascripts/lib/utils/tappable_promise.js b/app/assets/javascripts/lib/utils/tappable_promise.js new file mode 100644 index 00000000000..8d327dabe1b --- /dev/null +++ b/app/assets/javascripts/lib/utils/tappable_promise.js @@ -0,0 +1,49 @@ +/** + * A promise that is also tappable, i.e. something you can subscribe + * to to get progress of a promise until it resolves. + * + * @example Usage + * const tp = new TappablePromise((resolve, reject, tap) => { + * for (let i = 0; i < 10; i++) { + * tap(i/10); + * } + * resolve(); + * }); + * + * tp.tap((progress) => { + * console.log(progress); + * }).then(() => { + * console.log('done'); + * }); + * + * // Output: + * // 0 + * // 0.1 + * // 0.2 + * // ... + * // 0.9 + * // done + * + * + * @param {(resolve: Function, reject: Function, tap: Function) => void} callback + * @returns {Promise & { tap: Function }}} + */ +export default function TappablePromise(callback) { + let progressCallback; + + const promise = new Promise((resolve, reject) => { + try { + const tap = (progress) => progressCallback?.(progress); + resolve(callback(tap, resolve, reject)); + } catch (e) { + reject(e); + } + }); + + promise.tap = function tap(_progressCallback) { + progressCallback = _progressCallback; + return this; + }; + + return promise; +} diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 05ed08931bb..a2873622682 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import Shortcuts from '~/behaviors/shortcuts/shortcuts'; import { insertText } from '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; const LINK_TAG_PATTERN = '[{text}](url)'; const INDENT_CHAR = ' '; @@ -370,7 +371,7 @@ export function insertMarkdownText({ }); } -function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) { +export function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) { const $textArea = $(textArea); textArea = $textArea.get(0); const text = $textArea.val(); @@ -625,10 +626,9 @@ export function addMarkdownListeners(form) { Shortcuts.initMarkdownEditorShortcuts($(this), updateTextForToolbarBtn); }); - // eslint-disable-next-line @gitlab/no-global-event-off - const $allToolbarBtns = $('.js-md', form) - .off('click') - .on('click', function () { + const $allToolbarBtns = $(form) + .off('click', '.js-md') + .on('click', '.js-md', function () { const $toolbarBtn = $(this); return updateTextForToolbarBtn($toolbarBtn); @@ -669,3 +669,50 @@ export function removeMarkdownListeners(form) { // eslint-disable-next-line @gitlab/no-global-event-off return $('.js-md', form).off('click'); } + +/** + * If the textarea cursor is positioned in a Markdown image declaration, + * it uses the Markdown API to resolve the image’s absolute URL. + * @param {Object} textarea Textarea DOM element + * @param {String} markdownPreviewPath Markdown API path + * @returns {Object} an object containing the image’s absolute URL, filename, + * and the markdown declaration. If the textarea cursor is not positioned + * in an image, it returns null. + */ +export const resolveSelectedImage = async (textArea, markdownPreviewPath = '') => { + const { lines, startPos } = linesFromSelection(textArea); + + // image declarations can’t span more than one line in Markdown + if (lines > 0) { + return null; + } + + const selectedLine = lines[0]; + + if (!/!\[.+?\]\(.+?\)/.test(selectedLine)) return null; + + const lineSelectionStart = textArea.selectionStart - startPos; + const preExlm = selectedLine.substring(0, lineSelectionStart).lastIndexOf('!'); + const postClose = selectedLine.substring(lineSelectionStart).indexOf(')'); + + if (preExlm >= 0 && postClose >= 0) { + const imageMarkdown = selectedLine.substring(preExlm, lineSelectionStart + postClose + 1); + const { data } = await axios.post(markdownPreviewPath, { text: imageMarkdown }); + const parser = new DOMParser(); + + const dom = parser.parseFromString(data.body, 'text/html'); + const imageURL = dom.body.querySelector('a').getAttribute('href'); + + if (imageURL) { + const filename = imageURL.substring(imageURL.lastIndexOf('/') + 1); + + return { + imageMarkdown, + imageURL, + filename, + }; + } + } + + return null; +}; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 367180714df..963041dd5d0 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,4 +1,5 @@ import { isString, memoize } from 'lodash'; +import { sprintf, __ } from '~/locale'; import { base64ToBuffer, bufferToBase64 } from '~/authentication/webauthn/util'; import { TRUNCATE_WIDTH_DEFAULT_WIDTH, @@ -482,7 +483,7 @@ export const markdownConfig = { 'ul', 'var', ], - ALLOWED_ATTR: ['class', 'style', 'href', 'src'], + ALLOWED_ATTR: ['class', 'style', 'href', 'src', 'dir'], ALLOW_DATA_ATTR: false, }; @@ -525,3 +526,45 @@ export function base64DecodeUnicode(str) { const decoder = new TextDecoder('utf8'); return decoder.decode(base64ToBuffer(str)); } + +// returns an array of errors (if there are any) +const INVALID_BRANCH_NAME_CHARS = [' ', '~', '^', ':', '?', '*', '[', '..', '@{', '\\', '//']; + +/** + * Returns an array of invalid characters found in a branch name + * + * @param {String} name branch name to check + * @return {Array} Array of invalid characters found + */ +export const findInvalidBranchNameCharacters = (name) => { + const invalidChars = []; + + INVALID_BRANCH_NAME_CHARS.forEach((pattern) => { + if (name.indexOf(pattern) > -1) { + invalidChars.push(pattern); + } + }); + + return invalidChars; +}; + +/** + * Returns a string describing validation errors for a branch name + * + * @param {Array} invalidChars Array of invalid characters that were found + * @return {String} Error message describing on the invalid characters found + */ +export const humanizeBranchValidationErrors = (invalidChars = []) => { + const chars = invalidChars.filter((c) => INVALID_BRANCH_NAME_CHARS.includes(c)); + + if (chars.length && !chars.includes(' ')) { + return sprintf(__("Can't contain %{chars}"), { chars: chars.join(', ') }); + } else if (chars.includes(' ') && chars.length <= 1) { + return __("Can't contain spaces"); + } else if (chars.includes(' ') && chars.length > 1) { + return sprintf(__("Can't contain spaces, %{chars}"), { + chars: chars.filter((c) => c !== ' ').join(', '), + }); + } + return ''; +}; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index f33484f4192..f16ff188edb 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -5,8 +5,11 @@ const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`); const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`); const SHA_REGEX = /[\da-f]{40}/gi; +// GitLab default domain (override in jh) +export const DOMAIN = 'gitlab.com'; + // About GitLab default host (overwrite in jh) -export const PROMO_HOST = 'about.gitlab.com'; +export const PROMO_HOST = `about.${DOMAIN}`; // about.gitlab.com // About Gitlab default url (overwrite in jh) export const PROMO_URL = `https://${PROMO_HOST}`; @@ -269,6 +272,11 @@ export const setUrlFragment = (url, fragment) => { return `${rootUrl}#${encodedFragment}`; }; +/** + * Navigates to a URL + * @param {*} url - url to navigate to + * @param {*} external - if true, open a new page or tab + */ export function visitUrl(url, external = false) { if (external) { // Simulate `target="_blank" rel="noopener noreferrer"` @@ -281,6 +289,19 @@ export function visitUrl(url, external = false) { } } +export function refreshCurrentPage() { + visitUrl(window.location.href); +} + +/** + * Navigates to a URL + * @deprecated Use visitUrl from ~/lib/utils/url_utility.js instead + * @param {*} url + */ +export function redirectTo(url) { + return window.location.assign(url); +} + export function updateHistory({ state = {}, title = '', url, replace = false, win = window } = {}) { if (win.history) { if (replace) { @@ -291,14 +312,6 @@ export function updateHistory({ state = {}, title = '', url, replace = false, wi } } -export function refreshCurrentPage() { - visitUrl(window.location.href); -} - -export function redirectTo(url) { - return window.location.assign(url); -} - export const escapeFileUrl = (fileUrl) => encodeURIComponent(fileUrl).replace(/%2F/g, '/'); export function webIDEUrl(route = undefined) { @@ -318,15 +331,6 @@ export function getBaseURL() { } /** - * Takes a URL and returns content from the start until the final '/' - * - * @param {String} url - full url, including protocol and host - */ -export function stripFinalUrlSegment(url) { - return new URL('.', url).href; -} - -/** * Returns true if url is an absolute URL * * @param {String} url diff --git a/app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js b/app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js new file mode 100644 index 00000000000..ec8feb7d2e6 --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; + +export const compatFunctionalMixin = Vue.version.startsWith('3') + ? { + created() { + this.props = this.$props; + this.listeners = this.$listeners; + }, + } + : { + created() { + throw new Error('This mixin should not be executed in Vue.js 2'); + }, + }; diff --git a/app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js b/app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js new file mode 100644 index 00000000000..b69f5e0c546 --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js @@ -0,0 +1,9 @@ +// See https://v3-migration.vuejs.org/breaking-changes/custom-directives.html#edge-case-accessing-the-component-instance +export function getInstanceFromDirective({ binding, vnode }) { + if (binding.instance) { + // this is Vue.js 3, even in compat mode + return binding.instance; + } + + return vnode.context; +} diff --git a/app/assets/javascripts/lib/utils/vue3compat/mark_raw.js b/app/assets/javascripts/lib/utils/vue3compat/mark_raw.js new file mode 100644 index 00000000000..daafbad8ba1 --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/mark_raw.js @@ -0,0 +1,9 @@ +// this will be replaced by markRaw from vue.js v3 +export function markRaw(obj) { + Object.defineProperty(obj, '__v_skip', { + value: true, + configurable: true, + }); + + return obj; +} diff --git a/app/assets/javascripts/lib/utils/vue3compat/normalize_children.js b/app/assets/javascripts/lib/utils/vue3compat/normalize_children.js new file mode 100644 index 00000000000..616bd4786a9 --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/normalize_children.js @@ -0,0 +1,11 @@ +export function normalizeChildren(children) { + if (typeof children !== 'object' || Array.isArray(children)) { + return children; + } + + if (typeof children.default === 'function') { + return children.default(); + } + + return children; +} diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js new file mode 100644 index 00000000000..fd08d34a80e --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js @@ -0,0 +1,78 @@ +import Vue from 'vue'; +import { createApolloProvider } from '@vue/apollo-option'; +import { ApolloMutation } from '@vue/apollo-components'; + +export { ApolloMutation }; + +const installed = new WeakMap(); + +function callLifecycle(hookName, ...extraArgs) { + const { GITLAB_INTERNAL_ADDED_MIXINS: addedMixins } = this.$; + if (!addedMixins) { + return []; + } + + return addedMixins.map((m) => m[hookName]?.apply(this, extraArgs)); +} + +function createMixinForLateInit({ install, shouldInstall }) { + return { + created() { + callLifecycle.call(this, 'created'); + }, + // @vue/compat normalizez lifecycle hook names so there is no error here + destroyed() { + callLifecycle.call(this, 'unmounted'); + }, + + data(...args) { + const extraData = callLifecycle.call(this, 'data', ...args); + if (!extraData.length) { + return {}; + } + + return Object.assign({}, ...extraData); + }, + + beforeCreate() { + if (shouldInstall(this)) { + const { mixins } = this.$.appContext; + const globalMixinsBeforeInit = new Set(mixins); + install(this); + + this.$.GITLAB_INTERNAL_ADDED_MIXINS = mixins.filter((m) => !globalMixinsBeforeInit.has(m)); + + callLifecycle.call(this, 'beforeCreate'); + } + }, + }; +} + +export default class VueCompatApollo { + constructor(...args) { + // eslint-disable-next-line no-constructor-return + return createApolloProvider(...args); + } + + static install() { + Vue.mixin( + createMixinForLateInit({ + shouldInstall: (vm) => + vm.$options.apolloProvider && + !installed.get(vm.$.appContext.app)?.has(vm.$options.apolloProvider), + install: (vm) => { + const { app } = vm.$.appContext; + const { apolloProvider } = vm.$options; + + if (!installed.has(app)) { + installed.set(app, new WeakSet()); + } + + installed.get(app).add(apolloProvider); + + vm.$.appContext.app.use(vm.$options.apolloProvider); + }, + }), + ); + } +} diff --git a/app/assets/javascripts/lib/utils/vue3compat/vue_router.js b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js new file mode 100644 index 00000000000..aa2963ece31 --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/vue_router.js @@ -0,0 +1,115 @@ +import Vue from 'vue'; +import { + createRouter, + createMemoryHistory, + createWebHistory, + createWebHashHistory, +} from 'vue-router-vue3'; + +const mode = (value, options) => { + if (!value) return null; + let history; + // eslint-disable-next-line default-case + switch (value) { + case 'history': + history = createWebHistory(options.base); + break; + case 'hash': + history = createWebHashHistory(); + break; + case 'abstract': + history = createMemoryHistory(); + break; + } + return { history }; +}; + +const base = () => null; + +const toNewCatchAllPath = (path) => { + if (path === '*') return '/:pathMatch(.*)*'; + return path; +}; + +const routes = (value) => { + if (!value) return null; + const newRoutes = value.reduce(function handleRoutes(acc, route) { + const newRoute = { + ...route, + path: toNewCatchAllPath(route.path), + }; + if (route.children) { + newRoute.children = route.children.reduce(handleRoutes, []); + } + acc.push(newRoute); + return acc; + }, []); + return { routes: newRoutes }; +}; + +const scrollBehavior = (value) => { + return { + scrollBehavior(...args) { + const { x, y, left, top } = value(...args); + return { left: x || left, top: y || top }; + }, + }; +}; + +const transformers = { + mode, + base, + routes, + scrollBehavior, +}; + +const transformOptions = (options = {}) => { + const defaultConfig = { + routes: [], + history: createWebHashHistory(), + }; + return Object.keys(options).reduce((acc, key) => { + const value = options[key]; + if (key in transformers) { + Object.assign(acc, transformers[key](value, options)); + } else { + acc[key] = value; + } + return acc; + }, defaultConfig); +}; + +const installed = new WeakMap(); + +export default class VueRouterCompat { + constructor(options) { + // eslint-disable-next-line no-constructor-return + return new Proxy(createRouter(transformOptions(options)), { + get(target, prop) { + const result = target[prop]; + // eslint-disable-next-line no-underscore-dangle + if (result?.__v_isRef) { + return result.value; + } + + return result; + }, + }); + } + + static install() { + Vue.mixin({ + beforeCreate() { + const { app } = this.$.appContext; + const { router } = this.$options; + if (router && !installed.get(app)?.has(router)) { + if (!installed.has(app)) { + installed.set(app, new WeakSet()); + } + installed.get(app).add(router); + this.$.appContext.app.use(this.$options.router); + } + }, + }); + } +} diff --git a/app/assets/javascripts/lib/utils/vue3compat/vuex.js b/app/assets/javascripts/lib/utils/vue3compat/vuex.js new file mode 100644 index 00000000000..ff94ff3d04a --- /dev/null +++ b/app/assets/javascripts/lib/utils/vue3compat/vuex.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import { + createStore, + mapState, + mapGetters, + mapActions, + mapMutations, + createNamespacedHelpers, +} from 'vuex-vue3'; + +export { mapState, mapGetters, mapActions, mapMutations, createNamespacedHelpers }; + +const installedStores = new WeakMap(); + +export default { + Store: class VuexCompatStore { + constructor(...args) { + // eslint-disable-next-line no-constructor-return + return createStore(...args); + } + }, + + install() { + Vue.mixin({ + beforeCreate() { + const { app } = this.$.appContext; + const { store } = this.$options; + if (store && !installedStores.get(app)?.has(store)) { + if (!installedStores.has(app)) { + installedStores.set(app, new WeakSet()); + } + installedStores.get(app).add(store); + this.$.appContext.app.use(this.$options.store); + } + }, + }); + }, +}; diff --git a/app/assets/javascripts/lib/utils/web_ide_navigator.js b/app/assets/javascripts/lib/utils/web_ide_navigator.js new file mode 100644 index 00000000000..f0579b5886d --- /dev/null +++ b/app/assets/javascripts/lib/utils/web_ide_navigator.js @@ -0,0 +1,24 @@ +import { visitUrl, webIDEUrl } from '~/lib/utils/url_utility'; + +/** + * Takes a project path and optional file path and branch + * and then redirects the user to the web IDE. + * + * @param {string} projectPath - Full path to project including namespace (ex. flightjs/Flight) + * @param {string} filePath - optional path to file to be edited, otherwise will open at base directory (ex. README.md) + * @param {string} branch - optional branch to open the IDE, defaults to 'main' + */ + +export const openWebIDE = (projectPath, filePath, branch = 'main') => { + if (!projectPath) { + throw new TypeError('projectPath parameter is required'); + } + + const pathnameSegments = [projectPath, 'edit', branch, '-']; + + if (filePath) { + pathnameSegments.push(filePath); + } + + visitUrl(webIDEUrl(`/${pathnameSegments.join('/')}/`)); +}; |