Commit cb6d5041 authored by Scott Hampton's avatar Scott Hampton

Merge branch 'add-share-with-group-to-modal' into 'master'

Add "Invite a group" feature to invite members modal [RUN ALL RSPEC]

See merge request gitlab-org/gitlab!50701
parents fe4ba437 718218e8
...@@ -24,6 +24,7 @@ const Api = { ...@@ -24,6 +24,7 @@ const Api = {
projectPackagesPath: '/api/:version/projects/:id/packages', projectPackagesPath: '/api/:version/projects/:id/packages',
projectPackagePath: '/api/:version/projects/:id/packages/:package_id', projectPackagePath: '/api/:version/projects/:id/packages/:package_id',
groupProjectsPath: '/api/:version/groups/:id/projects.json', groupProjectsPath: '/api/:version/groups/:id/projects.json',
groupSharePath: '/api/:version/groups/:id/share',
projectsPath: '/api/:version/projects.json', projectsPath: '/api/:version/projects.json',
projectPath: '/api/:version/projects/:id', projectPath: '/api/:version/projects/:id',
forkedProjectsPath: '/api/:version/projects/:id/forks', forkedProjectsPath: '/api/:version/projects/:id/forks',
...@@ -39,6 +40,7 @@ const Api = { ...@@ -39,6 +40,7 @@ const Api = {
projectRunnersPath: '/api/:version/projects/:id/runners', projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
projectSearchPath: '/api/:version/projects/:id/search', projectSearchPath: '/api/:version/projects/:id/search',
projectSharePath: '/api/:version/projects/:id/share',
projectMilestonesPath: '/api/:version/projects/:id/milestones', projectMilestonesPath: '/api/:version/projects/:id/milestones',
projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid', projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid',
mergeRequestsPath: '/api/:version/merge_requests', mergeRequestsPath: '/api/:version/merge_requests',
...@@ -365,6 +367,16 @@ const Api = { ...@@ -365,6 +367,16 @@ const Api = {
}); });
}, },
projectShareWithGroup(id, options = {}) {
const url = Api.buildUrl(Api.projectSharePath).replace(':id', encodeURIComponent(id));
return axios.post(url, {
expires_at: options.expires_at,
group_access: options.group_access,
group_id: options.group_id,
});
},
projectMilestones(id, params = {}) { projectMilestones(id, params = {}) {
const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id)); const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id));
...@@ -426,6 +438,16 @@ const Api = { ...@@ -426,6 +438,16 @@ const Api = {
}); });
}, },
groupShareWithGroup(id, options = {}) {
const url = Api.buildUrl(Api.groupSharePath).replace(':id', encodeURIComponent(id));
return axios.post(url, {
expires_at: options.expires_at,
group_access: options.group_access,
group_id: options.group_id,
});
},
commit(id, sha, params = {}) { commit(id, sha, params = {}) {
const url = Api.buildUrl(this.commitPath) const url = Api.buildUrl(this.commitPath)
.replace(':id', encodeURIComponent(id)) .replace(':id', encodeURIComponent(id))
......
<script>
import { GlDropdown, GlDropdownItem, GlDropdownText, GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash';
import Api from '~/api';
import { s__ } from '~/locale';
import { SEARCH_DELAY } from '../constants';
export default {
name: 'GroupSelect',
components: {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
},
model: {
prop: 'selectedGroup',
},
data() {
return {
isFetching: false,
groups: [],
selectedGroup: {},
searchTerm: '',
};
},
computed: {
selectedGroupName() {
return this.selectedGroup.name || this.$options.i18n.dropdownText;
},
isFetchResultEmpty() {
return this.groups.length === 0;
},
},
watch: {
searchTerm() {
this.retrieveGroups();
},
},
mounted() {
this.retrieveGroups();
},
methods: {
retrieveGroups: debounce(function debouncedRetrieveGroups() {
this.isFetching = true;
return Api.groups(this.searchTerm, this.$options.defaultFetchOptions)
.then((response) => {
this.groups = response.map((group) => ({
id: group.id,
name: group.full_name,
path: group.path,
}));
this.isFetching = false;
})
.catch(() => {
this.isFetching = false;
});
}, SEARCH_DELAY),
selectGroup(group) {
this.selectedGroup = group;
this.$emit('input', this.selectedGroup);
},
},
i18n: {
dropdownText: s__('GroupSelect|Select a group'),
searchPlaceholder: s__('GroupSelect|Search groups'),
emptySearchResult: s__('GroupSelect|No matching results'),
},
defaultFetchOptions: {
exclude_internal: true,
active: true,
},
};
</script>
<template>
<div>
<gl-dropdown
data-testid="group-select-dropdown"
:text="selectedGroupName"
block
menu-class="gl-w-full!"
>
<gl-search-box-by-type
v-model.trim="searchTerm"
:is-loading="isFetching"
:placeholder="$options.i18n.searchPlaceholder"
data-qa-selector="group_select_dropdown_search_field"
/>
<gl-dropdown-item
v-for="group in groups"
:key="group.id"
:name="group.name"
@click="selectGroup(group)"
>
{{ group.name }}
</gl-dropdown-item>
<gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message">
<span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
</gl-dropdown-text>
</gl-dropdown>
</div>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
GlButton,
},
props: {
displayText: {
type: String,
required: false,
default: s__('InviteMembers|Invite a group'),
},
classes: {
type: String,
required: false,
default: '',
},
},
methods: {
openModal() {
eventHub.$emit('openModal', { inviteeType: 'group' });
},
},
};
</script>
<template>
<gl-button :class="classes" data-qa-selector="invite_a_group_button" @click="openModal">
{{ displayText }}
</gl-button>
</template>
...@@ -11,9 +11,10 @@ import { ...@@ -11,9 +11,10 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import { partition, isString } from 'lodash'; import { partition, isString } from 'lodash';
import Api from '~/api'; import Api from '~/api';
import GroupSelect from '~/invite_members/components/group_select.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default { export default {
...@@ -28,6 +29,7 @@ export default { ...@@ -28,6 +29,7 @@ export default {
GlButton, GlButton,
GlFormInput, GlFormInput,
MembersTokenSelect, MembersTokenSelect,
GroupSelect,
}, },
props: { props: {
id: { id: {
...@@ -60,21 +62,21 @@ export default { ...@@ -60,21 +62,21 @@ export default {
visible: true, visible: true,
modalId: 'invite-members-modal', modalId: 'invite-members-modal',
selectedAccessLevel: this.defaultAccessLevel, selectedAccessLevel: this.defaultAccessLevel,
inviteeType: 'members',
newUsersToInvite: [], newUsersToInvite: [],
selectedDate: undefined, selectedDate: undefined,
groupToBeSharedWith: {},
}; };
}, },
computed: { computed: {
inviteToName() { isInviteGroup() {
return this.name.toUpperCase(); return this.inviteeType === 'group';
},
inviteToType() {
return this.isProject ? __('project') : __('group');
}, },
introText() { introText() {
return sprintf(s__("InviteMembersModal|You're inviting members to the %{name} %{type}"), { const inviteTo = this.isProject ? 'toProject' : 'toGroup';
name: this.inviteToName,
type: this.inviteToType, return sprintf(this.$options.labels[this.inviteeType][inviteTo].introText, {
name: this.name.toUpperCase(),
}); });
}, },
toastOptions() { toastOptions() {
...@@ -82,12 +84,12 @@ export default { ...@@ -82,12 +84,12 @@ export default {
onComplete: () => { onComplete: () => {
this.selectedAccessLevel = this.defaultAccessLevel; this.selectedAccessLevel = this.defaultAccessLevel;
this.newUsersToInvite = []; this.newUsersToInvite = [];
this.groupToBeSharedWith = {};
}, },
}; };
}, },
basePostData() { basePostData() {
return { return {
access_level: this.selectedAccessLevel,
expires_at: this.selectedDate, expires_at: this.selectedDate,
format: 'json', format: 'json',
}; };
...@@ -97,9 +99,16 @@ export default { ...@@ -97,9 +99,16 @@ export default {
(key) => this.accessLevels[key] === Number(this.selectedAccessLevel), (key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
); );
}, },
inviteDisabled() {
return (
this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0
);
},
}, },
mounted() { mounted() {
eventHub.$on('openModal', this.openModal); eventHub.$on('openModal', (options) => {
this.openModal(options);
});
}, },
methods: { methods: {
partitionNewUsersToInvite() { partitionNewUsersToInvite() {
...@@ -113,26 +122,42 @@ export default { ...@@ -113,26 +122,42 @@ export default {
usersToAddById.map((user) => user.id).join(','), usersToAddById.map((user) => user.id).join(','),
]; ];
}, },
openModal() { openModal({ inviteeType }) {
this.inviteeType = inviteeType;
this.$root.$emit(BV_SHOW_MODAL, this.modalId); this.$root.$emit(BV_SHOW_MODAL, this.modalId);
}, },
closeModal() { closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId); this.$root.$emit(BV_HIDE_MODAL, this.modalId);
}, },
sendInvite() { sendInvite() {
this.submitForm(); if (this.isInviteGroup) {
this.submitShareWithGroup();
} else {
this.submitInviteMembers();
}
this.closeModal(); this.closeModal();
}, },
cancelInvite() { cancelInvite() {
this.selectedAccessLevel = this.defaultAccessLevel; this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined; this.selectedDate = undefined;
this.newUsersToInvite = ''; this.newUsersToInvite = [];
this.groupToBeSharedWith = {};
this.closeModal(); this.closeModal();
}, },
changeSelectedItem(item) { changeSelectedItem(item) {
this.selectedAccessLevel = item; this.selectedAccessLevel = item;
}, },
submitForm() { submitShareWithGroup() {
const apiShareWithGroup = this.isProject
? Api.projectShareWithGroup.bind(Api)
: Api.groupShareWithGroup.bind(Api);
apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
.then(this.showToastMessageSuccess)
.catch(this.showToastMessageError);
},
submitInviteMembers() {
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = []; const promises = [];
...@@ -155,10 +180,25 @@ export default { ...@@ -155,10 +180,25 @@ export default {
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError); Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
}, },
inviteByEmailPostData(usersToInviteByEmail) { inviteByEmailPostData(usersToInviteByEmail) {
return { ...this.basePostData, email: usersToInviteByEmail }; return {
...this.basePostData,
email: usersToInviteByEmail,
access_level: this.selectedAccessLevel,
};
}, },
addByUserIdPostData(usersToAddById) { addByUserIdPostData(usersToAddById) {
return { ...this.basePostData, user_id: usersToAddById }; return {
...this.basePostData,
user_id: usersToAddById,
access_level: this.selectedAccessLevel,
};
},
shareWithGroupPostData(groupToBeSharedWith) {
return {
...this.basePostData,
group_id: groupToBeSharedWith,
group_access: this.selectedAccessLevel,
};
}, },
showToastMessageSuccess() { showToastMessageSuccess() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
...@@ -170,9 +210,28 @@ export default { ...@@ -170,9 +210,28 @@ export default {
}, },
}, },
labels: { labels: {
modalTitle: s__('InviteMembersModal|Invite team members'), members: {
newUsersToInvite: s__('InviteMembersModal|GitLab member or Email address'), modalTitle: s__('InviteMembersModal|Invite team members'),
userPlaceholder: s__('InviteMembersModal|Search for members to invite'), searchField: s__('InviteMembersModal|GitLab member or Email address'),
placeHolder: s__('InviteMembersModal|Search for members to invite'),
toGroup: {
introText: s__("InviteMembersModal|You're inviting members to the %{name} group"),
},
toProject: {
introText: s__("InviteMembersModal|You're inviting members to the %{name} project"),
},
},
group: {
modalTitle: s__('InviteMembersModal|Invite a group'),
searchField: s__('InviteMembersModal|Select a group to invite'),
placeHolder: s__('InviteMembersModal|Search for a group to invite'),
toGroup: {
introText: s__("InviteMembersModal|You're inviting a group to the %{name} group"),
},
toProject: {
introText: s__("InviteMembersModal|You're inviting a group to the %{name} project"),
},
},
accessLevel: s__('InviteMembersModal|Choose a role permission'), accessLevel: s__('InviteMembersModal|Choose a role permission'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'), toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
...@@ -189,27 +248,34 @@ export default { ...@@ -189,27 +248,34 @@ export default {
<gl-modal <gl-modal
:modal-id="modalId" :modal-id="modalId"
size="sm" size="sm"
:title="$options.labels.modalTitle" data-qa-selector="invite_members_modal_content"
:title="$options.labels[inviteeType].modalTitle"
:header-close-label="$options.labels.headerCloseLabel" :header-close-label="$options.labels.headerCloseLabel"
> >
<div class="gl-ml-5 gl-mr-5"> <div>
<div>{{ introText }}</div> <p ref="introText">{{ introText }}</p>
<label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{ <label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{
$options.labels.newUsersToInvite $options.labels[inviteeType].searchField
}}</label> }}</label>
<div class="gl-mt-2"> <div class="gl-mt-2">
<members-token-select <members-token-select
v-if="!isInviteGroup"
v-model="newUsersToInvite" v-model="newUsersToInvite"
:label="$options.labels.newUsersToInvite"
:aria-labelledby="$options.membersTokenSelectLabelId" :aria-labelledby="$options.membersTokenSelectLabelId"
:placeholder="$options.labels.userPlaceholder" :placeholder="$options.labels[inviteeType].placeHolder"
/> />
<group-select v-if="isInviteGroup" v-model="groupToBeSharedWith" />
</div> </div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> <label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full"> <div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName"> <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"> <template v-for="(key, item) in accessLevels">
<gl-dropdown-item <gl-dropdown-item
:key="key" :key="key"
...@@ -223,7 +289,7 @@ export default { ...@@ -223,7 +289,7 @@ export default {
</gl-dropdown> </gl-dropdown>
</div> </div>
<div class="gl-mt-2"> <div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-sprintf :message="$options.labels.readMoreText"> <gl-sprintf :message="$options.labels.readMoreText">
<template #link="{ content }"> <template #link="{ content }">
<gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
...@@ -231,7 +297,7 @@ export default { ...@@ -231,7 +297,7 @@ export default {
</gl-sprintf> </gl-sprintf>
</div> </div>
<label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{ <label class="gl-font-weight-bold gl-mt-5 gl-display-block" for="expires_at">{{
$options.labels.accessExpireDate $options.labels.accessExpireDate
}}</label> }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
...@@ -253,15 +319,16 @@ export default { ...@@ -253,15 +319,16 @@ export default {
</div> </div>
<template #modal-footer> <template #modal-footer>
<div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3"> <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0">
<gl-button ref="cancelButton" @click="cancelInvite"> <gl-button ref="cancelButton" @click="cancelInvite">
{{ $options.labels.cancelButtonText }} {{ $options.labels.cancelButtonText }}
</gl-button> </gl-button>
<div class="gl-mr-3"></div> <div class="gl-mr-3"></div>
<gl-button <gl-button
ref="inviteButton" ref="inviteButton"
:disabled="!newUsersToInvite" :disabled="inviteDisabled"
variant="success" variant="success"
data-qa-selector="invite_button"
@click="sendInvite" @click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button >{{ $options.labels.inviteButtonText }}</gl-button
> >
......
...@@ -27,14 +27,14 @@ export default { ...@@ -27,14 +27,14 @@ export default {
}, },
methods: { methods: {
openModal() { openModal() {
eventHub.$emit('openModal'); eventHub.$emit('openModal', { inviteeType: 'members' });
}, },
}, },
}; };
</script> </script>
<template> <template>
<gl-link :class="classes" @click="openModal"> <gl-link :class="classes" data-qa-selector="invite_members_button" @click="openModal">
<div v-if="icon" class="nav-icon-container"> <div v-if="icon" class="nav-icon-container">
<gl-icon :size="16" :name="icon" /> <gl-icon :size="16" :name="icon" />
</div> </div>
......
...@@ -3,7 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/u ...@@ -3,7 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/u
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { getUsers } from '~/rest_api'; import { getUsers } from '~/rest_api';
import { USER_SEARCH_DELAY } from '../constants'; import { SEARCH_DELAY } from '../constants';
export default { export default {
components: { components: {
...@@ -67,7 +67,7 @@ export default { ...@@ -67,7 +67,7 @@ export default {
.catch(() => { .catch(() => {
this.loading = false; this.loading = false;
}); });
}, USER_SEARCH_DELAY), }, SEARCH_DELAY),
handleInput() { handleInput() {
this.$emit('input', this.selectedTokens); this.$emit('input', this.selectedTokens);
}, },
......
export const USER_SEARCH_DELAY = 200; export const SEARCH_DELAY = 200;
import Vue from 'vue';
import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue';
export default function initInviteGroupTrigger() {
const el = document.querySelector('.js-invite-group-trigger');
if (!el) {
return false;
}
return new Vue({
el,
render: (createElement) =>
createElement(InviteGroupTrigger, {
props: {
...el.dataset,
},
}),
});
}
import Vue from 'vue'; import Vue from 'vue';
import { groupMemberRequestFormatter } from '~/groups/members/utils'; import { groupMemberRequestFormatter } from '~/groups/members/utils';
import groupsSelect from '~/groups_select'; import groupsSelect from '~/groups_select';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
...@@ -70,5 +71,6 @@ memberExpirationDate('.js-access-expiration-date-groups'); ...@@ -70,5 +71,6 @@ memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal(); mountRemoveMemberModal();
initInviteMembersModal(); initInviteMembersModal();
initInviteMembersTrigger(); initInviteMembersTrigger();
initInviteGroupTrigger();
new UsersSelect(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new
import Vue from 'vue'; import Vue from 'vue';
import { deprecatedCreateFlash as flash } from '~/flash'; import { deprecatedCreateFlash as flash } from '~/flash';
import groupsSelect from '~/groups_select'; import groupsSelect from '~/groups_select';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { __ } from '~/locale'; import { __ } from '~/locale';
...@@ -29,6 +30,7 @@ memberExpirationDate('.js-access-expiration-date-groups'); ...@@ -29,6 +30,7 @@ memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal(); mountRemoveMemberModal();
initInviteMembersModal(); initInviteMembersModal();
initInviteMembersTrigger(); initInviteMembersTrigger();
initInviteGroupTrigger();
new Members(); // eslint-disable-line no-new new Members(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new
......
...@@ -16,8 +16,9 @@ ...@@ -16,8 +16,9 @@
= html_escape(_('You can invite a new member to %{strong_start}%{group_name}%{strong_end}.')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } = html_escape(_('You can invite a new member to %{strong_start}%{group_name}%{strong_end}.')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- if can_invite_members_for_group?(@group) - if can_invite_members_for_group?(@group)
.gl-w-half.gl-xs-w-full .gl-w-half.gl-xs-w-full
.gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2.gl-mb-3 .gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-mb-3
.js-invite-members-trigger.gl-px-2.gl-sm-w-auto.gl-w-full.gl-mb-4{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite members') } } .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } }
.js-invite-members-trigger{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } }
= render 'groups/invite_members_modal', group: @group = render 'groups/invite_members_modal', group: @group
- if can_manage_members && !can_invite_members_for_group?(@group) - if can_manage_members && !can_invite_members_for_group?(@group)
%hr.gl-mt-4 %hr.gl-mt-4
......
...@@ -18,8 +18,9 @@ ...@@ -18,8 +18,9 @@
%p %p
= html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe } = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe }
.col-md-12.col-lg-6 .col-md-12.col-lg-6
.gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2.gl-mb-3 .gl-display-flex.gl-flex-wrap.gl-justify-content-end
.js-invite-members-trigger.gl-px-2.gl-sm-w-auto.gl-w-full.gl-mb-4{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite members') } } .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } }
.js-invite-members-trigger{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } }
= render 'projects/invite_members_modal', project: @project = render 'projects/invite_members_modal', project: @project
- else - else
......
...@@ -72,7 +72,9 @@ RSpec.describe 'Project > Members > Invite group and members', :js do ...@@ -72,7 +72,9 @@ RSpec.describe 'Project > Members > Invite group and members', :js do
it_behaves_like 'the project can be shared with groups and members' it_behaves_like 'the project can be shared with groups and members'
context 'when `vue_project_members_list` feature flag is enabled' do context 'when `vue_project_members_list` feature flag is enabled' do
it 'allows the project to be shared with another group' do it 'allows the project to be shared with another group using the invite form' do
stub_feature_flags(invite_members_group_modal: false)
visit project_project_members_path(project) visit project_project_members_path(project)
click_on 'invite-group-tab' click_on 'invite-group-tab'
...@@ -87,6 +89,26 @@ RSpec.describe 'Project > Members > Invite group and members', :js do ...@@ -87,6 +89,26 @@ RSpec.describe 'Project > Members > Invite group and members', :js do
expect(page).to have_content(group_to_share_with.name) expect(page).to have_content(group_to_share_with.name)
end end
end end
it 'allows the project to be shared with another group using the invite modal' do
stub_feature_flags(invite_members_group_modal: true)
visit project_project_members_path(project)
click_on 'Invite a group'
click_on 'Select a group'
wait_for_requests
click_button group_to_share_with.name
click_button 'Invite'
visit project_project_members_path(project)
click_link 'Groups'
page.within(members_table) do
expect(page).to have_content(group_to_share_with.name)
end
end
end end
context 'when `vue_project_members_list` feature flag is disabled' do context 'when `vue_project_members_list` feature flag is disabled' do
......
...@@ -14479,6 +14479,15 @@ msgstr "" ...@@ -14479,6 +14479,15 @@ msgstr ""
msgid "GroupSAML|should be a random persistent ID, emails are discouraged" msgid "GroupSAML|should be a random persistent ID, emails are discouraged"
msgstr "" msgstr ""
msgid "GroupSelect|No matching results"
msgstr ""
msgid "GroupSelect|Search groups"
msgstr ""
msgid "GroupSelect|Select a group"
msgstr ""
msgid "GroupSettings|Apply integration settings to all Projects" msgid "GroupSettings|Apply integration settings to all Projects"
msgstr "" msgstr ""
...@@ -16303,6 +16312,9 @@ msgstr "" ...@@ -16303,6 +16312,9 @@ msgstr ""
msgid "Invite Members" msgid "Invite Members"
msgstr "" msgstr ""
msgid "Invite a group"
msgstr ""
msgid "Invite group" msgid "Invite group"
msgstr "" msgstr ""
...@@ -16399,19 +16411,40 @@ msgstr "" ...@@ -16399,19 +16411,40 @@ msgstr ""
msgid "InviteMembersModal|Invite" msgid "InviteMembersModal|Invite"
msgstr "" msgstr ""
msgid "InviteMembersModal|Invite a group"
msgstr ""
msgid "InviteMembersModal|Invite team members" msgid "InviteMembersModal|Invite team members"
msgstr "" msgstr ""
msgid "InviteMembersModal|Members were successfully added" msgid "InviteMembersModal|Members were successfully added"
msgstr "" msgstr ""
msgid "InviteMembersModal|Search for a group to invite"
msgstr ""
msgid "InviteMembersModal|Search for members to invite" msgid "InviteMembersModal|Search for members to invite"
msgstr "" msgstr ""
msgid "InviteMembersModal|Select a group to invite"
msgstr ""
msgid "InviteMembersModal|Some of the members could not be added" msgid "InviteMembersModal|Some of the members could not be added"
msgstr "" msgstr ""
msgid "InviteMembersModal|You're inviting members to the %{name} %{type}" msgid "InviteMembersModal|You're inviting a group to the %{name} group"
msgstr ""
msgid "InviteMembersModal|You're inviting a group to the %{name} project"
msgstr ""
msgid "InviteMembersModal|You're inviting members to the %{name} group"
msgstr ""
msgid "InviteMembersModal|You're inviting members to the %{name} project"
msgstr ""
msgid "InviteMembers|Invite a group"
msgstr "" msgstr ""
msgid "InviteMembers|Invite team members" msgid "InviteMembers|Invite team members"
......
...@@ -489,6 +489,7 @@ module QA ...@@ -489,6 +489,7 @@ module QA
autoload :ProjectSelector, 'qa/page/component/project_selector' autoload :ProjectSelector, 'qa/page/component/project_selector'
autoload :Snippet, 'qa/page/component/snippet' autoload :Snippet, 'qa/page/component/snippet'
autoload :NewSnippet, 'qa/page/component/new_snippet' autoload :NewSnippet, 'qa/page/component/new_snippet'
autoload :InviteMembersModal, 'qa/page/component/invite_members_modal'
module Issuable module Issuable
autoload :Common, 'qa/page/component/issuable/common' autoload :Common, 'qa/page/component/issuable/common'
......
...@@ -5,6 +5,16 @@ module QA ...@@ -5,6 +5,16 @@ module QA
module Project module Project
module_function module_function
def add_member(project:, username:)
project.visit!
Page::Project::Menu.perform(&:click_members)
Page::Project::Members.perform do |member_settings|
member_settings.add_member(username)
end
end
def go_to_create_project_from_template def go_to_create_project_from_template
if Page::Project::NewExperiment.perform(&:shown?) if Page::Project::NewExperiment.perform(&:shown?)
Page::Project::NewExperiment.perform(&:click_create_from_template_link) Page::Project::NewExperiment.perform(&:click_create_from_template_link)
......
# frozen_string_literal: true
module QA
module Page
module Component
module InviteMembersModal
extend QA::Page::PageConcern
def self.included(base)
super
base.view 'app/assets/javascripts/invite_members/components/invite_members_modal.vue' do
element :invite_button
element :access_level_dropdown
element :invite_members_modal_content
end
base.view 'app/assets/javascripts/invite_members/components/group_select.vue' do
element :group_select_dropdown_search_field
end
base.view 'app/assets/javascripts/invite_members/components/invite_group_trigger.vue' do
element :invite_a_group_button
end
base.view 'app/assets/javascripts/invite_members/components/invite_members_trigger.vue' do
element :invite_members_button
end
end
def open_invite_members_modal
click_element :invite_members_button
end
def open_invite_group_modal
click_element :invite_a_group_button
end
def add_member(username, access_level = Resource::Members::AccessLevel::DEVELOPER)
open_invite_members_modal
within_element(:invite_members_modal_content) do
fill_element :access_level_dropdown, with: access_level
fill_in 'Search for members to invite', with: username
Support::WaitForRequests.wait_for_requests
click_button username
click_element :invite_button
end
Support::WaitForRequests.wait_for_requests
page.refresh
end
def invite_group(group_name, group_access = Resource::Members::AccessLevel::GUEST)
open_invite_group_modal
fill_element :access_level_dropdown, with: group_access
click_button 'Select a group'
fill_element :group_select_dropdown_search_field, group_name
Support::WaitForRequests.wait_for_requests
click_button group_name
click_element :invite_button
Support::WaitForRequests.wait_for_requests
page.refresh
end
end
end
end
end
...@@ -4,18 +4,13 @@ module QA ...@@ -4,18 +4,13 @@ module QA
module Page module Page
module Group module Group
class Members < Page::Base class Members < Page::Base
include QA::Page::Component::Select2 include Page::Component::InviteMembersModal
include Page::Component::UsersSelect include Page::Component::UsersSelect
view 'app/assets/javascripts/vue_shared/components/remove_member_modal.vue' do view 'app/assets/javascripts/vue_shared/components/remove_member_modal.vue' do
element :remove_member_modal_content element :remove_member_modal_content
end end
view 'app/views/shared/members/_invite_member.html.haml' do
element :member_select_field
element :invite_member_button
end
view 'app/assets/javascripts/pages/groups/group_members/index.js' do view 'app/assets/javascripts/pages/groups/group_members/index.js' do
element :member_row element :member_row
element :groups_list element :groups_list
...@@ -32,31 +27,9 @@ module QA ...@@ -32,31 +27,9 @@ module QA
end end
view 'app/views/groups/group_members/index.html.haml' do view 'app/views/groups/group_members/index.html.haml' do
element :invite_group_tab
element :groups_list_tab element :groups_list_tab
end end
view 'app/views/shared/members/_invite_group.html.haml' do
element :group_select_field
element :invite_group_button
end
def select_group(group_name)
click_element :group_select_field
search_and_select(group_name)
end
def invite_group(group_name)
click_element :invite_group_tab
select_group(group_name)
click_element :invite_group_button
end
def add_member(username)
select_user :member_select_field, username
click_element :invite_member_button
end
def update_access_level(username, access_level) def update_access_level(username, access_level)
within_element(:member_row, text: username) do within_element(:member_row, text: username) do
click_element :access_level_dropdown click_element :access_level_dropdown
......
...@@ -4,21 +4,18 @@ module QA ...@@ -4,21 +4,18 @@ module QA
module Page module Page
module Project module Project
class Members < Page::Base class Members < Page::Base
include QA::Page::Component::Select2 include QA::Page::Component::InviteMembersModal
view 'app/views/shared/members/_invite_member.html.haml' do
element :member_select_field
element :invite_member_button
end
view 'app/views/projects/project_members/index.html.haml' do view 'app/views/projects/project_members/index.html.haml' do
element :invite_group_tab
element :groups_list_tab element :groups_list_tab
end end
view 'app/views/shared/members/_invite_group.html.haml' do view 'app/assets/javascripts/invite_members/components/invite_group_trigger.vue' do
element :group_select_field element :invite_a_group_button
element :invite_group_button end
view 'app/assets/javascripts/invite_members/components/invite_members_trigger.vue' do
element :invite_members_button
end end
view 'app/assets/javascripts/pages/projects/project_members/index.js' do view 'app/assets/javascripts/pages/projects/project_members/index.js' do
...@@ -33,25 +30,7 @@ module QA ...@@ -33,25 +30,7 @@ module QA
element :remove_group_link_modal_content element :remove_group_link_modal_content
end end
def select_group(group_name)
click_element :group_select_field
search_and_select(group_name)
end
def invite_group(group_name)
click_element :invite_group_tab
select_group(group_name)
click_element :invite_group_button
end
def add_member(username)
click_element :member_select_field
search_and_select username
click_element :invite_member_button
end
def remove_group(group_name) def remove_group(group_name)
click_element :invite_group_tab
click_element :groups_list_tab click_element :groups_list_tab
within_element(:group_row, text: group_name) do within_element(:group_row, text: group_name) do
......
...@@ -26,10 +26,23 @@ module QA ...@@ -26,10 +26,23 @@ module QA
JSON.parse(get(Runtime::API::Request.new(api_client, api_members_path).url).body) JSON.parse(get(Runtime::API::Request.new(api_client, api_members_path).url).body)
end end
def invite_group(group, access_level = AccessLevel::GUEST)
Support::Retrier.retry_until do
QA::Runtime::Logger.debug(%Q[Sharing #{self.class.name} with #{group.name}])
response = post Runtime::API::Request.new(api_client, api_share_path).url, { group_id: group.id, group_access: access_level }
response.code == QA::Support::Api::HTTP_STATUS_CREATED
end
end
def api_members_path def api_members_path
"#{api_get_path}/members" "#{api_get_path}/members"
end end
def api_share_path
"#{api_get_path}/share"
end
class AccessLevel class AccessLevel
NO_ACCESS = 0 NO_ACCESS = 0
MINIMAL_ACCESS = 5 MINIMAL_ACCESS = 5
......
...@@ -22,7 +22,7 @@ module QA ...@@ -22,7 +22,7 @@ module QA
token_page.fill_token_name(name || 'api-test-token') token_page.fill_token_name(name || 'api-test-token')
token_page.check_api token_page.check_api
# Expire in 2 days just in case the token is created just before midnight # Expire in 2 days just in case the token is created just before midnight
token_page.fill_expiry_date(Date.today + 2) token_page.fill_expiry_date(Time.now.utc.to_date + 2)
token_page.click_create_token_button token_page.click_create_token_button
end end
end end
......
...@@ -272,10 +272,6 @@ module QA ...@@ -272,10 +272,6 @@ module QA
parse_body(get(Runtime::API::Request.new(api_client, api_pipelines_path).url)) parse_body(get(Runtime::API::Request.new(api_client, api_pipelines_path).url))
end end
def share_with_group(invitee, access_level = Resource::Members::AccessLevel::DEVELOPER)
post Runtime::API::Request.new(api_client, "/projects/#{id}/share").url, { group_id: invitee.id, group_access: access_level }
end
private private
def transform_api_resource(api_resource) def transform_api_resource(api_resource)
......
...@@ -29,6 +29,7 @@ module QA ...@@ -29,6 +29,7 @@ module QA
end end
before do before do
Runtime::Feature.enable(:invite_members_group_modal, group: group)
group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER) group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER)
end end
......
...@@ -31,6 +31,7 @@ module QA ...@@ -31,6 +31,7 @@ module QA
let(:two_fa_expected_text) { /The group settings for.*require you to enable Two-Factor Authentication for your account.*You need to do this before/ } let(:two_fa_expected_text) { /The group settings for.*require you to enable Two-Factor Authentication for your account.*You need to do this before/ }
before do before do
Runtime::Feature.enable(:invite_members_group_modal, group: group)
group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER) group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER)
end end
......
...@@ -6,7 +6,6 @@ module QA ...@@ -6,7 +6,6 @@ module QA
before do before do
Runtime::Feature.enable('vue_project_members_list') Runtime::Feature.enable('vue_project_members_list')
end end
after do after do
Runtime::Feature.disable('vue_project_members_list') Runtime::Feature.disable('vue_project_members_list')
end end
...@@ -16,9 +15,13 @@ module QA ...@@ -16,9 +15,13 @@ module QA
user = Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) user = Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1)
Resource::Project.fabricate_via_api! do |project| project = Resource::Project.fabricate_via_api! do |project|
project.name = 'add-member-project' project.name = 'add-member-project'
end.visit! end
Runtime::Feature.enable(:invite_members_group_modal)
project.visit!
Page::Project::Menu.perform(&:click_members) Page::Project::Menu.perform(&:click_members)
Page::Project::Members.perform do |members| Page::Project::Members.perform do |members|
......
...@@ -21,6 +21,7 @@ module QA ...@@ -21,6 +21,7 @@ module QA
end end
before do before do
Runtime::Feature.enable(:invite_members_group_modal, group: group)
group.add_member(user, Resource::Members::AccessLevel::MAINTAINER) group.add_member(user, Resource::Members::AccessLevel::MAINTAINER)
end end
......
...@@ -10,6 +10,10 @@ module QA ...@@ -10,6 +10,10 @@ module QA
end end
end end
before do
Runtime::Feature.enable(:invite_members_group_modal, project: project)
end
let(:developer_user) { Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) } let(:developer_user) { Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) }
let(:maintainer_user) { Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_2, Runtime::Env.gitlab_qa_password_2) } let(:maintainer_user) { Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_2, Runtime::Env.gitlab_qa_password_2) }
let(:tag_name) { 'v0.0.1' } let(:tag_name) { 'v0.0.1' }
......
...@@ -17,6 +17,7 @@ module QA ...@@ -17,6 +17,7 @@ module QA
before do before do
Runtime::Feature.enable('vue_project_members_list', project: project) Runtime::Feature.enable('vue_project_members_list', project: project)
Runtime::Feature.enable(:invite_members_group_modal)
Flow::Login.sign_in Flow::Login.sign_in
end end
......
...@@ -18,6 +18,8 @@ module QA ...@@ -18,6 +18,8 @@ module QA
describe 'check xss occurence in @mentions in issues', :requires_admin do describe 'check xss occurence in @mentions in issues', :requires_admin do
before do before do
Runtime::Feature.enable(:invite_members_group_modal)
Flow::Login.sign_in Flow::Login.sign_in
project.add_member(user) project.add_member(user)
......
...@@ -13,6 +13,7 @@ module QA ...@@ -13,6 +13,7 @@ module QA
before do before do
Flow::Login.sign_in Flow::Login.sign_in
Runtime::Feature.enable(:invite_members_group_modal, project: project)
project.add_member(user) project.add_member(user)
......
...@@ -14,6 +14,7 @@ module QA ...@@ -14,6 +14,7 @@ module QA
before do before do
Runtime::Feature.enable('real_time_issue_sidebar', project: project) Runtime::Feature.enable('real_time_issue_sidebar', project: project)
Runtime::Feature.enable('broadcast_issue_updates', project: project) Runtime::Feature.enable('broadcast_issue_updates', project: project)
Runtime::Feature.enable(:invite_members_group_modal, project: project)
Flow::Login.sign_in Flow::Login.sign_in
......
...@@ -19,6 +19,7 @@ module QA ...@@ -19,6 +19,7 @@ module QA
end end
before do before do
Runtime::Feature.enable(:invite_members_group_modal, project: parent_project)
parent_project.add_member(user) parent_project.add_member(user)
end end
......
...@@ -24,23 +24,24 @@ module QA ...@@ -24,23 +24,24 @@ module QA
end end
end end
describe 'Group' do describe 'Group', :requires_admin do
let(:group) do let(:group) do
Resource::Group.fabricate_via_api! do |resource| Resource::Group.fabricate_via_api! do |resource|
resource.path = "test-group-#{SecureRandom.hex(8)}" resource.path = "test-group-#{SecureRandom.hex(8)}"
end end
end end
before do
@event_count = get_audit_event_count(group)
end
let(:project) do let(:project) do
Resource::Project.fabricate_via_api! do |resource| Resource::Project.fabricate_via_api! do |resource|
resource.name = 'project-shared-with-group' resource.name = 'project-shared-with-group'
end end
end end
before do
@event_count = get_audit_event_count(group)
Runtime::Feature.enable(:invite_members_group_modal)
end
let(:user) { Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) } let(:user) { Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) }
context 'Add group', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/733' do context 'Add group', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/733' do
...@@ -103,7 +104,7 @@ module QA ...@@ -103,7 +104,7 @@ module QA
context 'Add and remove project access', :requires_admin, testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/735' do context 'Add and remove project access', :requires_admin, testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/735' do
before do before do
Runtime::Feature.enable('vue_project_members_list', project: project) Runtime::Feature.enable('vue_project_members_list', project: project)
Runtime::Feature.enable(:invite_members_group_modal)
sign_in sign_in
project.visit! project.visit!
......
...@@ -12,14 +12,21 @@ module QA ...@@ -12,14 +12,21 @@ module QA
end end
before(:all) do before(:all) do
@original_personal_access_token = Runtime::Env.personal_access_token
# Todo: Remove the 5 lines below when invite_members_group_modal feature flag is enabled by default or removed
# We need to nil out any existing personal token generated for the non-admin LDAP user and also set Runtime::Env.ldap_username=nil so that a new admin token is created for use to enable the feature flag.
Runtime::Env.personal_access_token = nil
ldap_username = Runtime::Env.ldap_username
Runtime::Env.ldap_username = nil
Runtime::Feature.enable(:invite_members_group_modal)
Runtime::Env.ldap_username = ldap_username
# Create the sandbox group as the LDAP user. Without this the admin user # Create the sandbox group as the LDAP user. Without this the admin user
# would own the sandbox group and then in subsequent tests the LDAP user # would own the sandbox group and then in subsequent tests the LDAP user
# would not have enough permission to push etc. # would not have enough permission to push etc.
Resource::Sandbox.fabricate_via_api! Resource::Sandbox.fabricate_via_api!
# Create an admin personal access token and use it for the remaining API calls
@original_personal_access_token = Runtime::Env.personal_access_token
Page::Main::Menu.perform do |menu| Page::Main::Menu.perform do |menu|
menu.sign_out if menu.has_personal_area? menu.sign_out if menu.has_personal_area?
end end
......
...@@ -13,6 +13,8 @@ module QA ...@@ -13,6 +13,8 @@ module QA
sandbox_group.path = "saml_sso_group_#{SecureRandom.hex(8)}" sandbox_group.path = "saml_sso_group_#{SecureRandom.hex(8)}"
end end
Runtime::Feature.enable(:invite_members_group_modal, group: @group)
@developer_user = Resource::User.fabricate_via_api! @developer_user = Resource::User.fabricate_via_api!
@group.add_member(@developer_user) @group.add_member(@developer_user)
......
...@@ -15,6 +15,8 @@ module QA ...@@ -15,6 +15,8 @@ module QA
sandbox_group.path = "saml_sso_group_#{SecureRandom.hex(8)}" sandbox_group.path = "saml_sso_group_#{SecureRandom.hex(8)}"
end end
Runtime::Feature.enable(:invite_members_group_modal, group: @group)
@saml_idp_service = Flow::Saml.run_saml_idp_service(@group.path) @saml_idp_service = Flow::Saml.run_saml_idp_service(@group.path)
@api_client = Runtime::API::Client.new(:gitlab, personal_access_token: Runtime::Env.admin_personal_access_token) @api_client = Runtime::API::Client.new(:gitlab, personal_access_token: Runtime::Env.admin_personal_access_token)
......
...@@ -24,6 +24,8 @@ module QA ...@@ -24,6 +24,8 @@ module QA
project.initialize_with_readme = true project.initialize_with_readme = true
end end
Runtime::Feature.enable(:invite_members_group_modal, project: @project)
@project.add_member(@user) @project.add_member(@user)
@api_client = Runtime::API::Client.new(:gitlab, user: @user) @api_client = Runtime::API::Client.new(:gitlab, user: @user)
......
...@@ -31,6 +31,9 @@ module QA ...@@ -31,6 +31,9 @@ module QA
end end
before do before do
Runtime::Feature.enable(:invite_members_group_modal, group: source_group_with_members)
Runtime::Feature.enable(:invite_members_group_modal, group: target_group_with_project)
source_group_with_members.add_member(maintainer_user, Resource::Members::AccessLevel::MAINTAINER) source_group_with_members.add_member(maintainer_user, Resource::Members::AccessLevel::MAINTAINER)
end end
......
...@@ -11,7 +11,7 @@ module QA ...@@ -11,7 +11,7 @@ module QA
end end
end end
describe 'Project' do describe 'Project', :requires_admin do
let(:project) do let(:project) do
Resource::Project.fabricate_via_api! do |project| Resource::Project.fabricate_via_api! do |project|
project.name = 'awesome-project' project.name = 'awesome-project'
...@@ -38,6 +38,7 @@ module QA ...@@ -38,6 +38,7 @@ module QA
context "Add user access as guest", testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/729' do context "Add user access as guest", testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/729' do
before do before do
Runtime::Feature.enable(:invite_members_group_modal)
project.visit! project.visit!
Page::Project::Menu.perform(&:click_members) Page::Project::Menu.perform(&:click_members)
......
...@@ -12,6 +12,8 @@ module QA ...@@ -12,6 +12,8 @@ module QA
end end
before do before do
Runtime::Feature.enable(:invite_members_group_modal, project: label_board_list.project)
Flow::Login.sign_in Flow::Login.sign_in
label_board_list.project.add_member(qa_user, Resource::Members::AccessLevel::GUEST) label_board_list.project.add_member(qa_user, Resource::Members::AccessLevel::GUEST)
......
...@@ -15,6 +15,8 @@ module QA ...@@ -15,6 +15,8 @@ module QA
project.name = 'project-to-test-issue-with-multiple-assignees' project.name = 'project-to-test-issue-with-multiple-assignees'
end end
Runtime::Feature.enable(:invite_members_group_modal, project: project)
project.add_member(user_1) project.add_member(user_1)
project.add_member(user_2) project.add_member(user_2)
project.add_member(user_3) project.add_member(user_3)
......
...@@ -10,6 +10,8 @@ module QA ...@@ -10,6 +10,8 @@ module QA
end end
before do before do
Runtime::Feature.enable(:invite_members_group_modal, project: project)
Flow::Login.sign_in Flow::Login.sign_in
user_1 = Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) user_1 = Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1)
......
...@@ -15,6 +15,8 @@ module QA ...@@ -15,6 +15,8 @@ module QA
end end
before do before do
Runtime::Feature.enable(:invite_members_group_modal, project: project)
Runtime::Feature.enable(:invite_members_group_modal, group: project.group)
project.add_member(approver1) project.add_member(approver1)
project.group.add_member(approver2) project.group.add_member(approver2)
......
...@@ -17,6 +17,7 @@ module QA ...@@ -17,6 +17,7 @@ module QA
let(:branch_name) { 'protected-branch' } let(:branch_name) { 'protected-branch' }
before do before do
Runtime::Feature.enable(:invite_members_group_modal, project: project)
project.add_member(approver, Resource::Members::AccessLevel::DEVELOPER) project.add_member(approver, Resource::Members::AccessLevel::DEVELOPER)
project.add_member(non_approver, Resource::Members::AccessLevel::DEVELOPER) project.add_member(non_approver, Resource::Members::AccessLevel::DEVELOPER)
......
# frozen_string_literal: true # frozen_string_literal: true
module QA module QA
RSpec.describe 'Create' do RSpec.describe 'Create', :requires_admin do
describe 'Codeowners' do describe 'Codeowners' do
let(:files) do let(:files) do
[ [
...@@ -26,6 +26,7 @@ module QA ...@@ -26,6 +26,7 @@ module QA
@project = Resource::Project.fabricate_via_api! do |project| @project = Resource::Project.fabricate_via_api! do |project|
project.name = "codeowners" project.name = "codeowners"
end end
Runtime::Feature.enable(:invite_members_group_modal)
@project.visit! @project.visit!
Page::Project::Menu.perform(&:click_members) Page::Project::Menu.perform(&:click_members)
......
...@@ -20,6 +20,9 @@ module QA ...@@ -20,6 +20,9 @@ module QA
end end
before do before do
Runtime::Feature.enable(:invite_members_group_modal, project: project)
Runtime::Feature.enable(:invite_members_group_modal, group: root_group)
group_or_project.add_member(approver, Resource::Members::AccessLevel::MAINTAINER) group_or_project.add_member(approver, Resource::Members::AccessLevel::MAINTAINER)
Flow::Login.sign_in Flow::Login.sign_in
......
...@@ -18,6 +18,8 @@ module QA ...@@ -18,6 +18,8 @@ module QA
end end
before do before do
Runtime::Feature.enable(:invite_members_group_modal)
group_or_project.add_member(approver, Resource::Members::AccessLevel::MAINTAINER) group_or_project.add_member(approver, Resource::Members::AccessLevel::MAINTAINER)
Flow::Login.sign_in Flow::Login.sign_in
......
...@@ -213,6 +213,8 @@ module QA ...@@ -213,6 +213,8 @@ module QA
project.name = 'push_rules' project.name = 'push_rules'
end end
Runtime::Feature.enable(:invite_members_group_modal, project: @project)
Resource::Repository::ProjectPush.fabricate! do |push| Resource::Repository::ProjectPush.fabricate! do |push|
push.project = @project push.project = @project
push.files = standard_file push.files = standard_file
......
...@@ -80,7 +80,7 @@ module QA ...@@ -80,7 +80,7 @@ module QA
login login
group.add_member(user_developer, Resource::Members::AccessLevel::DEVELOPER) group.add_member(user_developer, Resource::Members::AccessLevel::DEVELOPER)
project.share_with_group(group, Resource::Members::AccessLevel::DEVELOPER) project.invite_group(group, Resource::Members::AccessLevel::DEVELOPER)
project.add_member(user_maintainer, Resource::Members::AccessLevel::MAINTAINER) project.add_member(user_maintainer, Resource::Members::AccessLevel::MAINTAINER)
......
...@@ -69,6 +69,7 @@ module QA ...@@ -69,6 +69,7 @@ module QA
end end
before do before do
Runtime::Feature.enable(:invite_members_group_modal, project: project)
Flow::Login.sign_in Flow::Login.sign_in
project.visit! project.visit!
Flow::MergeRequest.enable_merge_trains Flow::MergeRequest.enable_merge_trains
......
...@@ -15,7 +15,7 @@ RSpec.describe 'Groups > Members > Manage members' do ...@@ -15,7 +15,7 @@ RSpec.describe 'Groups > Members > Manage members' do
sign_in(user1) sign_in(user1)
end end
shared_examples 'includes the correct Invite Members link' do |should_include, should_not_include| shared_examples 'includes the correct Invite link' do |should_include, should_not_include|
it 'includes either the form or the modal trigger' do it 'includes either the form or the modal trigger' do
group.add_owner(user1) group.add_owner(user1)
...@@ -31,15 +31,13 @@ RSpec.describe 'Groups > Members > Manage members' do ...@@ -31,15 +31,13 @@ RSpec.describe 'Groups > Members > Manage members' do
stub_feature_flags(invite_members_group_modal: true) stub_feature_flags(invite_members_group_modal: true)
end end
it_behaves_like 'includes the correct Invite Members link', '.js-invite-members-trigger', '.invite-users-form' it_behaves_like 'includes the correct Invite link', '.js-invite-members-trigger', '.invite-users-form'
it_behaves_like 'includes the correct Invite link', '.js-invite-group-trigger', '.invite-group-form'
end end
context 'when Invite Members modal is disabled' do context 'when Invite Members modal is disabled' do
before do it_behaves_like 'includes the correct Invite link', '.invite-users-form', '.js-invite-members-trigger'
stub_feature_flags(invite_members_group_modal: false) it_behaves_like 'includes the correct Invite link', '.invite-group-form', '.js-invite-group-trigger'
end
it_behaves_like 'includes the correct Invite Members link', '.invite-users-form', '.js-invite-members-trigger'
end end
it 'update user to owner level', :js do it 'update user to owner level', :js do
......
...@@ -482,6 +482,30 @@ describe('Api', () => { ...@@ -482,6 +482,30 @@ describe('Api', () => {
}); });
}); });
describe('projectShareWithGroup', () => {
it('invites a group to share access with the authenticated project', () => {
const projectId = 1;
const sharedGroupId = 99;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/share`;
const options = {
group_id: sharedGroupId,
group_access: 10,
expires_at: undefined,
};
jest.spyOn(axios, 'post');
mock.onPost(expectedUrl).reply(200, {
status: 'success',
});
return Api.projectShareWithGroup(projectId, options).then(({ data }) => {
expect(data.status).toBe('success');
expect(axios.post).toHaveBeenCalledWith(expectedUrl, options);
});
});
});
describe('projectMilestones', () => { describe('projectMilestones', () => {
it('fetches project milestones', (done) => { it('fetches project milestones', (done) => {
const projectId = 1; const projectId = 1;
...@@ -638,6 +662,30 @@ describe('Api', () => { ...@@ -638,6 +662,30 @@ describe('Api', () => {
}); });
}); });
describe('groupShareWithGroup', () => {
it('invites a group to share access with the authenticated group', () => {
const groupId = 1;
const sharedGroupId = 99;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/share`;
const options = {
group_id: sharedGroupId,
group_access: 10,
expires_at: undefined,
};
jest.spyOn(axios, 'post');
mock.onPost(expectedUrl).reply(200, {
status: 'success',
});
return Api.groupShareWithGroup(groupId, options).then(({ data }) => {
expect(data.status).toBe('success');
expect(axios.post).toHaveBeenCalledWith(expectedUrl, options);
});
});
});
describe('commit', () => { describe('commit', () => {
const projectId = 'user/project'; const projectId = 'user/project';
const sha = 'abcd0123'; const sha = 'abcd0123';
......
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import GroupSelect from '~/invite_members/components/group_select.vue';
const createComponent = () => {
return mount(GroupSelect, {});
};
const group1 = { id: 1, full_name: 'Group One' };
const group2 = { id: 2, full_name: 'Group Two' };
const allGroups = [group1, group2];
describe('GroupSelect', () => {
let wrapper;
beforeEach(() => {
jest.spyOn(Api, 'groups').mockResolvedValue(allGroups);
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownToggle = () => findDropdown().find('button[aria-haspopup="true"]');
const findDropdownItemByText = (text) =>
wrapper
.findAllComponents(GlDropdownItem)
.wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.text() === text);
it('renders GlSearchBoxByType with default attributes', () => {
expect(findSearchBoxByType().exists()).toBe(true);
expect(findSearchBoxByType().vm.$attrs).toMatchObject({
placeholder: 'Search groups',
});
});
describe('when user types in the search input', () => {
let resolveApiRequest;
beforeEach(() => {
jest.spyOn(Api, 'groups').mockImplementation(
() =>
new Promise((resolve) => {
resolveApiRequest = resolve;
}),
);
findSearchBoxByType().vm.$emit('input', group1.name);
});
it('calls the API', () => {
resolveApiRequest({ data: allGroups });
expect(Api.groups).toHaveBeenCalledWith(group1.name, {
active: true,
exclude_internal: true,
});
});
it('displays loading icon while waiting for API call to resolve', async () => {
expect(findSearchBoxByType().props('isLoading')).toBe(true);
resolveApiRequest({ data: allGroups });
await waitForPromises();
expect(findSearchBoxByType().props('isLoading')).toBe(false);
});
});
describe('when group is selected from the dropdown', () => {
beforeEach(() => {
findDropdownItemByText(group1.full_name).vm.$emit('click');
});
it('emits `input` event used by `v-model`', () => {
expect(wrapper.emitted('input')[0][0].id).toEqual(group1.id);
});
it('sets dropdown toggle text to selected item', () => {
expect(findDropdownToggle().text()).toBe(group1.full_name);
});
});
});
import { GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue';
import eventHub from '~/invite_members/event_hub';
const displayText = 'Invite a group';
const createComponent = (props = {}) => {
return mount(InviteGroupTrigger, {
propsData: {
displayText,
...props,
},
});
};
describe('InviteGroupTrigger', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findButton = () => wrapper.findComponent(GlButton);
describe('displayText', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('includes the correct displayText for the link', () => {
expect(findButton().text()).toBe(displayText);
});
});
describe('when button is clicked', () => {
beforeEach(() => {
eventHub.$emit = jest.fn();
wrapper = createComponent();
findButton().trigger('click');
});
it('emits event that triggers opening the modal', () => {
expect(eventHub.$emit).toHaveBeenLastCalledWith('openModal', { inviteeType: 'group' });
});
});
});
...@@ -6,8 +6,9 @@ import Api from '~/api'; ...@@ -6,8 +6,9 @@ import Api from '~/api';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
const id = '1'; const id = '1';
const name = 'testgroup'; const name = 'test name';
const isProject = false; const isProject = false;
const inviteeType = 'members';
const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 };
const defaultAccessLevel = '10'; const defaultAccessLevel = '10';
const helpLink = 'https://example.com'; const helpLink = 'https://example.com';
...@@ -20,16 +21,19 @@ const user3 = { ...@@ -20,16 +21,19 @@ const user3 = {
username: 'one_2', username: 'one_2',
avatar_url: '', avatar_url: '',
}; };
const sharedGroup = { id: '981' };
const createComponent = (data = {}) => { const createComponent = (data = {}, props = {}) => {
return shallowMount(InviteMembersModal, { return shallowMount(InviteMembersModal, {
propsData: { propsData: {
id, id,
name, name,
isProject, isProject,
inviteeType,
accessLevels, accessLevels,
defaultAccessLevel, defaultAccessLevel,
helpLink, helpLink,
...props,
}, },
data() { data() {
return data; return data;
...@@ -46,6 +50,22 @@ const createComponent = (data = {}) => { ...@@ -46,6 +50,22 @@ const createComponent = (data = {}) => {
}); });
}; };
const createInviteMembersToProjectWrapper = () => {
return createComponent({ inviteeType: 'members' }, { isProject: true });
};
const createInviteMembersToGroupWrapper = () => {
return createComponent({ inviteeType: 'members' }, { isProject: false });
};
const createInviteGroupToProjectWrapper = () => {
return createComponent({ inviteeType: 'group' }, { isProject: true });
};
const createInviteGroupToGroupWrapper = () => {
return createComponent({ inviteeType: 'group' }, { isProject: false });
};
describe('InviteMembersModal', () => { describe('InviteMembersModal', () => {
let wrapper; let wrapper;
...@@ -54,12 +74,13 @@ describe('InviteMembersModal', () => { ...@@ -54,12 +74,13 @@ describe('InviteMembersModal', () => {
wrapper = null; wrapper = null;
}); });
const findDropdown = () => wrapper.find(GlDropdown); const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => findDropdown().findAll(GlDropdownItem); const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
const findDatepicker = () => wrapper.find(GlDatepicker); const findDatepicker = () => wrapper.findComponent(GlDatepicker);
const findLink = () => wrapper.find(GlLink); const findLink = () => wrapper.findComponent(GlLink);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); const findIntroText = () => wrapper.find({ ref: 'introText' }).text();
const findInviteButton = () => wrapper.find({ ref: 'inviteButton' }); const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' });
const findInviteButton = () => wrapper.findComponent({ ref: 'inviteButton' });
const clickInviteButton = () => findInviteButton().vm.$emit('click'); const clickInviteButton = () => findInviteButton().vm.$emit('click');
describe('rendering the modal', () => { describe('rendering the modal', () => {
...@@ -68,7 +89,7 @@ describe('InviteMembersModal', () => { ...@@ -68,7 +89,7 @@ describe('InviteMembersModal', () => {
}); });
it('renders the modal with the correct title', () => { it('renders the modal with the correct title', () => {
expect(wrapper.find(GlModal).props('title')).toBe('Invite team members'); expect(wrapper.findComponent(GlModal).props('title')).toBe('Invite team members');
}); });
it('renders the Cancel button text correctly', () => { it('renders the Cancel button text correctly', () => {
...@@ -102,6 +123,44 @@ describe('InviteMembersModal', () => { ...@@ -102,6 +123,44 @@ describe('InviteMembersModal', () => {
}); });
}); });
describe('displaying the correct introText', () => {
describe('when inviting to a project', () => {
describe('when inviting members', () => {
it('includes the correct invitee, type, and formatted name', () => {
wrapper = createInviteMembersToProjectWrapper();
expect(findIntroText()).toBe("You're inviting members to the TEST NAME project");
});
});
describe('when sharing with a group', () => {
it('includes the correct invitee, type, and formatted name', () => {
wrapper = createInviteGroupToProjectWrapper();
expect(findIntroText()).toBe("You're inviting a group to the TEST NAME project");
});
});
});
describe('when inviting to a group', () => {
describe('when inviting members', () => {
it('includes the correct invitee, type, and formatted name', () => {
wrapper = createInviteMembersToGroupWrapper();
expect(wrapper.html()).toContain("You're inviting members to the TEST NAME group");
});
});
describe('when sharing with a group', () => {
it('includes the correct invitee, type, and formatted name', () => {
wrapper = createInviteGroupToGroupWrapper();
expect(wrapper.html()).toContain("You're inviting a group to the TEST NAME group");
});
});
});
});
describe('submitting the invite form', () => { describe('submitting the invite form', () => {
const apiErrorMessage = 'Member already exists'; const apiErrorMessage = 'Member already exists';
...@@ -115,8 +174,9 @@ describe('InviteMembersModal', () => { ...@@ -115,8 +174,9 @@ describe('InviteMembersModal', () => {
describe('when invites are sent successfully', () => { describe('when invites are sent successfully', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: [user1] }); wrapper = createInviteMembersToGroupWrapper();
wrapper.setData({ newUsersToInvite: [user1] });
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
...@@ -283,5 +343,58 @@ describe('InviteMembersModal', () => { ...@@ -283,5 +343,58 @@ describe('InviteMembersModal', () => {
}); });
}); });
}); });
describe('when inviting a group to share', () => {
describe('when sharing the group is successful', () => {
const groupPostData = {
group_id: sharedGroup.id,
group_access: '10',
expires_at: undefined,
format: 'json',
};
beforeEach(() => {
wrapper = createComponent({ groupToBeSharedWith: sharedGroup });
wrapper.setData({ inviteeType: 'group' });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
clickInviteButton();
});
it('calls Api groupShareWithGroup with the correct params', () => {
expect(Api.groupShareWithGroup).toHaveBeenCalledWith(id, groupPostData);
});
it('displays the successful toastMessage', () => {
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
});
});
describe('when sharing the group fails', () => {
beforeEach(() => {
wrapper = createComponent({ groupToBeSharedWith: sharedGroup });
wrapper.setData({ inviteeType: 'group' });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'groupShareWithGroup')
.mockRejectedValue({ response: { data: { success: false } } });
jest.spyOn(wrapper.vm, 'showToastMessageError');
clickInviteButton();
});
it('displays the generic error toastMessage', async () => {
await waitForPromises();
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
});
});
});
}); });
}); });
...@@ -23,7 +23,7 @@ describe('InviteMembersTrigger', () => { ...@@ -23,7 +23,7 @@ describe('InviteMembersTrigger', () => {
}); });
describe('displayText', () => { describe('displayText', () => {
const findLink = () => wrapper.find(GlLink); const findLink = () => wrapper.findComponent(GlLink);
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
...@@ -35,7 +35,7 @@ describe('InviteMembersTrigger', () => { ...@@ -35,7 +35,7 @@ describe('InviteMembersTrigger', () => {
}); });
describe('icon', () => { describe('icon', () => {
const findIcon = () => wrapper.find(GlIcon); const findIcon = () => wrapper.findComponent(GlIcon);
it('includes the correct icon when an icon is sent', () => { it('includes the correct icon when an icon is sent', () => {
wrapper = createComponent({ icon }); wrapper = createComponent({ icon });
......
...@@ -37,7 +37,7 @@ describe('MembersTokenSelect', () => { ...@@ -37,7 +37,7 @@ describe('MembersTokenSelect', () => {
wrapper = null; wrapper = null;
}); });
const findTokenSelector = () => wrapper.find(GlTokenSelector); const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
describe('rendering the token-selector component', () => { describe('rendering the token-selector component', () => {
it('renders with the correct props', () => { it('renders with the correct props', () => {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment