Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-05-17 19:05:49 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-05-17 19:05:49 +0300
commit43a25d93ebdabea52f99b05e15b06250cd8f07d7 (patch)
treedceebdc68925362117480a5d672bcff122fb625b /app/assets/javascripts/lib
parent20c84b99005abd1c82101dfeff264ac50d2df211 (diff)
Add latest changes from gitlab-org/gitlab@16-0-stable-eev16.0.0-rc42
Diffstat (limited to 'app/assets/javascripts/lib')
-rw-r--r--app/assets/javascripts/lib/apollo/indexed_db_persistent_storage.js97
-rw-r--r--app/assets/javascripts/lib/apollo/local_db.js14
-rw-r--r--app/assets/javascripts/lib/graphql.js69
-rw-r--r--app/assets/javascripts/lib/mermaid.js11
-rw-r--r--app/assets/javascripts/lib/mousetrap.js59
-rw-r--r--app/assets/javascripts/lib/swagger.js9
-rw-r--r--app/assets/javascripts/lib/utils/chart_utils.js38
-rw-r--r--app/assets/javascripts/lib/utils/color_utils.js28
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_action.js2
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue7
-rw-r--r--app/assets/javascripts/lib/utils/constants.js5
-rw-r--r--app/assets/javascripts/lib/utils/css_utils.js25
-rw-r--r--app/assets/javascripts/lib/utils/datetime/constants.js7
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js40
-rw-r--r--app/assets/javascripts/lib/utils/datetime/time_spent_utility.js13
-rw-r--r--app/assets/javascripts/lib/utils/datetime/timeago_utility.js40
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js2
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js2
-rw-r--r--app/assets/javascripts/lib/utils/error_message.js15
-rw-r--r--app/assets/javascripts/lib/utils/file_upload.js7
-rw-r--r--app/assets/javascripts/lib/utils/keys.js4
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js49
-rw-r--r--app/assets/javascripts/lib/utils/ref_validator.js145
-rw-r--r--app/assets/javascripts/lib/utils/resize_observer.js10
-rw-r--r--app/assets/javascripts/lib/utils/secret_detection.js45
-rw-r--r--app/assets/javascripts/lib/utils/sticky.js60
-rw-r--r--app/assets/javascripts/lib/utils/tappable_promise.js49
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js57
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js45
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js40
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/compat_functional_mixin.js14
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/get_instance_from_directive.js9
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/mark_raw.js9
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/normalize_children.js11
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_apollo.js78
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vue_router.js115
-rw-r--r--app/assets/javascripts/lib/utils/vue3compat/vuex.js38
-rw-r--r--app/assets/javascripts/lib/utils/web_ide_navigator.js24
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('/')}/`));
+};