Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-02-11 21:12:17 +00:00
parent e57809ded8
commit f8dfaa8d41
66 changed files with 1485 additions and 1004 deletions

View File

@ -38,7 +38,7 @@ Personas are described at https://about.gitlab.com/handbook/marketing/product-ma
-->
### Metrics
### Feature Usage Metrics
<!-- How are you going to track usage of this feature? Think about user behavior and their interaction with the product. What indicates someone is getting value from it?

View File

@ -31,14 +31,6 @@ Personas are described at https://about.gitlab.com/handbook/marketing/product-ma
* [Eddie (Content Editor)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#eddie-content-editor)
-->
### Metrics
<!-- How are you going to track uage of this feature? Think about user behavior and their interaction with the product. What indicates someone is getting value from it?
Create tracking issue using the Snowplow event tracking template. See https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/issue_templates/Snowplow%20event%20tracking.md
-->
### User experience goal
<!-- What is the single user experience workflow this problem addresses?
@ -99,6 +91,14 @@ See the test engineering planning process and reach out to your counterpart Soft
* Ultimate/Gold
-->
### Feature Usage Metrics
<!-- How are you going to track usage of this feature? Think about user behavior and their interaction with the product. What indicates someone is getting value from it?
Create tracking issue using the Snowplow event tracking template. See https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/issue_templates/Snowplow%20event%20tracking.md
-->
### What does success look like, and how can we measure that?
<!--

View File

@ -21,7 +21,7 @@ export default {
},
methods: {
openModal() {
eventHub.$emit('openModal', { inviteeType: 'group' });
eventHub.$emit('openGroupModal');
},
},
};

View File

@ -0,0 +1,146 @@
<script>
import { uniqueId } from 'lodash';
import Api from '~/api';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants';
import eventHub from '../event_hub';
import GroupSelect from './group_select.vue';
import InviteModalBase from './invite_modal_base.vue';
export default {
name: 'InviteMembersModal',
components: {
GroupSelect,
InviteModalBase,
},
props: {
id: {
type: String,
required: true,
},
isProject: {
type: Boolean,
required: true,
},
name: {
type: String,
required: true,
},
accessLevels: {
type: Object,
required: true,
},
defaultAccessLevel: {
type: Number,
required: true,
},
helpLink: {
type: String,
required: true,
},
groupSelectFilter: {
type: String,
required: false,
default: GROUP_FILTERS.ALL,
},
groupSelectParentId: {
type: Number,
required: false,
default: null,
},
invalidGroups: {
type: Array,
required: true,
},
},
data() {
return {
modalId: uniqueId('invite-groups-modal-'),
groupToBeSharedWith: {},
};
},
computed: {
labelIntroText() {
return this.$options.labels[this.inviteTo].introText;
},
inviteTo() {
return this.isProject ? 'toProject' : 'toGroup';
},
toastOptions() {
return {
onComplete: () => {
this.groupToBeSharedWith = {};
},
};
},
inviteDisabled() {
return Object.keys(this.groupToBeSharedWith).length === 0;
},
},
mounted() {
eventHub.$on('openGroupModal', () => {
this.openModal();
});
},
methods: {
openModal() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
const apiShareWithGroup = this.isProject
? Api.projectShareWithGroup.bind(Api)
: Api.groupShareWithGroup.bind(Api);
apiShareWithGroup(this.id, {
format: 'json',
group_id: this.groupToBeSharedWith.id,
group_access: accessLevel,
expires_at: expiresAt,
})
.then(() => {
onSuccess();
this.showSuccessMessage();
})
.catch(onError);
},
resetFields() {
this.groupToBeSharedWith = {};
},
showSuccessMessage() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
this.closeModal();
},
},
labels: GROUP_MODAL_LABELS,
};
</script>
<template>
<invite-modal-base
:modal-id="modalId"
:modal-title="$options.labels.title"
:name="name"
:access-levels="accessLevels"
:default-access-level="defaultAccessLevel"
:help-link="helpLink"
v-bind="$attrs"
:label-intro-text="labelIntroText"
:label-search-field="$options.labels.searchField"
:submit-disabled="inviteDisabled"
@reset="resetFields"
@submit="sendInvite"
>
<template #select="{ clearValidation }">
<group-select
v-model="groupToBeSharedWith"
:access-levels="accessLevels"
:groups-filter="groupSelectFilter"
:parent-group-id="groupSelectParentId"
:invalid-groups="invalidGroups"
@input="clearValidation"
/>
</template>
</invite-modal-base>
</template>

View File

@ -1,56 +1,40 @@
<script>
import {
GlAlert,
GlFormGroup,
GlModal,
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlLink,
GlSprintf,
GlButton,
GlFormInput,
GlFormCheckboxGroup,
} from '@gitlab/ui';
import { partition, isString, unescape, uniqueId } from 'lodash';
import { partition, isString, uniqueId } from 'lodash';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { sanitize } from '~/lib/dompurify';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import {
GROUP_FILTERS,
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
MODAL_LABELS,
MEMBER_MODAL_LABELS,
LEARN_GITLAB,
} from '../constants';
import eventHub from '../event_hub';
import {
responseMessageFromError,
responseMessageFromSuccess,
} from '../utils/response_message_parser';
import { responseMessageFromSuccess } from '../utils/response_message_parser';
import ModalConfetti from './confetti.vue';
import GroupSelect from './group_select.vue';
import InviteModalBase from './invite_modal_base.vue';
import MembersTokenSelect from './members_token_select.vue';
export default {
name: 'InviteMembersModal',
components: {
GlAlert,
GlFormGroup,
GlDatepicker,
GlLink,
GlModal,
GlDropdown,
GlDropdownItem,
GlSprintf,
GlButton,
GlFormInput,
GlFormCheckboxGroup,
InviteModalBase,
MembersTokenSelect,
GroupSelect,
ModalConfetti,
},
inject: ['newProjectPath'],
@ -75,15 +59,9 @@ export default {
type: Number,
required: true,
},
groupSelectFilter: {
helpLink: {
type: String,
required: false,
default: GROUP_FILTERS.ALL,
},
groupSelectParentId: {
type: Number,
required: false,
default: null,
required: true,
},
usersFilter: {
type: String,
@ -95,10 +73,6 @@ export default {
required: false,
default: null,
},
helpLink: {
type: String,
required: true,
},
tasksToBeDoneOptions: {
type: Array,
required: true,
@ -107,80 +81,34 @@ export default {
type: Array,
required: true,
},
invalidGroups: {
type: Array,
required: true,
},
},
data() {
return {
visible: true,
modalId: uniqueId('invite-members-modal-'),
selectedAccessLevel: this.defaultAccessLevel,
inviteeType: 'members',
newUsersToInvite: [],
selectedDate: undefined,
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
groupToBeSharedWith: {},
source: 'unknown',
invalidFeedbackMessage: '',
isLoading: false,
mode: 'default',
// Kept in sync with "base"
selectedAccessLevel: undefined,
};
},
computed: {
isCelebration() {
return this.mode === 'celebrate';
},
validationState() {
return this.invalidFeedbackMessage === '' ? null : false;
},
isInviteGroup() {
return this.inviteeType === 'group';
},
modalTitle() {
return this.$options.labels[this.inviteeType].modal[this.mode].title;
},
introText() {
return sprintf(this.$options.labels[this.inviteeType][this.inviteTo][this.mode].introText, {
name: this.name,
});
return this.$options.labels.modal[this.mode].title;
},
inviteTo() {
return this.isProject ? 'toProject' : 'toGroup';
},
toastOptions() {
return {
onComplete: () => {
this.selectedAccessLevel = this.defaultAccessLevel;
this.newUsersToInvite = [];
this.groupToBeSharedWith = {};
},
};
},
basePostData() {
return {
expires_at: this.selectedDate,
format: 'json',
};
},
selectedRoleName() {
return Object.keys(this.accessLevels).find(
(key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
);
labelIntroText() {
return this.$options.labels[this.inviteTo][this.mode].introText;
},
inviteDisabled() {
return (
this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0
);
},
errorFieldDescription() {
if (this.inviteeType === 'group') {
return '';
}
return this.$options.labels[this.inviteeType].placeHolder;
return this.newUsersToInvite.length === 0;
},
tasksToBeDoneEnabled() {
return (
@ -219,7 +147,7 @@ export default {
});
if (this.tasksToBeDoneEnabled) {
this.openModal({ inviteeType: 'members', source: 'in_product_marketing_email' });
this.openModal({ source: 'in_product_marketing_email' });
this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view);
}
},
@ -235,27 +163,76 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
openModal({ mode = 'default', inviteeType, source }) {
openModal({ mode = 'default', source }) {
this.mode = mode;
this.inviteeType = inviteeType;
this.source = source;
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
trackEvent(experimentName, eventName) {
const tracking = new ExperimentTracking(experimentName);
tracking.event(eventName);
},
closeModal() {
this.resetFields();
this.$refs.modal.hide();
},
sendInvite() {
if (this.isInviteGroup) {
this.submitShareWithGroup();
} else {
this.submitInviteMembers();
sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
const baseData = {
format: 'json',
expires_at: expiresAt,
access_level: accessLevel,
invite_source: this.source,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
};
if (usersToInviteByEmail !== '') {
const apiInviteByEmail = this.isProject
? Api.inviteProjectMembersByEmail.bind(Api)
: Api.inviteGroupMembersByEmail.bind(Api);
promises.push(
apiInviteByEmail(this.id, {
...baseData,
email: usersToInviteByEmail,
}),
);
}
if (usersToAddById !== '') {
const apiAddByUserId = this.isProject
? Api.addProjectMembersByUserId.bind(Api)
: Api.addGroupMembersByUserId.bind(Api);
promises.push(
apiAddByUserId(this.id, {
...baseData,
user_id: usersToAddById,
}),
);
}
this.trackinviteMembersForTask();
Promise.all(promises)
.then((responses) => {
const message = responseMessageFromSuccess(responses);
if (message) {
onError({
response: {
data: {
message,
},
},
});
} else {
onSuccess();
this.showSuccessMessage();
}
})
.catch(onError);
},
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
@ -264,234 +241,68 @@ export default {
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
resetFields() {
this.isLoading = false;
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
this.newUsersToInvite = [];
this.groupToBeSharedWith = {};
this.invalidFeedbackMessage = '';
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
changeSelectedTaskProject(project) {
this.selectedTaskProject = project;
},
submitShareWithGroup() {
const apiShareWithGroup = this.isProject
? Api.projectShareWithGroup.bind(Api)
: Api.groupShareWithGroup.bind(Api);
apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
.then(this.showSuccessMessage)
.catch(this.showInvalidFeedbackMessage);
},
submitInviteMembers() {
this.invalidFeedbackMessage = '';
this.isLoading = true;
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
if (usersToInviteByEmail !== '') {
const apiInviteByEmail = this.isProject
? Api.inviteProjectMembersByEmail.bind(Api)
: Api.inviteGroupMembersByEmail.bind(Api);
promises.push(apiInviteByEmail(this.id, this.inviteByEmailPostData(usersToInviteByEmail)));
}
if (usersToAddById !== '') {
const apiAddByUserId = this.isProject
? Api.addProjectMembersByUserId.bind(Api)
: Api.addGroupMembersByUserId.bind(Api);
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
this.trackinviteMembersForTask();
Promise.all(promises)
.then(this.conditionallyShowSuccessMessage)
.catch(this.showInvalidFeedbackMessage);
},
inviteByEmailPostData(usersToInviteByEmail) {
return {
...this.basePostData,
email: usersToInviteByEmail,
access_level: this.selectedAccessLevel,
invite_source: this.source,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
};
},
addByUserIdPostData(usersToAddById) {
return {
...this.basePostData,
user_id: usersToAddById,
access_level: this.selectedAccessLevel,
invite_source: this.source,
tasks_to_be_done: this.tasksToBeDoneForPost,
tasks_project_id: this.tasksProjectForPost,
};
},
shareWithGroupPostData(groupToBeSharedWith) {
return {
...this.basePostData,
group_id: groupToBeSharedWith,
group_access: this.selectedAccessLevel,
};
},
conditionallyShowSuccessMessage(response) {
const message = this.unescapeMsg(responseMessageFromSuccess(response));
if (message === '') {
this.showSuccessMessage();
return;
}
this.invalidFeedbackMessage = message;
this.isLoading = false;
},
showSuccessMessage() {
if (this.isOnLearnGitlab) {
eventHub.$emit('showSuccessfulInvitationsAlert');
} else {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
this.$toast.show(this.$options.labels.toastMessageSuccessful);
}
this.closeModal();
},
showInvalidFeedbackMessage(response) {
const message = this.unescapeMsg(responseMessageFromError(response));
this.isLoading = false;
this.invalidFeedbackMessage = message || this.$options.labels.invalidFeedbackMessageDefault;
},
handleMembersTokenSelectClear() {
this.invalidFeedbackMessage = '';
},
unescapeMsg(message) {
return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
onAccessLevelUpdate(val) {
this.selectedAccessLevel = val;
},
},
labels: MODAL_LABELS,
membersTokenSelectLabelId: 'invite-members-input',
labels: MEMBER_MODAL_LABELS,
};
</script>
<template>
<gl-modal
ref="modal"
<invite-modal-base
:modal-id="modalId"
size="sm"
data-qa-selector="invite_members_modal_content"
data-testid="invite-members-modal"
:title="modalTitle"
:header-close-label="$options.labels.headerCloseLabel"
@hidden="resetFields"
@close="resetFields"
@hide="resetFields"
:modal-title="modalTitle"
:name="name"
:access-levels="accessLevels"
:default-access-level="defaultAccessLevel"
:help-link="helpLink"
:label-intro-text="labelIntroText"
:label-search-field="$options.labels.searchField"
:form-group-description="$options.labels.placeHolder"
:submit-disabled="inviteDisabled"
@reset="resetFields"
@submit="sendInvite"
@access-level="onAccessLevelUpdate"
>
<div>
<div class="gl-display-flex">
<div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
<div>
<p ref="introText">
<gl-sprintf :message="introText">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
<br />
<span v-if="isCelebration">{{ $options.labels.members.modal.celebrate.intro }} </span>
<modal-confetti v-if="isCelebration" />
</p>
</div>
</div>
<gl-form-group
:invalid-feedback="invalidFeedbackMessage"
:state="validationState"
:description="errorFieldDescription"
data-testid="members-form-group"
>
<label :id="$options.membersTokenSelectLabelId" class="col-form-label">{{
$options.labels[inviteeType].searchField
}}</label>
<members-token-select
v-if="!isInviteGroup"
v-model="newUsersToInvite"
class="gl-mb-2"
:validation-state="validationState"
:aria-labelledby="$options.membersTokenSelectLabelId"
:users-filter="usersFilter"
:filter-id="filterId"
@clear="handleMembersTokenSelectClear"
/>
<group-select
v-if="isInviteGroup"
v-model="groupToBeSharedWith"
:access-levels="accessLevels"
:groups-filter="groupSelectFilter"
:parent-group-id="groupSelectParentId"
:invalid-groups="invalidGroups"
@input="handleMembersTokenSelectClear"
/>
</gl-form-group>
<label class="gl-font-weight-bold">{{ $options.labels.accessLevel }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-dropdown
class="gl-shadow-none gl-w-full"
data-qa-selector="access_level_dropdown"
v-bind="$attrs"
:text="selectedRoleName"
>
<template v-for="(key, item) in accessLevels">
<gl-dropdown-item
:key="key"
active-class="is-active"
is-check-item
:is-checked="key === selectedAccessLevel"
@click="changeSelectedItem(key)"
>
<div>{{ item }}</div>
</gl-dropdown-item>
</template>
</gl-dropdown>
</div>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-sprintf :message="$options.labels.readMoreText">
<template #link="{ content }">
<gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
<label class="gl-mt-5 gl-display-block" for="expires_at">{{
$options.labels.accessExpireDate
}}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
<gl-datepicker
v-model="selectedDate"
class="gl-display-inline!"
:min-date="new Date()"
:target="null"
>
<template #default="{ formattedDate }">
<gl-form-input
class="gl-w-full"
:value="formattedDate"
:placeholder="__(`YYYY-MM-DD`)"
/>
</template>
</gl-datepicker>
</div>
<template #intro-text-before>
<div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div>
</template>
<template #intro-text-after>
<br />
<span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span>
<modal-confetti v-if="isCelebration" />
</template>
<template #select="{ clearValidation, validationState, labelId }">
<members-token-select
v-model="newUsersToInvite"
class="gl-mb-2"
:validation-state="validationState"
:aria-labelledby="labelId"
:users-filter="usersFilter"
:filter-id="filterId"
@clear="clearValidation"
/>
</template>
<template #form-after>
<div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done">
<label class="gl-mt-5">
{{ $options.labels.members.tasksToBeDone.title }}
{{ $options.labels.tasksToBeDone.title }}
</label>
<template v-if="projects.length">
<gl-form-checkbox-group
@ -501,7 +312,7 @@ export default {
/>
<template v-if="showTaskProjects">
<label class="gl-mt-5 gl-display-block">
{{ $options.labels.members.tasksProject.title }}
{{ $options.labels.tasksProject.title }}
</label>
<gl-dropdown
class="gl-w-half gl-xs-w-full"
@ -528,7 +339,7 @@ export default {
:dismissible="false"
data-testid="invite-members-modal-no-projects-alert"
>
<gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects">
<gl-sprintf :message="$options.labels.tasksToBeDone.noProjects">
<template #link="{ content }">
<gl-link :href="newProjectPath" target="_blank" class="gl-label-link">
{{ content }}
@ -537,22 +348,6 @@ export default {
</gl-sprintf>
</gl-alert>
</div>
</div>
<template #modal-footer>
<gl-button data-testid="cancel-button" @click="closeModal">
{{ $options.labels.cancelButtonText }}
</gl-button>
<gl-button
:disabled="inviteDisabled"
:loading="isLoading"
variant="success"
data-qa-selector="invite_button"
data-testid="invite-button"
@click="sendInvite"
>
{{ $options.labels.inviteButtonText }}
</gl-button>
</template>
</gl-modal>
</invite-modal-base>
</template>

View File

@ -0,0 +1,276 @@
<script>
import {
GlFormGroup,
GlModal,
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlLink,
GlSprintf,
GlButton,
GlFormInput,
} from '@gitlab/ui';
import { unescape } from 'lodash';
import { sanitize } from '~/lib/dompurify';
import { sprintf } from '~/locale';
import {
ACCESS_LEVEL,
ACCESS_EXPIRE_DATE,
INVALID_FEEDBACK_MESSAGE_DEFAULT,
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
HEADER_CLOSE_LABEL,
} from '../constants';
import { responseMessageFromError } from '../utils/response_message_parser';
export default {
components: {
GlFormGroup,
GlDatepicker,
GlLink,
GlModal,
GlDropdown,
GlDropdownItem,
GlSprintf,
GlButton,
GlFormInput,
},
inheritAttrs: false,
props: {
modalTitle: {
type: String,
required: true,
},
modalId: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
accessLevels: {
type: Object,
required: true,
},
defaultAccessLevel: {
type: Number,
required: true,
},
helpLink: {
type: String,
required: true,
},
labelIntroText: {
type: String,
required: true,
},
labelSearchField: {
type: String,
required: true,
},
formGroupDescription: {
type: String,
required: false,
default: '',
},
submitDisabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
// Be sure to check out reset!
return {
invalidFeedbackMessage: '',
selectedAccessLevel: this.defaultAccessLevel,
selectedDate: undefined,
isLoading: false,
minDate: new Date(),
};
},
computed: {
introText() {
return sprintf(this.labelIntroText, { name: this.name });
},
validationState() {
return this.invalidFeedbackMessage ? false : null;
},
selectLabelId() {
return `${this.modalId}_select`;
},
selectedRoleName() {
return Object.keys(this.accessLevels).find(
(key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
);
},
},
watch: {
selectedAccessLevel: {
immediate: true,
handler(val) {
this.$emit('access-level', val);
},
},
},
methods: {
showInvalidFeedbackMessage(response) {
const message = this.unescapeMsg(responseMessageFromError(response));
this.invalidFeedbackMessage = message || INVALID_FEEDBACK_MESSAGE_DEFAULT;
},
reset() {
// This component isn't necessarily disposed,
// so we might need to reset it's state.
this.isLoading = false;
this.invalidFeedbackMessage = '';
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
this.$emit('reset');
},
closeModal() {
this.reset();
this.$refs.modal.hide();
},
clearValidation() {
this.invalidFeedbackMessage = '';
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
submit() {
this.isLoading = true;
this.invalidFeedbackMessage = '';
this.$emit('submit', {
onSuccess: () => {
this.isLoading = false;
},
onError: (...args) => {
this.isLoading = false;
this.showInvalidFeedbackMessage(...args);
},
data: {
accessLevel: this.selectedAccessLevel,
expiresAt: this.selectedDate,
},
});
},
unescapeMsg(message) {
return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
},
},
HEADER_CLOSE_LABEL,
ACCESS_EXPIRE_DATE,
ACCESS_LEVEL,
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
};
</script>
<template>
<gl-modal
ref="modal"
:modal-id="modalId"
data-qa-selector="invite_members_modal_content"
data-testid="invite-modal"
size="sm"
:title="modalTitle"
:header-close-label="$options.HEADER_CLOSE_LABEL"
@hidden="reset"
@close="reset"
@hide="reset"
>
<div class="gl-display-flex" data-testid="modal-base-intro-text">
<slot name="intro-text-before"></slot>
<p>
<gl-sprintf :message="introText">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<slot name="intro-text-after"></slot>
</div>
<gl-form-group
:invalid-feedback="invalidFeedbackMessage"
:state="validationState"
:description="formGroupDescription"
data-testid="members-form-group"
>
<label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label>
<slot
name="select"
v-bind="{ clearValidation, validationState, labelId: selectLabelId }"
></slot>
</gl-form-group>
<label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-dropdown
class="gl-shadow-none gl-w-full"
data-qa-selector="access_level_dropdown"
v-bind="$attrs"
:text="selectedRoleName"
>
<template v-for="(key, item) in accessLevels">
<gl-dropdown-item
:key="key"
active-class="is-active"
is-check-item
:is-checked="key === selectedAccessLevel"
@click="changeSelectedItem(key)"
>
<div>{{ item }}</div>
</gl-dropdown-item>
</template>
</gl-dropdown>
</div>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-sprintf :message="$options.READ_MORE_TEXT">
<template #link="{ content }">
<gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
<label class="gl-mt-5 gl-display-block" for="expires_at">{{
$options.ACCESS_EXPIRE_DATE
}}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
<gl-datepicker
v-model="selectedDate"
class="gl-display-inline!"
:min-date="minDate"
:target="null"
>
<template #default="{ formattedDate }">
<gl-form-input class="gl-w-full" :value="formattedDate" :placeholder="__(`YYYY-MM-DD`)" />
</template>
</gl-datepicker>
</div>
<slot name="form-after"></slot>
<template #modal-footer>
<gl-button data-testid="cancel-button" @click="closeModal">
{{ $options.CANCEL_BUTTON_TEXT }}
</gl-button>
<gl-button
:disabled="submitDisabled"
:loading="isLoading"
variant="success"
data-qa-selector="invite_button"
data-testid="invite-button"
@click="submit"
>
{{ $options.INVITE_BUTTON_TEXT }}
</gl-button>
</template>
</gl-modal>
</template>

View File

@ -72,67 +72,52 @@ export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite');
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
export const MODAL_LABELS = {
members: {
modal: {
default: {
title: MEMBERS_MODAL_DEFAULT_TITLE,
},
celebrate: {
title: MEMBERS_MODAL_CELEBRATE_TITLE,
intro: MEMBERS_MODAL_CELEBRATE_INTRO,
},
export const MEMBER_MODAL_LABELS = {
modal: {
default: {
title: MEMBERS_MODAL_DEFAULT_TITLE,
},
toGroup: {
default: {
introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT,
},
},
toProject: {
default: {
introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT,
},
celebrate: {
introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
},
},
searchField: MEMBERS_SEARCH_FIELD,
placeHolder: MEMBERS_PLACEHOLDER,
tasksToBeDone: {
title: MEMBERS_TASKS_TO_BE_DONE_TITLE,
noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS,
},
tasksProject: {
title: MEMBERS_TASKS_PROJECTS_TITLE,
celebrate: {
title: MEMBERS_MODAL_CELEBRATE_TITLE,
intro: MEMBERS_MODAL_CELEBRATE_INTRO,
},
},
group: {
modal: {
default: {
title: GROUP_MODAL_DEFAULT_TITLE,
},
toGroup: {
default: {
introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT,
},
toGroup: {
default: {
introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
},
},
toProject: {
default: {
introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
},
},
searchField: GROUP_SEARCH_FIELD,
placeHolder: GROUP_PLACEHOLDER,
},
accessLevel: ACCESS_LEVEL,
accessExpireDate: ACCESS_EXPIRE_DATE,
toProject: {
default: {
introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT,
},
celebrate: {
introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
},
},
searchField: MEMBERS_SEARCH_FIELD,
placeHolder: MEMBERS_PLACEHOLDER,
tasksToBeDone: {
title: MEMBERS_TASKS_TO_BE_DONE_TITLE,
noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS,
},
tasksProject: {
title: MEMBERS_TASKS_PROJECTS_TITLE,
},
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
};
export const GROUP_MODAL_LABELS = {
title: GROUP_MODAL_DEFAULT_TITLE,
toGroup: {
introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
},
toProject: {
introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
},
searchField: GROUP_SEARCH_FIELD,
placeHolder: GROUP_PLACEHOLDER,
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
invalidFeedbackMessageDefault: INVALID_FEEDBACK_MESSAGE_DEFAULT,
readMoreText: READ_MORE_TEXT,
inviteButtonText: INVITE_BUTTON_TEXT,
cancelButtonText: CANCEL_BUTTON_TEXT,
headerCloseLabel: HEADER_CLOSE_LABEL,
};
export const LEARN_GITLAB = 'learn_gitlab';

View File

@ -0,0 +1,44 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(GlToast);
let initedInviteGroupsModal;
export default function initInviteGroupsModal() {
if (initedInviteGroupsModal) {
// if we already loaded this in another part of the dom, we don't want to do it again
// else we will stack the modals
return false;
}
// https://gitlab.com/gitlab-org/gitlab/-/issues/344955
// bug lying in wait here for someone to put group and project invite in same screen
// once that happens we'll need to mount these differently, perhaps split
// group/project to each mount one, with many ways to open it.
const el = document.querySelector('.js-invite-groups-modal');
if (!el) {
return false;
}
initedInviteGroupsModal = true;
return new Vue({
el,
render: (createElement) =>
createElement(InviteGroupsModal, {
props: {
...el.dataset,
isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels),
defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
groupSelectFilter: el.dataset.groupsFilter,
groupSelectParentId: parseInt(el.dataset.parentId, 10),
invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'),
},
}),
});
}

View File

@ -38,9 +38,6 @@ export default function initInviteMembersModal() {
isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels),
defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
groupSelectFilter: el.dataset.groupsFilter,
groupSelectParentId: parseInt(el.dataset.parentId, 10),
invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
projects: JSON.parse(el.dataset.projects || '[]'),
usersFilter: el.dataset.usersFilter,

View File

@ -1,6 +1,7 @@
import { groupMemberRequestFormatter } from '~/groups/members/utils';
import groupsSelect from '~/groups_select';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
@ -56,6 +57,7 @@ groupsSelect();
memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();

View File

@ -3,6 +3,7 @@ import initImportAProjectModal from '~/invite_members/init_import_a_project_moda
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
@ -17,6 +18,7 @@ memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
initImportAProjectModal();
initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();

View File

@ -199,7 +199,6 @@ export default {
requestAccessEnabled: true,
highlightChangesClass: false,
emailsDisabled: false,
showDiffPreviewInEmail: true,
cveIdRequestEnabled: true,
featureAccessLevelEveryone,
featureAccessLevelMembers,
@ -761,24 +760,6 @@ export default {
s__('ProjectSettings|Override user notification preferences for all project members.')
}}</span>
</project-setting-row>
<project-setting-row class="mb-3">
<input
:value="showDiffPreviewInEmail"
type="hidden"
name="project[project_setting_attributes][show_diff_preview_in_email]"
/>
<gl-form-checkbox
v-model="showDiffPreviewInEmail"
name="project[project_setting_attributes][show_diff_preview_in_email]"
>
{{ s__('ProjectSettings|Include diff preview in merge request notification emails') }}
<template #help>{{
s__(
'ProjectSettings|Include the code diff preview on comment threads in merge request notification emails.',
)
}}</template>
</gl-form-checkbox>
</project-setting-row>
<project-setting-row class="mb-3">
<input
:value="showDefaultAwardEmojis"

View File

@ -427,7 +427,6 @@ class ProjectsController < Projects::ApplicationController
%i[
show_default_award_emojis
squash_option
show_diff_preview_in_email
mr_default_target_self
warn_about_potentially_unwanted_characters
]

View File

@ -8,4 +8,8 @@ module GraphqlTriggers
def self.issue_crm_contacts_updated(issue)
GitlabSchema.subscriptions.trigger('issueCrmContactsUpdated', { issuable_id: issue.to_gid }, issue)
end
def self.issuable_title_updated(issuable)
GitlabSchema.subscriptions.trigger('issuableTitleUpdated', { issuable_id: issuable.to_gid }, issuable)
end
end

View File

@ -5,10 +5,12 @@ module Types
graphql_name 'Issuable'
description 'Represents an issuable.'
possible_types Types::IssueType, Types::MergeRequestType
possible_types Types::IssueType, Types::MergeRequestType, Types::WorkItemType
def self.resolve_type(object, context)
case object
when WorkItem
Types::WorkItemType
when Issue
Types::IssueType
when MergeRequest

View File

@ -9,5 +9,8 @@ module Types
field :issue_crm_contacts_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the crm contacts of an issuable are updated.'
field :issuable_title_updated, subscription: Subscriptions::IssuableUpdated, null: true,
description: 'Triggered when the title of an issuable is updated.'
end
end

View File

@ -33,12 +33,23 @@ module InviteMembersHelper
end
end
def common_invite_group_modal_data(source, member_class, is_project)
{
id: source.id,
name: source.name,
default_access_level: Gitlab::Access::GUEST,
invalid_groups: source.related_group_ids,
help_link: help_page_url('user/permissions'),
is_project: is_project,
access_levels: member_class.access_level_roles.to_json
}
end
def common_invite_modal_dataset(source)
dataset = {
id: source.id,
name: source.name,
default_access_level: Gitlab::Access::GUEST,
invalid_groups: source.related_group_ids
default_access_level: Gitlab::Access::GUEST
}
if show_invite_members_for_task?(source)

View File

@ -586,7 +586,6 @@ module ProjectsHelper
metricsDashboardAccessLevel: feature.metrics_dashboard_access_level,
operationsAccessLevel: feature.operations_access_level,
showDefaultAwardEmojis: project.show_default_award_emojis?,
showDiffPreviewInEmail: project.show_diff_preview_in_email?,
warnAboutPotentiallyUnwantedCharacters: project.warn_about_potentially_unwanted_characters?,
securityAndComplianceAccessLevel: project.security_and_compliance_access_level,
containerRegistryAccessLevel: feature.container_registry_access_level

View File

@ -7,6 +7,11 @@ module CrossDatabaseModification
DEBUG_STACK = Rails.env.test? && ENV['DEBUG_GITLAB_TRANSACTION_STACK']
LOG_FILENAME = Rails.root.join("log", "gitlab_transaction_stack.log")
EXCLUDE_DEBUG_TRACE = %w[
lib/gitlab/database/query_analyzer
app/models/concerns/cross_database_modification.rb
].freeze
def self.logger
@logger ||= Logger.new(LOG_FILENAME, formatter: ->(_, _, _, msg) { Gitlab::Json.dump(msg) + "\n" })
end
@ -18,7 +23,7 @@ module CrossDatabaseModification
message += " in example #{example}" if example
cleaned_backtrace = Gitlab::BacktraceCleaner.clean_backtrace(caller)
.reject { |line| line.include?('lib/gitlab/database/query_analyzer') }
.reject { |line| EXCLUDE_DEBUG_TRACE.any? { |exclusion| line.include?(exclusion) } }
.first(5)
logger.warn({

View File

@ -433,7 +433,6 @@ class Project < ApplicationRecord
alias_method :container_registry_enabled, :container_registry_enabled?
delegate :show_default_award_emojis, :show_default_award_emojis=, :show_default_award_emojis?,
:warn_about_potentially_unwanted_characters, :warn_about_potentially_unwanted_characters=, :warn_about_potentially_unwanted_characters?,
:show_diff_preview_in_email, :show_diff_preview_in_email=, :show_diff_preview_in_email?,
to: :project_setting, allow_nil: true
delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
prefix: :import, to: :import_state, allow_nil: true

View File

@ -1,6 +1,10 @@
# frozen_string_literal: true
class ProjectSetting < ApplicationRecord
include IgnorableColumns
ignore_column :show_diff_preview_in_email, remove_with: '14.10', remove_after: '2022-03-22'
belongs_to :project, inverse_of: :project_setting
enum squash_option: {

View File

@ -229,7 +229,6 @@ class ProjectPolicy < BasePolicy
enable :set_note_created_at
enable :set_emails_disabled
enable :set_show_default_award_emojis
enable :set_show_diff_preview_in_email
enable :set_warn_about_potentially_unwanted_characters
end

View File

@ -2,5 +2,12 @@
module WorkItems
class UpdateService < ::Issues::UpdateService
private
def after_update(issuable)
super
GraphqlTriggers.issuable_title_updated(issuable) if issuable.previous_changes.key?(:title)
end
end
end

View File

@ -1,7 +1,8 @@
- max_first_name_length = max_last_name_length = 127
- omniauth_providers_placement ||= :bottom
- borderless ||= false
.gl-mb-3.gl-p-4.gl-border-gray-100.gl-border-1.gl-border-solid.gl-rounded-base
.gl-mb-3.gl-p-4{ class: (borderless ? '' : 'gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base') }
- if show_omniauth_providers && omniauth_providers_placement == :top
= render 'devise/shared/signup_omniauth_providers_top'

View File

@ -0,0 +1,3 @@
- return unless can_admin_group_member?(group)
.js-invite-groups-modal{ data: common_invite_group_modal_data(group, GroupMember, 'false').merge(group_select_data(group)) }

View File

@ -2,5 +2,4 @@
.js-invite-members-modal{ data: { is_project: 'false',
access_levels: GroupMember.access_level_roles.to_json,
default_access_level: Gitlab::Access::GUEST,
help_link: help_page_url('user/permissions') }.merge(group_select_data(group)).merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) }
help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) }

View File

@ -19,6 +19,7 @@
classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',
trigger_source: 'group-members-page',
display_text: _('Invite members') } }
= render 'groups/invite_groups_modal', group: @group
= render 'groups/invite_members_modal', group: @group
- if can_admin_group_member?(@group) && Feature.disabled?(:invite_members_group_modal, @group, default_enabled: :yaml)
%hr.gl-mt-4

View File

@ -20,7 +20,8 @@
discussion on #{link_to(discussion.file_path, target_url)}
- else
= link_to 'discussion', target_url
- if discussion&.diff_discussion? && discussion.on_text? && @project.show_diff_preview_in_email?
- if discussion&.diff_discussion? && discussion.on_text?
= content_for :head do
= stylesheet_link_tag 'mailers/highlighted_diff_email'

View File

@ -20,7 +20,7 @@
<% end -%>
<% if discussion&.diff_discussion? && discussion.on_text? && @project.show_diff_preview_in_email? -%>
<% if discussion&.diff_discussion? && discussion.on_text? -%>
<% discussion.truncated_diff_lines(highlight: false, diff_limit: diff_limit).each do |line| -%>
<%= "> #{line.text}\n" -%>
<% end -%>

View File

@ -0,0 +1,3 @@
- return unless can_admin_project_member?(project)
.js-invite-groups-modal{ data: common_invite_group_modal_data(project, ProjectMember, 'true') }

View File

@ -21,6 +21,7 @@
.js-import-a-project-modal{ data: { project_id: @project.id, project_name: @project.name } }
- if @project.allowed_to_share_with_group?
.js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } }
= render 'projects/invite_groups_modal', project: @project
- if can_admin_project_member?(@project)
.js-invite-members-trigger{ data: { variant: 'success',
classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',

View File

@ -0,0 +1,18 @@
- name: "Elasticsearch 6.8"
announcement_milestone: "14.8"
announcement_date: "2022-02-22"
removal_milestone: "15.0"
removal_date: "2022-05-22"
breaking_change: true
body: |
Elasticsearch 6.8 is deprecated in GitLab 14.8 and scheduled for removal in GitLab 15.0.
Customers using Elasticsearch 6.8 need to upgrade their Elasticsearch version to 7.x prior to upgrading to GitLab 15.0.
We recommend using the latest version of Elasticsearch 7 to benefit from all Elasticsearch improvements.
Elasticsearch 6.8 is also incompatible with Amazon OpenSearch, which we [plan to support in GitLab 15.0](https://gitlab.com/gitlab-org/gitlab/-/issues/327560).
stage: Enablement
tiers: [Premium, Ultimate]
issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/350275
documentation_url: https://docs.gitlab.com/ee/integration/elasticsearch.html#version-requirements

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddPartialIndexForBatchingActiveClusterImageScanningVulnerabilities < Gitlab::Database::Migration[1.0]
INDEX_NAME = 'index_vulnerabilities_on_project_id_and_id_active_cis'
INDEX_FILTER_CONDITION = 'report_type = 7 AND state = ANY(ARRAY[1, 4])'
disable_ddl_transaction!
def up
add_concurrent_index :vulnerabilities, [:project_id, :id], where: INDEX_FILTER_CONDITION, name: INDEX_NAME
end
def down
remove_concurrent_index :vulnerabilities, [:project_id, :id], name: INDEX_NAME
end
end

View File

@ -0,0 +1 @@
9462258bcbe45ab80f7ef5a02f8b8d5c0ed6ac69bf04b8934ae3dee2261ba458

View File

@ -28144,6 +28144,8 @@ CREATE INDEX index_vulnerabilities_on_milestone_id ON vulnerabilities USING btre
CREATE INDEX index_vulnerabilities_on_project_id_and_id ON vulnerabilities USING btree (project_id, id);
CREATE INDEX index_vulnerabilities_on_project_id_and_id_active_cis ON vulnerabilities USING btree (project_id, id) WHERE ((report_type = 7) AND (state = ANY (ARRAY[1, 4])));
CREATE INDEX index_vulnerabilities_on_project_id_and_state_and_severity ON vulnerabilities USING btree (project_id, state, severity);
CREATE INDEX index_vulnerabilities_on_resolved_by_id ON vulnerabilities USING btree (resolved_by_id);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@ -4,7 +4,13 @@ group: Respond
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/#assignments
---
# Request Profiling **(FREE SELF)**
# Request profiling (DEPRECATED) **(FREE SELF)**
> [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/352488) in GitLab 14.8, and planned for removal in GitLab 15.0.
WARNING:
This feature is in its end-of-life process. It is [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/352488)
for use in GitLab 14.8, and is planned for removal in GitLab 15.0.
To profile a request:

View File

@ -10,143 +10,115 @@ disqus_identifier: 'https://docs.gitlab.com/ee/administration/custom_hooks.html'
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/196051) in GitLab 12.8 replacing Custom Hooks.
Git supports hooks that are executed on different actions. These hooks run on the server and can be
used to enforce specific commit policies or perform other tasks based on the state of the
repository.
Server hooks run custom logic on the GitLab server. Users can use them to run Git-related tasks such as:
Git supports the following hooks:
- Enforcing specific commit policies.
- Performing tasks based on the state of the repository.
- `pre-receive`
- `post-receive`
- `update`
Server hooks use `pre-receive`, `post-receive`, and `update`
[Git server-side hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks#_server_side_hooks).
See [the Git documentation](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks#_server_side_hooks)
for more information about each hook type.
GitLab administrators configure server hooks on the file system of the GitLab server. If you don't have file system access,
alternatives to server hooks include:
Server-side Git hooks can be configured for:
- [Webhooks](../user/project/integrations/webhooks.md).
- [GitLab CI/CD](../ci/index.md).
- [Push rules](../push_rules/push_rules.md), for a user-configurable Git hook interface.
- [A single repository](#create-a-server-hook-for-a-repository).
- [All repositories](#create-a-global-server-hook-for-all-repositories).
[Geo](geo/index.md) doesn't replicate server hooks to secondary nodes.
Note the following about server hooks:
## Create a server hook for a single repository
- Server hooks must be configured on the file system of the GitLab server. Only GitLab server
administrators are able to complete these tasks. If you don't have file system access, see
possible alternatives such as:
- [Webhooks](../user/project/integrations/webhooks.md).
- [GitLab CI/CD](../ci/index.md).
- [Push Rules](../push_rules/push_rules.md), for a user-configurable Git hook
interface.
- Server hooks aren't replicated to [Geo](geo/index.md) secondary nodes.
To create a server hook for a single repository:
## Create a server hook for a repository
1. On the top bar, select **Menu > Admin**.
1. Go to **Overview > Projects** and select the project you want to add a server hook to.
1. On the page that appears, locate the value of **Gitaly relative path**. This path is where server hooks must be located.
- If you are using [hashed storage](repository_storage_types.md#hashed-storage), see
[Translate hashed storage paths](repository_storage_types.md#translate-hashed-storage-paths) for information on
interpreting the relative path.
- If you are not using [hashed storage](repository_storage_types.md#hashed-storage):
- For Omnibus GitLab installations, the path is usually `/var/opt/gitlab/git-data/repositories/<group>/<project>.git`.
- For an installation from source, the path is usually `/home/git/repositories/<group>/<project>.git`.
1. On the file system, create a new directory in the correct location called `custom_hooks`.
1. In the new `custom_hooks` directory, create a file with a name that matches the hook type. For example, for a
`pre-receive` server hook, the filename should be `pre-receive` with no extension.
1. Make the server hook file executable and ensure that it's owned by the Git user.
1. Write the code to make the server hook function as expected. Server hooks can be in any programming language. Ensure
the [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) at the top reflects the language type. For
example, if the script is in Ruby the shebang is probably `#!/usr/bin/env ruby`.
If you are not using [hashed storage](repository_storage_types.md#hashed-storage), the project's
repository directory might not exactly match the instructions below. In that case:
- For an installation from source, the path is usually
`/home/git/repositories/<group>/<project>.git`.
- For Omnibus GitLab installs, the path is usually
`/var/opt/gitlab/git-data/repositories/<group>/<project>.git`.
Follow the steps below to set up a server-side hook for a repository:
1. Go to **Admin area > Projects** and select the project you want to add a server hook to.
1. Locate the **Gitaly relative path** on the page that appears. This is where the server hook
must be implemented. For information on interpreting the relative path, see
[Translate hashed storage paths](repository_storage_types.md#translate-hashed-storage-paths).
1. On the file system, create a new directory in this location called `custom_hooks`.
1. Inside the new `custom_hooks` directory, create a file with a name matching the hook type. For
example, for a pre-receive hook the filename should be `pre-receive` with no extension.
1. Make the hook file executable and ensure that it's owned by the Git user.
1. Write the code to make the server hook function as expected. Hooks can be in any language. Ensure
the ["shebang"](https://en.wikipedia.org/wiki/Shebang_(Unix)) at the top properly reflects the
language type. For example, if the script is in Ruby the shebang is probably
`#!/usr/bin/env ruby`.
Assuming the hook code is properly implemented, the hook code is executed as appropriate.
If the server hook code is properly implemented, it should execute when the Git hook is next triggered.
## Create a global server hook for all repositories
To create a Git hook that applies to all of the repositories in your instance, set a global server
hook. The default global server hook directory is in the GitLab Shell directory. Any
hook added there applies to all repositories, including:
To create a Git hook that applies to all repositories, set a global server hook. The default global server hook directory
is in the GitLab Shell directory. Any server hook added there applies to all repositories, including:
- [Project and group wiki](../user/project/wiki/index.md) repositories,
whose storage directory names are in the format `<id>.wiki.git`.
- [Design management](../user/project/issues/design_management.md) repositories under a
project, whose storage directory names are in the format `<id>.design.git`.
- [Project and group wiki](../user/project/wiki/index.md) repositories. Their storage directory names are in the format
`<id>.wiki.git`.
- [Design management](../user/project/issues/design_management.md) repositories under a project. Their storage directory
names are in the format `<id>.design.git`.
The default directory:
### Choose a server hook directory
Before creating a global server hook, you must choose a directory for it. The default global server hook directory:
- For Omnibus GitLab installations is usually `/opt/gitlab/embedded/service/gitlab-shell/hooks`.
- For an installation from source is usually `/home/git/gitlab-shell/hooks`.
- For Omnibus GitLab installs is usually `/opt/gitlab/embedded/service/gitlab-shell/hooks`.
To use a different directory for global server hooks, set `custom_hooks_dir` in Gitaly
configuration:
To use a different directory for global server hooks, set `custom_hooks_dir` in Gitaly configuration:
- For Omnibus installations, this is set in `gitlab.rb`.
- For Omnibus installations, set in `gitlab.rb`.
- For source installations, the configuration location depends on the GitLab version. For:
- GitLab 13.0 and earlier, this is set in `gitlab-shell/config.yml`.
- GitLab 13.1 and later, this is set in `gitaly/config.toml` under the `[hooks]` section.
- GitLab 13.0 and earlier, set in `gitlab-shell/config.yml`.
- GitLab 13.1 and later, set in `gitaly/config.toml` under the `[hooks]` section. However, GitLab honors the
`custom_hooks_dir` value in `gitlab-shell/config.yml` if the value in `gitaly/config.toml` is blank or non-existent.
NOTE:
The `custom_hooks_dir` value in `gitlab-shell/config.yml` is still honored in GitLab 13.1 and later
if the value in `gitaly/config.toml` is blank or non-existent.
### Create the global server hook
Follow the steps below to set up a global server hook for all repositories:
To create a global server hook for all repositories:
1. On the GitLab server, navigate to the configured global server hook directory.
1. Create a new directory in this location. Depending on the type of hook, it can be either a
`pre-receive.d`, `post-receive.d`, or `update.d` directory.
1. Inside this new directory, add your hook. Hooks can be in any language. Ensure the
["shebang"](https://en.wikipedia.org/wiki/Shebang_(Unix)) at the top properly reflects the
language type. For example, if the script is in Ruby the shebang is probably
`#!/usr/bin/env ruby`.
1. Make the hook file executable and ensure that it's owned by the Git user.
1. On the GitLab server, go to the configured global server hook directory.
1. Create a new directory in this location called `pre-receive.d`, `post-receive.d`, or `update.d`, depending on the type
of server hook. Any other names are ignored.
1. Inside this new directory, add your server hook. Server hooks can be in any programming language. Ensure the
[shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) at the top reflects the language type. For example, if the
script is in Ruby the shebang is probably `#!/usr/bin/env ruby`.
1. Make the hook file executable, ensure that it's owned by the Git user, and ensure it does not match the backup file
pattern (`*~`).
Now test the hook to check whether it is functioning properly.
If the server hook code is properly implemented, it should execute when the Git hook is next triggered.
## Chained hooks
## Chained server hooks
Server hooks set [per project](#create-a-server-hook-for-a-repository) or
[globally](#create-a-global-server-hook-for-all-repositories) can be executed in a chain.
GitLab can execute server hooks in a chain. GitLab searches for and executes server hooks in the following order:
Server hooks are searched for and executed in the following order of priority:
- Built-in GitLab server hooks. These are not user-customizable.
- `<project>.git/custom_hooks/<hook_name>`: Per-project hooks. This was kept for backwards
compatibility.
- Built-in GitLab server hooks. These server hooks are not customizable by users.
- `<project>.git/custom_hooks/<hook_name>`: Per-project hooks. This location is kept for backwards compatibility.
- `<project>.git/custom_hooks/<hook_name>.d/*`: Location for per-project hooks.
- `<custom_hooks_dir>/<hook_name>.d/*`: Location for all executable global hook files
except editor backup files.
- `<custom_hooks_dir>/<hook_name>.d/*`: Location for all executable global hook files except editor backup files.
Within a directory, server hooks:
Within a server hooks directory, hooks:
- Are executed in alphabetical order.
- Stop executing when a hook exits with a non-zero value.
`<hook_name>.d` must be either `pre-receive.d`, `post-receive.d`, or `update.d` to work properly.
Any other names are ignored.
## Environment variables available to server hooks
Files in `.d` directories must be executable and not match the backup file pattern (`*~`).
You can pass any environment variable to server hooks, but you should only rely on supported environment variables.
For `<project>.git` you need to [translate](repository_storage_types.md#translate-hashed-storage-paths)
your project name into the hashed storage format that GitLab uses.
The following GitLab environment variables are supported for all server hooks:
## Environment Variables
| Environment variable | Description |
|:---------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `GL_ID` | GitLab identifier of user that initiated the push. For example, `user-2234`. |
| `GL_PROJECT_PATH` | (GitLab 13.2 and later) GitLab project path. |
| `GL_PROTOCOL` | (GitLab 13.2 and later) Protocol used for this change. One of: `http` (Git `push` using HTTP), `ssh` (Git `push` using SSH), or `web` (all other actions). |
| `GL_REPOSITORY` | `project-<id>` where `id` is the ID of the project. |
| `GL_USERNAME` | GitLab username of the user that initiated the push. |
The following set of environment variables are available to server hooks.
| Environment variable | Description |
|:---------------------|:----------------------------------------------------------------------------|
| `GL_ID` | GitLab identifier of user that initiated the push. For example, `user-2234` |
| `GL_PROJECT_PATH` | (GitLab 13.2 and later) GitLab project path |
| `GL_PROTOCOL` | (GitLab 13.2 and later) Protocol used for this change. One of: `http` (Git Push using HTTP), `ssh` (Git Push using SSH), or `web` (all other actions). |
| `GL_REPOSITORY` | `project-<id>` where `id` is the ID of the project |
| `GL_USERNAME` | GitLab username of the user that initiated the push |
Pre-receive and post-receive server hooks can also access the following Git environment variables.
The following Git environment variables are supported for `pre-receive` and `post-receive` server hooks:
| Environment variable | Description |
|:-----------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
@ -155,26 +127,18 @@ Pre-receive and post-receive server hooks can also access the following Git envi
| `GIT_PUSH_OPTION_COUNT` | Number of push options. See [Git `pre-receive` documentation](https://git-scm.com/docs/githooks#pre-receive). |
| `GIT_PUSH_OPTION_<i>` | Value of push options where `i` is from `0` to `GIT_PUSH_OPTION_COUNT - 1`. See [Git `pre-receive` documentation](https://git-scm.com/docs/githooks#pre-receive). |
NOTE:
While other environment variables can be passed to server hooks, your application should not rely on
them as they can change.
## Custom error messages
To have custom error messages appear in the GitLab UI when a commit is declined or an error occurs
during the Git hook, your script should:
You can have custom error messages appear in the GitLab UI when a commit is declined or an error occurs during the Git
hook. To display a custom error message, your script must:
- Send the custom error messages to either the script's `stdout` or `stderr`.
- Prefix each message with `GL-HOOK-ERR:` with no characters appearing before the prefix.
### Example custom error message
This hook script written in Bash generates the following message in the GitLab UI:
For example:
```shell
#!/bin/sh
echo "GL-HOOK-ERR: My custom error message.";
exit 1
```
![Custom message from custom Git hook](img/custom_hooks_error_msg.png)

View File

@ -18790,6 +18790,7 @@ One of:
- [`Epic`](#epic)
- [`Issue`](#issue)
- [`MergeRequest`](#mergerequest)
- [`WorkItem`](#workitem)
#### `JobNeedUnion`

View File

@ -53,9 +53,16 @@ Example:
### Require approvals for a protected environment
NOTE:
At this time, only API-based configuration is available. UI-based configuration is planned for the near future. See [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/344675).
At this time, it is not possible to require approvals for an existing protected environment. The workaround is to unprotect the environment and configure approvals when re-protecting the environment.
Use the [Protected Environments API](../../api/protected_environments.md#protect-repository-environments) to create an environment with `required_approval_count` > 0. After this is set, all jobs deploying to this environment automatically go into a blocked state and wait for approvals before running.
There are two ways to configure approvals for a protected environment:
1. Using the [UI](protected_environments.md#protecting-environments)
1. Set the **Required approvals** field to 1 or more.
1. Using the [REST API](../../api/protected_environments.md#protect-repository-environments)
2. Set the `required_approval_count` field to 1 or more.
After this is configured, all jobs deploying to this environment automatically go into a blocked state and wait for approvals before running. Ensure that the number of required approvals is less than the number of users allowed to deploy.
Example:
@ -66,6 +73,7 @@ curl --header 'Content-Type: application/json' --request POST \
"https://gitlab.example.com/api/v4/projects/22034114/protected_environments"
```
NOTE:
To protect, update, or unprotect an environment, you must have at least the
Maintainer role.

View File

@ -38,6 +38,9 @@ To protect an environment:
- You can select groups that are already associated with the project only.
- Users must have at least the Developer role to appear in
the **Allowed to deploy** list.
1. In the **Required approvals** list, select the number of approvals required to deploy to this environment.
- Ensure that this number is less than the number of users allowed to deploy.
- See [Deployment Approvals](deployment_approvals.md) for more information about this feature.
1. Select **Protect**.
The protected environment now appears in the list of protected environments.
@ -94,7 +97,7 @@ Alternatively, you can use the API to protect an environment:
1. Use the API to add the group with protected environment access:
```shell
curl --header 'Content-Type: application/json' --request POST --data '{"name": "production", "deploy_access_levels": [{"group_id": 9899826}]}' \
curl --header 'Content-Type: application/json' --request POST --data '{"name": "production", "deploy_access_levels": [{"group_id": 9899826}], "required_approval_count": 0}' \
--header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.com/api/v4/projects/22034114/protected_environments"
```

View File

@ -14,16 +14,19 @@ Advanced Search provides faster search response times and [improved search featu
## Version requirements
> Support for Elasticsearch 6.8 was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/350275) in GitLab 14.8 and is scheduled for removal in GitLab 15.0.
<!-- Remember to update ee/lib/system_check/app/elasticsearch_check.rb if this changes -->
| GitLab version | Elasticsearch version |
|---------------------------------------------|-------------------------------|
| GitLab Enterprise Edition 13.9 or greater | Elasticsearch 6.8 through 7.x |
| GitLab Enterprise Edition 13.3 through 13.8 | Elasticsearch 6.4 through 7.x |
| GitLab Enterprise Edition 12.7 through 13.2 | Elasticsearch 6.x through 7.x |
| GitLab Enterprise Edition 11.5 through 12.6 | Elasticsearch 5.6 through 6.x |
| GitLab Enterprise Edition 9.0 through 11.4 | Elasticsearch 5.1 through 5.5 |
| GitLab Enterprise Edition 8.4 through 8.17 | Elasticsearch 2.4 with [Delete By Query Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/2.4/plugins-delete-by-query.html) installed |
| GitLab version | Elasticsearch version |
|------------------------------------------------|--------------------------------|
| GitLab Enterprise Edition 14.8 or later | Elasticsearch 7.x through 7.17 |
| GitLab Enterprise Edition 13.9 through 14.7 | Elasticsearch 6.8 through 7.17 |
| GitLab Enterprise Edition 13.3 through 13.8 | Elasticsearch 6.4 through 7.x |
| GitLab Enterprise Edition 12.7 through 13.2 | Elasticsearch 6.x through 7.x |
| GitLab Enterprise Edition 11.5 through 12.6 | Elasticsearch 5.6 through 6.x |
| GitLab Enterprise Edition 9.0 through 11.4 | Elasticsearch 5.1 through 5.5 |
| GitLab Enterprise Edition 8.4 through 8.17 | Elasticsearch 2.4 with [Delete By Query Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/2.4/plugins-delete-by-query.html) installed |
The Elasticsearch Integration works with supported versions of
Elasticsearch and follows Elasticsearch's [End of Life Policy](https://www.elastic.co/support/eol).
@ -35,9 +38,9 @@ before we remove them.
GitLab does not support:
- [Amazon's OpenSearch](https://aws.amazon.com/blogs/opensource/opensearch-1-0-launches/)
(a [fork of Elasticsearch](https://www.elastic.co/what-is/opensearch)).
(a [fork of Elasticsearch](https://www.elastic.co/what-is/opensearch)). Use AWS Elasticsearch Service 7.10 instead.
For updates, see [issue #327560](https://gitlab.com/gitlab-org/gitlab/-/issues/327560).
- Elasticsearch 8.0. For updates, see [issue #350600](https://gitlab.com/gitlab-org/gitlab/-/issues/350600).
- Elasticsearch 8.0. For updates, see [issue #350600](https://gitlab.com/gitlab-org/gitlab/-/issues/350600). Use Elasticsearch 7.17 instead.
## System requirements

View File

@ -809,6 +809,22 @@ The following `geo:db:*` tasks will be replaced with their corresponding `db:*:g
**Planned removal milestone: 15.0 (2022-05-22)**
### Elasticsearch 6.8
WARNING:
This feature will be changed or removed in 15.0
as a [breaking change](https://docs.gitlab.com/ee/development/contributing/#breaking-changes).
Before updating GitLab, review the details carefully to determine if you need to make any
changes to your code, settings, or workflow.
Elasticsearch 6.8 is deprecated in GitLab 14.8 and scheduled for removal in GitLab 15.0.
Customers using Elasticsearch 6.8 need to upgrade their Elasticsearch version to 7.x prior to upgrading to GitLab 15.0.
We recommend using the latest version of Elasticsearch 7 to benefit from all Elasticsearch improvements.
Elasticsearch 6.8 is also incompatible with Amazon OpenSearch, which we [plan to support in GitLab 15.0](https://gitlab.com/gitlab-org/gitlab/-/issues/327560).
**Planned removal milestone: 15.0 (2022-05-22)**
### External status check API breaking changes
WARNING:

View File

@ -113,66 +113,9 @@ NOTE:
The linked tutorial connects the cluster to GitLab through cluster certificates,
and this method was [deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8)
in GitLab 14.5. You can still create a cluster through IaC and then connect it to GitLab
through the [Agent](../../clusters/agent/index.md), the default and fully supported
through the [agent](../../clusters/agent/index.md), the default and fully supported
method to connect clusters to GitLab.
## Troubleshooting
### `gitlab_group_share_group` resources not detected when subgroup state is refreshed
The GitLab Terraform provider can fail to detect existing `gitlab_group_share_group` resources
due to the issue ["User with permissions cannot retrieve `share_with_groups` from the API"](https://gitlab.com/gitlab-org/gitlab/-/issues/328428).
This results in an error when running `terraform apply` because Terraform attempts to recreate an
existing resource.
For example, consider the following group/subgroup configuration:
```plaintext
parent-group
├── subgroup-A
└── subgroup-B
```
Where:
- User `user-1` creates `parent-group`, `subgroup-A`, and `subgroup-B`.
- `subgroup-A` is shared with `subgroup-B`.
- User `terraform-user` is member of `parent-group` with inherited `owner` access to both subgroups.
When the Terraform state is refreshed, the API query `GET /groups/:subgroup-A_id` issued by the provider does not return the
details of `subgroup-B` in the `shared_with_groups` array. This leads to the error.
To workaround this issue, make sure to apply one of the following conditions:
1. The `terraform-user` creates all subgroup resources.
1. Grant Maintainer or Owner role to the `terraform-user` user on `subgroup-B`.
1. The `terraform-user` inherited access to `subgroup-B` and `subgroup-B` contains at least one project.
### Invalid CI/CD syntax error when using the "latest" base template
On GitLab 14.2 and later, you might get a CI/CD syntax error when using the
`latest` Base Terraform template:
```yaml
include:
- template: Terraform/Base.latest.gitlab-ci.yml
my-Terraform-job:
extends: .init
```
The base template's [jobs were renamed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67719/)
with better Terraform-specific names. To resolve the syntax error, you can:
- Use the stable `Terraform/Base.gitlab-ci.yml` template, which has not changed.
- Update your pipeline configuration to use the new job names in
`https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates/Terraform/Base.latest.gitlab-ci.yml`.
For example:
```yaml
include:
- template: Terraform/Base.latest.gitlab-ci.yml
my-Terraform-job:
extends: .terraform:init # The updated name.
```
See the [troubleshooting](troubleshooting.md) documentation.

View File

@ -0,0 +1,68 @@
---
stage: Configure
group: Configure
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/#assignments
---
# Troubleshooting the Terraform integration with GitLab
When you are using the integration with Terraform and GitLab, you might experience issues you need to troubleshoot.
## `gitlab_group_share_group` resources not detected when subgroup state is refreshed
The GitLab Terraform provider can fail to detect existing `gitlab_group_share_group` resources
due to the issue ["User with permissions cannot retrieve `share_with_groups` from the API"](https://gitlab.com/gitlab-org/gitlab/-/issues/328428).
This results in an error when running `terraform apply` because Terraform attempts to recreate an
existing resource.
For example, consider the following group/subgroup configuration:
```plaintext
parent-group
├── subgroup-A
└── subgroup-B
```
Where:
- User `user-1` creates `parent-group`, `subgroup-A`, and `subgroup-B`.
- `subgroup-A` is shared with `subgroup-B`.
- User `terraform-user` is member of `parent-group` with inherited `owner` access to both subgroups.
When the Terraform state is refreshed, the API query `GET /groups/:subgroup-A_id` issued by the provider does not return the
details of `subgroup-B` in the `shared_with_groups` array. This leads to the error.
To workaround this issue, make sure to apply one of the following conditions:
1. The `terraform-user` creates all subgroup resources.
1. Grant Maintainer or Owner role to the `terraform-user` user on `subgroup-B`.
1. The `terraform-user` inherited access to `subgroup-B` and `subgroup-B` contains at least one project.
### Invalid CI/CD syntax error when using the `latest` base template
On GitLab 14.2 and later, you might get a CI/CD syntax error when using the
`latest` Base Terraform template:
```yaml
include:
- template: Terraform/Base.latest.gitlab-ci.yml
my-Terraform-job:
extends: .init
```
The base template's [jobs were renamed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67719/)
with better Terraform-specific names. To resolve the syntax error, you can:
- Use the stable `Terraform/Base.gitlab-ci.yml` template, which has not changed.
- Update your pipeline configuration to use the new job names in
`https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates/Terraform/Base.latest.gitlab-ci.yml`.
For example:
```yaml
include:
- template: Terraform/Base.latest.gitlab-ci.yml
my-Terraform-job:
extends: .terraform:init # The updated name.
```

View File

@ -99,10 +99,26 @@ base, and each begins a new build stage. You can selectively copy artifacts from
another, leaving behind everything you don't want in the final image. This is especially useful when
you need to install build dependencies, but you don't need them to be present in your final image.
## Move to GitLab Ultimate
## Use an image pull policy
When using the `docker` or `docker+machine` executors, you can set a [`pull_policy`](https://docs.gitlab.com/runner/executors/docker.html#using-the-if-not-present-pull-policy)
parameter in your runner `config.toml` that defines how the runner works when pulling Docker images.
To avoid transferring data when using large and rarely updated images, consider using the
`if-not-present` pull policy when pulling images from remote registries.
## Use Docker layer caching
When running `docker build`, each command in `Dockerfile` results in a layer. These layers are kept
as a cache and can be reused if there haven't been any changes. You can specify a tagged image to be
used as a cache source for the `docker build` command by using the `--cache-from` argument. Multiple
images can be specified as a cache source by using multiple `--cache-from` arguments. This can speed
up your builds and reduce the amount of data transferred. For more information, see the
[documentation on Docker layer caching](../../../ci/docker/using_docker_build.md#make-docker-in-docker-builds-faster-with-docker-layer-caching).
## Move to GitLab Premium or Ultimate
GitLab data transfer limits are set at the tier level. If you need a higher limit, consider
upgrading to GitLab Ultimate.
upgrading to [GitLab Premium or Ultimate](https://about.gitlab.com/upgrade/).
## Purchase additional data transfer

View File

@ -6351,6 +6351,9 @@ msgstr ""
msgid "CICDAnalytics|Shared Runners Usage"
msgstr ""
msgid "CICDAnalytics|Shared runner pipeline minute duration by month"
msgstr ""
msgid "CICDAnalytics|Shared runner usage"
msgstr ""
@ -28437,12 +28440,6 @@ msgstr ""
msgid "ProjectSettings|If merge trains are enabled, merging is only possible if the branch can be rebased without conflicts."
msgstr ""
msgid "ProjectSettings|Include diff preview in merge request notification emails"
msgstr ""
msgid "ProjectSettings|Include the code diff preview on comment threads in merge request notification emails."
msgstr ""
msgid "ProjectSettings|Internal"
msgstr ""
@ -29343,6 +29340,9 @@ msgstr ""
msgid "ProtectedEnvironment|Protected Environment (%{protected_environments_count})"
msgstr ""
msgid "ProtectedEnvironment|Required approvals"
msgstr ""
msgid "ProtectedEnvironment|Select an environment"
msgstr ""

View File

@ -9,7 +9,7 @@ module QA
def self.included(base)
super
base.view 'app/assets/javascripts/invite_members/components/invite_members_modal.vue' do
base.view 'app/assets/javascripts/invite_members/components/invite_modal_base.vue' do
element :invite_button
element :access_level_dropdown
element :invite_members_modal_content

View File

@ -85,36 +85,6 @@ RSpec.describe 'Projects settings' do
end
end
context 'show diffs in emails', :js do
it 'does not hide diffs by default' do
visit edit_project_path(project)
show_diff_preview_in_email_input = find('input[name="project[project_setting_attributes][show_diff_preview_in_email]"]', visible: :hidden)
expect(show_diff_preview_in_email_input.value).to eq('true')
end
it 'hides diffs in emails when toggled' do
visit edit_project_path(project)
show_diff_preview_in_email_input = find('input[name="project[project_setting_attributes][show_diff_preview_in_email]"]', visible: :hidden)
show_diff_preview_in_email_checkbox = find('input[name="project[project_setting_attributes][show_diff_preview_in_email]"][type=checkbox]')
expect(show_diff_preview_in_email_input.value).to eq('true')
show_diff_preview_in_email_checkbox.click
expect(show_diff_preview_in_email_input.value).to eq('false')
page.within('.sharing-permissions') do
find('[data-testid="project-features-save-button"]').click
end
wait_for_requests
expect(show_diff_preview_in_email_input.value).to eq('false')
end
end
def expect_toggle_state(state)
is_collapsed = state == :collapsed

View File

@ -44,7 +44,7 @@ describe('InviteGroupTrigger', () => {
});
it('emits event that triggers opening the modal', () => {
expect(eventHub.$emit).toHaveBeenLastCalledWith('openModal', { inviteeType: 'group' });
expect(eventHub.$emit).toHaveBeenLastCalledWith('openGroupModal');
});
});
});

View File

@ -0,0 +1,143 @@
import { GlModal, GlSprintf } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Api from '~/api';
import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue';
import InviteModalBase from '~/invite_members/components/invite_modal_base.vue';
import GroupSelect from '~/invite_members/components/group_select.vue';
import { stubComponent } from 'helpers/stub_component';
import { propsData, sharedGroup } from '../mock_data/group_modal';
describe('InviteGroupsModal', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(InviteGroupsModal, {
propsData: {
...propsData,
...props,
},
stubs: {
InviteModalBase,
GlSprintf,
GlModal: stubComponent(GlModal, {
template: '<div><slot></slot><slot name="modal-footer"></slot></div>',
}),
},
});
};
const createInviteGroupToProjectWrapper = () => {
createComponent({ isProject: true });
};
const createInviteGroupToGroupWrapper = () => {
createComponent({ isProject: false });
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGroupSelect = () => wrapper.findComponent(GroupSelect);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findInviteButton = () => wrapper.findByTestId('invite-button');
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const membersFormGroupInvalidFeedback = () =>
findMembersFormGroup().attributes('invalid-feedback');
const clickInviteButton = () => findInviteButton().vm.$emit('click');
const clickCancelButton = () => findCancelButton().vm.$emit('click');
const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val);
describe('displaying the correct introText and form group description', () => {
describe('when inviting to a project', () => {
it('includes the correct type, and formatted intro text', () => {
createInviteGroupToProjectWrapper();
expect(findIntroText()).toBe("You're inviting a group to the test name project.");
});
});
describe('when inviting to a group', () => {
it('includes the correct type, and formatted intro text', () => {
createInviteGroupToGroupWrapper();
expect(findIntroText()).toBe("You're inviting a group to the test name group.");
});
});
});
describe('submitting the invite form', () => {
describe('when sharing the group is successful', () => {
const groupPostData = {
group_id: sharedGroup.id,
group_access: propsData.defaultAccessLevel,
expires_at: undefined,
format: 'json',
};
beforeEach(() => {
createComponent();
triggerGroupSelect(sharedGroup);
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData });
clickInviteButton();
});
it('calls Api groupShareWithGroup with the correct params', () => {
expect(Api.groupShareWithGroup).toHaveBeenCalledWith(propsData.id, groupPostData);
});
it('displays the successful toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
});
});
describe('when sharing the group fails', () => {
beforeEach(() => {
createInviteGroupToGroupWrapper();
triggerGroupSelect(sharedGroup);
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'groupShareWithGroup')
.mockRejectedValue({ response: { data: { success: false } } });
clickInviteButton();
});
it('does not show the toast message on failure', () => {
expect(wrapper.vm.$toast.show).not.toHaveBeenCalled();
});
it('displays the generic error for http server error', () => {
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
});
describe('clearing the invalid state and message', () => {
it('clears the error when the cancel button is clicked', async () => {
clickCancelButton();
await nextTick();
expect(membersFormGroupInvalidFeedback()).toBe('');
});
it('clears the error when the modal is hidden', async () => {
wrapper.findComponent(GlModal).vm.$emit('hide');
await nextTick();
expect(membersFormGroupInvalidFeedback()).toBe('');
});
});
});
});
});

View File

@ -1,12 +1,4 @@
import {
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlFormGroup,
GlSprintf,
GlLink,
GlModal,
} from '@gitlab/ui';
import { GlLink, GlModal, GlSprintf } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { stubComponent } from 'helpers/stub_component';
@ -15,15 +7,13 @@ import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
import InviteModalBase from '~/invite_members/components/invite_modal_base.vue';
import ModalConfetti from '~/invite_members/components/confetti.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import {
INVITE_MEMBERS_FOR_TASK,
CANCEL_BUTTON_TEXT,
INVITE_BUTTON_TEXT,
MEMBERS_MODAL_CELEBRATE_INTRO,
MEMBERS_MODAL_CELEBRATE_TITLE,
MEMBERS_MODAL_DEFAULT_TITLE,
MEMBERS_PLACEHOLDER,
MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
LEARN_GITLAB,
@ -33,9 +23,16 @@ import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import { getParameterValues } from '~/lib/utils/url_utility';
import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses';
let wrapper;
let mock;
import {
propsData,
inviteSource,
newProjectPath,
user1,
user2,
user3,
user4,
GlEmoji,
} from '../mock_data/member_modal';
jest.mock('~/experimentation/experiment_tracking');
jest.mock('~/lib/utils/url_utility', () => ({
@ -43,213 +40,125 @@ jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn(() => []),
}));
const id = '1';
const name = 'test name';
const isProject = false;
const invalidGroups = [];
const inviteeType = 'members';
const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 };
const defaultAccessLevel = 10;
const inviteSource = 'unknown';
const helpLink = 'https://example.com';
const tasksToBeDoneOptions = [
{ text: 'First task', value: 'first' },
{ text: 'Second task', value: 'second' },
];
const newProjectPath = 'projects/new';
const projects = [
{ text: 'First project', value: '1' },
{ text: 'Second project', value: '2' },
];
const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' };
const user3 = {
id: 'user-defined-token',
name: 'email@example.com',
username: 'one_2',
avatar_url: '',
};
const user4 = {
id: 'user-defined-token',
name: 'email4@example.com',
username: 'one_4',
avatar_url: '',
};
const sharedGroup = { id: '981' };
const GlEmoji = { template: '<img/>' };
const createComponent = (data = {}, props = {}) => {
wrapper = shallowMountExtended(InviteMembersModal, {
provide: {
newProjectPath,
},
propsData: {
id,
name,
isProject,
inviteeType,
accessLevels,
defaultAccessLevel,
tasksToBeDoneOptions,
projects,
helpLink,
invalidGroups,
...props,
},
data() {
return data;
},
stubs: {
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
}),
GlDropdown: true,
GlDropdownItem: true,
GlEmoji,
GlSprintf,
GlFormGroup: stubComponent(GlFormGroup, {
props: ['state', 'invalidFeedback', 'description'],
}),
},
});
};
const createInviteMembersToProjectWrapper = () => {
createComponent({ inviteeType: 'members' }, { isProject: true });
};
const createInviteMembersToGroupWrapper = () => {
createComponent({ inviteeType: 'members' }, { isProject: false });
};
const createInviteGroupToProjectWrapper = () => {
createComponent({ inviteeType: 'group' }, { isProject: true });
};
const createInviteGroupToGroupWrapper = () => {
createComponent({ inviteeType: 'group' }, { isProject: false });
};
beforeEach(() => {
gon.api_version = 'v4';
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
mock.restore();
});
describe('InviteMembersModal', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
const findLink = () => wrapper.findComponent(GlLink);
const findIntroText = () => wrapper.find({ ref: 'introText' }).text();
let wrapper;
let mock;
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(InviteMembersModal, {
provide: {
newProjectPath,
},
propsData: {
...propsData,
...props,
},
stubs: {
InviteModalBase,
GlSprintf,
GlModal: stubComponent(GlModal, {
template: '<div><slot></slot><slot name="modal-footer"></slot></div>',
}),
GlEmoji,
},
});
};
const createInviteMembersToProjectWrapper = () => {
createComponent({ isProject: true });
};
const createInviteMembersToGroupWrapper = () => {
createComponent({ isProject: false });
};
beforeEach(() => {
gon.api_version = 'v4';
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
mock.restore();
});
const findBase = () => wrapper.findComponent(InviteModalBase);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findInviteButton = () => wrapper.findByTestId('invite-button');
const clickInviteButton = () => findInviteButton().vm.$emit('click');
const clickCancelButton = () => findCancelButton().vm.$emit('click');
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const membersFormGroupInvalidFeedback = () => findMembersFormGroup().props('invalidFeedback');
const membersFormGroupDescription = () => findMembersFormGroup().props('description');
const membersFormGroupInvalidFeedback = () =>
findMembersFormGroup().attributes('invalid-feedback');
const membersFormGroupDescription = () => findMembersFormGroup().attributes('description');
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done');
const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks');
const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select');
const findNoProjectsAlert = () => wrapper.findByTestId('invite-members-modal-no-projects-alert');
const findCelebrationEmoji = () => wrapper.findComponent(GlModal).find(GlEmoji);
describe('rendering the modal', () => {
beforeEach(() => {
createComponent();
});
it('renders the modal with the correct title', () => {
expect(wrapper.findComponent(GlModal).props('title')).toBe(MEMBERS_MODAL_DEFAULT_TITLE);
});
it('renders the Cancel button text correctly', () => {
expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT);
});
it('renders the Invite button text correctly', () => {
expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT);
});
it('renders the Invite button modal without isLoading', () => {
expect(findInviteButton().props('loading')).toBe(false);
});
describe('rendering the access levels dropdown', () => {
it('sets the default dropdown text to the default access level name', () => {
expect(findDropdown().attributes('text')).toBe('Guest');
});
it('renders dropdown items for each accessLevel', () => {
expect(findDropdownItems()).toHaveLength(5);
});
});
describe('rendering the help link', () => {
it('renders the correct link', () => {
expect(findLink().attributes('href')).toBe(helpLink);
});
});
describe('rendering the access expiration date field', () => {
it('renders the datepicker', () => {
expect(findDatepicker().exists()).toBe(true);
});
});
});
const findCelebrationEmoji = () => wrapper.findComponent(GlEmoji);
const triggerOpenModal = async ({ mode = 'default', source }) => {
eventHub.$emit('openModal', { mode, source });
await nextTick();
};
const triggerMembersTokenSelect = async (val) => {
findMembersSelect().vm.$emit('input', val);
await nextTick();
};
const triggerTasks = async (val) => {
findTasks().vm.$emit('input', val);
await nextTick();
};
const triggerAccessLevel = async (val) => {
findBase().vm.$emit('access-level', val);
await nextTick();
};
describe('rendering the tasks to be done', () => {
const setupComponent = (
extraData = {},
props = {},
urlParameter = ['invite_members_for_task'],
) => {
const data = {
selectedAccessLevel: 30,
selectedTasksToBeDone: ['ci', 'code'],
...extraData,
};
const setupComponent = async (props = {}, urlParameter = ['invite_members_for_task']) => {
getParameterValues.mockImplementation(() => urlParameter);
createComponent(data, props);
createComponent(props);
await triggerAccessLevel(30);
};
const setupComponentWithTasks = async (...args) => {
await setupComponent(...args);
await triggerTasks(['ci', 'code']);
};
afterAll(() => {
getParameterValues.mockImplementation(() => []);
});
it('renders the tasks to be done', () => {
setupComponent();
it('renders the tasks to be done', async () => {
await setupComponent();
expect(findTasksToBeDone().exists()).toBe(true);
});
describe('when the selected access level is lower than 30', () => {
it('does not render the tasks to be done', () => {
setupComponent({ selectedAccessLevel: 20 });
it('does not render the tasks to be done', async () => {
await setupComponent();
await triggerAccessLevel(20);
expect(findTasksToBeDone().exists()).toBe(false);
});
});
describe('when the url does not contain the parameter `open_modal=invite_members_for_task`', () => {
it('does not render the tasks to be done', () => {
setupComponent({}, {}, []);
it('does not render the tasks to be done', async () => {
await setupComponent({}, []);
expect(findTasksToBeDone().exists()).toBe(false);
});
describe('when opened from the Learn GitLab page', () => {
it('does render the tasks to be done', () => {
setupComponent({ source: LEARN_GITLAB }, {}, []);
it('does render the tasks to be done', async () => {
await setupComponent({}, []);
await triggerOpenModal({ source: LEARN_GITLAB });
expect(findTasksToBeDone().exists()).toBe(true);
});
@ -257,27 +166,27 @@ describe('InviteMembersModal', () => {
});
describe('rendering the tasks', () => {
it('renders the tasks', () => {
setupComponent();
it('renders the tasks', async () => {
await setupComponent();
expect(findTasks().exists()).toBe(true);
});
it('does not render an alert', () => {
setupComponent();
it('does not render an alert', async () => {
await setupComponent();
expect(findNoProjectsAlert().exists()).toBe(false);
});
describe('when there are no projects passed in the data', () => {
it('does not render the tasks', () => {
setupComponent({}, { projects: [] });
it('does not render the tasks', async () => {
await setupComponent({ projects: [] });
expect(findTasks().exists()).toBe(false);
});
it('renders an alert with a link to the new projects path', () => {
setupComponent({}, { projects: [] });
it('renders an alert with a link to the new projects path', async () => {
await setupComponent({ projects: [] });
expect(findNoProjectsAlert().exists()).toBe(true);
expect(findNoProjectsAlert().findComponent(GlLink).attributes('href')).toBe(
@ -288,23 +197,23 @@ describe('InviteMembersModal', () => {
});
describe('rendering the project dropdown', () => {
it('renders the project select', () => {
setupComponent();
it('renders the project select', async () => {
await setupComponentWithTasks();
expect(findProjectSelect().exists()).toBe(true);
});
describe('when the modal is shown for a project', () => {
it('does not render the project select', () => {
setupComponent({}, { isProject: true });
it('does not render the project select', async () => {
await setupComponentWithTasks({ isProject: true });
expect(findProjectSelect().exists()).toBe(false);
});
});
describe('when no tasks are selected', () => {
it('does not render the project select', () => {
setupComponent({ selectedTasksToBeDone: [] });
it('does not render the project select', async () => {
await setupComponent();
expect(findProjectSelect().exists()).toBe(false);
});
@ -312,8 +221,8 @@ describe('InviteMembersModal', () => {
});
describe('tracking events', () => {
it('tracks the view for invite_members_for_task', () => {
setupComponent();
it('tracks the view for invite_members_for_task', async () => {
await setupComponentWithTasks();
expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name);
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
@ -321,8 +230,8 @@ describe('InviteMembersModal', () => {
);
});
it('tracks the submit for invite_members_for_task', () => {
setupComponent();
it('tracks the submit for invite_members_for_task', async () => {
await setupComponentWithTasks();
clickInviteButton();
expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name, {
@ -355,8 +264,9 @@ describe('InviteMembersModal', () => {
});
describe('when inviting members with celebration', () => {
beforeEach(() => {
createComponent({ mode: 'celebrate', inviteeType: 'members' }, { isProject: true });
beforeEach(async () => {
createComponent({ isProject: true });
await triggerOpenModal({ mode: 'celebrate' });
});
it('renders the modal with confetti', () => {
@ -375,34 +285,14 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER);
});
});
describe('when sharing with a group', () => {
it('includes the correct invitee, type, and formatted name', () => {
createInviteGroupToProjectWrapper();
expect(findIntroText()).toBe("You're inviting a group to the test name project.");
expect(membersFormGroupDescription()).toBe('');
});
});
});
describe('when inviting to a group', () => {
describe('when inviting members', () => {
it('includes the correct invitee, type, and formatted name', () => {
createInviteMembersToGroupWrapper();
it('includes the correct invitee, type, and formatted name', () => {
createInviteMembersToGroupWrapper();
expect(findIntroText()).toBe("You're inviting members to the test name group.");
expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER);
});
});
describe('when sharing with a group', () => {
it('includes the correct invitee, type, and formatted name', () => {
createInviteGroupToGroupWrapper();
expect(findIntroText()).toBe("You're inviting a group to the test name group.");
expect(membersFormGroupDescription()).toBe('');
});
expect(findIntroText()).toBe("You're inviting members to the test name group.");
expect(membersFormGroupDescription()).toBe(MEMBERS_PLACEHOLDER);
});
});
});
@ -422,7 +312,7 @@ describe('InviteMembersModal', () => {
describe('when inviting an existing user to group by user ID', () => {
const postData = {
user_id: '1,2',
access_level: defaultAccessLevel,
access_level: propsData.defaultAccessLevel,
expires_at: undefined,
invite_source: inviteSource,
format: 'json',
@ -431,8 +321,9 @@ describe('InviteMembersModal', () => {
};
describe('when member is added successfully', () => {
beforeEach(() => {
createComponent({ newUsersToInvite: [user1, user2] });
beforeEach(async () => {
createComponent();
await triggerMembersTokenSelect([user1, user2]);
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
@ -448,19 +339,17 @@ describe('InviteMembersModal', () => {
});
it('calls Api addGroupMembersByUserId with the correct params', () => {
expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData);
expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, postData);
});
it('displays the successful toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added');
});
});
describe('when opened from a Learn GitLab page', () => {
it('emits the `showSuccessfulInvitationsAlert` event', async () => {
eventHub.$emit('openModal', { inviteeType: 'members', source: LEARN_GITLAB });
await triggerOpenModal({ source: LEARN_GITLAB });
jest.spyOn(eventHub, '$emit').mockImplementation();
@ -474,12 +363,10 @@ describe('InviteMembersModal', () => {
});
describe('when member is not added successfully', () => {
beforeEach(() => {
beforeEach(async () => {
createInviteMembersToGroupWrapper();
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ newUsersToInvite: [user1] });
await triggerMembersTokenSelect([user1]);
});
it('displays "Member already exists" api message for http status conflict', async () => {
@ -490,7 +377,6 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
expect(findMembersFormGroup().props('state')).toBe(false);
expect(findMembersSelect().props('validationState')).toBe(false);
expect(findInviteButton().props('loading')).toBe(false);
});
@ -506,7 +392,6 @@ describe('InviteMembersModal', () => {
it('clears the error when the list of members to invite is cleared', async () => {
expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
expect(findMembersFormGroup().props('state')).toBe(false);
expect(findMembersSelect().props('validationState')).toBe(false);
findMembersSelect().vm.$emit('clear');
@ -514,7 +399,6 @@ describe('InviteMembersModal', () => {
await nextTick();
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersFormGroup().props('state')).not.toBe(false);
expect(findMembersSelect().props('validationState')).not.toBe(false);
});
@ -524,7 +408,6 @@ describe('InviteMembersModal', () => {
await nextTick();
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersFormGroup().props('state')).not.toBe(false);
expect(findMembersSelect().props('validationState')).not.toBe(false);
});
@ -534,7 +417,6 @@ describe('InviteMembersModal', () => {
await nextTick();
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersFormGroup().props('state')).not.toBe(false);
expect(findMembersSelect().props('validationState')).not.toBe(false);
});
});
@ -547,7 +429,6 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
expect(findMembersFormGroup().props('state')).toBe(false);
expect(findMembersSelect().props('validationState')).toBe(false);
expect(findInviteButton().props('loading')).toBe(false);
@ -556,8 +437,7 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersFormGroup().props('state')).not.toBe(false);
expect(findMembersSelect().props('validationState')).not.toBe(false);
expect(findMembersSelect().props('validationState')).toBe(null);
expect(findInviteButton().props('loading')).toBe(false);
});
@ -611,7 +491,7 @@ describe('InviteMembersModal', () => {
describe('when inviting a new user by email address', () => {
const postData = {
access_level: defaultAccessLevel,
access_level: propsData.defaultAccessLevel,
expires_at: undefined,
email: 'email@example.com',
invite_source: inviteSource,
@ -621,8 +501,9 @@ describe('InviteMembersModal', () => {
};
describe('when invites are sent successfully', () => {
beforeEach(() => {
createComponent({ newUsersToInvite: [user3] });
beforeEach(async () => {
createComponent();
await triggerMembersTokenSelect([user3]);
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
@ -634,24 +515,20 @@ describe('InviteMembersModal', () => {
});
it('calls Api inviteGroupMembersByEmail with the correct params', () => {
expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, postData);
expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, postData);
});
it('displays the successful toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added');
});
});
});
describe('when invites are not sent successfully', () => {
beforeEach(() => {
beforeEach(async () => {
createInviteMembersToGroupWrapper();
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ newUsersToInvite: [user3] });
await triggerMembersTokenSelect([user3]);
});
it('displays the api error for invalid email syntax', async () => {
@ -686,9 +563,7 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added');
expect(findMembersSelect().props('validationState')).toBe(null);
});
@ -719,9 +594,7 @@ describe('InviteMembersModal', () => {
it('displays the invalid syntax error if one of the emails is invalid', async () => {
createInviteMembersToGroupWrapper();
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ newUsersToInvite: [user3, user4] });
await triggerMembersTokenSelect([user3, user4]);
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID);
clickInviteButton();
@ -736,7 +609,7 @@ describe('InviteMembersModal', () => {
describe('when inviting members and non-members in same click', () => {
const postData = {
access_level: defaultAccessLevel,
access_level: propsData.defaultAccessLevel,
expires_at: undefined,
invite_source: inviteSource,
format: 'json',
@ -748,8 +621,9 @@ describe('InviteMembersModal', () => {
const idPostData = { ...postData, user_id: '1' };
describe('when invites are sent successfully', () => {
beforeEach(() => {
createComponent({ newUsersToInvite: [user1, user3] });
beforeEach(async () => {
createComponent();
await triggerMembersTokenSelect([user1, user3]);
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
@ -762,30 +636,28 @@ describe('InviteMembersModal', () => {
});
it('calls Api inviteGroupMembersByEmail with the correct params', () => {
expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, emailPostData);
expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, emailPostData);
});
it('calls Api addGroupMembersByUserId with the correct params', () => {
expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, idPostData);
expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, idPostData);
});
it('displays the successful toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added');
});
});
it('calls Apis with the invite source passed through to openModal', () => {
eventHub.$emit('openModal', { inviteeType: 'members', source: '_invite_source_' });
it('calls Apis with the invite source passed through to openModal', async () => {
await triggerOpenModal({ source: '_invite_source_' });
clickInviteButton();
expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, {
expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, {
...emailPostData,
invite_source: '_invite_source_',
});
expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, {
expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, {
...idPostData,
invite_source: '_invite_source_',
});
@ -793,12 +665,10 @@ describe('InviteMembersModal', () => {
});
describe('when any invite failed for any reason', () => {
beforeEach(() => {
beforeEach(async () => {
createInviteMembersToGroupWrapper();
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ newUsersToInvite: [user1, user3] });
await triggerMembersTokenSelect([user1, user3]);
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
mockMembersApi(httpStatus.OK, '200 OK');
@ -814,64 +684,10 @@ describe('InviteMembersModal', () => {
});
});
describe('when inviting a group to share', () => {
describe('when sharing the group is successful', () => {
const groupPostData = {
group_id: sharedGroup.id,
group_access: defaultAccessLevel,
expires_at: undefined,
format: 'json',
};
beforeEach(() => {
createComponent({ groupToBeSharedWith: sharedGroup });
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ inviteeType: 'group' });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData });
clickInviteButton();
});
it('calls Api groupShareWithGroup with the correct params', () => {
expect(Api.groupShareWithGroup).toHaveBeenCalledWith(id, groupPostData);
});
it('displays the successful toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
});
});
describe('when sharing the group fails', () => {
beforeEach(() => {
createInviteGroupToGroupWrapper();
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ groupToBeSharedWith: sharedGroup });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'groupShareWithGroup')
.mockRejectedValue({ response: { data: { success: false } } });
clickInviteButton();
});
it('displays the generic error message', () => {
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
expect(membersFormGroupDescription()).toBe('');
});
});
});
describe('tracking', () => {
beforeEach(() => {
createComponent({ newUsersToInvite: [user3] });
beforeEach(async () => {
createComponent();
await triggerMembersTokenSelect([user3]);
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({});

View File

@ -0,0 +1,103 @@
import {
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlFormGroup,
GlSprintf,
GlLink,
GlModal,
} from '@gitlab/ui';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import InviteModalBase from '~/invite_members/components/invite_modal_base.vue';
import { CANCEL_BUTTON_TEXT, INVITE_BUTTON_TEXT } from '~/invite_members/constants';
import { propsData } from '../mock_data/modal_base';
describe('InviteModalBase', () => {
let wrapper;
const createComponent = (data = {}, props = {}) => {
wrapper = shallowMountExtended(InviteModalBase, {
propsData: {
...propsData,
...props,
},
data() {
return data;
},
stubs: {
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
}),
GlDropdown: true,
GlDropdownItem: true,
GlSprintf,
GlFormGroup: stubComponent(GlFormGroup, {
props: ['state', 'invalidFeedback', 'description'],
}),
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
const findLink = () => wrapper.findComponent(GlLink);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findInviteButton = () => wrapper.findByTestId('invite-button');
describe('rendering the modal', () => {
beforeEach(() => {
createComponent();
});
it('renders the modal with the correct title', () => {
expect(wrapper.findComponent(GlModal).props('title')).toBe(propsData.modalTitle);
});
it('displays the introText', () => {
expect(findIntroText()).toBe(propsData.labelIntroText);
});
it('renders the Cancel button text correctly', () => {
expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT);
});
it('renders the Invite button text correctly', () => {
expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT);
});
it('renders the Invite button modal without isLoading', () => {
expect(findInviteButton().props('loading')).toBe(false);
});
describe('rendering the access levels dropdown', () => {
it('sets the default dropdown text to the default access level name', () => {
expect(findDropdown().attributes('text')).toBe('Guest');
});
it('renders dropdown items for each accessLevel', () => {
expect(findDropdownItems()).toHaveLength(5);
});
});
describe('rendering the help link', () => {
it('renders the correct link', () => {
expect(findLink().attributes('href')).toBe(propsData.helpLink);
});
});
describe('rendering the access expiration date field', () => {
it('renders the datepicker', () => {
expect(findDatepicker().exists()).toBe(true);
});
});
});
});

View File

@ -0,0 +1,11 @@
export const propsData = {
id: '1',
name: 'test name',
isProject: false,
invalidGroups: [],
accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
defaultAccessLevel: 10,
helpLink: 'https://example.com',
};
export const sharedGroup = { id: '981' };

View File

@ -0,0 +1,36 @@
export const propsData = {
id: '1',
name: 'test name',
isProject: false,
accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
defaultAccessLevel: 30,
helpLink: 'https://example.com',
tasksToBeDoneOptions: [
{ text: 'First task', value: 'first' },
{ text: 'Second task', value: 'second' },
],
projects: [
{ text: 'First project', value: '1' },
{ text: 'Second project', value: '2' },
],
};
export const inviteSource = 'unknown';
export const newProjectPath = 'projects/new';
export const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
export const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' };
export const user3 = {
id: 'user-defined-token',
name: 'email@example.com',
username: 'one_2',
avatar_url: '',
};
export const user4 = {
id: 'user-defined-token',
name: 'email4@example.com',
username: 'one_4',
avatar_url: '',
};
export const GlEmoji = { template: '<img/>' };

View File

@ -0,0 +1,11 @@
export const propsData = {
modalTitle: '_modal_title_',
modalId: '_modal_id_',
name: '_name_',
accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
defaultAccessLevel: 10,
helpLink: 'https://example.com',
labelIntroText: '_label_intro_text_',
labelSearchField: '_label_search_field_',
formGroupDescription: '_form_group_description_',
};

View File

@ -28,7 +28,6 @@ const defaultProps = {
emailsDisabled: false,
packagesEnabled: true,
showDefaultAwardEmojis: true,
showDiffPreviewInEmail: true,
warnAboutPotentiallyUnwantedCharacters: true,
},
isGitlabCom: true,
@ -102,9 +101,6 @@ describe('Settings Panel', () => {
const findEmailSettings = () => wrapper.find({ ref: 'email-settings' });
const findShowDefaultAwardEmojis = () =>
wrapper.find('input[name="project[project_setting_attributes][show_default_award_emojis]"]');
const findShowDiffPreviewInEmail = () =>
wrapper.find('input[name="project[project_setting_attributes][show_diff_preview_in_email]"]');
const findWarnAboutPuc = () =>
wrapper.find(
'input[name="project[project_setting_attributes][warn_about_potentially_unwanted_characters]"]',
@ -589,13 +585,6 @@ describe('Settings Panel', () => {
expect(findShowDefaultAwardEmojis().exists()).toBe(true);
});
});
describe('Hide diffs in email', () => {
it('should show the "Hide Diffs in email" input', () => {
wrapper = mountComponent();
expect(findShowDiffPreviewInEmail().exists()).toBe(true);
});
});
describe('Warn about potentially unwanted characters', () => {
it('should have a "Warn about Potentially Unwanted Characters" input', () => {

View File

@ -17,4 +17,18 @@ RSpec.describe GraphqlTriggers do
GraphqlTriggers.issuable_assignees_updated(issue)
end
end
describe '.issuable_title_updated' do
it 'triggers the issuableTitleUpdated subscription' do
work_item = create(:work_item)
expect(GitlabSchema.subscriptions).to receive(:trigger).with(
'issuableTitleUpdated',
{ issuable_id: work_item.to_gid },
work_item
).and_call_original
GraphqlTriggers.issuable_title_updated(work_item)
end
end
end

View File

@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['Issuable'] do
it 'returns possible types' do
expect(described_class.possible_types).to include(Types::IssueType, Types::MergeRequestType)
expect(described_class.possible_types).to include(Types::IssueType, Types::MergeRequestType, Types::WorkItemType)
end
describe '.resolve_type' do
@ -16,6 +16,10 @@ RSpec.describe GitlabSchema.types['Issuable'] do
expect(described_class.resolve_type(build(:merge_request), {})).to eq(Types::MergeRequestType)
end
it 'resolves work items' do
expect(described_class.resolve_type(build(:work_item), {})).to eq(Types::WorkItemType)
end
it 'raises an error for invalid types' do
expect { described_class.resolve_type(build(:user), {}) }.to raise_error 'Unsupported issuable type'
end

View File

@ -7,6 +7,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do
expected_fields = %i[
issuable_assignees_updated
issue_crm_contacts_updated
issuable_title_updated
]
expect(described_class).to have_graphql_fields(*expected_fields).only

View File

@ -15,13 +15,28 @@ RSpec.describe InviteMembersHelper do
helper.extend(Gitlab::Experimentation::ControllerConcern)
end
describe '#common_invite_modal_dataset' do
describe '#common_invite_group_modal_data' do
it 'has expected common attributes' do
attributes = {
id: project.id,
name: project.name,
default_access_level: Gitlab::Access::GUEST,
invalid_groups: project.related_group_ids
invalid_groups: project.related_group_ids,
help_link: help_page_url('user/permissions'),
is_project: 'true',
access_levels: ProjectMember.access_level_roles.to_json
}
expect(helper.common_invite_group_modal_data(project, ProjectMember, 'true')).to include(attributes)
end
end
describe '#common_invite_modal_dataset' do
it 'has expected common attributes' do
attributes = {
id: project.id,
name: project.name,
default_access_level: Gitlab::Access::GUEST
}
expect(helper.common_invite_modal_dataset(project)).to include(attributes)

View File

@ -964,7 +964,6 @@ RSpec.describe ProjectsHelper do
metricsDashboardAccessLevel: project.project_feature.metrics_dashboard_access_level,
operationsAccessLevel: project.project_feature.operations_access_level,
showDefaultAwardEmojis: project.show_default_award_emojis?,
showDiffPreviewInEmail: project.show_diff_preview_in_email?,
securityAndComplianceAccessLevel: project.security_and_compliance_access_level,
containerRegistryAccessLevel: project.project_feature.container_registry_access_level
)

View File

@ -145,7 +145,6 @@ project_setting:
- project_id
- push_rule_id
- show_default_award_emojis
- show_diff_preview_in_email
- updated_at
- cve_id_request_enabled
- mr_default_target_self

View File

@ -18,6 +18,26 @@ RSpec.describe WorkItems::UpdateService do
stub_spam_services
end
context 'when title is changed' do
let(:opts) { { title: 'changed' } }
it 'triggers issuable_title_updated graphql subscription' do
expect(GraphqlTriggers).to receive(:issuable_title_updated).with(work_item).and_call_original
update_work_item
end
end
context 'when title is not changed' do
let(:opts) { { description: 'changed' } }
it 'does not trigger issuable_title_updated graphql subscription' do
expect(GraphqlTriggers).not_to receive(:issuable_title_updated)
update_work_item
end
end
context 'when updating state_event' do
context 'when state_event is close' do
let(:opts) { { state_event: 'close' } }

View File

@ -8,7 +8,7 @@ module Spec
def invite_member(name, role: 'Guest', expires_at: nil)
click_on 'Invite members'
page.within '[data-testid="invite-members-modal"]' do
page.within '[data-testid="invite-modal"]' do
find('[data-testid="members-token-select-input"]').set(name)
wait_for_requests

View File

@ -63,6 +63,22 @@ RSpec.describe 'devise/shared/_signup_box' do
end
end
context 'using the borderless option' do
let(:border_css_classes) { '.gl-border-gray-100.gl-border-1.gl-border-solid.gl-rounded-base' }
it 'renders with a border by default' do
render
expect(rendered).to have_selector(border_css_classes)
end
it 'renders without a border when borderless is truthy' do
render('devise/shared/signup_box', borderless: true)
expect(rendered).not_to have_selector(border_css_classes)
end
end
def stub_devise
allow(view).to receive(:devise_mapping).and_return(Devise.mappings[:user])
allow(view).to receive(:resource).and_return(spy)