diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-02-18 12:45:46 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-02-18 12:45:46 +0300 |
commit | a7b3560714b4d9cc4ab32dffcd1f74a284b93580 (patch) | |
tree | 7452bd5c3545c2fa67a28aa013835fb4fa071baf /app/assets/javascripts/lib/utils | |
parent | ee9173579ae56a3dbfe5afe9f9410c65bb327ca7 (diff) |
Add latest changes from gitlab-org/gitlab@14-8-stable-eev14.8.0-rc42
Diffstat (limited to 'app/assets/javascripts/lib/utils')
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)); +} |