Commit 4a0dc0ca authored by Doug Stull's avatar Doug Stull Committed by Brandon Labuschagne

Add invite member link for non priveledged users

- to collect experiment tracking.
parent 2b9221d3
<script>
import { GlModal, GlLink } from '@gitlab/ui';
import eventHub from '../event_hub';
import { s__, __ } from '~/locale';
import { OPEN_MODAL, MODAL_ID } from '../constants';
export default {
cancelProps: {
text: __('Got it'),
attributes: [
{
variant: 'info',
},
],
},
modalId: MODAL_ID,
components: {
GlLink,
GlModal,
},
inject: {
membersPath: {
default: '',
},
},
i18n: {
modalTitle: s__("InviteMember|Oops, this feature isn't ready yet"),
bodyTopMessage: s__(
"InviteMember|We're working to allow everyone to invite new members, making it easier for teams to get started with GitLab",
),
bodyMiddleMessage: s__(
'InviteMember|Until then, ask an owner to invite new project members for you',
),
linkText: s__('InviteMember|See who can invite members for you'),
},
mounted() {
eventHub.$on(OPEN_MODAL, this.openModal);
},
methods: {
openModal() {
this.$root.$emit('bv::show::modal', MODAL_ID);
},
},
};
</script>
<template>
<gl-modal :modal-id="$options.modalId" size="sm" :action-cancel="$options.cancelProps">
<template #modal-title>
{{ $options.i18n.modalTitle }}
<gl-emoji
class="gl-vertical-align-baseline font-size-inherit gl-mr-1"
data-name="sweat_smile"
/>
</template>
<p>{{ $options.i18n.bodyTopMessage }}</p>
<p>{{ $options.i18n.bodyMiddleMessage }}</p>
<gl-link
:href="membersPath"
data-track-event="click_who_can_invite_link"
data-track-label="invite_members_message"
>{{ $options.i18n.linkText }}</gl-link
>
</gl-modal>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import eventHub from '../event_hub';
import { OPEN_MODAL } from '../constants';
export default {
components: {
GlLink,
},
inject: {
displayText: {
default: '',
},
event: {
default: '',
},
label: {
default: '',
},
},
methods: {
openModal() {
eventHub.$emit(OPEN_MODAL);
},
},
};
</script>
<template>
<gl-link
data-is-link="true"
:data-track-event="event"
:data-track-label="label"
@click="openModal"
>{{ displayText }}
</gl-link>
</template>
export const OPEN_MODAL = 'openModal';
export const MODAL_ID = 'invite-member-modal';
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import InviteMemberModal from './components/invite_member_modal.vue';
Vue.use(GlToast);
export default function initInviteMembersModal() {
const el = document.querySelector('.js-invite-member-modal');
if (!el) {
return false;
}
const { membersPath } = el.dataset;
return new Vue({
el,
provide: { membersPath },
render: createElement => createElement(InviteMemberModal),
});
}
import Vue from 'vue';
import InviteMemberTrigger from './components/invite_member_trigger.vue';
export default function initInviteMembersTrigger() {
const el = document.querySelector('.js-invite-member-trigger');
if (!el) {
return false;
}
return new Vue({
el,
provide: { ...el.dataset },
render: createElement => createElement(InviteMemberTrigger),
});
}
......@@ -11,6 +11,8 @@ import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_iss
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
export default function() {
const { issueType, ...issuableData } = parseIssuableData();
......@@ -35,4 +37,6 @@ export default function() {
initIssuableSidebar();
loadAwardsHandler();
initInviteMemberModal();
initInviteMemberTrigger();
}
......@@ -6,6 +6,8 @@ import howToMerge from '~/how_to_merge';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
import initSourcegraph from '~/sourcegraph';
import loadAwardsHandler from '~/awards_handler';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
export default function() {
new ZenMode(); // eslint-disable-line no-new
......@@ -16,4 +18,6 @@ export default function() {
howToMerge();
initSourcegraph();
loadAwardsHandler();
initInviteMemberModal();
initInviteMemberTrigger();
}
......@@ -52,6 +52,9 @@ class Projects::IssuesController < Projects::ApplicationController
real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(real_time_feature_flag, @project)
gon.push({ features: { real_time_feature_flag.to_s.camelize(:lower) => real_time_enabled } }, true)
record_experiment_user(:invite_members_version_a)
record_experiment_user(:invite_members_version_b)
end
before_action only: :index do
......
......@@ -40,6 +40,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:highlight_current_diff_row, @project)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
push_frontend_feature_flag(:core_security_mr_widget, @project)
record_experiment_user(:invite_members_version_a)
record_experiment_user(:invite_members_version_b)
end
before_action do
......
# frozen_string_literal: true
module InviteMembersHelper
include Gitlab::Utils::StrongMemoize
def invite_members_allowed?(group)
Feature.enabled?(:invite_members_group_modal, group) && can?(current_user, :admin_group_member, group)
end
def directly_invite_members?
strong_memoize(:directly_invite_members) do
experiment_enabled?(:invite_members_version_a) && can_import_members?
end
end
def indirectly_invite_members?
strong_memoize(:indirectly_invite_members) do
experiment_enabled?(:invite_members_version_b) && !can_import_members?
end
end
end
......@@ -39,17 +39,25 @@
- data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
- options[:data].merge!(data)
- if experiment_enabled?(:invite_members_version_a) && can_import_members?
- if directly_invite_members? || indirectly_invite_members?
- options[:dropdown_class] += ' dropdown-extended-height'
- options[:footer_content] = true
- options[:wrapper_class] = 'js-sidebar-assignee-dropdown'
- invite_text = _('Invite Members')
- track_label = 'edit_assignee'
= dropdown_tag(title, options: options) do
%ul.dropdown-footer-list
%li
= link_to _('Invite Members'),
project_project_members_path(@project),
title: _('Invite Members'),
data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': 'edit_assignee' }
- if directly_invite_members?
= link_to invite_text,
project_project_members_path(@project),
title: invite_text,
data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': track_label }
- else
.js-invite-member-trigger{ data: { display_text: invite_text, event: 'click_invite_members_version_b', label: track_label } }
- else
= dropdown_tag(title, options: options)
- if indirectly_invite_members?
.js-invite-member-modal{ data: { members_path: project_project_members_path(@project, sort: :access_level_desc) } }
......@@ -48,6 +48,9 @@ module Gitlab
invite_members_version_a: {
tracking_category: 'Growth::Expansion::Experiment::InviteMembersVersionA'
},
invite_members_version_b: {
tracking_category: 'Growth::Expansion::Experiment::InviteMembersVersionB'
},
new_create_project_ui: {
tracking_category: 'Manage::Import::Experiment::NewCreateProjectUi'
},
......
......@@ -14288,6 +14288,18 @@ msgstr ""
msgid "InviteMembers|Invite team members"
msgstr ""
msgid "InviteMember|Oops, this feature isn't ready yet"
msgstr ""
msgid "InviteMember|See who can invite members for you"
msgstr ""
msgid "InviteMember|Until then, ask an owner to invite new project members for you"
msgstr ""
msgid "InviteMember|We're working to allow everyone to invite new members, making it easier for teams to get started with GitLab"
msgstr ""
msgid "InviteReminderEmail|%{inviter} is still waiting for you to join GitLab"
msgstr ""
......
......@@ -16,128 +16,71 @@ RSpec.describe 'Issue Sidebar' do
sign_in(user)
end
context 'assignee', :js do
context 'when concerning the assignee', :js do
let(:user2) { create(:user) }
let(:issue2) { create(:issue, project: project, author: user2) }
context 'when invite_members_version_a experiment is enabled' do
before do
stub_experiment_for_user(invite_members_version_a: true)
end
context 'when user can not see invite members' do
before do
project.add_developer(user)
visit_issue(project, issue2)
include_examples 'issuable invite members experiments' do
let(:issuable_path) { project_issue_path(project, issue2) }
end
find('.block.assignee .edit-link').click
context 'when user is a developer' do
before do
project.add_developer(user)
visit_issue(project, issue2)
wait_for_requests
end
find('.block.assignee .edit-link').click
it 'does not see link to invite members' do
page.within '.dropdown-menu-user' do
expect(page).not_to have_link('Invite Members')
end
end
wait_for_requests
end
context 'when user can see invite members' do
before do
project.add_maintainer(user)
visit_issue(project, issue2)
find('.block.assignee .edit-link').click
wait_for_requests
end
it 'sees link to invite members' do
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite Members', href: project_project_members_path(project))
expect(page).to have_selector('[data-track-event="click_invite_members"]')
expect(page).to have_selector("[data-track-label='edit_assignee']")
end
it 'shows author in assignee dropdown' do
page.within '.dropdown-menu-user' do
expect(page).to have_content(user2.name)
end
end
end
context 'when invite_members_version_a experiment is not enabled' do
context 'when user is a developer' do
before do
project.add_developer(user)
visit_issue(project, issue2)
find('.block.assignee .edit-link').click
wait_for_requests
end
it 'shows author in assignee dropdown' do
page.within '.dropdown-menu-user' do
expect(page).to have_content(user2.name)
end
end
it 'shows author when filtering assignee dropdown' do
page.within '.dropdown-menu-user' do
find('.dropdown-input-field').native.send_keys user2.name
sleep 1 # Required to wait for end of input delay
wait_for_requests
expect(page).to have_content(user2.name)
end
end
it 'assigns yourself' do
find('.block.assignee .dropdown-menu-toggle').click
click_button 'assign yourself'
it 'shows author when filtering assignee dropdown' do
page.within '.dropdown-menu-user' do
find('.dropdown-input-field').native.send_keys user2.name
sleep 1 # Required to wait for end of input delay
wait_for_requests
find('.block.assignee .edit-link').click
page.within '.dropdown-menu-user' do
expect(page.find('.dropdown-header')).to be_visible
expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name)
end
expect(page).to have_content(user2.name)
end
end
it 'keeps your filtered term after filtering and dismissing the dropdown' do
find('.dropdown-input-field').native.send_keys user2.name
it 'assigns yourself' do
find('.block.assignee .dropdown-menu-toggle').click
wait_for_requests
click_button 'assign yourself'
page.within '.dropdown-menu-user' do
expect(page).not_to have_content 'Unassigned'
click_link user2.name
end
wait_for_requests
find('.js-right-sidebar').click
find('.block.assignee .edit-link').click
find('.block.assignee .edit-link').click
expect(page.all('.dropdown-menu-user li').length).to eq(1)
expect(find('.dropdown-input-field').value).to eq(user2.name)
page.within '.dropdown-menu-user' do
expect(page.find('.dropdown-header')).to be_visible
expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name)
end
end
context 'when user is a maintainer' do
before do
project.add_maintainer(user)
visit_issue(project, issue2)
it 'keeps your filtered term after filtering and dismissing the dropdown' do
find('.dropdown-input-field').native.send_keys user2.name
find('.block.assignee .edit-link').click
wait_for_requests
wait_for_requests
page.within '.dropdown-menu-user' do
expect(page).not_to have_content 'Unassigned'
click_link user2.name
end
it 'shows author in assignee dropdown and no invite link' do
page.within '.dropdown-menu-user' do
expect(page).not_to have_link('Invite Members')
end
end
find('.js-right-sidebar').click
find('.block.assignee .edit-link').click
expect(page.all('.dropdown-menu-user li').length).to eq(1)
expect(find('.dropdown-input-field').value).to eq(user2.name)
end
end
......
......@@ -20,7 +20,7 @@ RSpec.describe 'Merge request > User edits assignees sidebar', :js do
let(:sidebar_assignee_dropdown_item) { sidebar_assignee_block.find(".dropdown-menu li[data-user-id=\"#{assignee.id}\"]") }
let(:sidebar_assignee_dropdown_tooltip) { sidebar_assignee_dropdown_item.find('a')['data-title'] || '' }
context 'when invite_members_version_a experiment is not enabled' do
context 'when user is an owner' do
before do
stub_const('Autocomplete::UsersFinder::LIMIT', users_find_limit)
......@@ -52,12 +52,6 @@ RSpec.describe 'Merge request > User edits assignees sidebar', :js do
it "shows assignee tooltip '#{expected_tooltip}" do
expect(sidebar_assignee_dropdown_tooltip).to eql(expected_tooltip)
end
it 'does not show invite link' do
page.within '.dropdown-menu-user' do
expect(page).not_to have_link('Invite Members')
end
end
end
end
......@@ -74,48 +68,15 @@ RSpec.describe 'Merge request > User edits assignees sidebar', :js do
end
end
context 'when invite_members_version_a experiment is enabled' do
context 'with invite members experiment considerations' do
let_it_be(:user) { create(:user) }
before do
stub_experiment_for_user(invite_members_version_a: true)
sign_in(user)
end
context 'when user can not see invite members' do
before do
project.add_developer(user)
visit project_merge_request_path(project, merge_request)
find('.block.assignee .edit-link').click
wait_for_requests
end
it 'does not see link to invite members' do
page.within '.dropdown-menu-user' do
expect(page).not_to have_link('Invite Members')
end
end
end
context 'when user can see invite members' do
before do
project.add_maintainer(user)
visit project_merge_request_path(project, merge_request)
find('.block.assignee .edit-link').click
wait_for_requests
end
it 'sees link to invite members' do
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite Members', href: project_project_members_path(project))
expect(page).to have_selector('[data-track-event="click_invite_members"]')
expect(page).to have_selector("[data-track-label='edit_assignee']")
end
end
include_examples 'issuable invite members experiments' do
let(:issuable_path) { project_merge_request_path(project, merge_request) }
end
end
end
import { shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue';
const memberPath = 'member_path';
const createComponent = () => {
return shallowMount(InviteMemberModal, {
provide: {
membersPath: memberPath,
},
stubs: {
'gl-emoji': '<img/>',
'gl-modal': '<div><slot name="modal-title"></slot><slot></slot></div>',
},
});
};
describe('InviteMemberModal', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findLink = () => wrapper.find(GlLink);
describe('rendering the modal', () => {
it('renders the modal with the correct title', () => {
expect(wrapper.text()).toContain("Oops, this feature isn't ready yet");
});
describe('rendering the see who link', () => {
it('renders the correct link', () => {
expect(findLink().attributes('href')).toBe(memberPath);
});
});
});
describe('tracking', () => {
let trackingSpy;
afterEach(() => {
unmockTracking();
});
it('send an event when go to pipelines is clicked', () => {
trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
triggerEvent(findLink().element);
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_who_can_invite_link', {
label: 'invite_members_message',
});
});
});
});
const triggerProvides = {
displayText: 'Invite member',
event: 'click_invite_members_version_b',
label: 'edit_assignee',
};
export default triggerProvides;
import { shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue';
import triggerProvides from './invite_member_trigger_mock_data';
const createComponent = () => {
return shallowMount(InviteMemberTrigger, { provide: triggerProvides });
};
describe('InviteMemberTrigger', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findLink = () => wrapper.find(GlLink);
describe('displayText', () => {
it('includes the correct displayText for the link', () => {
expect(findLink().text()).toBe(triggerProvides.displayText);
});
});
describe('tracking', () => {
let trackingSpy;
afterEach(() => {
unmockTracking();
});
it('send an event when go to pipelines is clicked', () => {
trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
triggerEvent(findLink().element);
expect(trackingSpy).toHaveBeenCalledWith('_category_', triggerProvides.event, {
label: triggerProvides.label,
});
});
});
});
# frozen_string_literal: true
require "spec_helper"
RSpec.describe InviteMembersHelper do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user, developer_projects: [project]) }
let(:owner) { project.owner }
before do
assign(:project, project)
end
describe "#directly_invite_members?" do
context 'when the user is an owner' do
before do
allow(helper).to receive(:current_user) { owner }
end
it 'returns false' do
allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { false }
expect(helper.directly_invite_members?).to eq false
end
it 'returns true' do
allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { true }
expect(helper.directly_invite_members?).to eq true
end
end
context 'when the user is a developer' do
before do
allow(helper).to receive(:current_user) { developer }
end
it 'returns false' do
allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { true }
expect(helper.directly_invite_members?).to eq false
end
end
end
describe "#indirectly_invite_members?" do
context 'when a user is a developer' do
before do
allow(helper).to receive(:current_user) { developer }
end
it 'returns false' do
allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_b) { false }
expect(helper.indirectly_invite_members?).to eq false
end
it 'returns true' do
allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_b) { true }
expect(helper.indirectly_invite_members?).to eq true
end
end
context 'when a user is an owner' do
before do
allow(helper).to receive(:current_user) { owner }
end
it 'returns false' do
allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_b) { true }
expect(helper.indirectly_invite_members?).to eq false
end
end
end
end
# frozen_string_literal: true
RSpec.shared_examples 'issuable invite members experiments' do
context 'when invite_members_version_a experiment is enabled' do
before do
stub_experiment_for_user(invite_members_version_a: true)
end
it 'shows a link for inviting members and follows through to the members page' do
project.add_maintainer(user)
visit issuable_path
find('.block.assignee .edit-link').click
wait_for_requests
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite Members', href: project_project_members_path(project))
expect(page).to have_selector('[data-track-event="click_invite_members"]')
expect(page).to have_selector('[data-track-label="edit_assignee"]')
end
click_link 'Invite Members'
expect(current_path).to eq project_project_members_path(project)
end
end
context 'when invite_members_version_b experiment is enabled' do
before do
stub_experiment_for_user(invite_members_version_b: true)
end
it 'shows a link for inviting members and follows through to modal' do
project.add_developer(user)
visit issuable_path
find('.block.assignee .edit-link').click
wait_for_requests
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite Members', href: '#')
expect(page).to have_selector('[data-track-event="click_invite_members_version_b"]')
expect(page).to have_selector('[data-track-label="edit_assignee"]')
end
click_link 'Invite Members'
expect(page).to have_content("Oops, this feature isn't ready yet")
end
end
context 'when no invite members experiments are enabled' do
it 'shows author in assignee dropdown and no invite link' do
project.add_maintainer(user)
visit issuable_path
find('.block.assignee .edit-link').click
wait_for_requests
page.within '.dropdown-menu-user' do
expect(page).not_to have_link('Invite Members')
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