Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
a66475b6be
commit
0f5dcf55e5
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class GroupGroupLinkSerializer < BaseSerializer
|
||||
entity GroupGroupLinkEntity
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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") })
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add keyboard shortcuts for bold, italic, and link in markdown editors
|
||||
merge_request: 40328
|
||||
author:
|
||||
type: added
|
|
@ -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
|
|
@ -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
|
||||
"""
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 |
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 |
|
@ -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.
|
||||
|
|
|
@ -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 | | | | ✓ | ✓ |
|
||||
|
|
|
@ -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 |
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "entities/group_group_link.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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "group_member.json"
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -35,11 +35,6 @@ function factory(routeArg) {
|
|||
});
|
||||
}
|
||||
|
||||
jest.mock('mousetrap', () => ({
|
||||
bind: jest.fn(),
|
||||
unbind: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Design management router', () => {
|
||||
afterEach(() => {
|
||||
window.location.hash = '';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
];
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')" }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue