diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-12 03:08:44 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-12 03:08:44 +0300 |
commit | 0f5dcf55e543e2cb30697d4c8ea8ce509cf25375 (patch) | |
tree | 213f71f90ed2f6d356a90b0d09e8dbf5d14e816e | |
parent | a66475b6beb46d77b9ff3fe30453be2d52779048 (diff) |
Add latest changes from gitlab-org/gitlab@master
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 Binary files differnew file mode 100644 index 00000000000..15707ba9eb2 --- /dev/null +++ b/doc/user/application_security/img/cve_id_request_button.png diff --git a/doc/user/application_security/img/cve_request_communication.png b/doc/user/application_security/img/cve_request_communication.png Binary files differnew file mode 100644 index 00000000000..0766b371c11 --- /dev/null +++ b/doc/user/application_security/img/cve_request_communication.png diff --git a/doc/user/application_security/img/cve_request_communication_publication.png b/doc/user/application_security/img/cve_request_communication_publication.png Binary files differnew file mode 100644 index 00000000000..9e34c217e13 --- /dev/null +++ b/doc/user/application_security/img/cve_request_communication_publication.png diff --git a/doc/user/application_security/img/new_cve_request_issue.png b/doc/user/application_security/img/new_cve_request_issue.png Binary files differnew file mode 100644 index 00000000000..a342c73992e --- /dev/null +++ b/doc/user/application_security/img/new_cve_request_issue.png 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 Binary files differnew file mode 100644 index 00000000000..53ec804922c --- /dev/null +++ b/doc/user/project/settings/img/cve_id_request_toggle.png 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 |