diff options
65 files changed, 1540 insertions, 124 deletions
diff --git a/.gitlab/ci/qa-common/main.gitlab-ci.yml b/.gitlab/ci/qa-common/main.gitlab-ci.yml index 137db656787..1576ddaee7d 100644 --- a/.gitlab/ci/qa-common/main.gitlab-ci.yml +++ b/.gitlab/ci/qa-common/main.gitlab-ci.yml @@ -6,7 +6,7 @@ workflow: include: - project: gitlab-org/quality/pipeline-common - ref: 5.2.1 + ref: 5.2.2 file: - /ci/base.gitlab-ci.yml - /ci/allure-report.yml @@ -30,11 +30,14 @@ stages: # image path and registry needs to be defined explicitly image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-${DEBIAN_VERSION}-ruby-${RUBY_VERSION}:bundler-2.3 -.qa-install: +.bundler-variables: variables: BUNDLE_SUPPRESS_INSTALL_USING_MESSAGES: "true" BUNDLE_SILENCE_ROOT_WARNING: "true" + +.qa-install: extends: + - .bundler-variables - .gitlab-qa-install .update-script: @@ -46,8 +49,8 @@ stages: .qa: extends: + - .bundler-variables - .qa-base - - .qa-install - .gitlab-qa-report stage: test tags: diff --git a/.rubocop_todo/layout/first_hash_element_indentation.yml b/.rubocop_todo/layout/first_hash_element_indentation.yml index bd8bee3b69f..dc2f8610a51 100644 --- a/.rubocop_todo/layout/first_hash_element_indentation.yml +++ b/.rubocop_todo/layout/first_hash_element_indentation.yml @@ -50,7 +50,6 @@ Layout/FirstHashElementIndentation: - 'ee/app/services/elastic/cluster_reindexing_service.rb' - 'ee/app/services/gitlab_subscriptions/plan_upgrade_service.rb' - 'ee/app/services/iterations/create_service.rb' - - 'ee/app/services/registrations/base_namespace_create_service.rb' - 'ee/app/services/resource_events/change_iteration_service.rb' - 'ee/app/services/security/token_revocation_service.rb' - 'ee/app/services/timebox_report_service.rb' diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index 3ebb07807d2..17ad1a0b31d 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -10,6 +10,7 @@ const USER_PROJECTS_PATH = '/api/:version/users/:id/projects'; const USER_POST_STATUS_PATH = '/api/:version/user/status'; const USER_FOLLOW_PATH = '/api/:version/users/:id/follow'; const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow'; +const USER_FOLLOWERS_PATH = '/api/:version/users/:id/followers'; const USER_ASSOCIATIONS_COUNT_PATH = '/api/:version/users/:id/associations_count'; export function getUsers(query, options) { @@ -71,6 +72,16 @@ export function unfollowUser(userId) { return axios.post(url); } +export function getUserFollowers(userId, params) { + const url = buildApiUrl(USER_FOLLOWERS_PATH).replace(':id', encodeURIComponent(userId)); + return axios.get(url, { + params: { + per_page: DEFAULT_PER_PAGE, + ...params, + }, + }); +} + export function associationsCount(userId) { const url = buildApiUrl(USER_ASSOCIATIONS_COUNT_PATH).replace(':id', encodeURIComponent(userId)); return axios.get(url); diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue index 7c06417e6b3..167937d8245 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue @@ -44,6 +44,7 @@ export default { this.menuVisible = false; }, appendTo: () => document.body, + maxWidth: 'auto', }, }), ); diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue new file mode 100644 index 00000000000..900164fe60f --- /dev/null +++ b/app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue @@ -0,0 +1,218 @@ +<script> +import { + GlTooltipDirective as GlTooltip, + GlButton, + GlButtonGroup, + GlCollapsibleListbox, +} from '@gitlab/ui'; +import { __ } from '~/locale'; +import Reference from '../../extensions/reference'; +import ReferenceLabel from '../../extensions/reference_label'; +import EditorStateObserver from '../editor_state_observer.vue'; +import BubbleMenu from './bubble_menu.vue'; + +const REFERENCE_NODE_TYPES = [Reference.name, ReferenceLabel.name]; + +export default { + components: { + BubbleMenu, + EditorStateObserver, + GlButton, + GlCollapsibleListbox, + GlButtonGroup, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor', 'contentEditor'], + data() { + return { + nodeType: null, + + referenceType: null, + originalText: null, + + href: null, + text: null, + expandedText: null, + fullyExpandedText: null, + + selectedTextFormat: {}, + + loading: false, + }; + }, + computed: { + isIssue() { + return this.referenceType === 'issue'; + }, + isMergeRequest() { + return this.referenceType === 'merge_request'; + }, + isEpic() { + return this.referenceType === 'epic'; + }, + isExpandable() { + return this.isIssue || this.isMergeRequest || this.isEpic; + }, + textFormats() { + return [ + { + value: '', + text: this.$options.i18n.referenceId[this.referenceType], + matcher: (text) => !text.endsWith('+') && !text.endsWith('+s'), + getText: () => this.text, + shouldShow: true, + }, + { + value: '+', + text: this.$options.i18n.referenceTitle[this.referenceType], + matcher: (text) => text.endsWith('+'), + getText: () => this.expandedText, + shouldShow: true, + }, + { + value: '+s', + text: this.$options.i18n.referenceSummary[this.referenceType], + matcher: (text) => text.endsWith('+s'), + getText: () => this.fullyExpandedText, + shouldShow: this.isIssue || this.isMergeRequest, + }, + ]; + }, + }, + methods: { + shouldShow: ({ editor }) => { + return REFERENCE_NODE_TYPES.some((type) => editor.isActive(type)); + }, + async updateReferenceInfoToState() { + this.nodeType = REFERENCE_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)); + if (!this.nodeType) return; + + const { + referenceType, + href, + originalText, + text: alternateText, + } = this.tiptapEditor.getAttributes(this.nodeType); + + this.href = href; + this.referenceType = referenceType; + this.originalText = originalText || alternateText; + this.selectedTextFormat = this.textFormats.find(({ matcher }) => matcher(this.originalText)); + + this.loading = true; + + const { text, expandedText, fullyExpandedText } = await this.contentEditor.resolveReference( + this.originalText, + ); + + this.text = text; + this.expandedText = expandedText; + this.fullyExpandedText = fullyExpandedText; + + this.loading = false; + }, + removeReference() { + this.tiptapEditor.chain().focus().deleteSelection().run(); + }, + copyReferenceURL() { + navigator.clipboard.writeText(this.href); + }, + applyFormat(value) { + const format = this.textFormats.find((v) => v.value === value); + + this.tiptapEditor + .chain() + .focus() + .updateAttributes(this.nodeType, { + text: format.getText(), + originalText: `${this.originalText.replace(/(\+|\+s)$/, '')}${format.value}`, + }) + .run(); + + this.selectedTextFormat = format; + }, + }, + tippyOptions: { + placement: 'bottom', + }, + i18n: { + referenceId: { + issue: __('Issue ID'), + merge_request: __('Merge request ID'), + epic: __('Epic ID'), + }, + referenceTitle: { + issue: __('Issue title'), + merge_request: __('Merge request title'), + epic: __('Epic title'), + }, + referenceSummary: { + issue: __('Issue summary'), + merge_request: __('Merge request summary'), + epic: __('Epic summary'), + }, + copyURLLabel: { + issue: __('Copy issue URL'), + merge_request: __('Copy merge request URL'), + epic: __('Copy epic URL'), + }, + removeLabel: { + issue: __('Remove issue reference'), + merge_request: __('Remove merge request reference'), + epic: __('Remove epic reference'), + }, + }, +}; +</script> +<template> + <editor-state-observer :debounce="0" @transaction="updateReferenceInfoToState"> + <bubble-menu + v-show="isExpandable" + class="gl-shadow gl-rounded-base gl-bg-white" + plugin-key="bubbleMenuReference" + :should-show="shouldShow" + :tippy-options="$options.tippyOptions" + > + <gl-button-group class="gl-display-flex gl-align-items-center"> + <span class="gl-py-2 gl-px-3 gl-text-secondary gl-white-space-nowrap"> + {{ __('Display as:') }} + </span> + <gl-collapsible-listbox + v-show="!loading" + category="tertiary" + boundary="viewport" + :selected="selectedTextFormat.value" + :items="textFormats" + :loading="loading" + :toggle-text="selectedTextFormat.text" + toggle-class="gl-rounded-0!" + @select="applyFormat" + /> + <gl-button + v-gl-tooltip.bottom + variant="default" + category="tertiary" + size="medium" + data-testid="copy-reference-url" + :aria-label="$options.i18n.copyURLLabel[referenceType]" + :title="$options.i18n.copyURLLabel[referenceType]" + icon="copy-to-clipboard" + @click="copyReferenceURL" + /> + <gl-button + v-gl-tooltip.bottom + variant="default" + category="tertiary" + size="medium" + data-testid="remove-reference" + :aria-label="$options.i18n.removeLabel[referenceType]" + :title="$options.i18n.removeLabel[referenceType]" + icon="remove" + @click="removeReference" + /> + </gl-button-group> + </bubble-menu> + </editor-state-observer> +</template> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 4c5bbca4110..54ce5f8b3c9 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -11,6 +11,7 @@ import EditorStateObserver from './editor_state_observer.vue'; import CodeBlockBubbleMenu from './bubble_menus/code_block_bubble_menu.vue'; import LinkBubbleMenu from './bubble_menus/link_bubble_menu.vue'; import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue'; +import ReferenceBubbleMenu from './bubble_menus/reference_bubble_menu.vue'; import FormattingToolbar from './formatting_toolbar.vue'; import LoadingIndicator from './loading_indicator.vue'; @@ -27,6 +28,7 @@ export default { LinkBubbleMenu, MediaBubbleMenu, EditorStateObserver, + ReferenceBubbleMenu, }, props: { renderMarkdown: { @@ -226,6 +228,7 @@ export default { <code-block-bubble-menu /> <link-bubble-menu /> <media-bubble-menu /> + <reference-bubble-menu /> <div v-if="showPlaceholder" class="gl-absolute gl-text-gray-400 gl-px-5 gl-pt-4"> {{ placeholder }} </div> diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue index 4126c65d87f..2b4b9891c77 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/reference.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue @@ -13,6 +13,11 @@ export default { type: Object, required: true, }, + selected: { + type: Boolean, + required: false, + default: false, + }, }, computed: { text() { @@ -31,13 +36,18 @@ export default { }; </script> <template> - <node-view-wrapper class="gl-display-inline-block"> + <node-view-wrapper as="span"> <span v-if="isCommand">{{ text }}</span> <gl-link v-else href="#" - class="gfm" - :class="{ 'gfm-project_member': isMember, 'current-user': isMember && isCurrentUser }" + tabindex="-1" + class="gfm gl-cursor-text" + :class="{ + 'gfm-project_member': isMember, + 'current-user': isMember && isCurrentUser, + 'ProseMirror-selectednode': selected, + }" @click.prevent.stop >{{ text }}</gl-link > diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue index 4206c866032..08efcd64367 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/reference_label.vue @@ -14,6 +14,11 @@ export default { type: Object, required: true, }, + selected: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isScopedLabel() { @@ -23,12 +28,13 @@ export default { }; </script> <template> - <node-view-wrapper class="gl-display-inline-block"> + <node-view-wrapper as="span" :class="{ 'ProseMirror-selectednode': selected }"> <gl-label size="sm" :scoped="isScopedLabel" :background-color="node.attrs.color" :title="node.attrs.text" + class="gl-pointer-events-none" /> </node-view-wrapper> </template> diff --git a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js index 8c3012ecf59..0d453919571 100644 --- a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js +++ b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js @@ -1,7 +1,6 @@ import { create } from '~/drawio/content_editor_facade'; import { launchDrawioEditor } from '~/drawio/drawio_editor'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; -import createAssetResolver from '../services/asset_resolver'; import Image from './image'; export default Image.extend({ @@ -10,7 +9,7 @@ export default Image.extend({ return { ...this.parent?.(), uploadsPath: null, - renderMarkdown: null, + assetResolver: null, }; }, parseHTML() { @@ -32,7 +31,7 @@ export default Image.extend({ tiptapEditor: this.editor, drawioNodeName: this.name, uploadsPath: this.options.uploadsPath, - assetResolver: createAssetResolver({ renderMarkdown: this.options.renderMarkdown }), + assetResolver: this.options.assetResolver, }), }); }, diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index b56aa8596a0..ef69b9bbda6 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -1,4 +1,4 @@ -import { Node } from '@tiptap/core'; +import { Node, InputRule } from '@tiptap/core'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; import ReferenceWrapper from '../components/wrappers/reference.vue'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; @@ -8,6 +8,21 @@ const getAnchor = (element) => { return element.querySelector('a'); }; +const findReference = (editor, reference) => { + let position; + + editor.view.state.doc.descendants((descendant, pos) => { + if (descendant.isText && descendant.text.includes(reference)) { + position = pos + descendant.text.indexOf(reference); + return false; + } + + return true; + }); + + return position; +}; + export default Node.create({ name: 'reference', @@ -17,6 +32,12 @@ export default Node.create({ atom: true, + addOptions() { + return { + assetResolver: null, + }; + }, + addAttributes() { return { className: { @@ -42,6 +63,54 @@ export default Node.create({ }; }, + addInputRules() { + const { editor } = this; + const { assetResolver } = this.options; + const referenceInputRegex = /(?:^|\s)([\w/]*([!&#])\d+(\+?s?))(?:\s|\n)/m; + const referenceTypes = { + '#': 'issue', + '!': 'merge_request', + '&': 'epic', + }; + + return [ + new InputRule({ + find: referenceInputRegex, + handler: async ({ match }) => { + const [, referenceId, referenceSymbol, expansionType] = match; + const referenceType = referenceTypes[referenceSymbol]; + + const { + href, + text, + expandedText, + fullyExpandedText, + } = await assetResolver.resolveReference(referenceId); + + if (!text) return; + + let referenceText = text; + if (expansionType === '+') referenceText = expandedText; + if (expansionType === '+s') referenceText = fullyExpandedText; + + const position = findReference(editor, referenceId); + if (!position) return; + + editor.view.dispatch( + editor.state.tr.replaceWith(position, position + referenceId.length, [ + this.type.create({ + referenceType, + originalText: referenceId, + href, + text: referenceText, + }), + ]), + ); + }, + }), + ]; + }, + parseHTML() { return [ { @@ -51,6 +120,19 @@ export default Node.create({ ]; }, + renderHTML({ node }) { + return [ + 'gl-reference', + { + 'data-reference-type': node.attrs.referenceType, + 'data-original-text': node.attrs.originalText, + href: node.attrs.href, + text: node.attrs.text, + }, + node.attrs.text, + ]; + }, + addNodeView() { return new VueNodeViewRenderer(ReferenceWrapper); }, diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js index 0441f8ef8d2..6fe904ed787 100644 --- a/app/assets/javascripts/content_editor/extensions/reference_label.js +++ b/app/assets/javascripts/content_editor/extensions/reference_label.js @@ -4,7 +4,7 @@ import LabelWrapper from '../components/wrappers/reference_label.vue'; import Reference from './reference'; export default Reference.extend({ - name: 'reference_label', + name: 'referenceLabel', addAttributes() { return { @@ -25,6 +25,10 @@ export default Reference.extend({ }; }, + addInputRules() { + return []; + }, + parseHTML() { return [{ tag: 'span.gl-label' }]; }, diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js index e72b5c7365c..f29222a5289 100644 --- a/app/assets/javascripts/content_editor/extensions/suggestions.js +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -162,7 +162,7 @@ export default Node.create({ editor: this.editor, char: '~', dataSource: this.options.autocompleteDataSources.labels, - nodeType: 'reference_label', + nodeType: 'referenceLabel', nodeProps: { referenceType: 'label', }, diff --git a/app/assets/javascripts/content_editor/services/asset_resolver.js b/app/assets/javascripts/content_editor/services/asset_resolver.js index c0bcddbe58d..0d4396fc176 100644 --- a/app/assets/javascripts/content_editor/services/asset_resolver.js +++ b/app/assets/javascripts/content_editor/services/asset_resolver.js @@ -2,23 +2,46 @@ import { memoize } from 'lodash'; const parser = new DOMParser(); -export default ({ renderMarkdown }) => ({ - resolveUrl: memoize(async (canonicalSrc) => { - const html = await renderMarkdown(`[link](${canonicalSrc})`); +export default class AssetResolver { + constructor({ renderMarkdown }) { + this.renderMarkdown = renderMarkdown; + } + + resolveUrl = memoize(async (canonicalSrc) => { + const html = await this.renderMarkdown(`[link](${canonicalSrc})`); if (!html) return canonicalSrc; const { body } = parser.parseFromString(html, 'text/html'); return body.querySelector('a').getAttribute('href'); - }), + }); + + resolveReference = memoize(async (originalText) => { + const text = originalText.replace(/(\+|\+s)$/, ''); + const toRender = `${text} ${text}+ ${text}+s`; + const html = await this.renderMarkdown(toRender); + + if (!html) return {}; + + const { body } = parser.parseFromString(html, 'text/html'); + const a = body.querySelectorAll('a'); + if (!a.length) return {}; + + return { + href: a[0].getAttribute('href'), + text: a[0].textContent, + expandedText: a[1].textContent, + fullyExpandedText: a[2].textContent, + }; + }); - renderDiagram: memoize(async (code, language) => { + renderDiagram = memoize(async (code, language) => { const backticks = '`'.repeat(4); - const html = await renderMarkdown(`${backticks}${language}\n${code}\n${backticks}`); + const html = await this.renderMarkdown(`${backticks}${language}\n${code}\n${backticks}`); const { body } = parser.parseFromString(html, 'text/html'); const img = body.querySelector('img'); if (!img) return ''; return img.dataset.src || img.getAttribute('src'); - }), -}); + }); +} diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index a988e1df2a6..ec0f2f028d9 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -56,6 +56,10 @@ export class ContentEditor { return this._assetResolver.resolveUrl(canonicalSrc); } + resolveReference(originalText) { + return this._assetResolver.resolveReference(originalText); + } + renderDiagram(code, language) { return this._assetResolver.renderDiagram(code, language); } diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 3958f77745a..834fb72daba 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -67,7 +67,7 @@ import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import createGlApiMarkdownDeserializer from './gl_api_markdown_deserializer'; import createRemarkMarkdownDeserializer from './remark_markdown_deserializer'; -import createAssetResolver from './asset_resolver'; +import AssetResolver from './asset_resolver'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; const createTiptapEditor = ({ extensions = [], ...options } = {}) => @@ -96,6 +96,7 @@ export const createContentEditor = ({ } const eventHub = eventHubFactory(); + const assetResolver = new AssetResolver({ renderMarkdown }); const builtInContentEditorExtensions = [ Attachment.configure({ uploadsPath, renderMarkdown, eventHub }), @@ -139,7 +140,7 @@ export const createContentEditor = ({ OrderedList, Paragraph, PasteMarkdown.configure({ eventHub, renderMarkdown }), - Reference, + Reference.configure({ assetResolver }), ReferenceLabel, ReferenceDefinition, Selection, @@ -162,7 +163,7 @@ export const createContentEditor = ({ const allExtensions = [...builtInContentEditorExtensions, ...extensions]; if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources })); - if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, renderMarkdown })); + if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, assetResolver })); const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions }); @@ -172,7 +173,6 @@ export const createContentEditor = ({ : createGlApiMarkdownDeserializer({ render: renderMarkdown, }); - const assetResolver = createAssetResolver({ renderMarkdown }); return new ContentEditor({ tiptapEditor, diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 4301fbf2f0e..46500510e8d 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -287,9 +287,9 @@ export function visitUrl(url, external = false) { // See https://mathiasbynens.github.io/rel-noopener/ const otherWindow = window.open(); otherWindow.opener = null; - otherWindow.location = url; + otherWindow.location.assign(url); } else { - window.location.href = url; + window.location.assign(url); } } diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js index 30c351359e4..af55a5dc01a 100644 --- a/app/assets/javascripts/pages/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -2,11 +2,16 @@ import $ from 'jquery'; import { setCookie } from '~/lib/utils/common_utils'; import UserCallout from '~/user_callout'; import { initReportAbuse } from '~/users/profile'; +import { initProfileTabs } from '~/profile'; import UserTabs from './user_tabs'; function initUserProfile(action) { - // eslint-disable-next-line no-new - new UserTabs({ parentEl: '.user-profile', action }); + if (gon.features?.profileTabsVue) { + initProfileTabs(); + } else { + // eslint-disable-next-line no-new + new UserTabs({ parentEl: '.user-profile', action }); + } // hide project limit message $('.hide-project-limit-message').on('click', (e) => { diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js index c213753257d..47424ec1dd3 100644 --- a/app/assets/javascripts/pages/users/show/index.js +++ b/app/assets/javascripts/pages/users/show/index.js @@ -1,7 +1,3 @@ -import { initProfileTabs, initUserAchievements } from '~/profile'; - -if (gon.features?.profileTabsVue) { - initProfileTabs(); -} +import { initUserAchievements } from '~/profile'; initUserAchievements(); diff --git a/app/assets/javascripts/profile/components/follow.vue b/app/assets/javascripts/profile/components/follow.vue new file mode 100644 index 00000000000..7bab8a1c30d --- /dev/null +++ b/app/assets/javascripts/profile/components/follow.vue @@ -0,0 +1,88 @@ +<script> +import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { DEFAULT_PER_PAGE } from '~/api'; +import { NEXT, PREV } from '~/vue_shared/components/pagination/constants'; + +export default { + i18n: { + prev: PREV, + next: NEXT, + }, + components: { + GlAvatarLabeled, + GlAvatarLink, + GlLoadingIcon, + GlPagination, + }, + props: { + /** + * Expected format: + * + * { + * avatar_url: string; + * id: number; + * name: string; + * state: string; + * username: string; + * web_url: string; + * }[] + */ + users: { + type: Array, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + page: { + type: Number, + required: true, + }, + totalItems: { + type: Number, + required: true, + }, + perPage: { + type: Number, + required: false, + default: DEFAULT_PER_PAGE, + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" /> + <div v-else> + <div class="gl-my-n3 gl-mx-n3 gl-display-flex gl-flex-wrap"> + <div v-for="user in users" :key="user.id" class="gl-p-3 gl-w-full gl-md-w-half gl-lg-w-25p"> + <gl-avatar-link + :href="user.web_url" + class="js-user-link gl-border gl-rounded-base gl-w-full gl-p-5" + :data-user-id="user.id" + :data-username="user.username" + > + <gl-avatar-labeled + :src="user.avatar_url" + :size="48" + :entity-id="user.id" + :entity-name="user.name" + :label="user.name" + :sub-label="user.username" + /> + </gl-avatar-link> + </div> + </div> + <gl-pagination + align="center" + class="gl-mt-5" + :value="page" + :total-items="totalItems" + :per-page="perPage" + :prev-text="$options.i18n.prev" + :next-text="$options.i18n.next" + @input="$emit('pagination-input', $event)" + /> + </div> +</template> diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue index 5b69f835294..1fa579bc611 100644 --- a/app/assets/javascripts/profile/components/followers_tab.vue +++ b/app/assets/javascripts/profile/components/followers_tab.vue @@ -1,16 +1,59 @@ <script> import { GlBadge, GlTab } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { getUserFollowers } from '~/rest_api'; +import { createAlert } from '~/alert'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import Follow from './follow.vue'; export default { i18n: { title: s__('UserProfile|Followers'), + errorMessage: s__( + 'UserProfile|An error occurred loading the followers. Please refresh the page to try again.', + ), }, components: { GlBadge, GlTab, + Follow, + }, + inject: ['followersCount', 'userId'], + data() { + return { + followers: [], + loading: true, + totalItems: 0, + page: 1, + }; + }, + watch: { + page: { + async handler() { + this.loading = true; + + try { + const { data: followers, headers } = await getUserFollowers(this.userId, { + page: this.page, + }); + const { total } = parseIntPagination(normalizeHeaders(headers)); + + this.followers = followers; + this.totalItems = total; + } catch (error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + } finally { + this.loading = false; + } + }, + immediate: true, + }, + }, + methods: { + onPaginationInput(page) { + this.page = page; + }, }, - inject: ['followers'], }; </script> @@ -18,7 +61,14 @@ export default { <gl-tab> <template #title> <span>{{ $options.i18n.title }}</span> - <gl-badge size="sm" class="gl-ml-2">{{ followers }}</gl-badge> + <gl-badge size="sm" class="gl-ml-2">{{ followersCount }}</gl-badge> </template> + <follow + :users="followers" + :loading="loading" + :page="page" + :total-items="totalItems" + @pagination-input="onPaginationInput" + /> </gl-tab> </template> diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue index d39d15a08f3..8ee878e3dcc 100644 --- a/app/assets/javascripts/profile/components/following_tab.vue +++ b/app/assets/javascripts/profile/components/following_tab.vue @@ -10,7 +10,7 @@ export default { GlBadge, GlTab, }, - inject: ['followees'], + inject: ['followeesCount'], }; </script> @@ -18,7 +18,7 @@ export default { <gl-tab> <template #title> <span>{{ $options.i18n.title }}</span> - <gl-badge size="sm" class="gl-ml-2">{{ followees }}</gl-badge> + <gl-badge size="sm" class="gl-ml-2">{{ followeesCount }}</gl-badge> </template> </gl-tab> </template> diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue index fdc31edfe5f..3a30c3bdc9b 100644 --- a/app/assets/javascripts/profile/components/profile_tabs.vue +++ b/app/assets/javascripts/profile/components/profile_tabs.vue @@ -91,7 +91,7 @@ export default { </script> <template> - <gl-tabs nav-class="gl-bg-gray-10" align="center"> + <gl-tabs nav-class="gl-bg-gray-10" content-class="gl-bg-white gl-pt-5" align="center"> <component :is="component" v-for="{ key, component } in $options.tabs" diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js index 894912d8e4b..70c60c2d884 100644 --- a/app/assets/javascripts/profile/index.js +++ b/app/assets/javascripts/profile/index.js @@ -14,8 +14,8 @@ export const initProfileTabs = () => { if (!el) return false; const { - followees, - followers, + followeesCount, + followersCount, userCalendarPath, utcOffset, userId, @@ -31,8 +31,8 @@ export const initProfileTabs = () => { apolloProvider, name: 'ProfileRoot', provide: { - followees: parseInt(followers, 10), - followers: parseInt(followees, 10), + followeesCount: parseInt(followeesCount, 10), + followersCount: parseInt(followersCount, 10), userCalendarPath, utcOffset, userId, diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index c50fcd9218b..7f66d335f41 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -8,7 +8,9 @@ background-color: transparent; } - &:not(.ProseMirror-hideselection) .content-editor-selection { + &:not(.ProseMirror-hideselection) .content-editor-selection, + a.ProseMirror-selectednode, + span.ProseMirror-selectednode { background-color: $blue-100; box-shadow: 0 2px 0 $blue-100, 0 -2px 0 $blue-100; } @@ -160,5 +162,5 @@ } .bubble-menu-form { - width: 320px; + min-width: 320px; } diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index c5dc45e2138..108ee65bffd 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -184,8 +184,8 @@ module UsersHelper def user_profile_tabs_app_data(user) { - followees: user.followees.count, - followers: user.followers.count, + followees_count: user.followees.count, + followers_count: user.followers.count, user_calendar_path: user_calendar_path(user, :json), utc_offset: local_timezone_instance(user.timezone).now.utc_offset, user_id: user.id, diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb index 66a3cb04d98..efa9716d2c8 100644 --- a/app/services/clusters/agent_tokens/create_service.rb +++ b/app/services/clusters/agent_tokens/create_service.rb @@ -4,6 +4,7 @@ module Clusters module AgentTokens class CreateService ALLOWED_PARAMS = %i[agent_id description name].freeze + ACTIVE_TOKENS_LIMIT = 2 attr_reader :agent, :current_user, :params @@ -15,6 +16,7 @@ module Clusters def execute return error_no_permissions unless current_user.can?(:create_cluster, agent.project) + return error_active_tokens_limit_reached if active_tokens_limit_reached? token = ::Clusters::AgentToken.new(filtered_params.merge(agent_id: agent.id, created_by_user: current_user)) @@ -33,6 +35,16 @@ module Clusters ServiceResponse.error(message: s_('ClusterAgent|User has insufficient permissions to create a token for this project')) end + def error_active_tokens_limit_reached + ServiceResponse.error(message: s_('ClusterAgent|An agent can have only two active tokens at a time')) + end + + def active_tokens_limit_reached? + return false unless Feature.enabled?(:cluster_agents_limit_tokens_created) + + ::Clusters::AgentTokensFinder.new(agent, current_user, status: :active).execute.count >= ACTIVE_TOKENS_LIMIT + end + def filtered_params params.slice(*ALLOWED_PARAMS) end diff --git a/config/feature_flags/development/cluster_agents_limit_tokens_created.yml b/config/feature_flags/development/cluster_agents_limit_tokens_created.yml new file mode 100644 index 00000000000..1ad85185509 --- /dev/null +++ b/config/feature_flags/development/cluster_agents_limit_tokens_created.yml @@ -0,0 +1,8 @@ +--- +name: cluster_agents_limit_tokens_created +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120825 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/412399 +milestone: '16.1' +type: development +group: group::environments +default_enabled: false diff --git a/data/deprecations/15-10-grafana-chart.yml b/data/deprecations/15-10-grafana-chart.yml index 48070560bfb..ac66aca7910 100644 --- a/data/deprecations/15-10-grafana-chart.yml +++ b/data/deprecations/15-10-grafana-chart.yml @@ -33,5 +33,5 @@ If you're using the bundled Grafana, you should switch to the [newer chart version from Grafana Labs](https://artifacthub.io/packages/helm/grafana/grafana) or a Grafana Operator from a trusted provider. - In your new Grafana instance, you can [configure the GitLab provided Prometheus as a data source](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integration-with-gitlab-ui) - and [connect Grafana to the GitLab UI](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integration-with-gitlab-ui). + In your new Grafana instance, you can [configure the GitLab provided Prometheus as a data source](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#configure-grafana) + and [connect Grafana to the GitLab UI](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integrate-with-gitlab-ui). diff --git a/data/deprecations/16-0-deprecate-omnibus-grafana.yml b/data/deprecations/16-0-deprecate-omnibus-grafana.yml index 815c60099e9..93fd5c54071 100644 --- a/data/deprecations/16-0-deprecate-omnibus-grafana.yml +++ b/data/deprecations/16-0-deprecate-omnibus-grafana.yml @@ -7,8 +7,8 @@ issue_url: https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/7772 body: | # (required) Do not modify this line, instead modify the lines below. The version of [Grafana bundled with Omnibus GitLab](https://docs.gitlab.com/omnibus/settings/grafana.html) is - disabled in 16.0 and will be removed in 16.3. - If you are using the bundled Grafana, you must migrate to either: + [deprecated and disabled](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#deprecation-of-bundled-grafana) + in 16.0 and will be removed in 16.3. If you are using the bundled Grafana, you must migrate to either: - Another implementation of Grafana. For more information, see [Switch to new Grafana instance](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#switch-to-new-grafana-instance). diff --git a/data/removals/16_0/16-0-grafana-chart.yml b/data/removals/16_0/16-0-grafana-chart.yml index 3251f477bb0..7d26fc1764e 100644 --- a/data/removals/16_0/16-0-grafana-chart.yml +++ b/data/removals/16_0/16-0-grafana-chart.yml @@ -13,7 +13,7 @@ If you're using the bundled Grafana, you should switch to the [newer chart version from Grafana Labs](https://artifacthub.io/packages/helm/grafana/grafana) or a Grafana Operator from a trusted provider. - In your new Grafana instance, you can [configure the GitLab provided Prometheus as a data source](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integration-with-gitlab-ui) - and [connect Grafana to the GitLab UI](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integration-with-gitlab-ui). + In your new Grafana instance, [configure the GitLab provided Prometheus as a data source](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#configure-grafana) + and [connect Grafana to the GitLab UI](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integrate-with-gitlab-ui). tiers: # (optional - may be required in the future) An array of tiers that the feature is available in currently. e.g., [Free, Silver, Gold, Core, Premium, Ultimate] documentation_url: https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html diff --git a/doc/.vale/gitlab/OutdatedVersions.yml b/doc/.vale/gitlab/OutdatedVersions.yml index 10fbaa0a676..e55c3063bbb 100644 --- a/doc/.vale/gitlab/OutdatedVersions.yml +++ b/doc/.vale/gitlab/OutdatedVersions.yml @@ -22,3 +22,4 @@ tokens: - "GitLab (v)?10." - "GitLab (v)?11." - "GitLab (v)?12." + - "GitLab (v)?13." diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md index 51201ec442f..745b0c02ed7 100644 --- a/doc/administration/gitaly/praefect.md +++ b/doc/administration/gitaly/praefect.md @@ -1466,7 +1466,10 @@ You can configure: ``` If `default_replication_factor` is unset, the repositories are always replicated on every node defined in `virtual_storages`. If a new -node is introduced to the virtual storage, both new and existing repositories are replicated to the node automatically. +node is introduced to the virtual storage, both new and existing repositories are replicated to the node automatically. For large Gitaly +Cluster deployments with many Gitaly nodes, replicating a repository to every storage is often not sensible and can cause problems. +The higher the replication factor, the higher the pressure on the primary repository. You should explicitly set the default +replication factor for large Gitaly Cluster deployments. ### Repository storage recommendations diff --git a/doc/administration/monitoring/performance/grafana_configuration.md b/doc/administration/monitoring/performance/grafana_configuration.md index 1113dcfef32..4045e06fbff 100644 --- a/doc/administration/monitoring/performance/grafana_configuration.md +++ b/doc/administration/monitoring/performance/grafana_configuration.md @@ -14,7 +14,7 @@ For more information, see [deprecation notes](#deprecation-of-bundled-grafana). [Grafana](https://grafana.com/) is a tool that enables you to visualize time series metrics through graphs and dashboards. GitLab writes performance data to Prometheus, -and Grafana allows you to query the data to display useful graphs. +and Grafana allows you to query the data to display graphs. ## Deprecation of bundled Grafana @@ -30,29 +30,22 @@ To switch away from bundled Grafana to a newer version of Grafana from Grafana L 1. Set up a version of Grafana from Grafana Labs. 1. [Export the existing dashboards](https://grafana.com/docs/grafana/latest/dashboards/manage-dashboards/#export-a-dashboard) from bundled Grafana. 1. [Import the existing dashboards](https://grafana.com/docs/grafana/latest/dashboards/manage-dashboards/#import-a-dashboard) in the new Grafana instance. -1. [Configure GitLab](#integration-with-gitlab-ui) to use the new Grafana instance. +1. [Configure GitLab](#integrate-with-gitlab-ui) to use the new Grafana instance. ### Temporary workaround In GitLab versions 16.0 to 16.2, you can still force Omnibus GitLab to enable and configure Grafana by setting the following: - `grafana['enable'] = true`. -- `grafana['enable_deprecated_service'] = true`. - -You see a deprecation message when reconfiguring GitLab. +- `grafana['enable_deprecated_service'] = true`. -## Installation +You see a deprecation message when reconfiguring GitLab. -Omnibus GitLab can [help you install Grafana (recommended)](https://docs.gitlab.com/omnibus/settings/grafana.html) -or Grafana supplies package repositories (Yum/Apt) for easy installation. -See [Grafana installation documentation](https://grafana.com/docs/grafana/latest/setup-grafana/installation/) -for detailed steps. +## Configure Grafana -Before starting Grafana for the first time, set the administration user -and password in `/etc/grafana/grafana.ini`. If you don't, the default password -is `admin`. +Prerequisites: -## Configuration +- Grafana installed. 1. Log in to Grafana as the administration user. 1. Select **Data Sources** from the **Configuration** menu. @@ -62,9 +55,9 @@ is `admin`. Grafana should indicate the data source is working. -## Import Dashboards +## Import dashboards -You can now import a set of default dashboards to start displaying useful information. +You can now import a set of default dashboards to start displaying information. GitLab has published a set of default [Grafana dashboards](https://gitlab.com/gitlab-org/grafana-dashboards) to get you started. To use them: @@ -86,11 +79,11 @@ instance. For more information about this process, see the [README of the Grafana dashboards](https://gitlab.com/gitlab-org/grafana-dashboards) repository. -## Integration with GitLab UI +## Integrate with GitLab UI > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/61005) in GitLab 12.1. -After setting up Grafana, you can enable a link to access it easily from the +After setting up Grafana, you can enable a link to access it from the GitLab sidebar: 1. On the top bar, select **Main menu > Admin**. @@ -129,7 +122,7 @@ configuration screen: > prior to 13.10, the API scope: > > - Is required to access Grafana through the GitLab OAuth provider. -> - Is set by enabling the Grafana application as shown in [Integration with GitLab UI](#integration-with-gitlab-ui). +> - Is set by enabling the Grafana application as shown in [Integration with GitLab UI](#integrate-with-gitlab-ui). ## Security Update diff --git a/doc/api/cluster_agents.md b/doc/api/cluster_agents.md index 4bd16b88d92..1753757e5d9 100644 --- a/doc/api/cluster_agents.md +++ b/doc/api/cluster_agents.md @@ -365,12 +365,15 @@ Example response: ## Create an agent token -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/347046) in GitLab 15.0. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/347046) in GitLab 15.0. +> - Two-token limit [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/361030) in GitLab 16.1. Creates a new token for an agent. You must have at least the Maintainer role to use this endpoint. +An agent can have only two active tokens at one time. + ```plaintext POST /projects/:id/cluster_agents/:agent_id/tokens ``` diff --git a/doc/ci/pipelines/index.md b/doc/ci/pipelines/index.md index b0c5f3a6a69..1065c083d37 100644 --- a/doc/ci/pipelines/index.md +++ b/doc/ci/pipelines/index.md @@ -189,6 +189,11 @@ In this example: - `DEPLOY_ENVIRONMENT` is pre-filled in the **Run pipeline** page with `canary` as the default value, and the message explains the other options. +NOTE: +Because of a [known issue](https://gitlab.com/gitlab-org/gitlab/-/issues/382857), projects that use [compliance pipelines](../../user/group/compliance_frameworks.md#compliance-pipelines) can have prefilled variables not appear +when running a pipeline manually. To workaround this issue, +[change the compliance pipeline configuration](../../user/group/compliance_frameworks.md#prefilled-variables-are-not-shown). + #### Configure a list of selectable prefilled variable values > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/363660) in GitLab 15.5 [with a flag](../../administration/feature_flags.md) named `run_pipeline_graphql`. Disabled by default. diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md index 6ef5dd27a4f..9f6751eb79c 100644 --- a/doc/update/deprecations.md +++ b/doc/update/deprecations.md @@ -858,8 +858,8 @@ be available in CI/CD jobs. </div> The version of [Grafana bundled with Omnibus GitLab](https://docs.gitlab.com/omnibus/settings/grafana.html) is -disabled in 16.0 and will be removed in 16.3. -If you are using the bundled Grafana, you must migrate to either: +[deprecated and disabled](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#deprecation-of-bundled-grafana) +in 16.0 and will be removed in 16.3. If you are using the bundled Grafana, you must migrate to either: - Another implementation of Grafana. For more information, see [Switch to new Grafana instance](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#switch-to-new-grafana-instance). @@ -977,8 +977,8 @@ The version of Grafana that the GitLab Helm Chart is currently providing is no l If you're using the bundled Grafana, you should switch to the [newer chart version from Grafana Labs](https://artifacthub.io/packages/helm/grafana/grafana) or a Grafana Operator from a trusted provider. -In your new Grafana instance, you can [configure the GitLab provided Prometheus as a data source](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integration-with-gitlab-ui) -and [connect Grafana to the GitLab UI](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integration-with-gitlab-ui). +In your new Grafana instance, you can [configure the GitLab provided Prometheus as a data source](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#configure-grafana) +and [connect Grafana to the GitLab UI](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integrate-with-gitlab-ui). </div> diff --git a/doc/update/index.md b/doc/update/index.md index f1b8bb17f14..00c55f1e4b4 100644 --- a/doc/update/index.md +++ b/doc/update/index.md @@ -192,7 +192,8 @@ accordingly, while also consulting the - GitLab 12: `12.0.12` > [`12.1.17`](#1210) > [`12.10.14`](#12100) - GitLab 13: `13.0.14` > [`13.1.11`](#1310) > [`13.8.8`](#1388) > [`13.12.15`](#13120) - GitLab 14: [`14.0.12`](#1400) > [`14.3.6`](#1430) > [`14.9.5`](#1490) > [`14.10.5`](#14100) -- GitLab 15: [`15.0.5`](#1500) > [`15.1.6`](#1510) (for GitLab instances with multiple web nodes) > [`15.4.6`](#1540) > [latest `15.Y.Z`](https://gitlab.com/gitlab-org/gitlab/-/releases) +- GitLab 15: [`15.0.5`](#1500) > [`15.1.6`](#1510) (for GitLab instances with multiple web nodes) > [`15.4.6`](#1540) > [`15.11.x`](#15110) +- GitLab 16: [latest `16.Y.Z`](https://gitlab.com/gitlab-org/gitlab/-/releases) NOTE: When not explicitly specified, upgrade GitLab to the latest available patch diff --git a/doc/update/removals.md b/doc/update/removals.md index 91af9d21a20..4f60674a0a2 100644 --- a/doc/update/removals.md +++ b/doc/update/removals.md @@ -80,8 +80,8 @@ The `global.grafana.enabled` setting for the GitLab Helm Chart has also been rem If you're using the bundled Grafana, you should switch to the [newer chart version from Grafana Labs](https://artifacthub.io/packages/helm/grafana/grafana) or a Grafana Operator from a trusted provider. -In your new Grafana instance, you can [configure the GitLab provided Prometheus as a data source](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integration-with-gitlab-ui) -and [connect Grafana to the GitLab UI](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integration-with-gitlab-ui). +In your new Grafana instance, [configure the GitLab provided Prometheus as a data source](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#configure-grafana) +and [connect Grafana to the GitLab UI](https://docs.gitlab.com/ee/administration/monitoring/performance/grafana_configuration.html#integrate-with-gitlab-ui). ### CAS OmniAuth provider is removed diff --git a/doc/user/admin_area/settings/index.md b/doc/user/admin_area/settings/index.md index 2091191b889..c91b8f0b6b0 100644 --- a/doc/user/admin_area/settings/index.md +++ b/doc/user/admin_area/settings/index.md @@ -108,7 +108,7 @@ The **Metrics and profiling** settings contain: - [Metrics - Prometheus](../../../administration/monitoring/prometheus/gitlab_metrics.md) - Enable and configure Prometheus metrics. -- [Metrics - Grafana](../../../administration/monitoring/performance/grafana_configuration.md#integration-with-gitlab-ui) - +- [Metrics - Grafana](../../../administration/monitoring/performance/grafana_configuration.md#integrate-with-gitlab-ui) - Enable and configure Grafana. - [Profiling - Performance bar](../../../administration/monitoring/performance/performance_bar.md#enable-the-performance-bar-for-non-administrators) - Enable access to the Performance Bar for non-administrator users in a given group. diff --git a/doc/user/clusters/agent/work_with_agent.md b/doc/user/clusters/agent/work_with_agent.md index 2d54f67724e..b2e8ac6ef16 100644 --- a/doc/user/clusters/agent/work_with_agent.md +++ b/doc/user/clusters/agent/work_with_agent.md @@ -91,6 +91,9 @@ For more information about debugging, see [troubleshooting documentation](troubl > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/327152) in GitLab 14.9. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/336641) in GitLab 14.10, the agent token can be revoked from the UI. +> - Two-token limit [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/361030) in GitLab 16.1. + +An agent can have only two active tokens at one time. To reset the agent token without downtime: diff --git a/doc/user/group/compliance_frameworks.md b/doc/user/group/compliance_frameworks.md index 2fca8b7b678..77fca862a5b 100644 --- a/doc/user/group/compliance_frameworks.md +++ b/doc/user/group/compliance_frameworks.md @@ -397,3 +397,22 @@ You could also have the following `.gitlab-ci.yml` configuration: This configuration doesn't overwrite the compliance pipeline and you get the following message: `take compliance action`. + +### Prefilled variables are not shown + +Because of a [known issue](https://gitlab.com/gitlab-org/gitlab/-/issues/382857), +compliance pipelines in GitLab 15.3 and later can prevent +[prefilled variables](../../ci/pipelines/index.md#prefill-variables-in-manual-pipelines) +from appearing when manually starting a pipeline. + +To workaround this issue, use `ref: '$CI_COMMIT_SHA'` instead of `ref: '$CI_COMMIT_REF_NAME'` +in the `include:` statement that executes the individual project's configuration. + +The [example configuration](#example-configuration) has been updated with this change: + +```yaml +include: + - project: '$CI_PROJECT_PATH' + file: '$CI_CONFIG_PATH' + ref: '$CI_COMMIT_SHA' +``` diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e445147d38f..de1f98d3638 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10415,6 +10415,9 @@ msgstr "" msgid "ClusterAgents|shared" msgstr "" +msgid "ClusterAgent|An agent can have only two active tokens at a time" +msgstr "" + msgid "ClusterAgent|User has insufficient permissions to create a token for this project" msgstr "" @@ -12352,6 +12355,9 @@ msgstr "" msgid "Copy environment" msgstr "" +msgid "Copy epic URL" +msgstr "" + msgid "Copy evidence SHA" msgstr "" @@ -12367,6 +12373,9 @@ msgstr "" msgid "Copy image URL" msgstr "" +msgid "Copy issue URL" +msgstr "" + msgid "Copy issue URL to clipboard" msgstr "" @@ -12388,6 +12397,9 @@ msgstr "" msgid "Copy link to chart" msgstr "" +msgid "Copy merge request URL" +msgstr "" + msgid "Copy reference" msgstr "" @@ -15915,6 +15927,9 @@ msgstr "" msgid "Display alerts from all configured monitoring tools." msgstr "" +msgid "Display as:" +msgstr "" + msgid "Display milestones" msgstr "" @@ -17223,6 +17238,9 @@ msgstr "" msgid "Epic Boards" msgstr "" +msgid "Epic ID" +msgstr "" + msgid "Epic actions" msgstr "" @@ -17241,6 +17259,12 @@ msgstr "" msgid "Epic not found for given params" msgstr "" +msgid "Epic summary" +msgstr "" + +msgid "Epic title" +msgstr "" + msgid "Epics" msgstr "" @@ -24755,6 +24779,9 @@ msgstr "" msgid "Issue Boards" msgstr "" +msgid "Issue ID" +msgstr "" + msgid "Issue Type" msgstr "" @@ -24800,6 +24827,12 @@ msgstr "" msgid "Issue published on status page." msgstr "" +msgid "Issue summary" +msgstr "" + +msgid "Issue title" +msgstr "" + msgid "Issue types" msgstr "" @@ -27972,6 +28005,9 @@ msgstr "" msgid "Merge request %{mr_link} was reviewed by %{mr_author}" msgstr "" +msgid "Merge request ID" +msgstr "" + msgid "Merge request actions" msgstr "" @@ -27999,6 +28035,12 @@ msgstr "" msgid "Merge request status" msgstr "" +msgid "Merge request summary" +msgstr "" + +msgid "Merge request title" +msgstr "" + msgid "Merge request was scheduled to merge after pipeline succeeds" msgstr "" @@ -37635,6 +37677,9 @@ msgstr "" msgid "Remove due date" msgstr "" +msgid "Remove epic reference" +msgstr "" + msgid "Remove favicon" msgstr "" @@ -37659,6 +37704,9 @@ msgstr "" msgid "Remove icon" msgstr "" +msgid "Remove issue reference" +msgstr "" + msgid "Remove iteration" msgstr "" @@ -37683,6 +37731,9 @@ msgstr "" msgid "Remove member" msgstr "" +msgid "Remove merge request reference" +msgstr "" + msgid "Remove milestone" msgstr "" @@ -48971,6 +49022,9 @@ msgstr "" msgid "UserProfile|Activity" msgstr "" +msgid "UserProfile|An error occurred loading the followers. Please refresh the page to try again." +msgstr "" + msgid "UserProfile|An error occurred loading the personal projects. Please refresh the page to try again." msgstr "" diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js index de1e8c99b54..48bc788afaa 100644 --- a/spec/frontend/__helpers__/mock_window_location_helper.js +++ b/spec/frontend/__helpers__/mock_window_location_helper.js @@ -12,6 +12,7 @@ const useMockLocation = (fn) => { Object.defineProperty(window, 'location', { get: () => currentWindowLocation, + assign: jest.fn(), }); beforeEach(() => { diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js index a879c229581..b2ecfeb8394 100644 --- a/spec/frontend/api/user_api_spec.js +++ b/spec/frontend/api/user_api_spec.js @@ -1,12 +1,14 @@ import MockAdapter from 'axios-mock-adapter'; import projects from 'test_fixtures/api/users/projects/get.json'; +import followers from 'test_fixtures/api/users/followers/get.json'; import { followUser, unfollowUser, associationsCount, updateUserStatus, getUserProjects, + getUserFollowers, } from '~/api/user_api'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -16,6 +18,7 @@ import { } from 'jest/admin/users/mock_data'; import { AVAILABILITY_STATUS } from '~/set_status_modal/constants'; import { timeRanges } from '~/vue_shared/constants'; +import { DEFAULT_PER_PAGE } from '~/api'; describe('~/api/user_api', () => { let axiosMock; @@ -112,4 +115,20 @@ describe('~/api/user_api', () => { expect(axiosMock.history.get[0].url).toBe(expectedUrl); }); }); + + describe('getUserFollowers', () => { + it('calls correct URL and returns expected response', async () => { + const expectedUrl = '/api/v4/users/1/followers'; + const expectedResponse = { data: followers }; + const params = { page: 2 }; + + axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, expectedResponse); + + await expect(getUserFollowers(1, params)).resolves.toEqual( + expect.objectContaining({ data: expectedResponse }), + ); + expect(axiosMock.history.get[0].url).toBe(expectedUrl); + expect(axiosMock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE }); + }); + }); }); diff --git a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js index 97716ce848c..309e5f76b9c 100644 --- a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js @@ -65,6 +65,7 @@ describe('content_editor/components/bubble_menus/bubble_menu', () => { onHidden: expect.any(Function), onShow: expect.any(Function), appendTo: expect.any(Function), + maxWidth: 'auto', ...tippyOptions, }), }); diff --git a/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js new file mode 100644 index 00000000000..169f77dc054 --- /dev/null +++ b/spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js @@ -0,0 +1,247 @@ +import { GlLoadingIcon, GlListboxItem, GlCollapsibleListbox } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import ReferenceBubbleMenu from '~/content_editor/components/bubble_menus/reference_bubble_menu.vue'; +import BubbleMenu from '~/content_editor/components/bubble_menus/bubble_menu.vue'; +import { stubComponent } from 'helpers/stub_component'; +import Reference from '~/content_editor/extensions/reference'; +import { createTestEditor, emitEditorEvent, createDocBuilder } from '../../test_utils'; + +const mockIssue = { + href: 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + text: '#24', + expandedText: 'Et fuga quos omnis enim dolores amet impedit. (#24)', + fullyExpandedText: + 'Et fuga quos omnis enim dolores amet impedit. (#24) • Fernanda Adams • Sprint - Eligendi quas non inventore eum quaerat sit.', +}; +const mockMergeRequest = { + href: 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + text: '!2', + expandedText: 'Qui possimus sit harum ut ipsam autem. (!2)', + fullyExpandedText: 'Qui possimus sit harum ut ipsam autem. (!2) • Margrett Wunsch • v0.0', +}; +const mockEpic = { + href: 'https://gitlab.com/groups/gitlab-org/-/epics/5', + text: '&5', + expandedText: 'Temporibus delectus distinctio quas sed non per... (&5)', +}; + +const supportedIssueDisplayFormats = ['Issue ID', 'Issue title', 'Issue summary']; + +const supportedMergeRequestDisplayFormats = [ + 'Merge request ID', + 'Merge request title', + 'Merge request summary', +]; + +const supportedEpicDisplayFormats = ['Epic ID', 'Epic title']; + +describe('content_editor/components/bubble_menus/reference_bubble_menu', () => { + let wrapper; + let tiptapEditor; + let contentEditor; + let eventHub; + let doc; + let p; + let reference; + + const buildExpectedDoc = (href, originalText, referenceType, text) => + doc(p(reference({ className: 'gfm', href, originalText, referenceType, text }))); + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [Reference] }); + contentEditor = { resolveReference: jest.fn().mockImplementation(() => new Promise(() => {})) }; + eventHub = eventHubFactory(); + + ({ + builders: { doc, p, reference }, + } = createDocBuilder({ + tiptapEditor, + names: { + reference: { nodeType: Reference.name }, + }, + })); + }; + + const expectedDocs = { + issue: [ + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + '#24', + 'issue', + '#24', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + '#24+', + 'issue', + 'Et fuga quos omnis enim dolores amet impedit. (#24)', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/issues/24', + '#24+s', + 'issue', + 'Et fuga quos omnis enim dolores amet impedit. (#24) • Fernanda Adams • Sprint - Eligendi quas non inventore eum quaerat sit.', + ), + ], + merge_request: [ + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + '!2', + 'merge_request', + '!2', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + '!2+', + 'merge_request', + 'Qui possimus sit harum ut ipsam autem. (!2)', + ), + () => + buildExpectedDoc( + 'https://gitlab.com/gitlab-org/gitlab-test/-/merge_requests/2', + '!2+s', + 'merge_request', + 'Qui possimus sit harum ut ipsam autem. (!2) • Margrett Wunsch • v0.0', + ), + ], + epic: [ + () => buildExpectedDoc('https://gitlab.com/groups/gitlab-org/-/epics/5', '&5', 'epic', '&5'), + () => + buildExpectedDoc( + 'https://gitlab.com/groups/gitlab-org/-/epics/5', + '&5+', + 'epic', + 'Temporibus delectus distinctio quas sed non per... (&5)', + ), + ], + }; + + const buildWrapper = () => { + wrapper = mountExtended(ReferenceBubbleMenu, { + provide: { + tiptapEditor, + contentEditor, + eventHub, + }, + stubs: { + BubbleMenu: stubComponent(BubbleMenu), + }, + }); + }; + + const showMenu = () => { + wrapper.findComponent(BubbleMenu).vm.$emit('show'); + return nextTick(); + }; + + const buildWrapperAndDisplayMenu = async () => { + buildWrapper(); + + await showMenu(); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }; + + beforeEach(() => { + buildEditor(); + + tiptapEditor + .chain() + .setContent( + '<a href="https://gitlab.com/gitlab-org/gitlab/issues/1" class="gfm" data-reference-type="issue" data-original="#1">#1</a>', + ) + .setNodeSelection(1) + .run(); + }); + + it('shows a loading indicator while the reference is being resolved', async () => { + await buildWrapperAndDisplayMenu(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + describe.each` + referenceType | mockReference | supportedDisplayFormats + ${'issue'} | ${mockIssue} | ${supportedIssueDisplayFormats} + ${'merge_request'} | ${mockMergeRequest} | ${supportedMergeRequestDisplayFormats} + ${'epic'} | ${mockEpic} | ${supportedEpicDisplayFormats} + `( + 'for reference type $referenceType', + ({ referenceType, mockReference, supportedDisplayFormats }) => { + beforeEach(async () => { + tiptapEditor + .chain() + .setContent( + `<a href="${mockReference.href}" class="gfm" data-reference-type="${referenceType}" data-original="${mockReference.text}">${mockReference.text}</a>`, + ) + .setNodeSelection(1) + .run(); + + contentEditor.resolveReference.mockImplementation(() => Promise.resolve(mockReference)); + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + }); + + it('shows a dropdown with supported display formats', async () => { + await buildWrapperAndDisplayMenu(); + + supportedDisplayFormats.forEach((format) => expect(wrapper.text()).toContain(format)); + }); + + describe.each` + option | displayFormat | selectedValue + ${0} | ${supportedDisplayFormats[0]} | ${''} + ${1} | ${supportedDisplayFormats[1]} | ${'+'} + ${2} | ${supportedDisplayFormats[2]} | ${'+s'} + `('on selecting option $option', ({ option, displayFormat, selectedValue }) => { + if (!displayFormat) return; + + const findDropdownItem = () => wrapper.findAllComponents(GlListboxItem).at(option); + + beforeEach(async () => { + await buildWrapperAndDisplayMenu(); + + findDropdownItem().trigger('click'); + }); + + it('selects the option', () => { + expect(wrapper.findComponent(GlCollapsibleListbox).props()).toMatchObject({ + selected: selectedValue, + toggleText: displayFormat, + }); + }); + + it('updates the reference in content editor', () => { + expect(tiptapEditor.getJSON()).toEqual(expectedDocs[referenceType][option]().toJSON()); + }); + }); + }, + ); + + describe('copy URL button', () => { + it('copies the reference link to clipboard', async () => { + jest.spyOn(navigator.clipboard, 'writeText'); + + await buildWrapperAndDisplayMenu(); + await wrapper.findByTestId('copy-reference-url').trigger('click'); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'https://gitlab.com/gitlab-org/gitlab/issues/1', + ); + }); + }); + + describe('remove reference button', () => { + it('removes the reference', async () => { + await buildWrapperAndDisplayMenu(); + await wrapper.findByTestId('remove-reference').trigger('click'); + + expect(tiptapEditor.getHTML()).toBe('<p></p>'); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 852c8a9591a..44dd328025a 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -9,6 +9,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block_bubble_menu.vue'; import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble_menu.vue'; import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue'; +import ReferenceBubbleMenu from '~/content_editor/components/bubble_menus/reference_bubble_menu.vue'; import FormattingToolbar from '~/content_editor/components/formatting_toolbar.vue'; import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; import waitForPromises from 'helpers/wait_for_promises'; @@ -267,7 +268,8 @@ describe('ContentEditor', () => { ${'link'} | ${LinkBubbleMenu} ${'media'} | ${MediaBubbleMenu} ${'codeBlock'} | ${CodeBlockBubbleMenu} - `('renders formatting bubble menu', ({ component }) => { + ${'reference'} | ${ReferenceBubbleMenu} + `('renders $name bubble menu', ({ component }) => { createWrapper(); expect(wrapper.findComponent(component).exists()).toBe(true); diff --git a/spec/frontend/content_editor/extensions/drawio_diagram_spec.js b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js index 61dc164c99a..63ed08096b2 100644 --- a/spec/frontend/content_editor/extensions/drawio_diagram_spec.js +++ b/spec/frontend/content_editor/extensions/drawio_diagram_spec.js @@ -1,6 +1,5 @@ import DrawioDiagram from '~/content_editor/extensions/drawio_diagram'; import Image from '~/content_editor/extensions/image'; -import createAssetResolver from '~/content_editor/services/asset_resolver'; import { create } from '~/drawio/content_editor_facade'; import { launchDrawioEditor } from '~/drawio/drawio_editor'; import { createTestEditor, createDocBuilder } from '../test_utils'; @@ -19,12 +18,15 @@ describe('content_editor/extensions/drawio_diagram', () => { let paragraph; let image; let drawioDiagram; + let assetResolver; + const uploadsPath = '/uploads'; - const renderMarkdown = () => {}; beforeEach(() => { + assetResolver = new (class {})(); + tiptapEditor = createTestEditor({ - extensions: [Image, DrawioDiagram.configure({ uploadsPath, renderMarkdown })], + extensions: [Image, DrawioDiagram.configure({ uploadsPath, assetResolver })], }); const { builders } = createDocBuilder({ tiptapEditor, @@ -72,19 +74,12 @@ describe('content_editor/extensions/drawio_diagram', () => { describe('createOrEditDiagram command', () => { let editorFacade; - let assetResolver; beforeEach(() => { editorFacade = {}; - assetResolver = {}; tiptapEditor.commands.createOrEditDiagram(); create.mockReturnValueOnce(editorFacade); - createAssetResolver.mockReturnValueOnce(assetResolver); - }); - - it('creates a new instance of asset resolver', () => { - expect(createAssetResolver).toHaveBeenCalledWith({ renderMarkdown }); }); it('creates a new instance of the content_editor_facade', () => { diff --git a/spec/frontend/content_editor/extensions/reference_spec.js b/spec/frontend/content_editor/extensions/reference_spec.js new file mode 100644 index 00000000000..c25c7c41d75 --- /dev/null +++ b/spec/frontend/content_editor/extensions/reference_spec.js @@ -0,0 +1,162 @@ +import Reference from '~/content_editor/extensions/reference'; +import AssetResolver from '~/content_editor/services/asset_resolver'; +import { + RESOLVED_ISSUE_HTML, + RESOLVED_MERGE_REQUEST_HTML, + RESOLVED_EPIC_HTML, +} from '../test_constants'; +import { + createTestEditor, + createDocBuilder, + triggerNodeInputRule, + waitUntilTransaction, +} from '../test_utils'; + +describe('content_editor/extensions/reference', () => { + let tiptapEditor; + let doc; + let p; + let reference; + let renderMarkdown; + let assetResolver; + + beforeEach(() => { + renderMarkdown = jest.fn().mockImplementation(() => new Promise(() => {})); + assetResolver = new AssetResolver({ renderMarkdown }); + + tiptapEditor = createTestEditor({ + extensions: [Reference.configure({ assetResolver })], + }); + + ({ + builders: { doc, p, reference }, + } = createDocBuilder({ + tiptapEditor, + names: { + reference: { nodeType: Reference.name }, + }, + })); + }); + + describe('when typing a valid reference input rule', () => { + const buildExpectedDoc = (href, originalText, referenceType, text) => + doc(p(reference({ className: null, href, originalText, referenceType, text }), ' ')); + + it.each` + inputRuleText | mockReferenceHtml | expectedDoc + ${'#1 '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1', 'issue', '#1 (closed)')} + ${'#1+ '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1+', 'issue', '500 error on MR approvers edit page (#1 - closed)')} + ${'#1+s '} | ${RESOLVED_ISSUE_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/issues/1', '#1+s', 'issue', '500 error on MR approvers edit page (#1 - closed) • Unassigned')} + ${'!1 '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1', 'merge_request', '!1 (merged)')} + ${'!1+ '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1+', 'merge_request', 'Enhance the LDAP group synchronization (!1 - merged)')} + ${'!1+s '} | ${RESOLVED_MERGE_REQUEST_HTML} | ${() => buildExpectedDoc('/gitlab-org/gitlab/-/merge_requests/1', '!1+s', 'merge_request', 'Enhance the LDAP group synchronization (!1 - merged) • John Doe')} + ${'&1 '} | ${RESOLVED_EPIC_HTML} | ${() => buildExpectedDoc('/groups/gitlab-org/-/epics/1', '&1', 'epic', '&1')} + ${'&1+ '} | ${RESOLVED_EPIC_HTML} | ${() => buildExpectedDoc('/groups/gitlab-org/-/epics/1', '&1+', 'epic', 'Approvals in merge request list (&1)')} + `( + 'replaces the input rule ($inputRuleText) with a reference node', + async ({ inputRuleText, mockReferenceHtml, expectedDoc }) => { + await waitUntilTransaction({ + number: 2, + tiptapEditor, + action() { + renderMarkdown.mockResolvedValueOnce(mockReferenceHtml); + + tiptapEditor.commands.insertContent({ type: 'text', text: inputRuleText }); + triggerNodeInputRule({ tiptapEditor, inputRuleText }); + }, + }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc().toJSON()); + }, + ); + + it('resolves multiple references in the same paragraph correctly', async () => { + await waitUntilTransaction({ + number: 2, + tiptapEditor, + action() { + renderMarkdown.mockResolvedValueOnce(RESOLVED_ISSUE_HTML); + + tiptapEditor.commands.insertContent({ type: 'text', text: '#1+ ' }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: '#1+ ' }); + }, + }); + + await waitUntilTransaction({ + number: 2, + tiptapEditor, + action() { + renderMarkdown.mockResolvedValueOnce(RESOLVED_MERGE_REQUEST_HTML); + + tiptapEditor.commands.insertContent({ type: 'text', text: 'was resolved with !1+ ' }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: 'was resolved with !1+ ' }); + }, + }); + + expect(tiptapEditor.getJSON()).toEqual( + doc( + p( + reference({ + referenceType: 'issue', + originalText: '#1+', + text: '500 error on MR approvers edit page (#1 - closed)', + href: '/gitlab-org/gitlab/-/issues/1', + }), + ' was resolved with ', + reference({ + referenceType: 'merge_request', + originalText: '!1+', + text: 'Enhance the LDAP group synchronization (!1 - merged)', + href: '/gitlab-org/gitlab/-/merge_requests/1', + }), + ' ', + ), + ).toJSON(), + ); + }); + + it('resolves the input rule lazily in the correct position if the user makes a change before the request resolves', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + renderMarkdown.mockImplementation(() => promise); + + tiptapEditor.commands.insertContent({ type: 'text', text: '#1+ ' }); + triggerNodeInputRule({ tiptapEditor, inputRuleText: '#1+ ' }); + + // insert a new paragraph at a random location + tiptapEditor.commands.insertContentAt(0, { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + }); + + // update selection + tiptapEditor.commands.selectAll(); + + await waitUntilTransaction({ + number: 1, + tiptapEditor, + action() { + resolvePromise(RESOLVED_ISSUE_HTML); + }, + }); + + expect(tiptapEditor.state.doc).toEqual( + doc( + p('Hello'), + p( + reference({ + referenceType: 'issue', + originalText: '#1+', + text: '500 error on MR approvers edit page (#1 - closed)', + href: '/gitlab-org/gitlab/-/issues/1', + }), + ' ', + ), + ), + ); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/asset_resolver_spec.js b/spec/frontend/content_editor/services/asset_resolver_spec.js index 0a99f823be3..292eec6db77 100644 --- a/spec/frontend/content_editor/services/asset_resolver_spec.js +++ b/spec/frontend/content_editor/services/asset_resolver_spec.js @@ -1,4 +1,9 @@ -import createAssetResolver from '~/content_editor/services/asset_resolver'; +import AssetResolver from '~/content_editor/services/asset_resolver'; +import { + RESOLVED_ISSUE_HTML, + RESOLVED_MERGE_REQUEST_HTML, + RESOLVED_EPIC_HTML, +} from '../test_constants'; describe('content_editor/services/asset_resolver', () => { let renderMarkdown; @@ -6,7 +11,7 @@ describe('content_editor/services/asset_resolver', () => { beforeEach(() => { renderMarkdown = jest.fn(); - assetResolver = createAssetResolver({ renderMarkdown }); + assetResolver = new AssetResolver({ renderMarkdown }); }); describe('resolveUrl', () => { @@ -21,6 +26,65 @@ describe('content_editor/services/asset_resolver', () => { }); }); + describe('resolveReference', () => { + const resolvedEpic = { + expandedText: 'Approvals in merge request list (&1)', + fullyExpandedText: 'Approvals in merge request list (&1)', + href: '/groups/gitlab-org/-/epics/1', + text: '&1', + }; + + const resolvedIssue = { + expandedText: '500 error on MR approvers edit page (#1 - closed)', + fullyExpandedText: '500 error on MR approvers edit page (#1 - closed) • Unassigned', + href: '/gitlab-org/gitlab/-/issues/1', + text: '#1 (closed)', + }; + + const resolvedMergeRequest = { + expandedText: 'Enhance the LDAP group synchronization (!1 - merged)', + fullyExpandedText: 'Enhance the LDAP group synchronization (!1 - merged) • John Doe', + href: '/gitlab-org/gitlab/-/merge_requests/1', + text: '!1 (merged)', + }; + + describe.each` + referenceType | referenceId | sentMarkdown | returnedHtml | resolvedReference + ${'issue'} | ${'#1'} | ${'#1 #1+ #1+s'} | ${RESOLVED_ISSUE_HTML} | ${resolvedIssue} + ${'merge_request'} | ${'!1'} | ${'!1 !1+ !1+s'} | ${RESOLVED_MERGE_REQUEST_HTML} | ${resolvedMergeRequest} + ${'epic'} | ${'&1'} | ${'&1 &1+ &1+s'} | ${RESOLVED_EPIC_HTML} | ${resolvedEpic} + `( + 'for reference type $referenceType', + ({ referenceType, referenceId, sentMarkdown, returnedHtml, resolvedReference }) => { + it(`resolves ${referenceType} reference to href, text, title and summary`, async () => { + renderMarkdown.mockResolvedValue(returnedHtml); + + expect(await assetResolver.resolveReference(referenceId)).toEqual(resolvedReference); + }); + + it.each` + suffix + ${''} + ${'+'} + ${'+s'} + `('strips suffix ("$suffix") before resolving', ({ suffix }) => { + assetResolver.resolveReference(referenceId + suffix); + expect(renderMarkdown).toHaveBeenCalledWith(sentMarkdown); + }); + }, + ); + + it.each` + case | sentMarkdown | returnedHtml + ${'no html is returned'} | ${''} | ${''} + ${'html contains no anchor tags'} | ${'no anchor tags'} | ${'<p>no anchor tags</p>'} + `('returns an empty object if $case', async ({ sentMarkdown, returnedHtml }) => { + renderMarkdown.mockResolvedValue(returnedHtml); + + expect(await assetResolver.resolveReference(sentMarkdown)).toEqual({}); + }); + }); + describe('renderDiagram', () => { it('resolves a diagram code to a url containing the diagram image', async () => { renderMarkdown.mockResolvedValue( diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js index 53cd51b8c5f..b9a9c3ccd17 100644 --- a/spec/frontend/content_editor/services/create_content_editor_spec.js +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -2,6 +2,7 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants import { createContentEditor } from '~/content_editor/services/create_content_editor'; import createGlApiMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer'; import createRemarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer'; +import AssetResolver from '~/content_editor/services/asset_resolver'; import { createTestContentEditorExtension } from '../test_utils'; jest.mock('~/emoji'); @@ -89,7 +90,7 @@ describe('content_editor/services/create_content_editor', () => { .options, ).toMatchObject({ uploadsPath, - renderMarkdown, + assetResolver: expect.any(AssetResolver), }); }); }); diff --git a/spec/frontend/content_editor/test_constants.js b/spec/frontend/content_editor/test_constants.js index 749f1234de0..cbd4f555e97 100644 --- a/spec/frontend/content_editor/test_constants.js +++ b/spec/frontend/content_editor/test_constants.js @@ -35,3 +35,12 @@ export const PROJECT_WIKI_ATTACHMENT_DRAWIO_DIAGRAM_HTML = `<p data-sourcepos="1 export const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> </p>`; + +export const RESOLVED_ISSUE_HTML = + '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">#1 (closed)</a> <a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1+" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">500 error on MR approvers edit page (#1 - closed)</a> <a href="/gitlab-org/gitlab/-/issues/1" data-reference-type="issue" data-original="#1+s" data-link="false" data-link-reference="false" data-project="278964" data-issue="382515" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+s" data-issue-type="issue" data-container="body" data-placement="top" title="500 error on MR approvers edit page" class="gfm gfm-issue">500 error on MR approvers edit page (#1 - closed) • Unassigned</a></p>'; + +export const RESOLVED_MERGE_REQUEST_HTML = + '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">!1 (merged)</a> <a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1+" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">Enhance the LDAP group synchronization (!1 - merged)</a> <a href="/gitlab-org/gitlab/-/merge_requests/1" data-reference-type="merge_request" data-original="!1+s" data-link="false" data-link-reference="false" data-project="278964" data-merge-request="83382" data-project-path="gitlab-org/gitlab" data-iid="1" data-reference-format="+s" data-container="body" data-placement="top" title="Enhance the LDAP group synchronization" class="gfm gfm-merge_request">Enhance the LDAP group synchronization (!1 - merged) • John Doe</a></p>'; + +export const RESOLVED_EPIC_HTML = + '<p data-sourcepos="1:1-1:11" dir="auto"><a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;1" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">&1</a> <a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;1+" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-reference-format="+" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">Approvals in merge request list (&1)</a> <a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;1+s" data-link="false" data-link-reference="false" data-group="9970" data-epic="1" data-reference-format="+s" data-container="body" data-placement="top" title="Approvals in merge request list" class="gfm gfm-epic has-tooltip">Approvals in merge request list (&1)</a></p>'; diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index 1f4a367e46c..802ea49631f 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -212,6 +212,22 @@ export const waitUntilNextDocTransaction = ({ tiptapEditor, action = () => {} }) }); }; +export const waitUntilTransaction = ({ tiptapEditor, number, action }) => { + return new Promise((resolve) => { + let counter = 0; + const handleTransaction = () => { + counter += 1; + if (counter === number) { + tiptapEditor.off('update', handleTransaction); + resolve(); + } + }; + + tiptapEditor.on('update', handleTransaction); + action(); + }); +}; + export const expectDocumentAfterTransaction = ({ tiptapEditor, number, expectedDoc, action }) => { return new Promise((resolve) => { let counter = 0; diff --git a/spec/frontend/fixtures/users.rb b/spec/frontend/fixtures/users.rb index 0e9d7475bf9..fb028a2e055 100644 --- a/spec/frontend/fixtures/users.rb +++ b/spec/frontend/fixtures/users.rb @@ -3,12 +3,22 @@ require 'spec_helper' RSpec.describe 'Users (GraphQL fixtures)', feature_category: :user_profile do + include JavaScriptFixturesHelpers + include ApiHelpers + + let_it_be(:followers) { create_list(:user, 5) } + let_it_be(:user) { create(:user, followers: followers) } + + describe API::Users, '(JavaScript fixtures)', type: :request do + it 'api/users/followers/get.json' do + get api("/users/#{user.id}/followers", user) + + expect(response).to be_successful + end + end + describe GraphQL::Query, type: :request do - include ApiHelpers include GraphqlHelpers - include JavaScriptFixturesHelpers - - let_it_be(:user) { create(:user) } context 'for user achievements' do let_it_be(:group) { create(:group, :public) } diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 4bf3a779f00..f41fe140ba1 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -406,7 +406,9 @@ describe('URL utility', () => { Object.defineProperty(window, 'location', { writable: true, - value: new URL(TEST_HOST), + value: { + assign: jest.fn(), + }, }); }); @@ -417,11 +419,15 @@ describe('URL utility', () => { it('navigates to a page', () => { urlUtils.visitUrl(mockUrl); - expect(window.location.href).toBe(mockUrl); + expect(window.location.assign).toHaveBeenCalledWith(mockUrl); }); it('navigates to a new page', () => { - const otherWindow = {}; + const otherWindow = { + location: { + assign: jest.fn(), + }, + }; Object.defineProperty(window, 'open', { writable: true, @@ -431,7 +437,7 @@ describe('URL utility', () => { urlUtils.visitUrl(mockUrl, true); expect(otherWindow.opener).toBe(null); - expect(otherWindow.location).toBe(mockUrl); + expect(otherWindow.location.assign).toHaveBeenCalledWith(mockUrl); }); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js index a68087f7f57..e3feb99a9b5 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js @@ -286,8 +286,8 @@ describe('Container Expiration Policy Settings Form', () => { await submitForm(); - expect(window.location.href.endsWith('settings-path?showSetupSuccessAlert=true')).toBe( - true, + expect(window.location.assign).toHaveBeenCalledWith( + 'settings-path?showSetupSuccessAlert=true', ); }); diff --git a/spec/frontend/profile/components/follow_spec.js b/spec/frontend/profile/components/follow_spec.js new file mode 100644 index 00000000000..2555e41257f --- /dev/null +++ b/spec/frontend/profile/components/follow_spec.js @@ -0,0 +1,99 @@ +import { GlAvatarLabeled, GlAvatarLink, GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import users from 'test_fixtures/api/users/followers/get.json'; +import Follow from '~/profile/components/follow.vue'; +import { DEFAULT_PER_PAGE } from '~/api'; + +jest.mock('~/rest_api'); + +describe('FollowersTab', () => { + let wrapper; + + const defaultPropsData = { + users, + loading: false, + page: 1, + totalItems: 50, + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMount(Follow, { + propsData: { + ...defaultPropsData, + ...propsData, + }, + }); + }; + + const findPagination = () => wrapper.findComponent(GlPagination); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + describe('when `loading` prop is `true`', () => { + it('renders loading icon', () => { + createComponent({ propsData: { loading: true } }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('when `loading` prop is `false`', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders users', () => { + const avatarLinksHref = wrapper + .findAllComponents(GlAvatarLink) + .wrappers.map((avatarLinkWrapper) => avatarLinkWrapper.attributes('href')); + const expectedAvatarLinksHref = users.map((user) => user.web_url); + + const avatarLabeledProps = wrapper + .findAllComponents(GlAvatarLabeled) + .wrappers.map((avatarLabeledWrapper) => ({ + label: avatarLabeledWrapper.props('label'), + subLabel: avatarLabeledWrapper.props('subLabel'), + size: avatarLabeledWrapper.attributes('size'), + entityName: avatarLabeledWrapper.attributes('entity-name'), + entityId: avatarLabeledWrapper.attributes('entity-id'), + src: avatarLabeledWrapper.attributes('src'), + })); + const expectedAvatarLabeledProps = users.map((user) => ({ + src: user.avatar_url, + size: '48', + entityId: user.id.toString(), + entityName: user.name, + label: user.name, + subLabel: user.username, + })); + + expect(avatarLinksHref).toEqual(expectedAvatarLinksHref); + expect(avatarLabeledProps).toEqual(expectedAvatarLabeledProps); + }); + + it('renders `GlPagination` and passes correct props', () => { + expect(wrapper.findComponent(GlPagination).props()).toMatchObject({ + align: 'center', + value: defaultPropsData.page, + totalItems: defaultPropsData.totalItems, + perPage: DEFAULT_PER_PAGE, + prevText: Follow.i18n.prev, + nextText: Follow.i18n.next, + }); + }); + + describe('when `GlPagination` emits `input` event', () => { + it('emits `pagination-input` event', () => { + const nextPage = defaultPropsData.page + 1; + + findPagination().vm.$emit('input', nextPage); + + expect(wrapper.emitted('pagination-input')).toEqual([[nextPage]]); + }); + }); + }); +}); diff --git a/spec/frontend/profile/components/followers_tab_spec.js b/spec/frontend/profile/components/followers_tab_spec.js index 9cc5bdea9be..0370005d0a4 100644 --- a/spec/frontend/profile/components/followers_tab_spec.js +++ b/spec/frontend/profile/components/followers_tab_spec.js @@ -1,32 +1,127 @@ import { GlBadge, GlTab } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import followers from 'test_fixtures/api/users/followers/get.json'; import { s__ } from '~/locale'; import FollowersTab from '~/profile/components/followers_tab.vue'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Follow from '~/profile/components/follow.vue'; +import { getUserFollowers } from '~/rest_api'; +import { createAlert } from '~/alert'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; + +jest.mock('~/rest_api'); +jest.mock('~/alert'); describe('FollowersTab', () => { let wrapper; const createComponent = () => { - wrapper = shallowMountExtended(FollowersTab, { + wrapper = shallowMount(FollowersTab, { provide: { - followers: 2, + followersCount: 2, + userId: 1, + }, + stubs: { + GlTab: stubComponent(GlTab, { + template: ` + <li> + <slot name="title"></slot> + <slot></slot> + </li> + `, + }), }, }); }; - it('renders `GlTab` and sets title', () => { - createComponent(); + const findGlBadge = () => wrapper.findComponent(GlBadge); + const findFollow = () => wrapper.findComponent(Follow); + + describe('when API request is loading', () => { + beforeEach(() => { + getUserFollowers.mockReturnValueOnce(new Promise(() => {})); + createComponent(); + }); + + it('renders `Follow` component and sets `loading` prop to `true`', () => { + expect(findFollow().props('loading')).toBe(true); + }); + }); + + describe('when API request is successful', () => { + beforeEach(async () => { + getUserFollowers.mockResolvedValueOnce({ + data: followers, + headers: { 'X-TOTAL': '6' }, + }); + createComponent(); + + await waitForPromises(); + }); + + it('renders `GlTab` and sets title', () => { + expect(wrapper.findComponent(GlTab).text()).toContain(s__('UserProfile|Followers')); + }); + + it('renders `GlBadge`, sets size and content', () => { + expect(findGlBadge().props('size')).toBe('sm'); + expect(findGlBadge().text()).toBe('2'); + }); + + it('renders `Follow` component and passes correct props', () => { + expect(findFollow().props()).toMatchObject({ + users: followers, + loading: false, + page: 1, + totalItems: 6, + }); + }); + + describe('when `Follow` component emits `pagination-input` event', () => { + it('calls API and updates `users` and `page` props', async () => { + const lastFollower = followers.at(-1); + const paginationFollowers = [ + { + ...lastFollower, + id: lastFollower.id + 1, + name: 'page 2 follower', + }, + ]; + + getUserFollowers.mockResolvedValueOnce({ + data: paginationFollowers, + headers: { 'X-TOTAL': '6' }, + }); - expect(wrapper.findComponent(GlTab).element.textContent).toContain( - s__('UserProfile|Followers'), - ); + findFollow().vm.$emit('pagination-input', 2); + + await waitForPromises(); + + expect(findFollow().props()).toMatchObject({ + users: paginationFollowers, + loading: false, + page: 2, + totalItems: 6, + }); + }); + }); }); - it('renders `GlBadge`, sets size and content', () => { - createComponent(); + describe('when API request is not successful', () => { + beforeEach(async () => { + getUserFollowers.mockRejectedValueOnce(new Error()); + createComponent(); - expect(wrapper.findComponent(GlBadge).attributes('size')).toBe('sm'); - expect(wrapper.findComponent(GlBadge).element.textContent).toBe('2'); + await waitForPromises(); + }); + + it('shows error alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: FollowersTab.i18n.errorMessage, + error: new Error(), + captureError: true, + }); + }); }); }); diff --git a/spec/frontend/profile/components/following_tab_spec.js b/spec/frontend/profile/components/following_tab_spec.js index c9d56360c3e..c0583cf4877 100644 --- a/spec/frontend/profile/components/following_tab_spec.js +++ b/spec/frontend/profile/components/following_tab_spec.js @@ -10,7 +10,7 @@ describe('FollowingTab', () => { const createComponent = () => { wrapper = shallowMountExtended(FollowingTab, { provide: { - followees: 3, + followeesCount: 3, }, }); }; diff --git a/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb b/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb index 7998be19c20..cb01ff64d5d 100644 --- a/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb +++ b/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb @@ -50,6 +50,18 @@ RSpec.describe Mutations::Clusters::AgentTokens::Create do expect(token.description).to eq(description) expect(token.name).to eq(name) end + + context 'when the active agent tokens limit is reached' do + before do + create(:cluster_agent_token, agent: cluster_agent) + create(:cluster_agent_token, agent: cluster_agent) + end + + it 'raises an error' do + expect { subject }.not_to change { ::Clusters::AgentToken.count } + expect(subject[:errors]).to eq(["An agent can have only two active tokens at a time"]) + end + end end end end diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb index 74b90ce3c6e..29ef41a17a6 100644 --- a/spec/helpers/users_helper_spec.rb +++ b/spec/helpers/users_helper_spec.rb @@ -503,8 +503,8 @@ RSpec.describe UsersHelper do it 'returns expected hash' do expect(helper.user_profile_tabs_app_data(user)).to match({ - followees: 3, - followers: 2, + followees_count: 3, + followers_count: 2, user_calendar_path: '/users/root/calendar.json', utc_offset: 0, user_id: user.id, diff --git a/spec/requests/api/clusters/agent_tokens_spec.rb b/spec/requests/api/clusters/agent_tokens_spec.rb index 2647684c9f8..c18ebf7d044 100644 --- a/spec/requests/api/clusters/agent_tokens_spec.rb +++ b/spec/requests/api/clusters/agent_tokens_spec.rb @@ -162,6 +162,28 @@ RSpec.describe API::Clusters::AgentTokens, feature_category: :deployment_managem expect(response).to have_gitlab_http_status(:forbidden) end end + + context 'when the active agent tokens limit is reached' do + before do + # create an additional agent token to make it 2 + create(:cluster_agent_token, agent: agent) + end + + it 'returns a bad request (400) error' do + params = { + name: 'test-token', + description: 'Test description' + } + post(api("/projects/#{project.id}/cluster_agents/#{agent.id}/tokens", user), params: params) + + aggregate_failures "testing response" do + expect(response).to have_gitlab_http_status(:bad_request) + + error_message = json_response['message'] + expect(error_message).to eq('400 Bad request - An agent can have only two active tokens at a time') + end + end + end end describe 'DELETE /projects/:id/cluster_agents/:agent_id/tokens/:token_id' do diff --git a/spec/services/clusters/agent_tokens/create_service_spec.rb b/spec/services/clusters/agent_tokens/create_service_spec.rb index 803bd947629..431d7ce2079 100644 --- a/spec/services/clusters/agent_tokens/create_service_spec.rb +++ b/spec/services/clusters/agent_tokens/create_service_spec.rb @@ -78,6 +78,33 @@ RSpec.describe Clusters::AgentTokens::CreateService, feature_category: :deployme expect(subject.message).to eq(["Name can't be blank"]) end end + + context 'when the active agent tokens limit is reached' do + before do + create(:cluster_agent_token, agent: cluster_agent) + create(:cluster_agent_token, agent: cluster_agent) + end + + it 'returns an error' do + expect(subject.status).to eq(:error) + expect(subject.message).to eq('An agent can have only two active tokens at a time') + end + + context 'when cluster_agents_limit_tokens_created feature flag is disabled' do + before do + stub_feature_flags(cluster_agents_limit_tokens_created: false) + end + + it 'creates a new token' do + expect { subject }.to change { ::Clusters::AgentToken.count }.by(1) + end + + it 'returns success status', :aggregate_failures do + expect(subject.status).to eq(:success) + expect(subject.message).to be_nil + end + end + end end end end diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb index 41114197ff5..c37b6ecf929 100644 --- a/spec/support/shared_examples/features/content_editor_shared_examples.rb +++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.shared_examples 'edits content using the content editor' do +RSpec.shared_examples 'edits content using the content editor' do |params = { with_expanded_references: true }| include ContentEditorHelpers let(:content_editor_testid) { '[data-testid="content-editor"] [contenteditable].ProseMirror' } @@ -493,6 +493,28 @@ RSpec.shared_examples 'edits content using the content editor' do type_in_content_editor :enter end + if params[:with_expanded_references] + describe 'when expanding an issue reference' do + it 'displays full reference name' do + new_issue = create(:issue, project: project, title: 'Brand New Issue') + + type_in_content_editor "##{new_issue.iid}+s " + + expect(page).to have_text('Brand New Issue') + end + end + + describe 'when expanding an MR reference' do + it 'displays full reference name' do + new_mr = create(:merge_request, source_project: project, source_branch: 'branch-2', title: 'Brand New MR') + + type_in_content_editor "!#{new_mr.iid}+s " + + expect(page).to have_text('Brand New') + end + end + end + it 'shows suggestions for members with descriptions' do type_in_content_editor '@a' diff --git a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb index c1e4185e058..1877aa6490d 100644 --- a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb +++ b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb @@ -149,7 +149,7 @@ RSpec.shared_examples 'User updates wiki page' do end end - it_behaves_like 'edits content using the content editor' + it_behaves_like 'edits content using the content editor', { with_expanded_references: false } it_behaves_like 'inserts diagrams.net diagram using the content editor' it_behaves_like 'autocompletes items' end |