Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-09-12 00:08:44 +00:00
parent a66475b6be
commit 0f5dcf55e5
73 changed files with 1301 additions and 118 deletions

View File

@ -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

View File

@ -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);
});
}
}
}

View File

@ -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>

View File

@ -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,
},
});
},
});
};

View File

@ -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;
const $toolbarBtn = $(this);
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'),
});
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');
}

View File

@ -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
});

View File

@ -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: {

View File

@ -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"

View File

@ -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
}
}

View File

@ -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();
}

View File

@ -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>

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class GroupGroupLinkSerializer < BaseSerializer
entity GroupGroupLinkEntity
end

View File

@ -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

View File

@ -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") })

View File

@ -0,0 +1,5 @@
---
title: Add keyboard shortcuts for bold, italic, and link in markdown editors
merge_request: 40328
author:
type: added

View File

@ -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

View File

@ -9422,11 +9422,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
"""
@ -9720,7 +9730,7 @@ type MergeRequest implements CurrentUserTodos & Noteable {
Filter pipelines by their status
"""
status: PipelineStatusEnum
): PipelineConnection!
): PipelineConnection
"""
Alias for target_project
@ -9977,6 +9987,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
"""

View File

@ -26149,6 +26149,24 @@
"isDeprecated": false,
"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",
@ -26163,6 +26181,24 @@
"isDeprecated": false,
"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",
@ -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
@ -27741,6 +27773,24 @@
"isDeprecated": false,
"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",

View File

@ -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 |

View File

@ -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

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@ -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.

View File

@ -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 | | | | ✓ | ✓ |

View File

@ -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:**

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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" }
}
}
}
}

View File

@ -0,0 +1,6 @@
{
"type": "array",
"items": {
"$ref": "entities/group_group_link.json"
}
}

View File

@ -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" }
}
}
}
}

View File

@ -0,0 +1,6 @@
{
"type": "array",
"items": {
"$ref": "group_member.json"
}
}

View File

@ -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();

View File

@ -35,11 +35,6 @@ function factory(routeArg) {
});
}
jest.mock('mousetrap', () => ({
bind: jest.fn(),
unbind: jest.fn(),
}));
describe('Design management router', () => {
afterEach(() => {
window.location.hash = '';

View File

@ -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);
});
});

View File

@ -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,
},
];

View File

@ -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);
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');
new Shortcuts(); // eslint-disable-line no-new
});
describe('toggleMarkdownPreview', () => {
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');
new Shortcuts(); // eslint-disable-line no-new
});
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);
});
});
});
});

View File

@ -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();
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]);
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)');
});
});
});

View File

@ -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);
},
);
});
});

View File

@ -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

View File

@ -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?

View File

@ -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)

View File

@ -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')" }

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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