Commit 76180cda authored by Jackie Fraser's avatar Jackie Fraser Committed by Phil Hughes

Add Invite Members modal for Group

- behind feature flag :invite_members_group_modal
- no User dropdown search yet, that will be added next
- the trigger link in the side nav is behind the same feature flag
parent 5d89d75a
...@@ -112,6 +112,12 @@ const Api = { ...@@ -112,6 +112,12 @@ const Api = {
}); });
}, },
inviteGroupMember(id, data) {
const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
return axios.post(url, data);
},
groupMilestones(id, options) { groupMilestones(id, options) {
const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id)); const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id));
......
<script>
import {
GlModal,
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlLink,
GlSprintf,
GlSearchBoxByType,
GlButton,
GlFormInput,
} from '@gitlab/ui';
import eventHub from '../event_hub';
import { s__, sprintf } from '~/locale';
import Api from '~/api';
export default {
name: 'InviteMembersModal',
components: {
GlDatepicker,
GlLink,
GlModal,
GlDropdown,
GlDropdownItem,
GlSprintf,
GlSearchBoxByType,
GlButton,
GlFormInput,
},
props: {
groupId: {
type: String,
required: true,
},
groupName: {
type: String,
required: true,
},
accessLevels: {
type: Object,
required: true,
},
defaultAccessLevel: {
type: String,
required: true,
},
helpLink: {
type: String,
required: true,
},
},
data() {
return {
visible: true,
modalId: 'invite-members-modal',
selectedAccessLevel: this.defaultAccessLevel,
newUsersToInvite: '',
selectedDate: undefined,
};
},
computed: {
introText() {
return sprintf(s__("InviteMembersModal|You're inviting members to the %{group_name} group"), {
group_name: this.groupName,
});
},
toastOptions() {
return {
onComplete: () => {
this.selectedAccessLevel = this.defaultAccessLevel;
this.newUsersToInvite = '';
},
};
},
postData() {
return {
user_id: this.newUsersToInvite,
access_level: this.selectedAccessLevel,
expires_at: this.selectedDate,
format: 'json',
};
},
selectedRoleName() {
return Object.keys(this.accessLevels).find(
key => this.accessLevels[key] === Number(this.selectedAccessLevel),
);
},
},
mounted() {
eventHub.$on('openModal', this.openModal);
},
methods: {
openModal() {
this.$root.$emit('bv::show::modal', this.modalId);
},
closeModal() {
this.$root.$emit('bv::hide::modal', this.modalId);
},
sendInvite() {
this.submitForm(this.postData);
this.closeModal();
},
cancelInvite() {
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
this.newUsersToInvite = '';
this.closeModal();
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
submitForm(formData) {
return Api.inviteGroupMember(this.groupId, formData)
.then(() => {
this.showToastMessageSuccess();
})
.catch(error => {
this.showToastMessageError(error);
});
},
showToastMessageSuccess() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
},
showToastMessageError(error) {
const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful;
this.$toast.show(message, this.toastOptions);
},
},
labels: {
modalTitle: s__('InviteMembersModal|Invite team members'),
userToInvite: s__('InviteMembersModal|GitLab member or Email address'),
userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
accessLevel: s__('InviteMembersModal|Choose a role permission'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'),
toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'),
readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
inviteButtonText: s__('InviteMembersModal|Invite'),
cancelButtonText: s__('InviteMembersModal|Cancel'),
},
};
</script>
<template>
<gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle">
<div class="gl-ml-5 gl-mr-5">
<div>{{ introText }}</div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label>
<div class="gl-mt-2">
<gl-search-box-by-type
v-model="newUsersToInvite"
:placeholder="$options.labels.userPlaceholder"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
</div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-dropdown
menu-class="dropdown-menu-selectable"
class="gl-shadow-none gl-w-full"
v-bind="$attrs"
:text="selectedRoleName"
>
<template v-for="(key, item) in accessLevels">
<gl-dropdown-item
:key="key"
active-class="is-active"
:is-checked="key === selectedAccessLevel"
@click="changeSelectedItem(key)"
>
<div>{{ item }}</div>
</gl-dropdown-item>
</template>
</gl-dropdown>
</div>
<div class="gl-mt-2">
<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-font-weight-bold gl-mt-5" 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>
</div>
<template #modal-footer>
<div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3">
<gl-button ref="cancelButton" @click="cancelInvite">
{{ $options.labels.cancelButtonText }}
</gl-button>
<div class="gl-mr-3"></div>
<gl-button ref="inviteButton" variant="success" @click="sendInvite">{{
$options.labels.inviteButtonText
}}</gl-button>
</div>
</template>
</gl-modal>
</template>
<script>
import { GlLink, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
GlLink,
GlIcon,
},
props: {
displayText: {
type: String,
required: false,
default: s__('InviteMembers|Invite team members'),
},
icon: {
type: String,
required: false,
default: '',
},
},
methods: {
openModal() {
eventHub.$emit('openModal');
},
},
};
</script>
<template>
<gl-link @click="openModal">
<div v-if="icon" class="nav-icon-container">
<gl-icon :size="16" :name="icon" />
</div>
<span class="nav-item-name"> {{ displayText }} </span>
</gl-link>
</template>
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
Vue.use(GlToast);
export default function initInviteMembersModal() {
const el = document.querySelector('.js-invite-members-modal');
if (!el) {
return false;
}
return new Vue({
el,
render: createElement =>
createElement(InviteMembersModal, {
props: {
...el.dataset,
accessLevels: JSON.parse(el.dataset.accessLevels),
groupName: el.dataset.groupName.toUpperCase(),
},
}),
});
}
import Vue from 'vue';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
export default function initInviteMembersTrigger() {
const el = document.querySelector('.js-invite-members-trigger');
if (!el) {
return false;
}
return new Vue({
el,
render: createElement =>
createElement(InviteMembersTrigger, {
props: {
...el.dataset,
},
}),
});
}
# frozen_string_literal: true
module InviteMembersHelper
def invite_members_allowed?(group)
Feature.enabled?(:invite_members_group_modal, group) && can?(current_user, :admin_group_member, group)
end
end
- if invite_members_allowed?(group)
.js-invite-members-modal{ data: { group_id: group.id,
group_name: group.name,
access_levels: GroupMember.access_level_roles.to_json,
default_access_level: Gitlab::Access::GUEST,
help_link: help_page_url('user/permissions') } }
- if invite_members_allowed?(group) && body_data_page == 'groups:show'
%li
.js-invite-members-trigger{ data: { icon: 'plus', display_text: 'Invite team members' } }
...@@ -23,6 +23,8 @@ ...@@ -23,6 +23,8 @@
= render_if_exists 'groups/group_activity_analytics', group: @group = render_if_exists 'groups/group_activity_analytics', group: @group
= render_if_exists 'groups/invite_members_modal', group: @group
.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
.top-area.group-nav-container.justify-content-between .top-area.group-nav-container.justify-content-between
.scrolling-tabs-container.inner-page-scroll-tabs .scrolling-tabs-container.inner-page-scroll-tabs
......
...@@ -139,6 +139,8 @@ ...@@ -139,6 +139,8 @@
%strong.fly-out-top-item-name %strong.fly-out-top-item-name
= _('Members') = _('Members')
= render_if_exists 'groups/invite_members_side_nav_link', group: @group
- if group_sidebar_link?(:settings) - if group_sidebar_link?(:settings)
= nav_link(path: group_settings_nav_link_paths) do = nav_link(path: group_settings_nav_link_paths) do
= link_to edit_group_path(@group) do = link_to edit_group_path(@group) do
......
---
name: invite_members_group_modal
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37906
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247208
group: group::expansion
type: development
default_enabled: false
...@@ -2,6 +2,8 @@ import initGroupAnalytics from 'ee/analytics/group_analytics/group_analytics_bun ...@@ -2,6 +2,8 @@ import initGroupAnalytics from 'ee/analytics/group_analytics/group_analytics_bun
import leaveByUrl from '~/namespaces/leave_by_url'; import leaveByUrl from '~/namespaces/leave_by_url';
import initGroupDetails from '~/pages/groups/shared/group_details'; import initGroupDetails from '~/pages/groups/shared/group_details';
import initVueAlerts from '~/vue_alerts'; import initVueAlerts from '~/vue_alerts';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
leaveByUrl('group'); leaveByUrl('group');
...@@ -9,4 +11,6 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -9,4 +11,6 @@ document.addEventListener('DOMContentLoaded', () => {
initGroupDetails(); initGroupDetails();
initGroupAnalytics(); initGroupAnalytics();
initVueAlerts(); initVueAlerts();
initInviteMembersModal();
initInviteMembersTrigger();
}); });
...@@ -32,3 +32,9 @@ ...@@ -32,3 +32,9 @@
svg g { fill: $gray-600; } svg g { fill: $gray-600; }
} }
} }
#invite-members-modal {
.modal-footer {
flex-direction: row;
}
}
...@@ -231,4 +231,15 @@ RSpec.describe 'Group navbar' do ...@@ -231,4 +231,15 @@ RSpec.describe 'Group navbar' do
it_behaves_like 'verified navigation bar' it_behaves_like 'verified navigation bar'
end end
context 'when invite team members is available' do
it 'includes the div for js-invite-members-trigger' do
stub_feature_flags(invite_members_group_modal: true)
allow_any_instance_of( InviteMembersHelper ).to receive(:invite_members_allowed?).and_return(true)
visit group_path(group)
expect(page).to have_selector('.js-invite-members-trigger')
end
end
end end
...@@ -13956,6 +13956,42 @@ msgstr "" ...@@ -13956,6 +13956,42 @@ msgstr ""
msgid "InviteMembersBanner|We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge." msgid "InviteMembersBanner|We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge."
msgstr "" msgstr ""
msgid "InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions"
msgstr ""
msgid "InviteMembersModal|Access expiration date (optional)"
msgstr ""
msgid "InviteMembersModal|Cancel"
msgstr ""
msgid "InviteMembersModal|Choose a role permission"
msgstr ""
msgid "InviteMembersModal|GitLab member or Email address"
msgstr ""
msgid "InviteMembersModal|Invite"
msgstr ""
msgid "InviteMembersModal|Invite team members"
msgstr ""
msgid "InviteMembersModal|Search for members to invite"
msgstr ""
msgid "InviteMembersModal|User not invited. Feature coming soon!"
msgstr ""
msgid "InviteMembersModal|Users were succesfully added"
msgstr ""
msgid "InviteMembersModal|You're inviting members to the %{group_name} group"
msgstr ""
msgid "InviteMembers|Invite team members"
msgstr ""
msgid "Invited" msgid "Invited"
msgstr "" msgstr ""
...@@ -29000,6 +29036,9 @@ msgstr "" ...@@ -29000,6 +29036,9 @@ msgstr ""
msgid "Wrong extern UID provided. Make sure Auth0 is configured correctly." msgid "Wrong extern UID provided. Make sure Auth0 is configured correctly."
msgstr "" msgstr ""
msgid "YYYY-MM-DD"
msgstr ""
msgid "Yes" msgid "Yes"
msgstr "" msgstr ""
......
...@@ -72,4 +72,12 @@ RSpec.describe 'Group navbar' do ...@@ -72,4 +72,12 @@ RSpec.describe 'Group navbar' do
it_behaves_like 'verified navigation bar' it_behaves_like 'verified navigation bar'
end end
context 'when invite team members is not available' do
it 'does not display the js-invite-members-trigger' do
visit group_path(group)
expect(page).not_to have_selector('.js-invite-members-trigger')
end
end
end end
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink } from '@gitlab/ui';
import Api from '~/api';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
const groupId = '1';
const groupName = 'testgroup';
const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 };
const defaultAccessLevel = '10';
const helpLink = 'https://example.com';
const createComponent = () => {
return shallowMount(InviteMembersModal, {
propsData: {
groupId,
groupName,
accessLevels,
defaultAccessLevel,
helpLink,
},
stubs: {
GlSprintf,
'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>',
},
});
};
describe('InviteMembersModal', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findDatepicker = () => wrapper.find(GlDatepicker);
const findLink = () => wrapper.find(GlLink);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
const findInviteButton = () => wrapper.find({ ref: 'inviteButton' });
describe('rendering the modal', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the modal with the correct title', () => {
expect(wrapper.attributes('title')).toBe('Invite team members');
});
it('renders the Cancel button text correctly', () => {
expect(findCancelButton().text()).toBe('Cancel');
});
it('renders the Invite button text correctly', () => {
expect(findInviteButton().text()).toBe('Invite');
});
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()).toExist();
});
});
});
describe('submitting the invite form', () => {
const postData = {
user_id: '1',
access_level: '10',
expires_at: new Date(),
format: 'json',
};
beforeEach(() => {
wrapper = createComponent();
jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
wrapper.vm.$toast = { show: jest.fn() };
wrapper.vm.submitForm(postData);
});
it('calls Api inviteGroupMember with the correct params', () => {
expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData);
});
describe('when the invite was sent successfully', () => {
const toastMessageSuccessful = 'Users were succesfully added';
it('displays the successful toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
toastMessageSuccessful,
wrapper.vm.toastOptions,
);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlLink } from '@gitlab/ui';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
const displayText = 'Invite team members';
const icon = 'plus';
const createComponent = (props = {}) => {
return shallowMount(InviteMembersTrigger, {
propsData: {
displayText,
...props,
},
});
};
describe('InviteMembersTrigger', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('displayText', () => {
const findLink = () => wrapper.find(GlLink);
beforeEach(() => {
wrapper = createComponent();
});
it('includes the correct displayText for the link', () => {
expect(findLink().text()).toBe(displayText);
});
});
describe('icon', () => {
const findIcon = () => wrapper.find(GlIcon);
it('includes the correct icon when an icon is sent', () => {
wrapper = createComponent({ icon });
expect(findIcon().attributes('name')).toBe(icon);
});
it('does not include an icon when icon is not sent', () => {
wrapper = createComponent();
expect(findIcon().exists()).toBe(false);
});
it('does not include an icon when empty string is sent', () => {
wrapper = createComponent({ icon: '' });
expect(findIcon().exists()).toBe(false);
});
});
});
...@@ -3,12 +3,14 @@ ...@@ -3,12 +3,14 @@
RSpec.shared_examples 'verified navigation bar' do RSpec.shared_examples 'verified navigation bar' do
let(:expected_structure) do let(:expected_structure) do
structure.compact! structure.compact!
structure.each { |s| s[:nav_sub_items].compact! } structure.each { |s| s[:nav_sub_items]&.compact! }
structure structure
end end
it 'renders correctly' do it 'renders correctly' do
current_structure = page.all('.sidebar-top-level-items > li', class: ['!hidden']).map do |item| current_structure = page.all('.sidebar-top-level-items > li', class: ['!hidden']).map do |item|
next if item.find_all('a').empty?
nav_item = item.find_all('a').first.text.gsub(/\s+\d+$/, '') # remove counts at the end nav_item = item.find_all('a').first.text.gsub(/\s+\d+$/, '') # remove counts at the end
nav_sub_items = item.all('.sidebar-sub-level-items > li', class: ['!fly-out-top-item']).map do |list_item| nav_sub_items = item.all('.sidebar-sub-level-items > li', class: ['!fly-out-top-item']).map do |list_item|
...@@ -16,7 +18,7 @@ RSpec.shared_examples 'verified navigation bar' do ...@@ -16,7 +18,7 @@ RSpec.shared_examples 'verified navigation bar' do
end end
{ nav_item: nav_item, nav_sub_items: nav_sub_items } { nav_item: nav_item, nav_sub_items: nav_sub_items }
end end.compact
expect(current_structure).to eq(expected_structure) expect(current_structure).to eq(expected_structure)
end end
......
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