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:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-09-12 03:08:44 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-09-12 03:08:44 +0300
commit0f5dcf55e543e2cb30697d4c8ea8ce509cf25375 (patch)
tree213f71f90ed2f6d356a90b0d09e8dbf5d14e816e
parenta66475b6beb46d77b9ff3fe30453be2d52779048 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/ci/docs.gitlab-ci.yml1
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js92
-rw-r--r--app/assets/javascripts/groups/members/components/app.vue26
-rw-r--r--app/assets/javascripts/groups/members/index.js32
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js46
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue21
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js53
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue21
-rw-r--r--app/graphql/resolvers/merge_request_pipelines_resolver.rb2
-rw-r--r--app/graphql/types/merge_request_type.rb7
-rw-r--r--app/graphql/types/permission_types/merge_request.rb4
-rw-r--r--app/helpers/groups/group_members_helper.rb79
-rw-r--r--app/serializers/group_group_link_entity.rb26
-rw-r--r--app/serializers/group_group_link_serializer.rb5
-rw-r--r--app/views/groups/group_members/index.html.haml36
-rw-r--r--app/views/shared/blob/_markdown_buttons.html.haml18
-rw-r--r--changelogs/unreleased/nfriend-add-markdown-editor-shortcuts.yml5
-rw-r--r--config/feature_flags/development/vue_group_members_list.yml7
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql17
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json64
-rw-r--r--doc/api/graphql/reference/index.md3
-rw-r--r--doc/ci/yaml/README.md2
-rw-r--r--doc/user/application_security/cve_id_request.md69
-rw-r--r--doc/user/application_security/img/cve_id_request_button.pngbin0 -> 5220 bytes
-rw-r--r--doc/user/application_security/img/cve_request_communication.pngbin0 -> 45402 bytes
-rw-r--r--doc/user/application_security/img/cve_request_communication_publication.pngbin0 -> 66617 bytes
-rw-r--r--doc/user/application_security/img/new_cve_request_issue.pngbin0 -> 96795 bytes
-rw-r--r--doc/user/application_security/index.md4
-rw-r--r--doc/user/permissions.md1
-rw-r--r--doc/user/project/index.md2
-rw-r--r--doc/user/project/settings/img/cve_id_request_toggle.pngbin0 -> 5395 bytes
-rw-r--r--doc/user/project/settings/index.md10
-rw-r--r--doc/user/shortcuts.md7
-rw-r--r--haml_lint/linter/documentation_links.rb3
-rw-r--r--lib/gitlab/utils/markdown.rb2
-rw-r--r--locale/gitlab.pot21
-rw-r--r--spec/features/admin/admin_groups_spec.rb2
-rw-r--r--spec/features/groups/members/filter_members_spec.rb2
-rw-r--r--spec/features/groups/members/leave_group_spec.rb2
-rw-r--r--spec/features/groups/members/list_members_spec.rb2
-rw-r--r--spec/features/groups/members/manage_groups_spec.rb2
-rw-r--r--spec/features/groups/members/manage_members_spec.rb2
-rw-r--r--spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb2
-rw-r--r--spec/features/groups/members/master_manages_access_requests_spec.rb4
-rw-r--r--spec/features/groups/members/search_members_spec.rb2
-rw-r--r--spec/features/groups/members/sort_members_spec.rb2
-rw-r--r--spec/features/markdown/keyboard_shortcuts_spec.rb101
-rw-r--r--spec/fixtures/api/schemas/entities/group_group_link.json29
-rw-r--r--spec/fixtures/api/schemas/group_group_links.json6
-rw-r--r--spec/fixtures/api/schemas/group_member.json78
-rw-r--r--spec/fixtures/api/schemas/group_members.json6
-rw-r--r--spec/frontend/design_management/pages/design/index_spec.js4
-rw-r--r--spec/frontend/design_management/router_spec.js5
-rw-r--r--spec/frontend/groups/members/index_spec.js58
-rw-r--r--spec/frontend/groups/members/mock_data.js33
-rw-r--r--spec/frontend/shortcuts_spec.js85
-rw-r--r--spec/frontend/vue_shared/components/markdown/header_spec.js58
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js47
-rw-r--r--spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb7
-rw-r--r--spec/graphql/types/merge_request_type_spec.rb1
-rw-r--r--spec/graphql/types/permission_types/merge_request_spec.rb3
-rw-r--r--spec/haml_lint/linter/documentation_links_spec.rb7
-rw-r--r--spec/helpers/groups/group_members_helper_spec.rb48
-rw-r--r--spec/lib/gitlab/utils/markdown_spec.rb16
-rw-r--r--spec/requests/api/graphql/project/merge_request_spec.rb3
-rw-r--r--spec/serializers/group_group_link_entity_spec.rb13
-rw-r--r--spec/serializers/group_group_link_serializer_spec.rb13
-rw-r--r--spec/spec_helper.rb4
-rw-r--r--spec/support/shared_contexts/serializers/group_group_link_shared_context.rb17
73 files changed, 1301 insertions, 118 deletions
diff --git a/.gitlab/ci/docs.gitlab-ci.yml b/.gitlab/ci/docs.gitlab-ci.yml
index 3f8557366c6..0e0e156a64f 100644
--- a/.gitlab/ci/docs.gitlab-ci.yml
+++ b/.gitlab/ci/docs.gitlab-ci.yml
@@ -45,7 +45,6 @@ docs lint:
image: "registry.gitlab.com/gitlab-org/gitlab-docs/lint:vale-2.3.4-markdownlint-0.23.2"
stage: test
needs: []
- allow_failure: true
script:
- scripts/lint-doc.sh
# Prepare docs for build
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index f820396d05b..8a8b61a57cd 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import Cookies from 'js-cookie';
import Mousetrap from 'mousetrap';
import Vue from 'vue';
+import { flatten } from 'lodash';
import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
import ShortcutsToggle from './shortcuts_toggle.vue';
import axios from '../../lib/utils/axios_utils';
@@ -27,6 +28,39 @@ function initToggleButton() {
});
}
+/**
+ * The key used to save and fetch the local Mousetrap instance
+ * attached to a `<textarea>` element using `jQuery.data`
+ */
+const LOCAL_MOUSETRAP_DATA_KEY = 'local-mousetrap-instance';
+
+/**
+ * Gets a mapping of toolbar button => keyboard shortcuts
+ * associated to the given markdown editor `<textarea>` element
+ *
+ * @param {HTMLTextAreaElement} $textarea The jQuery-wrapped `<textarea>`
+ * element to extract keyboard shortcuts from
+ *
+ * @returns A Map with keys that are jQuery-wrapped toolbar buttons
+ * (i.e. `$toolbarBtn`) and values that are arrays of string
+ * keyboard shortcuts (e.g. `['command+k', 'ctrl+k]`).
+ */
+function getToolbarBtnToShortcutsMap($textarea) {
+ const $allToolbarBtns = $textarea.closest('.md-area').find('.js-md');
+ const map = new Map();
+
+ $allToolbarBtns.each(function attachToolbarBtnHandler() {
+ const $toolbarBtn = $(this);
+ const keyboardShortcuts = $toolbarBtn.data('md-shortcuts');
+
+ if (keyboardShortcuts?.length) {
+ map.set($toolbarBtn, keyboardShortcuts);
+ }
+ });
+
+ return map;
+}
+
export default class Shortcuts {
constructor() {
this.onToggleHelp = this.onToggleHelp.bind(this);
@@ -144,4 +178,62 @@ export default class Shortcuts {
e.preventDefault();
}
}
+
+ /**
+ * Initializes markdown editor shortcuts on the provided `<textarea>` element
+ *
+ * @param {JQuery} $textarea The jQuery-wrapped `<textarea>` element
+ * where markdown shortcuts should be enabled
+ * @param {Function} handler The handler to call when a
+ * keyboard shortcut is pressed inside the markdown `<textarea>`
+ */
+ static initMarkdownEditorShortcuts($textarea, handler) {
+ const toolbarBtnToShortcutsMap = getToolbarBtnToShortcutsMap($textarea);
+
+ const localMousetrap = new Mousetrap($textarea[0]);
+
+ // Save a reference to the local mousetrap instance on the <textarea>
+ // so that it can be retrieved when unbinding shortcut handlers
+ $textarea.data(LOCAL_MOUSETRAP_DATA_KEY, localMousetrap);
+
+ toolbarBtnToShortcutsMap.forEach((keyboardShortcuts, $toolbarBtn) => {
+ localMousetrap.bind(keyboardShortcuts, e => {
+ e.preventDefault();
+
+ handler($toolbarBtn);
+ });
+ });
+
+ // Get an array of all shortcut strings that have been added above
+ const allShortcuts = flatten([...toolbarBtnToShortcutsMap.values()]);
+
+ const originalStopCallback = Mousetrap.prototype.stopCallback;
+ localMousetrap.stopCallback = function newStopCallback(e, element, combo) {
+ if (allShortcuts.includes(combo)) {
+ return false;
+ }
+
+ return originalStopCallback.call(this, e, element, combo);
+ };
+ }
+
+ /**
+ * Removes markdown editor shortcut handlers originally attached
+ * with `initMarkdownEditorShortcuts`.
+ *
+ * Note: it is safe to call this function even if `initMarkdownEditorShortcuts`
+ * has _not_ yet been called on the given `<textarea>`.
+ *
+ * @param {JQuery} $textarea The jQuery-wrapped `<textarea>`
+ * to remove shortcut handlers from
+ */
+ static removeMarkdownEditorShortcuts($textarea) {
+ const localMousetrap = $textarea.data(LOCAL_MOUSETRAP_DATA_KEY);
+
+ if (localMousetrap) {
+ getToolbarBtnToShortcutsMap($textarea).forEach(keyboardShortcuts => {
+ localMousetrap.unbind(keyboardShortcuts);
+ });
+ }
+ }
}
diff --git a/app/assets/javascripts/groups/members/components/app.vue b/app/assets/javascripts/groups/members/components/app.vue
new file mode 100644
index 00000000000..e8570f7246f
--- /dev/null
+++ b/app/assets/javascripts/groups/members/components/app.vue
@@ -0,0 +1,26 @@
+<script>
+export default {
+ name: 'GroupMembersApp',
+ props: {
+ groupId: {
+ type: Number,
+ required: true,
+ },
+ currentUserId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ members: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <!-- Temporary empty template -->
+ </span>
+</template>
diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js
new file mode 100644
index 00000000000..68fab42b543
--- /dev/null
+++ b/app/assets/javascripts/groups/members/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import App from './components/app.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+export default el => {
+ if (!el) {
+ return () => {};
+ }
+
+ return new Vue({
+ el,
+ components: { App },
+ data() {
+ const { members, groupId, currentUserId } = this.$options.el.dataset;
+
+ return {
+ members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }),
+ groupId: parseInt(groupId, 10),
+ ...(currentUserId ? { currentUserId: parseInt(currentUserId, 10) } : {}),
+ };
+ },
+ render(createElement) {
+ return createElement('app', {
+ props: {
+ members: this.members,
+ groupId: this.groupId,
+ currentUserId: this.currentUserId,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 45d06e8e975..f4c6e4e3584 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -1,6 +1,7 @@
/* eslint-disable func-names, no-param-reassign, operator-assignment, consistent-return */
import $ from 'jquery';
import { insertText } from '~/lib/utils/common_utils';
+import Shortcuts from '~/behaviors/shortcuts/shortcuts';
const LINK_TAG_PATTERN = '[{text}](url)';
@@ -336,24 +337,34 @@ export function keypressNoteText(e) {
}
/* eslint-enable @gitlab/require-i18n-strings */
+export function updateTextForToolbarBtn($toolbarBtn) {
+ return updateText({
+ textArea: $toolbarBtn.closest('.md-area').find('textarea'),
+ tag: $toolbarBtn.data('mdTag'),
+ cursorOffset: $toolbarBtn.data('mdCursorOffset'),
+ blockTag: $toolbarBtn.data('mdBlock'),
+ wrap: !$toolbarBtn.data('mdPrepend'),
+ select: $toolbarBtn.data('mdSelect'),
+ tagContent: $toolbarBtn.data('mdTagContent'),
+ });
+}
+
export function addMarkdownListeners(form) {
- $('.markdown-area', form).on('keydown', keypressNoteText);
- return $('.js-md', form)
+ $('.markdown-area', form)
+ .on('keydown', keypressNoteText)
+ .each(function attachTextareaShortcutHandlers() {
+ Shortcuts.initMarkdownEditorShortcuts($(this), updateTextForToolbarBtn);
+ });
+
+ const $allToolbarBtns = $('.js-md', form)
.off('click')
.on('click', function() {
- const $this = $(this);
- const tag = this.dataset.mdTag;
-
- return updateText({
- textArea: $this.closest('.md-area').find('textarea'),
- tag,
- cursorOffset: $this.data('mdCursorOffset'),
- blockTag: $this.data('mdBlock'),
- wrap: !$this.data('mdPrepend'),
- select: $this.data('mdSelect'),
- tagContent: $this.data('mdTagContent'),
- });
+ const $toolbarBtn = $(this);
+
+ return updateTextForToolbarBtn($toolbarBtn);
});
+
+ return $allToolbarBtns;
}
export function addEditorMarkdownListeners(editor) {
@@ -376,6 +387,11 @@ export function addEditorMarkdownListeners(editor) {
}
export function removeMarkdownListeners(form) {
- $('.markdown-area', form).off('keydown', keypressNoteText);
+ $('.markdown-area', form)
+ .off('keydown', keypressNoteText)
+ .each(function removeTextareaShortcutHandlers() {
+ Shortcuts.removeMarkdownEditorShortcuts($(this));
+ });
+
return $('.js-md', form).off('click');
}
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index e146592e134..3fa3a132dfa 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -4,6 +4,7 @@ import memberExpirationDate from '~/member_expiration_date';
import UsersSelect from '~/users_select';
import groupsSelect from '~/groups_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
+import initGroupMembersApp from '~/groups/members';
function mountRemoveMemberModal() {
const el = document.querySelector('.js-remove-member-modal');
@@ -25,6 +26,11 @@ document.addEventListener('DOMContentLoaded', () => {
memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
+ initGroupMembersApp(document.querySelector('.js-group-members-list'));
+ initGroupMembersApp(document.querySelector('.js-group-linked-list'));
+ initGroupMembersApp(document.querySelector('.js-group-invited-members-list'));
+ initGroupMembersApp(document.querySelector('.js-group-access-requests-list'));
+
new Members(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new
});
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 44668170fe4..61cc950f058 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -55,13 +55,15 @@ export default {
},
methods: {
removeWipMutation() {
+ const { mergeRequestQueryVariables } = this;
+
this.isMakingRequest = true;
this.$apollo
.mutate({
mutation: removeWipMutation,
variables: {
- ...this.mergeRequestQueryVariables,
+ ...mergeRequestQueryVariables,
wip: false,
},
update(
@@ -83,14 +85,14 @@ export default {
const data = store.readQuery({
query: getStateQuery,
- variables: this.mergeRequestQueryVariables,
+ variables: mergeRequestQueryVariables,
});
data.project.mergeRequest.workInProgress = workInProgress;
data.project.mergeRequest.title = title;
store.writeQuery({
query: getStateQuery,
data,
- variables: this.mergeRequestQueryVariables,
+ variables: mergeRequestQueryVariables,
});
},
optimisticResponse: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 36a883869f1..d1d703b8c44 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -96,12 +96,11 @@ export default {
variables() {
return this.mergeRequestQueryVariables;
},
- result({
- data: {
- project: { mergeRequest },
- },
- }) {
- this.mr.setGraphqlData(mergeRequest);
+ result({ data: { project } }) {
+ if (project) {
+ this.mr.setGraphqlData(project);
+ this.loading = false;
+ }
},
},
},
@@ -120,9 +119,17 @@ export default {
mr: store,
state: store && store.state,
service: store && this.createService(store),
+ loading: true,
};
},
computed: {
+ isLoaded() {
+ if (window.gon?.features?.mergeRequestWidgetGraphql) {
+ return !this.loading;
+ }
+
+ return this.mr;
+ },
shouldRenderApprovals() {
return this.mr.state !== 'nothingToMerge';
},
@@ -409,7 +416,7 @@ export default {
};
</script>
<template>
- <div v-if="mr" class="mr-state-widget gl-mt-3">
+ <div v-if="isLoaded" class="mr-state-widget gl-mt-3">
<mr-widget-header :mr="mr" />
<mr-widget-suggest-pipeline
v-if="shouldSuggestPipelines"
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
index 488397e7735..44fc1cc7f23 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
@@ -1,7 +1,27 @@
query getState($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
+ archived
+ onlyAllowMergeIfPipelineSucceeds
+
mergeRequest(iid: $iid) {
- title
+ autoMergeEnabled
+ commitCount
+ conflicts
+ diffHeadSha
+ mergeError
+ mergeStatus
+ mergeableDiscussionsState
+ pipelines(first: 1) {
+ nodes {
+ status
+ }
+ }
+ shouldBeRebased
+ sourceBranchExists
+ targetBranchExists
+ userPermissions {
+ canMerge
+ }
workInProgress
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 78e40d16c22..0d46bac91a5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -43,12 +43,10 @@ export default class MergeRequestStore {
this.conflictsDocsPath = data.conflicts_docs_path;
this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path;
this.mergeTrainWhenPipelineSucceedsDocsPath = data.merge_train_when_pipeline_succeeds_docs_path;
- this.mergeStatus = data.merge_status;
this.commitMessage = data.default_merge_commit_message;
this.shortMergeCommitSha = data.short_merged_commit_sha;
this.mergeCommitSha = data.merged_commit_sha;
this.commitMessageWithDescription = data.default_merge_commit_message_with_description;
- this.commitsCount = data.commits_count;
this.divergedCommitsCount = data.diverged_commits_count;
this.pipeline = data.pipeline || {};
this.pipelineCoverageDelta = data.pipeline_coverage_delta;
@@ -61,9 +59,6 @@ export default class MergeRequestStore {
this.rebaseInProgress = data.rebase_in_progress;
this.mergeRequestDiffsPath = data.diffs_path;
this.approvalsWidgetType = data.approvals_widget_type;
- this.projectArchived = data.project_archived;
- this.branchMissing = data.branch_missing;
- this.hasConflicts = data.has_conflicts;
if (data.issues_links) {
const links = data.issues_links;
@@ -81,25 +76,18 @@ export default class MergeRequestStore {
this.setToAutoMergeBy = MergeRequestStore.formatUserObject(data.merge_user || {});
this.mergeUserId = data.merge_user_id;
this.currentUserId = gon.current_user_id;
- this.mergeError = data.merge_error;
this.sourceBranchRemoved = !data.source_branch_exists;
this.shouldRemoveSourceBranch = data.remove_source_branch || false;
- this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
- this.autoMergeEnabled = Boolean(data.auto_merge_enabled);
this.autoMergeStrategy = data.auto_merge_strategy;
this.availableAutoMergeStrategies = data.available_auto_merge_strategies;
this.preferredAutoMergeStrategy = MergeRequestStore.getPreferredAutoMergeStrategy(
this.availableAutoMergeStrategies,
);
this.ffOnlyEnabled = data.ff_only_enabled;
- this.shouldBeRebased = Boolean(data.should_be_rebased);
this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
this.mergeRequestState = data.state;
this.isOpen = this.mergeRequestState === 'opened';
- this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
- this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.latestSHA = data.diff_head_sha;
- this.canBeMerged = data.can_be_merged || false;
this.isMergeAllowed = data.mergeable || false;
this.mergeOngoing = data.merge_ongoing;
this.allowCollaboration = data.allow_collaboration;
@@ -109,7 +97,6 @@ export default class MergeRequestStore {
// CI related
this.hasCI = data.has_ci;
this.ciStatus = data.ci_status;
- this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled';
this.isPipelinePassing =
this.ciStatus === 'success' || this.ciStatus === 'success-with-warnings';
this.isPipelineSkipped = this.ciStatus === 'skipped';
@@ -134,11 +121,24 @@ export default class MergeRequestStore {
this.removeWIPPath = data.remove_wip_path;
this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path;
this.mergePath = data.merge_path;
- this.canMerge = Boolean(data.merge_path);
this.mergeCommitPath = data.merged_commit_path;
this.canPushToSourceBranch = data.can_push_to_source_branch;
- if (data.work_in_progress !== undefined) {
+ if (!window.gon?.features?.mergeRequestWidgetGraphql) {
+ this.autoMergeEnabled = Boolean(data.auto_merge_enabled);
+ this.canBeMerged = data.can_be_merged || false;
+ this.canMerge = Boolean(data.merge_path);
+ this.commitsCount = data.commits_count;
+ this.branchMissing = data.branch_missing;
+ this.hasConflicts = data.has_conflicts;
+ this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
+ this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled';
+ this.mergeError = data.merge_error;
+ this.mergeStatus = data.merge_status;
+ this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
+ this.projectArchived = data.project_archived;
+ this.isSHAMismatch = this.sha !== data.diff_head_sha;
+ this.shouldBeRebased = Boolean(data.should_be_rebased);
this.workInProgress = data.work_in_progress;
}
@@ -155,8 +155,27 @@ export default class MergeRequestStore {
this.setState();
}
- setGraphqlData(data) {
- this.workInProgress = data.workInProgress;
+ setGraphqlData(project) {
+ const { mergeRequest } = project;
+ const pipeline = mergeRequest.pipelines?.nodes?.[0];
+
+ this.projectArchived = project.archived;
+ this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds;
+
+ this.autoMergeEnabled = mergeRequest.autoMergeEnabled;
+ this.canBeMerged = mergeRequest.mergeStatus === 'can_be_merged';
+ this.canMerge = mergeRequest.userPermissions.canMerge;
+ this.ciStatus = pipeline?.status.toLowerCase();
+ this.commitsCount = mergeRequest.commitCount;
+ this.branchMissing = !mergeRequest.sourceBranchExists || !mergeRequest.targetBranchExists;
+ this.hasConflicts = mergeRequest.conflicts;
+ this.hasMergeableDiscussionsState = mergeRequest.mergeableDiscussionsState === false;
+ this.mergeError = mergeRequest.mergeError;
+ this.mergeStatus = mergeRequest.mergeStatus;
+ this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled';
+ this.isSHAMismatch = this.sha !== mergeRequest.diffHeadSha;
+ this.shouldBeRebased = mergeRequest.shouldBeRebased;
+ this.workInProgress = mergeRequest.workInProgress;
this.setState();
}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index f79c66514d2..a768bc617c3 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,6 +1,7 @@
<script>
import $ from 'jquery';
import { GlPopover, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
@@ -54,6 +55,15 @@ export default {
mdSuggestion() {
return ['```suggestion:-0+0', `{text}`, '```'].join('\n');
},
+ isMac() {
+ // Accessing properties using ?. to allow tests to use
+ // this component without setting up window.gl.client.
+ // In production, window.gl.client should always be present.
+ return Boolean(window.gl?.client?.isMac);
+ },
+ modifierKey() {
+ return this.isMac ? '⌘' : s__('KeyboardKey|Ctrl+');
+ },
},
mounted() {
$(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
@@ -128,8 +138,22 @@ export default {
</li>
<li :class="{ active: !previewMarkdown }" class="md-header-toolbar">
<div class="d-inline-block">
- <toolbar-button tag="**" :button-title="__('Add bold text')" icon="bold" />
- <toolbar-button tag="_" :button-title="__('Add italic text')" icon="italic" />
+ <toolbar-button
+ tag="**"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey })
+ "
+ :shortcuts="['command+b', 'ctrl+b']"
+ icon="bold"
+ />
+ <toolbar-button
+ tag="_"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey })
+ "
+ :shortcuts="['command+i', 'ctrl+i']"
+ icon="italic"
+ />
<toolbar-button
:prepend="true"
:tag="tag"
@@ -180,7 +204,10 @@ export default {
<toolbar-button
tag="[{text}](url)"
tag-select="url"
- :button-title="__('Add a link')"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey })
+ "
+ :shortcuts="['command+k', 'ctrl+k']"
icon="link"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index a23195cfc6e..6c35741e7e5 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -46,6 +46,26 @@ export default {
required: false,
default: 0,
},
+
+ /**
+ * A string (or an array of strings) of
+ * [mousetrap](https://craig.is/killing/mice) keyboard shortcuts
+ * that should be attached to this button. For example:
+ * "command+k"
+ * ...or...
+ * ["command+k", "ctrl+k"]
+ */
+ shortcuts: {
+ type: [String, Array],
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ shortcutsString() {
+ const shortcutArray = Array.isArray(this.shortcuts) ? this.shortcuts : [this.shortcuts];
+ return JSON.stringify(shortcutArray);
+ },
},
};
</script>
@@ -59,6 +79,7 @@ export default {
:data-md-block="tagBlock"
:data-md-tag-content="tagContent"
:data-md-prepend="prepend"
+ :data-md-shortcuts="shortcutsString"
:title="buttonTitle"
:aria-label="buttonTitle"
type="button"
diff --git a/app/graphql/resolvers/merge_request_pipelines_resolver.rb b/app/graphql/resolvers/merge_request_pipelines_resolver.rb
index b371f1335f8..b95e46d9cff 100644
--- a/app/graphql/resolvers/merge_request_pipelines_resolver.rb
+++ b/app/graphql/resolvers/merge_request_pipelines_resolver.rb
@@ -7,6 +7,8 @@ module Resolvers
alias_method :merge_request, :object
def resolve(**args)
+ return unless project
+
resolve_pipelines(project, args)
.merge(merge_request.all_pipelines)
end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 805ae111ff7..56c88491684 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -80,7 +80,7 @@ module Types
description: 'Error message due to a merge error'
field :allow_collaboration, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if members of the target project can push to the fork'
- field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false,
+ field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false, calls_gitaly: true,
description: 'Indicates if the merge request will be rebased'
field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true,
description: 'Rebase commit SHA of the merge request'
@@ -113,6 +113,7 @@ module Types
field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline,
description: 'The pipeline running on the branch HEAD of the merge request'
field :pipelines, Types::Ci::PipelineType.connection_type,
+ null: true,
description: 'Pipelines for the merge request',
resolver: Resolvers::MergeRequestPipelinesResolver
@@ -146,6 +147,10 @@ module Types
description: Types::TaskCompletionStatus.description
field :commit_count, GraphQL::INT_TYPE, null: true,
description: 'Number of commits in the merge request'
+ field :conflicts, GraphQL::BOOLEAN_TYPE, null: false, method: :cannot_be_merged?,
+ description: 'Indicates if the merge request has conflicts'
+ field :auto_merge_enabled, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates if auto merge is enabled for the merge request'
def diff_stats(path: nil)
stats = Array.wrap(object.diff_stats&.to_a)
diff --git a/app/graphql/types/permission_types/merge_request.rb b/app/graphql/types/permission_types/merge_request.rb
index 28b7ebd2af6..e9c89b0c92e 100644
--- a/app/graphql/types/permission_types/merge_request.rb
+++ b/app/graphql/types/permission_types/merge_request.rb
@@ -18,6 +18,10 @@ module Types
PERMISSION_FIELDS.each do |field_name|
permission_field field_name, method: :"can_#{field_name}?", calls_gitaly: true
end
+
+ permission_field :can_merge, calls_gitaly: true, resolve: -> (object, args, context) do
+ object.can_be_merged_by?(context[:current_user])
+ end
end
end
end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index 1952325c504..dcff2be34da 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
module Groups::GroupMembersHelper
+ include AvatarsHelper
+
+ AVATAR_SIZE = 40
+
def group_member_select_options
{ multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true }
end
@@ -8,6 +12,81 @@ module Groups::GroupMembersHelper
def render_invite_member_for_group(group, default_access_level)
render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: GroupMember.access_level_roles, default_access_level: default_access_level
end
+
+ def linked_groups_data_json(group_links)
+ GroupGroupLinkSerializer.new.represent(group_links).to_json
+ end
+
+ def members_data_json(group, members)
+ members_data(group, members).to_json
+ end
+
+ private
+
+ def members_data(group, members)
+ members.map do |member|
+ user = member.user
+ source = member.source
+
+ data = {
+ id: member.id,
+ created_at: member.created_at,
+ expires_at: member.expires_at&.to_time,
+ requested_at: member.requested_at,
+ can_update: member.can_update?,
+ can_remove: member.can_remove?,
+ can_override: member.can_override?,
+ access_level: {
+ string_value: member.human_access,
+ integer_value: member.access_level
+ },
+ source: {
+ id: source.id,
+ name: source.full_name,
+ web_url: Gitlab::UrlBuilder.build(source)
+ }
+ }.merge(member_created_by_data(member.created_by))
+
+ if user.present?
+ data[:user] = member_user_data(user)
+ else
+ data[:invite] = member_invite_data(member)
+ end
+
+ data
+ end
+ end
+
+ def member_created_by_data(created_by)
+ return {} unless created_by.present?
+
+ {
+ created_by: {
+ name: created_by.name,
+ web_url: Gitlab::UrlBuilder.build(created_by)
+ }
+ }
+ end
+
+ def member_user_data(user)
+ {
+ id: user.id,
+ name: user.name,
+ username: user.username,
+ web_url: Gitlab::UrlBuilder.build(user),
+ avatar_url: avatar_icon_for_user(user, AVATAR_SIZE),
+ blocked: user.blocked?,
+ two_factor_enabled: user.two_factor_enabled?
+ }
+ end
+
+ def member_invite_data(member)
+ {
+ email: member.invite_email,
+ avatar_url: avatar_icon_for_email(member.invite_email, AVATAR_SIZE),
+ can_resend: member.can_resend_invite?
+ }
+ end
end
Groups::GroupMembersHelper.prepend_if_ee('EE::Groups::GroupMembersHelper')
diff --git a/app/serializers/group_group_link_entity.rb b/app/serializers/group_group_link_entity.rb
new file mode 100644
index 00000000000..7a51e1a9316
--- /dev/null
+++ b/app/serializers/group_group_link_entity.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class GroupGroupLinkEntity < Grape::Entity
+ expose :id
+ expose :created_at
+ expose :expires_at do |group_link|
+ group_link.expires_at&.to_time
+ end
+
+ expose :access_level do
+ expose :human_access, as: :string_value
+ expose :group_access, as: :integer_value
+ end
+
+ expose :shared_with_group do
+ expose :avatar_url do |group_link|
+ group_link.shared_with_group.avatar_url(only_path: false)
+ end
+
+ expose :web_url do |group_link|
+ group_link.shared_with_group.web_url
+ end
+
+ expose :shared_with_group, merge: true, using: GroupBasicEntity
+ end
+end
diff --git a/app/serializers/group_group_link_serializer.rb b/app/serializers/group_group_link_serializer.rb
new file mode 100644
index 00000000000..6ae8daf9207
--- /dev/null
+++ b/app/serializers/group_group_link_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class GroupGroupLinkSerializer < BaseSerializer
+ entity GroupGroupLinkEntity
+end
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index c8e58a50b18..54da3e56ea8 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -3,6 +3,8 @@
- show_invited_members = can_manage_members && @invited_members.exists?
- show_access_requests = can_manage_members && @requesters.exists?
- invited_active = params[:search_invited].present? || params[:invited_members_page].present?
+- vue_members_list_enabled = Feature.enabled?(:vue_group_members_list, @group)
+- data_attributes = { group_id: @group.id, current_user_id: current_user&.id }
- form_item_label_css_class = 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center'
@@ -66,18 +68,24 @@
= render 'groups/group_members/tab_pane/form_item' do
= label_tag :sort_by, _('Sort by'), class: form_item_label_css_class
= render 'shared/members/sort_dropdown'
- %ul.content-list.members-list{ data: { qa_selector: 'members_list' } }
- = render partial: 'shared/members/member', collection: @members, as: :member
- = paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil }
+ - if vue_members_list_enabled
+ .js-group-members-list{ data: { members: members_data_json(@group, @members), **data_attributes } }
+ - else
+ %ul.content-list.members-list{ data: { qa_selector: 'members_list' } }
+ = render partial: 'shared/members/member', collection: @members, as: :member
+ = paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil }
- if @group.shared_with_group_links.any?
#tab-groups.tab-pane
.card.card-without-border
= render 'groups/group_members/tab_pane/header' do
= render 'groups/group_members/tab_pane/title' do
= html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- %ul.content-list.members-list{ data: { qa_selector: 'groups_list' } }
- - @group.shared_with_group_links.each do |group_link|
- = render 'shared/members/group', group_link: group_link, can_admin_member: can_manage_members, group_link_path: group_group_link_path(@group, group_link)
+ - if vue_members_list_enabled
+ .js-group-linked-list{ data: { members: linked_groups_data_json(@group.shared_with_group_links), **data_attributes } }
+ - else
+ %ul.content-list.members-list{ data: { qa_selector: 'groups_list' } }
+ - @group.shared_with_group_links.each do |group_link|
+ = render 'shared/members/group', group_link: group_link, can_admin_member: can_manage_members, group_link_path: group_group_link_path(@group, group_link)
- if show_invited_members
#tab-invited-members.tab-pane{ class: ('active' if invited_active) }
.card.card-without-border
@@ -86,14 +94,20 @@
= html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
= form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do
= render 'shared/members/search_field', name: 'search_invited'
- %ul.content-list.members-list
- = render partial: 'shared/members/member', collection: @invited_members, as: :member
- = paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab', params: { page: nil }
+ - if vue_members_list_enabled
+ .js-group-invited-members-list{ data: { members: members_data_json(@group, @invited_members), **data_attributes } }
+ - else
+ %ul.content-list.members-list
+ = render partial: 'shared/members/member', collection: @invited_members, as: :member
+ = paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab', params: { page: nil }
- if show_access_requests
#tab-access-requests.tab-pane
.card.card-without-border
= render 'groups/group_members/tab_pane/header' do
= render 'groups/group_members/tab_pane/title' do
= html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- %ul.content-list.members-list
- = render partial: 'shared/members/member', collection: @requesters, as: :member
+ - if vue_members_list_enabled
+ .js-group-access-requests-list{ data: { members: members_data_json(@group, @requesters), **data_attributes } }
+ - else
+ %ul.content-list.members-list
+ = render partial: 'shared/members/member', collection: @requesters, as: :member
diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml
index d0719bf21fe..7fc4bcdff38 100644
--- a/app/views/shared/blob/_markdown_buttons.html.haml
+++ b/app/views/shared/blob/_markdown_buttons.html.haml
@@ -1,9 +1,21 @@
+- modifier_key = client_js_flags[:isMac] ? '⌘' : s_('KeyboardKey|Ctrl+');
+
.md-header-toolbar.active
- = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: _("Add bold text") })
- = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "_" }, title: _("Add italic text") })
+ = markdown_toolbar_button({ icon: "bold",
+ data: { "md-tag" => "**", "md-shortcuts": '["command+b","ctrl+b"]' },
+ title: sprintf(s_("MarkdownEditor|Add bold text (%{modifier_key}B)") % { modifier_key: modifier_key }) })
+
+ = markdown_toolbar_button({ icon: "italic",
+ data: { "md-tag" => "_", "md-shortcuts": '["command+i","ctrl+i"]' },
+ title: sprintf(s_("MarkdownEditor|Add italic text (%{modifier_key}I)") % { modifier_key: modifier_key }) })
+
= markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") })
= markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") })
- = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: _("Add a link") })
+
+ = markdown_toolbar_button({ icon: "link",
+ data: { "md-tag" => "[{text}](url)", "md-select" => "url", "md-shortcuts": '["command+k","ctrl+k"]' },
+ title: sprintf(s_("MarkdownEditor|Add a link (%{modifier_key}K)") % { modifier_key: modifier_key }) })
+
= markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") })
= markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") })
= markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a task list") })
diff --git a/changelogs/unreleased/nfriend-add-markdown-editor-shortcuts.yml b/changelogs/unreleased/nfriend-add-markdown-editor-shortcuts.yml
new file mode 100644
index 00000000000..bfe4ae7f32e
--- /dev/null
+++ b/changelogs/unreleased/nfriend-add-markdown-editor-shortcuts.yml
@@ -0,0 +1,5 @@
+---
+title: Add keyboard shortcuts for bold, italic, and link in markdown editors
+merge_request: 40328
+author:
+type: added
diff --git a/config/feature_flags/development/vue_group_members_list.yml b/config/feature_flags/development/vue_group_members_list.yml
new file mode 100644
index 00000000000..c5a5ad638e4
--- /dev/null
+++ b/config/feature_flags/development/vue_group_members_list.yml
@@ -0,0 +1,7 @@
+---
+name: vue_group_members_list
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40548
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/241194
+group: group::access
+type: development
+default_enabled: false \ No newline at end of file
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index 6100142ccf1..ed9e0cb033f 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -9423,11 +9423,21 @@ type MergeRequest implements CurrentUserTodos & Noteable {
author: User
"""
+ Indicates if auto merge is enabled for the merge request
+ """
+ autoMergeEnabled: Boolean!
+
+ """
Number of commits in the merge request
"""
commitCount: Int
"""
+ Indicates if the merge request has conflicts
+ """
+ conflicts: Boolean!
+
+ """
Timestamp of when the merge request was created
"""
createdAt: Time!
@@ -9720,7 +9730,7 @@ type MergeRequest implements CurrentUserTodos & Noteable {
Filter pipelines by their status
"""
status: PipelineStatusEnum
- ): PipelineConnection!
+ ): PipelineConnection
"""
Alias for target_project
@@ -9978,6 +9988,11 @@ type MergeRequestPermissions {
adminMergeRequest: Boolean!
"""
+ Indicates the user can perform `can_merge` on this resource
+ """
+ canMerge: Boolean!
+
+ """
Indicates the user can perform `cherry_pick_on_current_merge_request` on this resource
"""
cherryPickOnCurrentMergeRequest: Boolean!
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 6aa9bfa6491..ec036646ace 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -26150,6 +26150,24 @@
"deprecationReason": null
},
{
+ "name": "autoMergeEnabled",
+ "description": "Indicates if auto merge is enabled for the merge request",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "commitCount",
"description": "Number of commits in the merge request",
"args": [
@@ -26164,6 +26182,24 @@
"deprecationReason": null
},
{
+ "name": "conflicts",
+ "description": "Indicates if the merge request has conflicts",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "createdAt",
"description": "Timestamp of when the merge request was created",
"args": [
@@ -26903,13 +26939,9 @@
}
],
"type": {
- "kind": "NON_NULL",
- "name": null,
- "ofType": {
- "kind": "OBJECT",
- "name": "PipelineConnection",
- "ofType": null
- }
+ "kind": "OBJECT",
+ "name": "PipelineConnection",
+ "ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@@ -27742,6 +27774,24 @@
"deprecationReason": null
},
{
+ "name": "canMerge",
+ "description": "Indicates the user can perform `can_merge` on this resource",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ }
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "cherryPickOnCurrentMergeRequest",
"description": "Indicates the user can perform `cherry_pick_on_current_merge_request` on this resource",
"args": [
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 4849940952b..1b5ffb78c24 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1417,7 +1417,9 @@ Autogenerated return type of MarkAsSpamSnippet
| `allowCollaboration` | Boolean | Indicates if members of the target project can push to the fork |
| `approved` | Boolean! | Indicates if the merge request has all the required approvals. Returns true if no required approvals are configured. |
| `author` | User | User who created this merge request |
+| `autoMergeEnabled` | Boolean! | Indicates if auto merge is enabled for the merge request |
| `commitCount` | Int | Number of commits in the merge request |
+| `conflicts` | Boolean! | Indicates if the merge request has conflicts |
| `createdAt` | Time! | Timestamp of when the merge request was created |
| `defaultMergeCommitMessage` | String | Default merge commit message of the merge request |
| `description` | String | Description of the merge request (Markdown rendered as HTML for caching) |
@@ -1488,6 +1490,7 @@ Check permissions for the current user on a merge request
| Field | Type | Description |
| ----- | ---- | ----------- |
| `adminMergeRequest` | Boolean! | Indicates the user can perform `admin_merge_request` on this resource |
+| `canMerge` | Boolean! | Indicates the user can perform `can_merge` on this resource |
| `cherryPickOnCurrentMergeRequest` | Boolean! | Indicates the user can perform `cherry_pick_on_current_merge_request` on this resource |
| `createNote` | Boolean! | Indicates the user can perform `create_note` on this resource |
| `pushToSourceBranch` | Boolean! | Indicates the user can perform `push_to_source_branch` on this resource |
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index 293dafb75b5..5a86d1cc03b 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -1656,7 +1656,7 @@ job:
- /^release/.*$/@gitlab-org/gitlab
```
-The above example will run `job` for all branches on `gitlab-org/gitlab`,
+The above example runs `job` for all branches on `gitlab-org/gitlab`,
except `master` and those with names prefixed with `release/`.
If a job does not have an `only` rule, `only: ['branches', 'tags']` is set by
diff --git a/doc/user/application_security/cve_id_request.md b/doc/user/application_security/cve_id_request.md
new file mode 100644
index 00000000000..94cacf2882f
--- /dev/null
+++ b/doc/user/application_security/cve_id_request.md
@@ -0,0 +1,69 @@
+---
+type: tutorial
+stage: Secure
+group: Vulnerability Research
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
+---
+
+# CVE ID Requests
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41203) in GitLab 13.4, only for public projects on GitLab.com.
+
+As part of [GitLab's role as a CVE Numbering Authority](https://about.gitlab.com/security/cve)
+([CNA](https://cve.mitre.org/cve/cna.html)), you may request
+[CVE](https://cve.mitre.org/index.html) identifiers from GitLab to track
+vulnerabilities found within your project.
+
+## Overview
+
+CVE identifiers track specific vulnerabilities within projects. Having a CVE assigned to a
+vulnerability in your project helps your users stay secure and informed. For example,
+[dependency scanning tools](../application_security/dependency_scanning/index.md)
+can detect when vulnerable versions of your project are used as a dependency.
+
+## Conditions
+
+If the following conditions are met, a **Request CVE ID** button appears in your issue sidebar:
+
+- The project is hosted in GitLab.com.
+- The project is public.
+- You are a maintainer of the project.
+- The issue is confidential.
+
+## Submitting a CVE ID Request
+
+Clicking the **Request CVE ID** button in the issue sidebar takes you to the new issue page for
+[GitLab's CVE project](https://gitlab.com/gitlab-org/cves).
+
+![CVE ID request button](img/cve_id_request_button.png)
+
+Creating the confidential issue starts the CVE request process.
+
+![New CVE ID request issue](img/new_cve_request_issue.png)
+
+You are required to fill in the issue description, which includes:
+
+- A description of the vulnerability
+- The project's vendor and name
+- Impacted versions
+- Fixed versions
+- The vulnerability type (a [CWE](https://cwe.mitre.org/data/index.html) identifier)
+- A [CVSS v3 vector](https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator)
+
+## CVE Assignment
+
+GitLab triages your submitted CVE ID request and communicates with you throughout the CVE validation
+and assignment process.
+
+![CVE ID request communication](img/cve_request_communication.png)
+
+Once a CVE identifier is assigned, you may use and reference it as you see fit.
+
+Details of the vulnerability submitted in the CVE ID request are published according to your
+schedule. It's common to request a CVE for an unpatched vulnerability, reference the assigned CVE
+identifier in release notes, and later publish the vulnerability's details after the fix is
+released.
+
+Separate communications notify you when different stages of the publication process are complete.
+
+![CVE ID request publication communication](img/cve_request_communication_publication.png)
diff --git a/doc/user/application_security/img/cve_id_request_button.png b/doc/user/application_security/img/cve_id_request_button.png
new file mode 100644
index 00000000000..15707ba9eb2
--- /dev/null
+++ b/doc/user/application_security/img/cve_id_request_button.png
Binary files differ
diff --git a/doc/user/application_security/img/cve_request_communication.png b/doc/user/application_security/img/cve_request_communication.png
new file mode 100644
index 00000000000..0766b371c11
--- /dev/null
+++ b/doc/user/application_security/img/cve_request_communication.png
Binary files differ
diff --git a/doc/user/application_security/img/cve_request_communication_publication.png b/doc/user/application_security/img/cve_request_communication_publication.png
new file mode 100644
index 00000000000..9e34c217e13
--- /dev/null
+++ b/doc/user/application_security/img/cve_request_communication_publication.png
Binary files differ
diff --git a/doc/user/application_security/img/new_cve_request_issue.png b/doc/user/application_security/img/new_cve_request_issue.png
new file mode 100644
index 00000000000..a342c73992e
--- /dev/null
+++ b/doc/user/application_security/img/new_cve_request_issue.png
Binary files differ
diff --git a/doc/user/application_security/index.md b/doc/user/application_security/index.md
index edc0310667c..d509176f2b2 100644
--- a/doc/user/application_security/index.md
+++ b/doc/user/application_security/index.md
@@ -247,7 +247,9 @@ You can create an issue for a vulnerability by visiting the vulnerability's page
This creates a [confidential issue](../project/issues/confidential_issues.md) in the project the
vulnerability came from, and pre-populates it with some useful information taken from the vulnerability
report. Once the issue is created, you are redirected to it so you can edit, assign, or comment on
-it.
+it. CVE identifiers can be requested from GitLab by clicking the
+[_CVE ID Request_ button](cve_id_request.md) that is enabled for maintainers of
+public projects on GitLab.com
Upon returning to the group security dashboard, the vulnerability now has an associated issue next
to the name.
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index cddb2ab3188..8fc45425060 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -122,6 +122,7 @@ The following table depicts the various user permission levels in a project.
| Manage Feature Flags **(PREMIUM)** | | | ✓ | ✓ | ✓ |
| Create/edit/delete metrics dashboard annotations | | | ✓ | ✓ | ✓ |
| Run CI/CD pipeline against a protected branch | | | ✓ (*5*) | ✓ | ✓ |
+| Request a CVE ID **(FREE ONLY)** | | | | ✓ | ✓ |
| Use environment terminals | | | | ✓ | ✓ |
| Run Web IDE's Interactive Web Terminals **(ULTIMATE ONLY)** | | | | ✓ | ✓ |
| Add new team members | | | | ✓ | ✓ |
diff --git a/doc/user/project/index.md b/doc/user/project/index.md
index da09b9d598e..c79f2be1d3f 100644
--- a/doc/user/project/index.md
+++ b/doc/user/project/index.md
@@ -37,6 +37,8 @@ When you create a project in GitLab, you'll have access to a large number of
- [Signing commits](gpg_signed_commits/index.md): use GPG to sign your commits
- [Deploy tokens](deploy_tokens/index.md): Manage project-based deploy tokens that allow permanent access to the repository and Container Registry.
- [Web IDE](web_ide/index.md)
+- [CVE ID Requests](../application_security/cve_id_request.md): Request a CVE identifier to track a
+ vulnerability in your project.
**Issues and merge requests:**
diff --git a/doc/user/project/settings/img/cve_id_request_toggle.png b/doc/user/project/settings/img/cve_id_request_toggle.png
new file mode 100644
index 00000000000..53ec804922c
--- /dev/null
+++ b/doc/user/project/settings/img/cve_id_request_toggle.png
Binary files differ
diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md
index 3be54a5c940..395d4bf30c5 100644
--- a/doc/user/project/settings/index.md
+++ b/doc/user/project/settings/index.md
@@ -100,6 +100,16 @@ Some features depend on others:
- Metrics dashboard access requires reading both project environments and deployments.
Users with access to the metrics dashboard can also access environments and deployments.
+#### Disabling the CVE ID request button
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41203) in GitLab 13.4, only for public projects on GitLab.com.
+
+In applicable environments, a [**Create CVE ID Request** button](../../application_security/cve_id_request.md)
+is present in the issue sidebar. The button may be disabled on a per-project basis by toggling the
+setting **Enable CVE ID requests in the issue sidebar**.
+
+![CVE ID Request toggle](img/cve_id_request_toggle.png)
+
#### Disabling email notifications
Project owners can disable all [email notifications](../../profile/notifications.md#gitlab-notification-emails)
diff --git a/doc/user/shortcuts.md b/doc/user/shortcuts.md
index 4d65cc61bd5..c34d5be5899 100644
--- a/doc/user/shortcuts.md
+++ b/doc/user/shortcuts.md
@@ -40,6 +40,13 @@ for example comments, replies, issue descriptions, and merge request description
| ---------------------------------------------------------------------- | ----------- |
| <kbd>↑</kbd> | Edit your last comment. You must be in a blank text field below a thread, and you must already have at least one comment in the thread. |
| <kbd>⌘</kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>p</kbd> | Toggle Markdown preview, when editing text in a text field that has **Write** and **Preview** tabs at the top. |
+| <kbd>⌘</kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>b</kbd> | Bold the selected text (surround it with `**`). |
+| <kbd>⌘</kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>i</kbd> | Italicize the selected text (surround it with `_`). |
+| <kbd>⌘</kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd>k</kbd> | Add a link (surround the selected text with `[]()`). |
+
+NOTE: **Note:**
+The shortcuts for editing in text fields are always enabled, even when
+other keyboard shortcuts are disabled as explained above.
## Project
diff --git a/haml_lint/linter/documentation_links.rb b/haml_lint/linter/documentation_links.rb
index 75a15c02c17..f8e0eec5cdc 100644
--- a/haml_lint/linter/documentation_links.rb
+++ b/haml_lint/linter/documentation_links.rb
@@ -92,8 +92,7 @@ module HamlLint
File.open(path_to_file).any? do |line|
result = line.match(MARKDOWN_HEADER)
- # TODO:Do an exact match for anchors (Follow-up https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39850)
- anchor.start_with?(string_to_anchor(result[:header].delete('*'))) if result
+ string_to_anchor(result[:header]) == anchor if result
end
end
end
diff --git a/lib/gitlab/utils/markdown.rb b/lib/gitlab/utils/markdown.rb
index 1f833f69642..e783ac785cc 100644
--- a/lib/gitlab/utils/markdown.rb
+++ b/lib/gitlab/utils/markdown.rb
@@ -4,7 +4,7 @@ module Gitlab
module Utils
module Markdown
PUNCTUATION_REGEXP = /[^\p{Word}\- ]/u.freeze
- PRODUCT_SUFFIX = /\s*\((core|starter|premium|ultimate)(\s+only)?\)/.freeze
+ PRODUCT_SUFFIX = /\s*\**\((core|starter|premium|ultimate)(\s+only)?\)\**/.freeze
def string_to_anchor(string)
string
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index fd1db6698fa..2eb92a49df6 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -14277,6 +14277,9 @@ msgstr ""
msgid "Keyboard shortcuts"
msgstr ""
+msgid "KeyboardKey|Ctrl+"
+msgstr ""
+
msgid "Keys"
msgstr ""
@@ -15169,6 +15172,24 @@ msgstr ""
msgid "Markdown is supported"
msgstr ""
+msgid "MarkdownEditor|Add a link (%{modifierKey}K)"
+msgstr ""
+
+msgid "MarkdownEditor|Add a link (%{modifier_key}K)"
+msgstr ""
+
+msgid "MarkdownEditor|Add bold text (%{modifierKey}B)"
+msgstr ""
+
+msgid "MarkdownEditor|Add bold text (%{modifier_key}B)"
+msgstr ""
+
+msgid "MarkdownEditor|Add italic text (%{modifierKey}I)"
+msgstr ""
+
+msgid "MarkdownEditor|Add italic text (%{modifier_key}I)"
+msgstr ""
+
msgid "Marked For Deletion At - %{deletion_time}"
msgstr ""
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
index 9cd335ffb8c..f5c5a73c042 100644
--- a/spec/features/admin/admin_groups_spec.rb
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -11,6 +11,8 @@ RSpec.describe 'Admin Groups' do
let!(:current_user) { create(:admin) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
sign_in(current_user)
stub_application_setting(default_group_visibility: internal)
end
diff --git a/spec/features/groups/members/filter_members_spec.rb b/spec/features/groups/members/filter_members_spec.rb
index 643c8407578..d667690af29 100644
--- a/spec/features/groups/members/filter_members_spec.rb
+++ b/spec/features/groups/members/filter_members_spec.rb
@@ -10,6 +10,8 @@ RSpec.describe 'Groups > Members > Filter members' do
let(:nested_group) { create(:group, parent: group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
group.add_owner(user)
group.add_maintainer(user_with_2fa)
nested_group.add_maintainer(nested_group_user)
diff --git a/spec/features/groups/members/leave_group_spec.rb b/spec/features/groups/members/leave_group_spec.rb
index fecc90f20c7..9eb5cc15c5e 100644
--- a/spec/features/groups/members/leave_group_spec.rb
+++ b/spec/features/groups/members/leave_group_spec.rb
@@ -8,6 +8,8 @@ RSpec.describe 'Groups > Members > Leave group' do
let(:group) { create(:group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
gitlab_sign_in(user)
end
diff --git a/spec/features/groups/members/list_members_spec.rb b/spec/features/groups/members/list_members_spec.rb
index 415c6927320..bcec2b50a24 100644
--- a/spec/features/groups/members/list_members_spec.rb
+++ b/spec/features/groups/members/list_members_spec.rb
@@ -12,6 +12,8 @@ RSpec.describe 'Groups > Members > List members' do
let(:nested_group) { create(:group, parent: group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
sign_in(user1)
end
diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb
index faf455e4ed9..e3bbbd4d73b 100644
--- a/spec/features/groups/members/manage_groups_spec.rb
+++ b/spec/features/groups/members/manage_groups_spec.rb
@@ -11,6 +11,8 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
let(:shared_group) { create(:group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
shared_group.add_owner(user)
sign_in(user)
end
diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb
index 0267bea2f53..aedb7c170f8 100644
--- a/spec/features/groups/members/manage_members_spec.rb
+++ b/spec/features/groups/members/manage_members_spec.rb
@@ -11,6 +11,8 @@ RSpec.describe 'Groups > Members > Manage members' do
let(:group) { create(:group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
sign_in(user1)
end
diff --git a/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb
index f80925186ed..d94cc85f411 100644
--- a/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb
+++ b/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb
@@ -11,6 +11,8 @@ RSpec.describe 'Groups > Members > Owner adds member with expiration date', :js
let(:group) { create(:group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
group.add_owner(user1)
sign_in(user1)
end
diff --git a/spec/features/groups/members/master_manages_access_requests_spec.rb b/spec/features/groups/members/master_manages_access_requests_spec.rb
index 71c9b280ebe..44fd7380b79 100644
--- a/spec/features/groups/members/master_manages_access_requests_spec.rb
+++ b/spec/features/groups/members/master_manages_access_requests_spec.rb
@@ -3,6 +3,10 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Maintainer manages access requests' do
+ before do
+ stub_feature_flags(vue_group_members_list: false)
+ end
+
it_behaves_like 'Maintainer manages access requests' do
let(:has_tabs) { true }
let(:entity) { create(:group, :public) }
diff --git a/spec/features/groups/members/search_members_spec.rb b/spec/features/groups/members/search_members_spec.rb
index ad4f5c0b579..a95b59cece1 100644
--- a/spec/features/groups/members/search_members_spec.rb
+++ b/spec/features/groups/members/search_members_spec.rb
@@ -14,6 +14,8 @@ RSpec.describe 'Search group member' do
end
before do
+ stub_feature_flags(vue_group_members_list: false)
+
sign_in(user)
visit group_group_members_path(guest_group)
end
diff --git a/spec/features/groups/members/sort_members_spec.rb b/spec/features/groups/members/sort_members_spec.rb
index cfc0e421aeb..d940550b18a 100644
--- a/spec/features/groups/members/sort_members_spec.rb
+++ b/spec/features/groups/members/sort_members_spec.rb
@@ -8,6 +8,8 @@ RSpec.describe 'Groups > Members > Sort members' do
let(:group) { create(:group) }
before do
+ stub_feature_flags(vue_group_members_list: false)
+
create(:group_member, :owner, user: owner, group: group, created_at: 5.days.ago)
create(:group_member, :developer, user: developer, group: group, created_at: 3.days.ago)
diff --git a/spec/features/markdown/keyboard_shortcuts_spec.rb b/spec/features/markdown/keyboard_shortcuts_spec.rb
new file mode 100644
index 00000000000..ff028a0281f
--- /dev/null
+++ b/spec/features/markdown/keyboard_shortcuts_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Markdown keyboard shortcuts', :js do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+
+ gitlab_sign_in(user)
+
+ visit path_to_visit
+
+ wait_for_requests
+ end
+
+ shared_examples 'keyboard shortcuts for modifier key' do
+ it 'bolds text when <modifier>+B is pressed' do
+ type_and_select('bold')
+
+ markdown_field.send_keys([modifier_key, 'b'])
+
+ expect(markdown_field.value).to eq('**bold**')
+ end
+
+ it 'italicizes text when <modifier>+I is pressed' do
+ type_and_select('italic')
+
+ markdown_field.send_keys([modifier_key, 'i'])
+
+ expect(markdown_field.value).to eq('_italic_')
+ end
+
+ it 'links text when <modifier>+K is pressed' do
+ type_and_select('link')
+
+ markdown_field.send_keys([modifier_key, 'k'])
+
+ expect(markdown_field.value).to eq('[link](url)')
+
+ # Type some more text to ensure the cursor
+ # and selection are set correctly
+ markdown_field.send_keys('https://example.com')
+
+ expect(markdown_field.value).to eq('[link](https://example.com)')
+ end
+
+ it 'does not affect non-markdown fields on the same page' do
+ non_markdown_field.send_keys('some text')
+
+ non_markdown_field.send_keys([modifier_key, 'b'])
+
+ expect(focused_element).to eq(non_markdown_field.native)
+ expect(markdown_field.value).to eq('')
+ end
+ end
+
+ shared_examples 'keyboard shortcuts for implementation' do
+ context 'Ctrl key' do
+ let(:modifier_key) { :control }
+
+ it_behaves_like 'keyboard shortcuts for modifier key'
+ end
+
+ context '⌘ key' do
+ let(:modifier_key) { :command }
+
+ it_behaves_like 'keyboard shortcuts for modifier key'
+ end
+ end
+
+ context 'Vue.js markdown editor' do
+ let(:path_to_visit) { new_project_release_path(project) }
+ let(:markdown_field) { find_field('Release notes') }
+ let(:non_markdown_field) { find_field('Release title') }
+
+ it_behaves_like 'keyboard shortcuts for implementation'
+ end
+
+ context 'Haml markdown editor' do
+ let(:path_to_visit) { new_project_issue_path(project) }
+ let(:markdown_field) { find_field('Description') }
+ let(:non_markdown_field) { find_field('Title') }
+
+ it_behaves_like 'keyboard shortcuts for implementation'
+ end
+
+ def type_and_select(text)
+ markdown_field.send_keys(text)
+
+ text.length.times do
+ markdown_field.send_keys([:shift, :arrow_left])
+ end
+ end
+
+ def focused_element
+ page.driver.browser.switch_to.active_element
+ end
+end
diff --git a/spec/fixtures/api/schemas/entities/group_group_link.json b/spec/fixtures/api/schemas/entities/group_group_link.json
new file mode 100644
index 00000000000..4c9aae140d2
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/group_group_link.json
@@ -0,0 +1,29 @@
+{
+ "type": "object",
+ "required": ["id", "created_at", "expires_at", "access_level"],
+ "properties": {
+ "id": { "type": "integer" },
+ "created_at": { "type": "date-time" },
+ "expires_at": { "type": ["date-time", "null"] },
+ "access_level": {
+ "type": "object",
+ "required": ["integer_value", "string_value"],
+ "properties": {
+ "integer_value": { "type": "integer" },
+ "string_value": { "type": "string" }
+ }
+ },
+ "shared_with_group": {
+ "type": "object",
+ "required": ["id", "name", "full_name", "full_path", "avatar_url", "web_url"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "full_name": { "type": "string" },
+ "full_path": { "type": "string" },
+ "avatar_url": { "type": ["string", "null"] },
+ "web_url": { "type": "string" }
+ }
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/group_group_links.json b/spec/fixtures/api/schemas/group_group_links.json
new file mode 100644
index 00000000000..f8b4e7f035b
--- /dev/null
+++ b/spec/fixtures/api/schemas/group_group_links.json
@@ -0,0 +1,6 @@
+{
+ "type": "array",
+ "items": {
+ "$ref": "entities/group_group_link.json"
+ }
+}
diff --git a/spec/fixtures/api/schemas/group_member.json b/spec/fixtures/api/schemas/group_member.json
new file mode 100644
index 00000000000..035c862d229
--- /dev/null
+++ b/spec/fixtures/api/schemas/group_member.json
@@ -0,0 +1,78 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "created_at",
+ "expires_at",
+ "access_level",
+ "requested_at",
+ "source",
+ "can_update",
+ "can_remove",
+ "can_override"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "created_at": { "type": "date-time" },
+ "expires_at": { "type": ["date-time", "null"] },
+ "requested_at": { "type": ["date-time", "null"] },
+ "can_update": { "type": "boolean" },
+ "can_remove": { "type": "boolean" },
+ "can_override": { "type": "boolean" },
+ "access_level": {
+ "type": "object",
+ "required": ["integer_value", "string_value"],
+ "properties": {
+ "integer_value": { "type": "integer" },
+ "string_value": { "type": "string" }
+ }
+ },
+ "source": {
+ "type": "object",
+ "required": ["id", "name", "web_url"],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "web_url": { "type": "string" }
+ }
+ },
+ "created_by": {
+ "type": "object",
+ "required": ["name", "web_url"],
+ "properties": {
+ "name": { "type": "string" },
+ "web_url": { "type": "string" }
+ }
+ },
+ "user": {
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "username",
+ "avatar_url",
+ "web_url",
+ "blocked",
+ "two_factor_enabled"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "username": { "type": "string" },
+ "avatar_url": { "type": ["string", "null"] },
+ "web_url": { "type": "string" },
+ "blocked": { "type": "boolean" },
+ "two_factor_enabled": { "type": "boolean" }
+ }
+ },
+ "invite": {
+ "type": "object",
+ "required": ["email", "avatar_url", "can_resend"],
+ "properties": {
+ "email": { "type": "string" },
+ "avatar_url": { "type": "string" },
+ "can_resend": { "type": "boolean" }
+ }
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/group_members.json b/spec/fixtures/api/schemas/group_members.json
new file mode 100644
index 00000000000..6268c7ef4d8
--- /dev/null
+++ b/spec/fixtures/api/schemas/group_members.json
@@ -0,0 +1,6 @@
+{
+ "type": "array",
+ "items": {
+ "$ref": "group_member.json"
+ }
+}
diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js
index d78d3dc7edd..d9f7146d258 100644
--- a/spec/frontend/design_management/pages/design/index_spec.js
+++ b/spec/frontend/design_management/pages/design/index_spec.js
@@ -22,10 +22,6 @@ import mockResponseNoDesigns from '../../mock_data/no_designs';
import mockAllVersions from '../../mock_data/all_versions';
jest.mock('~/flash');
-jest.mock('mousetrap', () => ({
- bind: jest.fn(),
- unbind: jest.fn(),
-}));
const focusInput = jest.fn();
diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js
index 2b8c7ee959b..d4cb9f75a77 100644
--- a/spec/frontend/design_management/router_spec.js
+++ b/spec/frontend/design_management/router_spec.js
@@ -35,11 +35,6 @@ function factory(routeArg) {
});
}
-jest.mock('mousetrap', () => ({
- bind: jest.fn(),
- unbind: jest.fn(),
-}));
-
describe('Design management router', () => {
afterEach(() => {
window.location.hash = '';
diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js
new file mode 100644
index 00000000000..8673f257a1f
--- /dev/null
+++ b/spec/frontend/groups/members/index_spec.js
@@ -0,0 +1,58 @@
+import { createWrapper } from '@vue/test-utils';
+import initGroupMembersApp from '~/groups/members';
+import GroupMembersApp from '~/groups/members/components/app.vue';
+import { membersJsonString, membersParsed } from './mock_data';
+
+describe('initGroupMembersApp', () => {
+ let el;
+ let wrapper;
+
+ const setup = () => {
+ const vm = initGroupMembersApp(el);
+ wrapper = createWrapper(vm);
+ };
+
+ const getGroupMembersApp = () => wrapper.find(GroupMembersApp);
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ el.setAttribute('data-members', membersJsonString);
+ el.setAttribute('data-current-user-id', '123');
+ el.setAttribute('data-group-id', '234');
+
+ document.body.appendChild(el);
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ el = null;
+
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('parses and passes `currentUserId` prop to `GroupMembersApp`', () => {
+ setup();
+
+ expect(getGroupMembersApp().props('currentUserId')).toBe(123);
+ });
+
+ it('does not pass `currentUserId` prop if not provided by the data attribute (user is not logged in)', () => {
+ el.removeAttribute('data-current-user-id');
+ setup();
+
+ expect(getGroupMembersApp().props('currentUserId')).toBeNull();
+ });
+
+ it('parses and passes `groupId` prop to `GroupMembersApp`', () => {
+ setup();
+
+ expect(getGroupMembersApp().props('groupId')).toBe(234);
+ });
+
+ it('parses and passes `members` prop to `GroupMembersApp`', () => {
+ setup();
+
+ expect(getGroupMembersApp().props('members')).toEqual(membersParsed);
+ });
+});
diff --git a/spec/frontend/groups/members/mock_data.js b/spec/frontend/groups/members/mock_data.js
new file mode 100644
index 00000000000..b84c9c6d446
--- /dev/null
+++ b/spec/frontend/groups/members/mock_data.js
@@ -0,0 +1,33 @@
+export const membersJsonString =
+ '[{"requested_at":null,"can_update":true,"can_remove":true,"can_override":false,"access_level":{"integer_value":50,"string_value":"Owner"},"source":{"id":323,"name":"My group / my subgroup","web_url":"http://127.0.0.1:3000/groups/my-group/my-subgroup"},"user":{"id":1,"name":"Administrator","username":"root","web_url":"http://127.0.0.1:3000/root","avatar_url":"https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80\u0026d=identicon","blocked":false,"two_factor_enabled":false},"id":524,"created_at":"2020-08-21T21:33:27.631Z","expires_at":null,"using_license":false,"group_sso":false,"group_managed_account":false}]';
+
+export const membersParsed = [
+ {
+ requestedAt: null,
+ canUpdate: true,
+ canRemove: true,
+ canOverride: false,
+ accessLevel: { integerValue: 50, stringValue: 'Owner' },
+ source: {
+ id: 323,
+ name: 'My group / my subgroup',
+ webUrl: 'http://127.0.0.1:3000/groups/my-group/my-subgroup',
+ },
+ user: {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon',
+ blocked: false,
+ twoFactorEnabled: false,
+ },
+ id: 524,
+ createdAt: '2020-08-21T21:33:27.631Z',
+ expiresAt: null,
+ usingLicense: false,
+ groupSso: false,
+ groupManagedAccount: false,
+ },
+];
diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js
index 3d16074154c..21258e09356 100644
--- a/spec/frontend/shortcuts_spec.js
+++ b/spec/frontend/shortcuts_spec.js
@@ -1,6 +1,18 @@
import $ from 'jquery';
+import { flatten } from 'lodash';
import Shortcuts from '~/behaviors/shortcuts/shortcuts';
+const mockMousetrap = {
+ bind: jest.fn(),
+ unbind: jest.fn(),
+};
+
+jest.mock('mousetrap', () => {
+ return jest.fn().mockImplementation(() => mockMousetrap);
+});
+
+jest.mock('mousetrap/plugins/pause/mousetrap-pause', () => {});
+
describe('Shortcuts', () => {
const fixtureName = 'snippets/show.html';
const createEvent = (type, target) =>
@@ -10,16 +22,16 @@ describe('Shortcuts', () => {
preloadFixtures(fixtureName);
- describe('toggleMarkdownPreview', () => {
- beforeEach(() => {
- loadFixtures(fixtureName);
+ beforeEach(() => {
+ loadFixtures(fixtureName);
- jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus');
- jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus');
+ jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus');
+ jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus');
- new Shortcuts(); // eslint-disable-line no-new
- });
+ new Shortcuts(); // eslint-disable-line no-new
+ });
+ describe('toggleMarkdownPreview', () => {
it('focuses preview button in form', () => {
Shortcuts.toggleMarkdownPreview(
createEvent('KeyboardEvent', document.querySelector('.js-new-note-form .js-note-text')),
@@ -43,4 +55,63 @@ describe('Shortcuts', () => {
expect(document.querySelector('.edit-note .js-md-preview-button').focus).toHaveBeenCalled();
});
});
+
+ describe('markdown shortcuts', () => {
+ let shortcuts;
+
+ beforeEach(() => {
+ // Get all shortcuts specified with md-shortcuts attributes in the fixture.
+ // `shortcuts` will look something like this:
+ // [
+ // [ 'command+b', 'ctrl+b' ],
+ // [ 'command+i', 'ctrl+i' ],
+ // [ 'command+k', 'ctrl+k' ]
+ // ]
+ shortcuts = $('.edit-note .js-md')
+ .map(function getShortcutsFromToolbarBtn() {
+ const mdShortcuts = $(this).data('md-shortcuts');
+
+ // jQuery.map() automatically unwraps arrays, so we
+ // have to double wrap the array to counteract this:
+ // https://stackoverflow.com/a/4875669/1063392
+ return mdShortcuts ? [mdShortcuts] : undefined;
+ })
+ .get();
+ });
+
+ describe('initMarkdownEditorShortcuts', () => {
+ beforeEach(() => {
+ Shortcuts.initMarkdownEditorShortcuts($('.edit-note textarea'));
+ });
+
+ it('attaches a Mousetrap handler for every markdown shortcut specified with md-shortcuts', () => {
+ const expectedCalls = shortcuts.map(s => [s, expect.any(Function)]);
+
+ expect(mockMousetrap.bind.mock.calls).toEqual(expectedCalls);
+ });
+
+ it('attaches a stopCallback that allows each markdown shortcut specified with md-shortcuts', () => {
+ flatten(shortcuts).forEach(s => {
+ expect(mockMousetrap.stopCallback(null, null, s)).toBe(false);
+ });
+ });
+ });
+
+ describe('removeMarkdownEditorShortcuts', () => {
+ it('does nothing if initMarkdownEditorShortcuts was not previous called', () => {
+ Shortcuts.removeMarkdownEditorShortcuts($('.edit-note textarea'));
+
+ expect(mockMousetrap.unbind.mock.calls).toEqual([]);
+ });
+
+ it('removes Mousetrap handlers for every markdown shortcut specified with md-shortcuts', () => {
+ Shortcuts.initMarkdownEditorShortcuts($('.edit-note textarea'));
+ Shortcuts.removeMarkdownEditorShortcuts($('.edit-note textarea'));
+
+ const expectedCalls = shortcuts.map(s => [s]);
+
+ expect(mockMousetrap.unbind.mock.calls).toEqual(expectedCalls);
+ });
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js
index 551d781d296..82bc9b9fe08 100644
--- a/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -22,6 +22,12 @@ describe('Markdown field header component', () => {
.at(0);
beforeEach(() => {
+ window.gl = {
+ client: {
+ isMac: true,
+ },
+ };
+
createWrapper();
});
@@ -30,24 +36,40 @@ describe('Markdown field header component', () => {
wrapper = null;
});
- it('renders markdown header buttons', () => {
- const buttons = [
- 'Add bold text',
- 'Add italic text',
- 'Insert a quote',
- 'Insert suggestion',
- 'Insert code',
- 'Add a link',
- 'Add a bullet list',
- 'Add a numbered list',
- 'Add a task list',
- 'Add a table',
- 'Go full screen',
- ];
- const elements = findToolbarButtons();
-
- elements.wrappers.forEach((buttonEl, index) => {
- expect(buttonEl.props('buttonTitle')).toBe(buttons[index]);
+ describe('markdown header buttons', () => {
+ it('renders the buttons with the correct title', () => {
+ const buttons = [
+ 'Add bold text (⌘B)',
+ 'Add italic text (⌘I)',
+ 'Insert a quote',
+ 'Insert suggestion',
+ 'Insert code',
+ 'Add a link (⌘K)',
+ 'Add a bullet list',
+ 'Add a numbered list',
+ 'Add a task list',
+ 'Add a table',
+ 'Go full screen',
+ ];
+ const elements = findToolbarButtons();
+
+ elements.wrappers.forEach((buttonEl, index) => {
+ expect(buttonEl.props('buttonTitle')).toBe(buttons[index]);
+ });
+ });
+
+ describe('when the user is on a non-Mac', () => {
+ beforeEach(() => {
+ delete window.gl.client.isMac;
+
+ createWrapper();
+ });
+
+ it('renders keyboard shortcuts with Ctrl+ instead of ⌘', () => {
+ const boldButton = findToolbarButtonByProp('icon', 'bold');
+
+ expect(boldButton.props('buttonTitle')).toBe('Add bold text (Ctrl+B)');
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js
new file mode 100644
index 00000000000..8a7946fd7b1
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js
@@ -0,0 +1,47 @@
+import { shallowMount } from '@vue/test-utils';
+import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue';
+
+describe('toolbar_button', () => {
+ let wrapper;
+
+ const defaultProps = {
+ buttonTitle: 'test button',
+ icon: 'rocket',
+ tag: 'test tag',
+ };
+
+ const createComponent = propUpdates => {
+ wrapper = shallowMount(ToolbarButton, {
+ propsData: {
+ ...defaultProps,
+ ...propUpdates,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const getButtonShortcutsAttr = () => {
+ return wrapper.find('button').attributes('data-md-shortcuts');
+ };
+
+ describe('keyboard shortcuts', () => {
+ it.each`
+ shortcutsProp | mdShortcutsAttr
+ ${undefined} | ${JSON.stringify([])}
+ ${[]} | ${JSON.stringify([])}
+ ${'command+b'} | ${JSON.stringify(['command+b'])}
+ ${['command+b', 'ctrl+b']} | ${JSON.stringify(['command+b', 'ctrl+b'])}
+ `(
+ 'adds the attribute data-md-shortcuts="$mdShortcutsAttr" to the button when the shortcuts prop is $shortcutsProp',
+ ({ shortcutsProp, mdShortcutsAttr }) => {
+ createComponent({ shortcuts: shortcutsProp });
+
+ expect(getButtonShortcutsAttr()).toBe(mdShortcutsAttr);
+ },
+ );
+ });
+});
diff --git a/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb b/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb
index 2fe3e86ec14..ae3097c1d9e 100644
--- a/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb
+++ b/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb
@@ -29,4 +29,11 @@ RSpec.describe Resolvers::MergeRequestPipelinesResolver do
it 'resolves only MRs for the passed merge request' do
expect(resolve_pipelines).to contain_exactly(pipeline)
end
+
+ describe 'with archived project' do
+ let(:archived_project) { create(:project, :archived) }
+ let(:merge_request) { create(:merge_request, source_project: archived_project) }
+
+ it { expect(resolve_pipelines).not_to contain_exactly(pipeline) }
+ end
end
diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb
index 1279f01f104..7272a952f77 100644
--- a/spec/graphql/types/merge_request_type_spec.rb
+++ b/spec/graphql/types/merge_request_type_spec.rb
@@ -27,6 +27,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
upvotes downvotes head_pipeline pipelines task_completion_status
milestone assignees participants subscribed labels discussion_locked time_estimate
total_time_spent reference author merged_at commit_count current_user_todos
+ conflicts auto_merge_enabled
]
if Gitlab.ee?
diff --git a/spec/graphql/types/permission_types/merge_request_spec.rb b/spec/graphql/types/permission_types/merge_request_spec.rb
index 73a178540a6..2849dead9a8 100644
--- a/spec/graphql/types/permission_types/merge_request_spec.rb
+++ b/spec/graphql/types/permission_types/merge_request_spec.rb
@@ -7,7 +7,8 @@ RSpec.describe Types::PermissionTypes::MergeRequest do
expected_permissions = [
:read_merge_request, :admin_merge_request, :update_merge_request,
:create_note, :push_to_source_branch, :remove_source_branch,
- :cherry_pick_on_current_merge_request, :revert_on_current_merge_request
+ :cherry_pick_on_current_merge_request, :revert_on_current_merge_request,
+ :can_merge
]
expect(described_class).to have_graphql_fields(expected_permissions)
diff --git a/spec/haml_lint/linter/documentation_links_spec.rb b/spec/haml_lint/linter/documentation_links_spec.rb
index aa4c5dd7c39..68de8317b82 100644
--- a/spec/haml_lint/linter/documentation_links_spec.rb
+++ b/spec/haml_lint/linter/documentation_links_spec.rb
@@ -20,13 +20,6 @@ RSpec.describe HamlLint::Linter::DocumentationLinks do
it { is_expected.not_to report_lint }
end
- # TODO: Remove me after https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39715 is merged
- context 'when link_to points to the existing file with partially matching anchor' do
- let(:haml) { "= link_to 'Description', help_page_path('README.md', anchor: 'overview-premium'), target: '_blank'" }
-
- it { is_expected.not_to report_lint }
- end
-
context 'when link_to points to the existing file path without .md extension' do
let(:haml) { "= link_to 'Description', help_page_path('README')" }
diff --git a/spec/helpers/groups/group_members_helper_spec.rb b/spec/helpers/groups/group_members_helper_spec.rb
index 90792331d9b..a25bf1c4157 100644
--- a/spec/helpers/groups/group_members_helper_spec.rb
+++ b/spec/helpers/groups/group_members_helper_spec.rb
@@ -3,6 +3,8 @@
require "spec_helper"
RSpec.describe Groups::GroupMembersHelper do
+ include MembersPresentation
+
describe '.group_member_select_options' do
let(:group) { create(:group) }
@@ -14,4 +16,50 @@ RSpec.describe Groups::GroupMembersHelper do
expect(helper.group_member_select_options).to include(multiple: true, scope: :all, email_user: true)
end
end
+
+ describe '#linked_groups_data_json' do
+ include_context 'group_group_link'
+
+ it 'matches json schema' do
+ json = helper.linked_groups_data_json(shared_group.shared_with_group_links)
+
+ expect(json).to match_schema('group_group_links')
+ end
+ end
+
+ describe '#members_data_json' do
+ let(:current_user) { create(:user) }
+ let(:group) { create(:group) }
+
+ before do
+ allow(helper).to receive(:can?).with(current_user, :owner_access, group).and_return(true)
+ allow(helper).to receive(:current_user).and_return(current_user)
+ end
+
+ shared_examples 'group_members.json' do
+ it 'matches json schema' do
+ json = helper.members_data_json(group, present_members([group_member]))
+
+ expect(json).to match_schema('group_members')
+ end
+ end
+
+ context 'for a group member' do
+ let(:group_member) { create(:group_member, group: group, created_by: current_user) }
+
+ it_behaves_like 'group_members.json'
+ end
+
+ context 'for an invited group member' do
+ let(:group_member) { create(:group_member, :invited, group: group, created_by: current_user) }
+
+ it_behaves_like 'group_members.json'
+ end
+
+ context 'for an access request' do
+ let(:group_member) { create(:group_member, :access_request, group: group, created_by: current_user) }
+
+ it_behaves_like 'group_members.json'
+ end
+ end
end
diff --git a/spec/lib/gitlab/utils/markdown_spec.rb b/spec/lib/gitlab/utils/markdown_spec.rb
index 0ac594ca830..93d91f7ed90 100644
--- a/spec/lib/gitlab/utils/markdown_spec.rb
+++ b/spec/lib/gitlab/utils/markdown_spec.rb
@@ -66,6 +66,22 @@ RSpec.describe Gitlab::Utils::Markdown do
is_expected.to eq 'my-header'
end
end
+
+ context 'with "*" around a product suffix' do
+ let(:string) { 'My Header **(STARTER)**' }
+
+ it 'ignores a product suffix' do
+ is_expected.to eq 'my-header'
+ end
+ end
+
+ context 'with "*" around a product suffix and only modifier' do
+ let(:string) { 'My Header **(STARTER ONLY)**' }
+
+ it 'ignores a product suffix' do
+ is_expected.to eq 'my-header'
+ end
+ end
end
context 'when string is empty' do
diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb
index c39358a2db1..fae52fe814d 100644
--- a/spec/requests/api/graphql/project/merge_request_spec.rb
+++ b/spec/requests/api/graphql/project/merge_request_spec.rb
@@ -124,7 +124,8 @@ RSpec.describe 'getting merge request information nested in a project' do
'removeSourceBranch' => false,
'cherryPickOnCurrentMergeRequest' => false,
'revertOnCurrentMergeRequest' => false,
- 'updateMergeRequest' => false
+ 'updateMergeRequest' => false,
+ 'canMerge' => false
}
post_graphql(query, current_user: current_user)
diff --git a/spec/serializers/group_group_link_entity_spec.rb b/spec/serializers/group_group_link_entity_spec.rb
new file mode 100644
index 00000000000..8384563e3e6
--- /dev/null
+++ b/spec/serializers/group_group_link_entity_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GroupGroupLinkEntity do
+ include_context 'group_group_link'
+
+ subject(:json) { described_class.new(group_group_link).to_json }
+
+ it 'matches json schema' do
+ expect(json).to match_schema('entities/group_group_link')
+ end
+end
diff --git a/spec/serializers/group_group_link_serializer_spec.rb b/spec/serializers/group_group_link_serializer_spec.rb
new file mode 100644
index 00000000000..0d977ea0a9a
--- /dev/null
+++ b/spec/serializers/group_group_link_serializer_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GroupGroupLinkSerializer do
+ include_context 'group_group_link'
+
+ subject(:json) { described_class.new.represent(shared_group.shared_with_group_links).to_json }
+
+ it 'matches json schema' do
+ expect(json).to match_schema('group_group_links')
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 9e3e4cc6d64..11a83bd9501 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -204,6 +204,10 @@ RSpec.configure do |config|
# unified diff lines works as expected
stub_feature_flags(unified_diff_lines: false)
+ # Merge request widget GraphQL requests are disabled in the tests
+ # for now whilst we migrate as much as we can over the GraphQL
+ stub_feature_flags(merge_request_widget_graphql: false)
+
enable_rugged = example.metadata[:enable_rugged].present?
# Disable Rugged features by default
diff --git a/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb b/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb
new file mode 100644
index 00000000000..fce78957eba
--- /dev/null
+++ b/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'group_group_link' do
+ let(:shared_with_group) { create(:group) }
+ let(:shared_group) { create(:group) }
+
+ let!(:group_group_link) do
+ create(
+ :group_group_link,
+ {
+ shared_group: shared_group,
+ shared_with_group: shared_with_group,
+ expires_at: '2020-05-12'
+ }
+ )
+ end
+end