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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab/ci/qa-common/main.gitlab-ci.yml9
-rw-r--r--.rubocop_todo/layout/first_hash_element_indentation.yml1
-rw-r--r--app/assets/javascripts/api/user_api.js11
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/reference_bubble_menu.vue218
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference.vue16
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference_label.vue8
-rw-r--r--app/assets/javascripts/content_editor/extensions/drawio_diagram.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js84
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference_label.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js2
-rw-r--r--app/assets/javascripts/content_editor/services/asset_resolver.js39
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js8
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js4
-rw-r--r--app/assets/javascripts/pages/users/index.js9
-rw-r--r--app/assets/javascripts/pages/users/show/index.js6
-rw-r--r--app/assets/javascripts/profile/components/follow.vue88
-rw-r--r--app/assets/javascripts/profile/components/followers_tab.vue54
-rw-r--r--app/assets/javascripts/profile/components/following_tab.vue4
-rw-r--r--app/assets/javascripts/profile/components/profile_tabs.vue2
-rw-r--r--app/assets/javascripts/profile/index.js8
-rw-r--r--app/assets/stylesheets/components/content_editor.scss6
-rw-r--r--app/helpers/users_helper.rb4
-rw-r--r--app/services/clusters/agent_tokens/create_service.rb12
-rw-r--r--config/feature_flags/development/cluster_agents_limit_tokens_created.yml8
-rw-r--r--data/deprecations/15-10-grafana-chart.yml4
-rw-r--r--data/deprecations/16-0-deprecate-omnibus-grafana.yml4
-rw-r--r--data/removals/16_0/16-0-grafana-chart.yml4
-rw-r--r--doc/.vale/gitlab/OutdatedVersions.yml1
-rw-r--r--doc/administration/gitaly/praefect.md5
-rw-r--r--doc/administration/monitoring/performance/grafana_configuration.md31
-rw-r--r--doc/api/cluster_agents.md5
-rw-r--r--doc/ci/pipelines/index.md5
-rw-r--r--doc/update/deprecations.md8
-rw-r--r--doc/update/index.md3
-rw-r--r--doc/update/removals.md4
-rw-r--r--doc/user/admin_area/settings/index.md2
-rw-r--r--doc/user/clusters/agent/work_with_agent.md3
-rw-r--r--doc/user/group/compliance_frameworks.md19
-rw-r--r--locale/gitlab.pot54
-rw-r--r--spec/frontend/__helpers__/mock_window_location_helper.js1
-rw-r--r--spec/frontend/api/user_api_spec.js19
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js1
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/reference_bubble_menu_spec.js247
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js4
-rw-r--r--spec/frontend/content_editor/extensions/drawio_diagram_spec.js15
-rw-r--r--spec/frontend/content_editor/extensions/reference_spec.js162
-rw-r--r--spec/frontend/content_editor/services/asset_resolver_spec.js68
-rw-r--r--spec/frontend/content_editor/services/create_content_editor_spec.js3
-rw-r--r--spec/frontend/content_editor/test_constants.js9
-rw-r--r--spec/frontend/content_editor/test_utils.js16
-rw-r--r--spec/frontend/fixtures/users.rb18
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js14
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js4
-rw-r--r--spec/frontend/profile/components/follow_spec.js99
-rw-r--r--spec/frontend/profile/components/followers_tab_spec.js119
-rw-r--r--spec/frontend/profile/components/following_tab_spec.js2
-rw-r--r--spec/graphql/mutations/clusters/agent_tokens/create_spec.rb12
-rw-r--r--spec/helpers/users_helper_spec.rb4
-rw-r--r--spec/requests/api/clusters/agent_tokens_spec.rb22
-rw-r--r--spec/services/clusters/agent_tokens/create_service_spec.rb27
-rw-r--r--spec/support/shared_examples/features/content_editor_shared_examples.rb24
-rw-r--r--spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb2
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;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">&amp;1</a> <a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;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 (&amp;1)</a> <a href="/groups/gitlab-org/-/epics/1" data-reference-type="epic" data-original="&amp;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 (&amp;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