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 = {
});
},
inviteGroupMember(id, data) {
const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
return axios.post(url, data);
},
groupMilestones(id, options) {
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 @@
= 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) } } }
.top-area.group-nav-container.justify-content-between
.scrolling-tabs-container.inner-page-scroll-tabs
......
......@@ -139,6 +139,8 @@
%strong.fly-out-top-item-name
= _('Members')
= render_if_exists 'groups/invite_members_side_nav_link', group: @group
- if group_sidebar_link?(:settings)
= nav_link(path: group_settings_nav_link_paths) 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
import leaveByUrl from '~/namespaces/leave_by_url';
import initGroupDetails from '~/pages/groups/shared/group_details';
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', () => {
leaveByUrl('group');
......@@ -9,4 +11,6 @@ document.addEventListener('DOMContentLoaded', () => {
initGroupDetails();
initGroupAnalytics();
initVueAlerts();
initInviteMembersModal();
initInviteMembersTrigger();
});
......@@ -32,3 +32,9 @@
svg g { fill: $gray-600; }
}
}
#invite-members-modal {
.modal-footer {
flex-direction: row;
}
}
......@@ -231,4 +231,15 @@ RSpec.describe 'Group navbar' do
it_behaves_like 'verified navigation bar'
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
......@@ -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."
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"
msgstr ""
......@@ -29000,6 +29036,9 @@ msgstr ""
msgid "Wrong extern UID provided. Make sure Auth0 is configured correctly."
msgstr ""
msgid "YYYY-MM-DD"
msgstr ""
msgid "Yes"
msgstr ""
......
......@@ -72,4 +72,12 @@ RSpec.describe 'Group navbar' do
it_behaves_like 'verified navigation bar'
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
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 @@
RSpec.shared_examples 'verified navigation bar' do
let(:expected_structure) do
structure.compact!
structure.each { |s| s[:nav_sub_items].compact! }
structure.each { |s| s[:nav_sub_items]&.compact! }
structure
end
it 'renders correctly' do
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_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
end
{ nav_item: nav_item, nav_sub_items: nav_sub_items }
end
end.compact
expect(current_structure).to eq(expected_structure)
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