Commit 953dcf16 authored by Jackie Fraser's avatar Jackie Fraser Committed by Ezekiel Kigbo

Add Invite team members to side nav as experiment

parent c710ad16
...@@ -2,6 +2,8 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; ...@@ -2,6 +2,8 @@ import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import $ from 'jquery'; import $ from 'jquery';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed'; export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed';
...@@ -112,5 +114,8 @@ export default class ContextualSidebar { ...@@ -112,5 +114,8 @@ export default class ContextualSidebar {
const collapse = parseBoolean(Cookies.get('sidebar_collapsed')); const collapse = parseBoolean(Cookies.get('sidebar_collapsed'));
this.toggleCollapsedSidebar(collapse, true); this.toggleCollapsedSidebar(collapse, true);
} }
initInviteMembersModal();
initInviteMembersTrigger();
} }
} }
<script> <script>
import { GlButton, GlLink } from '@gitlab/ui'; import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
import ExperimentTracking from '~/experimentation/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '../constants';
export default { export default {
components: { GlButton, GlLink }, components: { GlButton, GlLink, GlIcon },
props: { props: {
displayText: { displayText: {
type: String, type: String,
...@@ -53,13 +54,11 @@ export default { ...@@ -53,13 +54,11 @@ export default {
}, },
}, },
computed: { computed: {
isButton() {
return this.triggerElement === 'button';
},
componentAttributes() { componentAttributes() {
const baseAttributes = { const baseAttributes = {
class: this.classes, class: this.classes,
'data-qa-selector': 'invite_members_button', 'data-qa-selector': 'invite_members_button',
'data-test-id': 'invite-members-button',
}; };
if (this.event && this.label) { if (this.event && this.label) {
...@@ -77,6 +76,9 @@ export default { ...@@ -77,6 +76,9 @@ export default {
this.trackExperimentOnShow(); this.trackExperimentOnShow();
}, },
methods: { methods: {
checkTrigger(targetTriggerElement) {
return this.triggerElement === targetTriggerElement;
},
openModal() { openModal() {
eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource }); eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource });
}, },
...@@ -87,12 +89,14 @@ export default { ...@@ -87,12 +89,14 @@ export default {
} }
}, },
}, },
TRIGGER_ELEMENT_BUTTON,
TRIGGER_ELEMENT_SIDE_NAV,
}; };
</script> </script>
<template> <template>
<gl-button <gl-button
v-if="isButton" v-if="checkTrigger($options.TRIGGER_ELEMENT_BUTTON)"
v-bind="componentAttributes" v-bind="componentAttributes"
:variant="variant" :variant="variant"
:icon="icon" :icon="icon"
...@@ -100,6 +104,17 @@ export default { ...@@ -100,6 +104,17 @@ export default {
> >
{{ displayText }} {{ displayText }}
</gl-button> </gl-button>
<gl-link
v-else-if="checkTrigger($options.TRIGGER_ELEMENT_SIDE_NAV)"
v-bind="componentAttributes"
data-is-link="true"
@click="openModal"
>
<span class="nav-icon-container">
<gl-icon :name="icon" />
</span>
<span class="nav-item-name"> {{ displayText }} </span>
</gl-link>
<gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal"> <gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal">
{{ displayText }} {{ displayText }}
</gl-link> </gl-link>
......
...@@ -25,3 +25,5 @@ export const API_MESSAGES = { ...@@ -25,3 +25,5 @@ export const API_MESSAGES = {
}; };
export const USERS_FILTER_ALL = 'all'; export const USERS_FILTER_ALL = 'all';
export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id'; export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id';
export const TRIGGER_ELEMENT_BUTTON = 'button';
export const TRIGGER_ELEMENT_SIDE_NAV = 'side-nav';
...@@ -5,7 +5,15 @@ import { parseBoolean } from '~/lib/utils/common_utils'; ...@@ -5,7 +5,15 @@ import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(GlToast); Vue.use(GlToast);
let initedInviteMembersModal;
export default function initInviteMembersModal() { export default function initInviteMembersModal() {
if (initedInviteMembersModal) {
// if we already loaded this in another part of the dom, we don't want to do it again
// else we will stack the modals
return false;
}
// https://gitlab.com/gitlab-org/gitlab/-/issues/344955 // https://gitlab.com/gitlab-org/gitlab/-/issues/344955
// bug lying in wait here for someone to put group and project invite in same screen // bug lying in wait here for someone to put group and project invite in same screen
// once that happens we'll need to mount these differently, perhaps split // once that happens we'll need to mount these differently, perhaps split
...@@ -16,6 +24,8 @@ export default function initInviteMembersModal() { ...@@ -16,6 +24,8 @@ export default function initInviteMembersModal() {
return false; return false;
} }
initedInviteMembersModal = true;
return new Vue({ return new Vue({
el, el,
provide: { provide: {
......
.js-invite-members-trigger{ data: { trigger_source: 'group-side-nav',
classes: 'gl-text-decoration-none! gl-shadow-none! gl-text-body!',
icon: 'users',
display_text: title,
trigger_element: 'side-nav'} }
= render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu }
= render 'groups/invite_members_modal', group: group
...@@ -32,8 +32,6 @@ ...@@ -32,8 +32,6 @@
= render_if_exists 'groups/group_activity_analytics', group: @group = render_if_exists 'groups/group_activity_analytics', group: @group
= render '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
......
.js-invite-members-trigger{ data: { trigger_source: 'project-side-nav',
classes: 'gl-text-decoration-none! gl-shadow-none! gl-text-body!',
icon: 'users',
display_text: title,
trigger_element: 'side-nav'} }
= render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu }
= render 'projects/invite_members_modal', project: project
...@@ -16,15 +16,4 @@ ...@@ -16,15 +16,4 @@
%span.badge.badge-pill.count{ **sidebar_menu.pill_html_options } %span.badge.badge-pill.count{ **sidebar_menu.pill_html_options }
= number_with_delimiter(sidebar_menu.pill_count) = number_with_delimiter(sidebar_menu.pill_count)
%ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) } = render partial: 'shared/nav/sidebar_submenu', locals: { sidebar_menu: sidebar_menu }
= nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do
%span.fly-out-top-item-container
%strong.fly-out-top-item-name
= sidebar_menu.title
- if sidebar_menu.has_pill?
%span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options }
= number_with_delimiter(sidebar_menu.pill_count)
- if sidebar_menu.has_renderable_items?
%li.divider.fly-out-top-item
= render partial: 'shared/nav/sidebar_menu_item', collection: sidebar_menu.renderable_items
%ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) }
= nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do
%span.fly-out-top-item-container
%strong.fly-out-top-item-name
= sidebar_menu.title
- if sidebar_menu.has_pill?
%span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options }
= number_with_delimiter(sidebar_menu.pill_count)
- if sidebar_menu.has_renderable_items?
%li.divider.fly-out-top-item
= render partial: 'shared/nav/sidebar_menu_item', collection: sidebar_menu.renderable_items
---
name: invite_members_in_side_nav
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70451
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/342951
milestone: '14.5'
type: experiment
group: group::expansion
default_enabled: false
# frozen_string_literal: true
module Sidebars
module Groups
module Menus
class InviteTeamMembersMenu < ::Sidebars::Menu
override :title
def title
s_('InviteMember|Invite members')
end
override :render?
def render?
can?(context.current_user, :admin_group_member, context.group) && all_valid_members.size <= 1
end
override :menu_partial
def menu_partial
'groups/invite_members_side_nav_link'
end
override :menu_partial_options
def menu_partial_options
{
group: context.group,
title: title,
sidebar_menu: self
}
end
override :extra_nav_link_html_options
def extra_nav_link_html_options
{
'data-test-id': 'side-nav-invite-members'
}
end
private
def all_valid_members
GroupMembersFinder.new(context.group, context.current_user).execute
end
end
end
end
end
...@@ -15,12 +15,22 @@ module Sidebars ...@@ -15,12 +15,22 @@ module Sidebars
add_menu(Sidebars::Groups::Menus::PackagesRegistriesMenu.new(context)) add_menu(Sidebars::Groups::Menus::PackagesRegistriesMenu.new(context))
add_menu(Sidebars::Groups::Menus::CustomerRelationsMenu.new(context)) add_menu(Sidebars::Groups::Menus::CustomerRelationsMenu.new(context))
add_menu(Sidebars::Groups::Menus::SettingsMenu.new(context)) add_menu(Sidebars::Groups::Menus::SettingsMenu.new(context))
add_invite_members_menu
end end
override :aria_label override :aria_label
def aria_label def aria_label
context.group.subgroup? ? _('Subgroup navigation') : _('Group navigation') context.group.subgroup? ? _('Subgroup navigation') : _('Group navigation')
end end
private
def add_invite_members_menu
experiment(:invite_members_in_side_nav, group: context.group) do |e|
e.control {}
e.candidate { add_menu(Sidebars::Groups::Menus::InviteTeamMembersMenu.new(context)) }
end
end
end end
end end
end end
......
...@@ -4,6 +4,7 @@ module Sidebars ...@@ -4,6 +4,7 @@ module Sidebars
class Panel class Panel
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
include ::Sidebars::Concerns::PositionableList include ::Sidebars::Concerns::PositionableList
include Gitlab::Experiment::Dsl
attr_reader :context, :scope_menu, :hidden_menu attr_reader :context, :scope_menu, :hidden_menu
......
# frozen_string_literal: true
module Sidebars
module Projects
module Menus
class InviteTeamMembersMenu < ::Sidebars::Menu
override :title
def title
s_('InviteMember|Invite members')
end
override :render?
def render?
can?(context.current_user, :admin_project_member, context.project) && all_valid_members.size <= 1
end
override :menu_partial
def menu_partial
'projects/invite_members_side_nav_link'
end
override :menu_partial_options
def menu_partial_options
{
project: context.project,
title: title,
sidebar_menu: self
}
end
override :extra_nav_link_html_options
def extra_nav_link_html_options
{
'data-test-id': 'side-nav-invite-members'
}
end
private
def all_valid_members
MembersFinder.new(context.project, context.current_user)
.execute(include_relations: [:inherited, :direct, :invited_groups])
end
end
end
end
end
...@@ -36,6 +36,14 @@ module Sidebars ...@@ -36,6 +36,14 @@ module Sidebars
add_menu(Sidebars::Projects::Menus::ExternalWikiMenu.new(context)) add_menu(Sidebars::Projects::Menus::ExternalWikiMenu.new(context))
add_menu(Sidebars::Projects::Menus::SnippetsMenu.new(context)) add_menu(Sidebars::Projects::Menus::SnippetsMenu.new(context))
add_menu(Sidebars::Projects::Menus::SettingsMenu.new(context)) add_menu(Sidebars::Projects::Menus::SettingsMenu.new(context))
add_invite_members_menu
end
def add_invite_members_menu
experiment(:invite_members_in_side_nav, group: context.project.group) do |e|
e.control {}
e.candidate { add_menu(Sidebars::Projects::Menus::InviteTeamMembersMenu.new(context)) }
end
end end
def confluence_or_wiki_menu def confluence_or_wiki_menu
......
...@@ -3,35 +3,110 @@ ...@@ -3,35 +3,110 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'Contextual sidebar', :js do RSpec.describe 'Contextual sidebar', :js do
let_it_be(:project) { create(:project) } context 'when context is a project' do
let_it_be(:project) { create(:project) }
let(:user) { project.owner } let(:user) { project.owner }
before do before do
sign_in(user) sign_in(user)
end
visit project_path(project) context 'when analyzing the menu' do
end before do
visit project_path(project)
end
it 'shows flyout navs when collapsed or expanded apart from on the active item when expanded', :aggregate_failures do
expect(page).not_to have_selector('.js-sidebar-collapsed')
find('.rspec-link-pipelines').hover
expect(page).to have_selector('.is-showing-fly-out')
find('.rspec-project-link').hover
expect(page).not_to have_selector('.is-showing-fly-out')
find('.rspec-toggle-sidebar').click
find('.rspec-link-pipelines').hover
expect(page).to have_selector('.is-showing-fly-out')
it 'shows flyout navs when collapsed or expanded apart from on the active item when expanded', :aggregate_failures do find('.rspec-project-link').hover
expect(page).not_to have_selector('.js-sidebar-collapsed')
expect(page).to have_selector('.is-showing-fly-out')
end
end
context 'with invite_members_in_side_nav experiment', :experiment do
it 'allows opening of modal for the candidate experience' do
stub_experiments(invite_members_in_side_nav: :candidate)
expect(experiment(:invite_members_in_side_nav)).to track(:assignment)
.with_context(group: project.group)
.on_next_instance
visit project_path(project)
page.within '[data-test-id="side-nav-invite-members"' do
find('[data-test-id="invite-members-button"').click
end
expect(page).to have_content("You're inviting members to the")
end
it 'does not have invite members link in side nav for the control experience' do
stub_experiments(invite_members_in_side_nav: :control)
expect(experiment(:invite_members_in_side_nav)).to track(:assignment)
.with_context(group: project.group)
.on_next_instance
visit project_path(project)
expect(page).not_to have_css('[data-test-id="side-nav-invite-members"')
end
end
end
find('.rspec-link-pipelines').hover context 'when context is a group' do
let_it_be(:user) { create(:user) }
let_it_be(:group) do
create(:group).tap do |g|
g.add_owner(user)
end
end
expect(page).to have_selector('.is-showing-fly-out') before do
sign_in(user)
end
find('.rspec-project-link').hover context 'with invite_members_in_side_nav experiment', :experiment do
it 'allows opening of modal for the candidate experience' do
stub_experiments(invite_members_in_side_nav: :candidate)
expect(experiment(:invite_members_in_side_nav)).to track(:assignment)
.with_context(group: group)
.on_next_instance
expect(page).not_to have_selector('.is-showing-fly-out') visit group_path(group)
find('.rspec-toggle-sidebar').click page.within '[data-test-id="side-nav-invite-members"' do
find('[data-test-id="invite-members-button"').click
end
find('.rspec-link-pipelines').hover expect(page).to have_content("You're inviting members to the")
end
expect(page).to have_selector('.is-showing-fly-out') it 'does not have invite members link in side nav for the control experience' do
stub_experiments(invite_members_in_side_nav: :control)
expect(experiment(:invite_members_in_side_nav)).to track(:assignment)
.with_context(group: group)
.on_next_instance
find('.rspec-project-link').hover visit group_path(group)
expect(page).to have_selector('.is-showing-fly-out') expect(page).not_to have_css('[data-test-id="side-nav-invite-members"')
end
end
end end
end end
import { GlButton, GlLink } from '@gitlab/ui'; import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import ExperimentTracking from '~/experimentation/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import eventHub from '~/invite_members/event_hub'; import eventHub from '~/invite_members/event_hub';
import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '~/invite_members/constants';
jest.mock('~/experimentation/experiment_tracking'); jest.mock('~/experimentation/experiment_tracking');
...@@ -15,6 +16,7 @@ let findButton; ...@@ -15,6 +16,7 @@ let findButton;
const triggerComponent = { const triggerComponent = {
button: GlButton, button: GlButton,
anchor: GlLink, anchor: GlLink,
'side-nav': GlLink,
}; };
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
...@@ -27,9 +29,23 @@ const createComponent = (props = {}) => { ...@@ -27,9 +29,23 @@ const createComponent = (props = {}) => {
}); });
}; };
describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement) => { const triggerItems = [
triggerProps = { triggerElement, triggerSource }; {
findButton = () => wrapper.findComponent(triggerComponent[triggerElement]); triggerElement: TRIGGER_ELEMENT_BUTTON,
},
{
triggerElement: 'anchor',
},
{
triggerElement: TRIGGER_ELEMENT_SIDE_NAV,
icon: 'plus',
},
];
describe.each(triggerItems)('with triggerElement as %s', (triggerItem) => {
triggerProps = { ...triggerItem, triggerSource };
findButton = () => wrapper.findComponent(triggerComponent[triggerItem.triggerElement]);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -91,3 +107,14 @@ describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement ...@@ -91,3 +107,14 @@ describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement
}); });
}); });
}); });
describe('side-nav with icon', () => {
it('includes the specified icon with correct size when triggerElement is link', () => {
const findIcon = () => wrapper.findComponent(GlIcon);
createComponent({ triggerElement: TRIGGER_ELEMENT_SIDE_NAV, icon: 'plus' });
expect(findIcon().exists()).toBe(true);
expect(findIcon().props('name')).toBe('plus');
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Sidebars::Groups::Menus::InviteTeamMembersMenu do
let_it_be(:owner) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:group) do
build(:group).tap do |g|
g.add_owner(owner)
end
end
let(:context) { Sidebars::Groups::Context.new(current_user: owner, container: group) }
subject(:invite_menu) { described_class.new(context) }
context 'when the group is viewed by an owner of the group' do
describe '#render?' do
it 'renders the Invite team members link' do
expect(invite_menu.render?).to eq(true)
end
context 'when the group already has at least 2 members' do
before do
group.add_guest(guest)
end
it 'does not render the link' do
expect(invite_menu.render?).to eq(false)
end
end
end
describe '#title' do
it 'displays the correct Invite team members text for the link in the side nav' do
expect(invite_menu.title).to eq('Invite members')
end
end
end
context 'when the group is viewed by a guest user without admin permissions' do
let(:context) { Sidebars::Groups::Context.new(current_user: guest, container: group) }
before do
group.add_guest(guest)
end
describe '#render?' do
it 'does not render the link' do
expect(subject.render?).to eq(false)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Sidebars::Projects::Menus::InviteTeamMembersMenu do
let_it_be(:project) { create(:project) }
let_it_be(:guest) { create(:user) }
let(:context) { Sidebars::Projects::Context.new(current_user: owner, container: project) }
subject(:invite_menu) { described_class.new(context) }
context 'when the project is viewed by an owner of the group' do
let(:owner) { project.owner }
describe '#render?' do
it 'renders the Invite team members link' do
expect(invite_menu.render?).to eq(true)
end
context 'when the project already has at least 2 members' do
before do
project.add_guest(guest)
end
it 'does not render the link' do
expect(invite_menu.render?).to eq(false)
end
end
end
describe '#title' do
it 'displays the correct Invite team members text for the link in the side nav' do
expect(invite_menu.title).to eq('Invite members')
end
end
end
context 'when the project is viewed by a guest user without admin permissions' do
let(:context) { Sidebars::Projects::Context.new(current_user: guest, container: project) }
before do
project.add_guest(guest)
end
describe '#render?' do
it 'does not render' do
expect(invite_menu.render?).to eq(false)
end
end
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