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 | |
parent | ee9173579ae56a3dbfe5afe9f9410c65bb327ca7 (diff) |
Add latest changes from gitlab-org/gitlab@14-8-stable-eev14.8.0-rc42
Diffstat (limited to 'app/assets/javascripts/lib')
13 files changed, 368 insertions, 34 deletions
diff --git a/app/assets/javascripts/lib/apollo/instrumentation_link.js b/app/assets/javascripts/lib/apollo/instrumentation_link.js index 2ab364557b8..bbe16d260e7 100644 --- a/app/assets/javascripts/lib/apollo/instrumentation_link.js +++ b/app/assets/javascripts/lib/apollo/instrumentation_link.js @@ -1,4 +1,4 @@ -import { ApolloLink } from 'apollo-link'; +import { ApolloLink } from '@apollo/client/core'; import { memoize } from 'lodash'; export const FEATURE_CATEGORY_HEADER = 'x-gitlab-feature-category'; diff --git a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js index 9b7901685b6..b2a86ac257b 100644 --- a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js +++ b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js @@ -1,5 +1,5 @@ -import { Observable } from 'apollo-link'; -import { onError } from 'apollo-link-error'; +import { Observable } from '@apollo/client/core'; +import { onError } from '@apollo/client/link/error'; import { isNavigatingAway } from '~/lib/utils/is_navigating_away'; /** diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index df2e85afe24..f533ba3671c 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,11 +1,9 @@ -import { InMemoryCache } from 'apollo-cache-inmemory'; -import { ApolloClient } from 'apollo-client'; -import { ApolloLink } from 'apollo-link'; -import { BatchHttpLink } from 'apollo-link-batch-http'; -import { HttpLink } from 'apollo-link-http'; +import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core'; +import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; +import possibleTypes from '~/graphql_shared/possibleTypes.json'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import csrf from '~/lib/utils/csrf'; import { objectToQuery, queryToObject } from '~/lib/utils/url_utility'; @@ -21,6 +19,36 @@ export const fetchPolicies = { CACHE_ONLY: 'cache-only', }; +export const typePolicies = { + Repository: { + merge: true, + }, + UserPermissions: { + merge: true, + }, + MergeRequestPermissions: { + merge: true, + }, + ContainerRepositoryConnection: { + merge: true, + }, + TimelogConnection: { + merge: true, + }, + BranchList: { + merge: true, + }, + InstanceSecurityDashboard: { + merge: true, + }, + PipelinePermissions: { + merge: true, + }, + DesignCollection: { + merge: true, + }, +}; + export const stripWhitespaceFromQuery = (url, path) => { /* eslint-disable-next-line no-unused-vars */ const [_, params] = url.split(path); @@ -46,6 +74,30 @@ export const stripWhitespaceFromQuery = (url, path) => { return `${path}?${reassembled}`; }; +const acs = []; + +let pendingApolloMutations = 0; + +// ### Why track pendingApolloMutations, but calculate pendingApolloRequests? +// +// In Apollo 2, we had a single link for counting operations. +// +// With Apollo 3, the `forward().map(...)` of deduped queries is never called. +// So, we resorted to calculating the sum of `inFlightLinkObservables?.size`. +// However! Mutations don't use `inFLightLinkObservables`, but since they are likely +// not deduped we can count them... +// +// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55062#note_838943715 +// https://www.apollographql.com/docs/react/v2/networking/network-layer/#query-deduplication +Object.defineProperty(window, 'pendingApolloRequests', { + get() { + return acs.reduce( + (sum, ac) => sum + (ac?.queryManager?.inFlightLinkObservables?.size || 0), + pendingApolloMutations, + ); + }, +}); + export default (resolvers = {}, config = {}) => { const { baseUrl, @@ -56,6 +108,7 @@ export default (resolvers = {}, config = {}) => { path = '/api/graphql', useGet = false, } = config; + let ac = null; let uri = `${gon.relative_url_root || ''}${path}`; if (baseUrl) { @@ -75,16 +128,6 @@ export default (resolvers = {}, config = {}) => { batchMax, }; - const requestCounterLink = new ApolloLink((operation, forward) => { - window.pendingApolloRequests = window.pendingApolloRequests || 0; - window.pendingApolloRequests += 1; - - return forward(operation).map((response) => { - window.pendingApolloRequests -= 1; - return response; - }); - }); - /* This custom fetcher intervention is to deal with an issue where we are using GET to access eTag polling, but Apollo Client adds excessive whitespace, which causes the @@ -138,6 +181,22 @@ export default (resolvers = {}, config = {}) => { ); }; + const hasMutation = (operation) => + (operation?.query?.definitions || []).some((x) => x.operation === 'mutation'); + + const requestCounterLink = new ApolloLink((operation, forward) => { + if (hasMutation(operation)) { + pendingApolloMutations += 1; + } + + return forward(operation).map((response) => { + if (hasMutation(operation)) { + pendingApolloMutations -= 1; + } + return response; + }); + }); + const appLink = ApolloLink.split( hasSubscriptionOperation, new ActionCableLink(), @@ -155,19 +214,23 @@ export default (resolvers = {}, config = {}) => { ), ); - return new ApolloClient({ + ac = new ApolloClient({ typeDefs, link: appLink, cache: new InMemoryCache({ + typePolicies, + possibleTypes, ...cacheConfig, - freezeResults: true, }), resolvers, - assumeImmutableResults: true, defaultOptions: { query: { fetchPolicy, }, }, }); + + acs.push(ac); + + return ac; }; diff --git a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js new file mode 100644 index 00000000000..6473683c3af --- /dev/null +++ b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js @@ -0,0 +1,3 @@ +// Import from `src/to_markdown` to avoid unnecessary bundling of unused libs +// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79859 +export * from 'prosemirror-markdown/src/to_markdown'; 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)); +} |