summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/lib/utils
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/lib/utils')
-rw-r--r--app/assets/javascripts/lib/utils/apollo_startup_js_link.js2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js5
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue34
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js8
-rw-r--r--app/assets/javascripts/lib/utils/constants.js1
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js23
-rw-r--r--app/assets/javascripts/lib/utils/table_utility.js35
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js65
-rw-r--r--app/assets/javascripts/lib/utils/yaml.js121
9 files changed, 281 insertions, 13 deletions
diff --git a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
index 014823f3831..f240226e991 100644
--- a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
+++ b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js
@@ -1,4 +1,4 @@
-import { ApolloLink, Observable } from 'apollo-link';
+import { ApolloLink, Observable } from '@apollo/client/core';
import { parse } from 'graphql';
import { isEqual, pickBy } from 'lodash';
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index eff00dff7a7..cf6ce2c4889 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -705,7 +705,10 @@ export const scopedLabelKey = ({ title = '' }) => {
};
// Methods to set and get Cookie
-export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 });
+export const setCookie = (name, value, attributes) => {
+ const defaults = { expires: 365, secure: Boolean(window.gon?.secure) };
+ Cookies.set(name, value, { ...defaults, ...attributes });
+};
export const getCookie = (name) => Cookies.get(name);
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 733d0f69f5d..f3380b7b4ba 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
@@ -1,13 +1,21 @@
<script>
-import { GlModal } from '@gitlab/ui';
+import { GlModal, GlSafeHtmlDirective } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
cancelAction: { text: __('Cancel') },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
components: {
GlModal,
},
props: {
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
primaryText: {
type: String,
required: false,
@@ -18,11 +26,27 @@ export default {
required: false,
default: 'confirm',
},
+ modalHtmlMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hideCancel: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
primaryAction() {
return { text: this.primaryText, attributes: { variant: this.primaryVariant } };
},
+ cancelAction() {
+ return this.hideCancel ? null : this.$options.cancelAction;
+ },
+ shouldShowHeader() {
+ return Boolean(this.title?.length);
+ },
},
mounted() {
this.$refs.modal.show();
@@ -36,12 +60,14 @@ export default {
size="sm"
modal-id="confirmationModal"
body-class="gl-display-flex"
+ :title="title"
:action-primary="primaryAction"
- :action-cancel="$options.cancelAction"
- hide-header
+ :action-cancel="cancelAction"
+ :hide-header="!shouldShowHeader"
@primary="$emit('confirmed')"
@hidden="$emit('closed')"
>
- <div class="gl-align-self-center"><slot></slot></div>
+ <div v-if="!modalHtmlMessage" class="gl-align-self-center"><slot></slot></div>
+ <div v-else v-safe-html="modalHtmlMessage" class="gl-align-self-center"></div>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
index fdd0e045d07..a8a89d0644a 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
@@ -1,6 +1,9 @@
import Vue from 'vue';
-export function confirmAction(message, { primaryBtnVariant, primaryBtnText } = {}) {
+export function confirmAction(
+ message,
+ { primaryBtnVariant, primaryBtnText, modalHtmlMessage, title, hideCancel } = {},
+) {
return new Promise((resolve) => {
let confirmed = false;
@@ -15,6 +18,9 @@ export function confirmAction(message, { primaryBtnVariant, primaryBtnText } = {
props: {
primaryVariant: primaryBtnVariant,
primaryText: primaryBtnText,
+ title,
+ modalHtmlMessage,
+ hideCancel,
},
on: {
confirmed() {
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index 36c6545164e..379c57f3945 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -1,6 +1,7 @@
export const BYTES_IN_KIB = 1024;
export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250;
export const HIDDEN_CLASS = 'hidden';
+export const THOUSAND = 1000;
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index f46263c0e4d..b0e31fe729b 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -1,5 +1,5 @@
import { sprintf, __ } from '~/locale';
-import { BYTES_IN_KIB } from './constants';
+import { BYTES_IN_KIB, THOUSAND } from './constants';
/**
* Function that allows a number with an X amount of decimals
@@ -86,6 +86,27 @@ export function numberToHumanSize(size, digits = 2) {
}
/**
+ * Converts a number to kilos or megas.
+ *
+ * For example:
+ * - 123 becomes 123
+ * - 123456 becomes 123.4k
+ * - 123456789 becomes 123.4m
+ *
+ * @param number Number to format
+ * @param digits The number of digits to appear after the decimal point
+ * @return {string} Formatted number
+ */
+export function numberToMetricPrefix(number, digits = 1) {
+ if (number < THOUSAND) {
+ return number.toString();
+ }
+ if (number < THOUSAND ** 2) {
+ return `${(number / THOUSAND).toFixed(digits)}k`;
+ }
+ return `${(number / THOUSAND ** 2).toFixed(digits)}m`;
+}
+/**
* A simple method that returns the value of a + b
* It seems unessesary, but when combined with a reducer it
* adds up all the values in an array.
diff --git a/app/assets/javascripts/lib/utils/table_utility.js b/app/assets/javascripts/lib/utils/table_utility.js
index 33db7686e0f..6d66335b832 100644
--- a/app/assets/javascripts/lib/utils/table_utility.js
+++ b/app/assets/javascripts/lib/utils/table_utility.js
@@ -1,3 +1,4 @@
+import { convertToSnakeCase, convertToCamelCase } from '~/lib/utils/text_utility';
import { DEFAULT_TH_CLASSES } from './constants';
/**
@@ -7,3 +8,37 @@ import { DEFAULT_TH_CLASSES } from './constants';
* @returns {String} The classes to be used in GlTable fields object.
*/
export const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`;
+
+/**
+ * Converts a GlTable sort-changed event object into string format.
+ * This string can be used as a sort argument on GraphQL queries.
+ *
+ * @param {Object} - The table state context object.
+ * @returns {String} A string with the sort key and direction, for example 'NAME_DESC'.
+ */
+export const sortObjectToString = ({ sortBy, sortDesc }) => {
+ const sortingDirection = sortDesc ? 'DESC' : 'ASC';
+ const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
+
+ return `${sortingColumn}_${sortingDirection}`;
+};
+
+/**
+ * Converts a sort string into a sort state object that can be used to
+ * set the sort order on GlTable.
+ *
+ * @param {String} - The string with the sort key and direction, for example 'NAME_DESC'.
+ * @returns {Object} An object with the sortBy and sortDesc properties.
+ */
+export const sortStringToObject = (sortString) => {
+ let sortBy = null;
+ let sortDesc = null;
+
+ if (sortString && sortString.includes('_')) {
+ const [key, direction] = sortString.split(/_(ASC|DESC)$/);
+ sortBy = convertToCamelCase(key.toLowerCase());
+ sortDesc = direction === 'DESC';
+ }
+
+ return { sortBy, sortDesc };
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 40dd29bea76..ec6789d81ec 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -5,6 +5,12 @@ import { insertText } from '~/lib/utils/common_utils';
const LINK_TAG_PATTERN = '[{text}](url)';
+// at the start of a line, find any amount of whitespace followed by
+// a bullet point character (*+-) and an optional checkbox ([ ] [x])
+// OR a number with a . after it and an optional checkbox ([ ] [x])
+// followed by one or more whitespace characters
+const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isOl>[*+-])|(?<isUl>\d+\.))( \[([x ])\])?\s)(?<content>.)?/;
+
function selectedText(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
}
@@ -13,8 +19,15 @@ function addBlockTags(blockTag, selected) {
return `${blockTag}\n${selected}\n${blockTag}`;
}
-function lineBefore(text, textarea) {
- const split = text.substring(0, textarea.selectionStart).trim().split('\n');
+function lineBefore(text, textarea, trimNewlines = true) {
+ let split = text.substring(0, textarea.selectionStart);
+
+ if (trimNewlines) {
+ split = split.trim();
+ }
+
+ split = split.split('\n');
+
return split[split.length - 1];
}
@@ -284,9 +297,9 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
}
/* eslint-disable @gitlab/require-i18n-strings */
-export function keypressNoteText(e) {
+function handleSurroundSelectedText(e, textArea) {
if (!gon.markdown_surround_selection) return;
- if (this.selectionStart === this.selectionEnd) return;
+ if (textArea.selectionStart === textArea.selectionEnd) return;
const keys = {
'*': '**{text}**', // wraps with bold character
@@ -306,7 +319,7 @@ export function keypressNoteText(e) {
updateText({
tag,
- textArea: this,
+ textArea,
blockTag: '',
wrap: true,
select: '',
@@ -316,6 +329,48 @@ export function keypressNoteText(e) {
}
/* eslint-enable @gitlab/require-i18n-strings */
+function handleContinueList(e, textArea) {
+ if (!gon.features?.markdownContinueLists) return;
+ if (!(e.key === 'Enter')) return;
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
+ if (textArea.selectionStart !== textArea.selectionEnd) return;
+
+ const currentLine = lineBefore(textArea.value, textArea, false);
+ const result = currentLine.match(LIST_LINE_HEAD_PATTERN);
+
+ if (result) {
+ const { indent, content, leader } = result.groups;
+ const prevLineEmpty = !content;
+
+ if (prevLineEmpty) {
+ // erase previous empty list item - select the text and allow the
+ // natural line feed erase the text
+ textArea.selectionStart = textArea.selectionStart - result[0].length;
+ return;
+ }
+
+ const itemInsert = `${indent}${leader}`;
+
+ e.preventDefault();
+
+ updateText({
+ tag: itemInsert,
+ textArea,
+ blockTag: '',
+ wrap: false,
+ select: '',
+ tagContent: '',
+ });
+ }
+}
+
+export function keypressNoteText(e) {
+ const textArea = this;
+
+ handleContinueList(e, textArea);
+ handleSurroundSelectedText(e, textArea);
+}
+
export function updateTextForToolbarBtn($toolbarBtn) {
return updateText({
textArea: $toolbarBtn.closest('.md-area').find('textarea'),
diff --git a/app/assets/javascripts/lib/utils/yaml.js b/app/assets/javascripts/lib/utils/yaml.js
new file mode 100644
index 00000000000..9270d388342
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/yaml.js
@@ -0,0 +1,121 @@
+/**
+ * This file adds a merge function to be used with a yaml Document as defined by
+ * the yaml@2.x package: https://eemeli.org/yaml/#yaml
+ *
+ * Ultimately, this functionality should be merged upstream into the package,
+ * track the progress of that effort at https://github.com/eemeli/yaml/pull/347
+ * */
+
+import { visit, Scalar, isCollection, isDocument, isScalar, isNode, isMap, isSeq } from 'yaml';
+
+function getPath(ancestry) {
+ return ancestry.reduce((p, { key }) => {
+ return key !== undefined ? [...p, key.value] : p;
+ }, []);
+}
+
+function getFirstChildNode(collection) {
+ let firstChildKey;
+ let type;
+ switch (collection.constructor.name) {
+ case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings
+ return collection.items.find((i) => isNode(i));
+ case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings
+ firstChildKey = collection.items[0]?.key;
+ if (!firstChildKey) return undefined;
+ return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey);
+ default:
+ type = collection.constructor?.name || typeof collection;
+ throw Error(`Cannot identify a child Node for type ${type}`);
+ }
+}
+
+function moveMetaPropsToFirstChildNode(collection) {
+ const firstChildNode = getFirstChildNode(collection);
+ const { comment, commentBefore, spaceBefore } = collection;
+ if (!(comment || commentBefore || spaceBefore)) return;
+ if (!firstChildNode)
+ throw new Error('Cannot move meta properties to a child of an empty Collection'); // eslint-disable-line @gitlab/require-i18n-strings
+ Object.assign(firstChildNode, { comment, commentBefore, spaceBefore });
+ Object.assign(collection, {
+ comment: undefined,
+ commentBefore: undefined,
+ spaceBefore: undefined,
+ });
+}
+
+function assert(isTypeFn, node, path) {
+ if (![isSeq, isMap].includes(isTypeFn)) {
+ throw new Error('assert() can only be used with isSeq() and isMap()');
+ }
+ const expectedTypeName = isTypeFn === isSeq ? 'YAMLSeq' : 'YAMLMap'; // eslint-disable-line @gitlab/require-i18n-strings
+ if (!isTypeFn(node)) {
+ const type = node?.constructor?.name || typeof node;
+ throw new Error(
+ `Type conflict at "${path.join(
+ '.',
+ )}": Destination node is of type ${type}, the node to be merged is of type ${expectedTypeName}.`,
+ );
+ }
+}
+
+function mergeCollection(target, node, path) {
+ // In case both the source and the target node have comments or spaces
+ // We'll move them to their first child so they do not conflict
+ moveMetaPropsToFirstChildNode(node);
+ if (target.hasIn(path)) {
+ const targetNode = target.getIn(path, true);
+ assert(isSeq(node) ? isSeq : isMap, targetNode, path);
+ moveMetaPropsToFirstChildNode(targetNode);
+ }
+}
+
+function mergePair(target, node, path) {
+ if (!isScalar(node.value)) return undefined;
+ if (target.hasIn([...path, node.key.value])) {
+ target.setIn(path, node);
+ } else {
+ target.addIn(path, node);
+ }
+ return visit.SKIP;
+}
+
+function getVisitorFn(target, options) {
+ return {
+ Map: (_, node, ancestors) => {
+ mergeCollection(target, node, getPath(ancestors));
+ },
+ Pair: (_, node, ancestors) => {
+ mergePair(target, node, getPath(ancestors));
+ },
+ Seq: (_, node, ancestors) => {
+ const path = getPath(ancestors);
+ mergeCollection(target, node, path);
+ if (options.onSequence === 'replace') {
+ target.setIn(path, node);
+ return visit.SKIP;
+ }
+ node.items.forEach((item) => target.addIn(path, item));
+ return visit.SKIP;
+ },
+ };
+}
+
+/** Merge another collection into this */
+export function merge(target, source, options = {}) {
+ const opt = {
+ onSequence: 'replace',
+ ...options,
+ };
+ const sourceNode = target.createNode(isDocument(source) ? source.contents : source);
+ if (!isCollection(sourceNode)) {
+ const type = source?.constructor?.name || typeof source;
+ throw new Error(`Cannot merge type "${type}", expected a Collection`);
+ }
+ if (!isCollection(target.contents)) {
+ // If the target doc is empty add the source to it directly
+ Object.assign(target, { contents: sourceNode });
+ return;
+ }
+ visit(sourceNode, getVisitorFn(target, opt));
+}