Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
e57809ded8
commit
f8dfaa8d41
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
<!--
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
openModal() {
|
||||
eventHub.$emit('openModal', { inviteeType: 'group' });
|
||||
eventHub.$emit('openGroupModal');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 || '[]'),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
|
|
@ -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)) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -%>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
- return unless can_admin_project_member?(project)
|
||||
|
||||
.js-invite-groups-modal{ data: common_invite_group_modal_data(project, ProjectMember, 'true') }
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
9462258bcbe45ab80f7ef5a02f8b8d5c0ed6ac69bf04b8934ae3dee2261ba458
|
||||
|
|
@ -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 |
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -18790,6 +18790,7 @@ One of:
|
|||
- [`Epic`](#epic)
|
||||
- [`Issue`](#issue)
|
||||
- [`MergeRequest`](#mergerequest)
|
||||
- [`WorkItem`](#workitem)
|
||||
|
||||
#### `JobNeedUnion`
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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({});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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' };
|
||||
|
|
@ -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/>' };
|
||||
|
|
@ -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_',
|
||||
};
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' } }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue