Commit d62e2dcd authored by Terri Chu's avatar Terri Chu

Merge branch 'invite-for-help-continuous-onboarding' into 'master'

Open invite members for task modal in Learn GitLab project

See merge request gitlab-org/gitlab!73846
parents 0df08101 c97b99a9
...@@ -30,7 +30,7 @@ export function getAllExperimentContexts() { ...@@ -30,7 +30,7 @@ export function getAllExperimentContexts() {
return Object.values(getExperimentsData()).map(createGitlabExperimentContext); return Object.values(getExperimentsData()).map(createGitlabExperimentContext);
} }
export function isExperimentVariant(experimentName, variantName) { export function isExperimentVariant(experimentName, variantName = CANDIDATE_VARIANT) {
return getExperimentData(experimentName)?.variant === variantName; return getExperimentData(experimentName)?.variant === variantName;
} }
......
...@@ -26,6 +26,7 @@ import { ...@@ -26,6 +26,7 @@ import {
MEMBER_AREAS_OF_FOCUS, MEMBER_AREAS_OF_FOCUS,
INVITE_MEMBERS_FOR_TASK, INVITE_MEMBERS_FOR_TASK,
MODAL_LABELS, MODAL_LABELS,
LEARN_GITLAB,
} from '../constants'; } from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { import {
...@@ -200,7 +201,8 @@ export default { ...@@ -200,7 +201,8 @@ export default {
}, },
tasksToBeDoneEnabled() { tasksToBeDoneEnabled() {
return ( return (
getParameterValues('open_modal')[0] === 'invite_members_for_task' && (getParameterValues('open_modal')[0] === 'invite_members_for_task' ||
this.isOnLearnGitlab) &&
this.tasksToBeDoneOptions.length this.tasksToBeDoneOptions.length
); );
}, },
...@@ -221,11 +223,18 @@ export default { ...@@ -221,11 +223,18 @@ export default {
? this.selectedTaskProject.id ? this.selectedTaskProject.id
: ''; : '';
}, },
isOnLearnGitlab() {
return this.source === LEARN_GITLAB;
},
}, },
mounted() { mounted() {
eventHub.$on('openModal', (options) => { eventHub.$on('openModal', (options) => {
this.openModal(options); this.openModal(options);
if (this.isOnLearnGitlab) {
this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, this.source);
} else {
this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view); this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view);
}
}); });
if (this.tasksToBeDoneEnabled) { if (this.tasksToBeDoneEnabled) {
...@@ -303,7 +312,7 @@ export default { ...@@ -303,7 +312,7 @@ export default {
: Api.groupShareWithGroup.bind(Api); : Api.groupShareWithGroup.bind(Api);
apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id)) apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
.then(this.showToastMessageSuccess) .then(this.showSuccessMessage)
.catch(this.showInvalidFeedbackMessage); .catch(this.showInvalidFeedbackMessage);
}, },
submitInviteMembers() { submitInviteMembers() {
...@@ -332,7 +341,7 @@ export default { ...@@ -332,7 +341,7 @@ export default {
this.trackinviteMembersForTask(); this.trackinviteMembersForTask();
Promise.all(promises) Promise.all(promises)
.then(this.conditionallyShowToastSuccess) .then(this.conditionallyShowSuccessMessage)
.catch(this.showInvalidFeedbackMessage); .catch(this.showInvalidFeedbackMessage);
}, },
inviteByEmailPostData(usersToInviteByEmail) { inviteByEmailPostData(usersToInviteByEmail) {
...@@ -364,11 +373,11 @@ export default { ...@@ -364,11 +373,11 @@ export default {
group_access: this.selectedAccessLevel, group_access: this.selectedAccessLevel,
}; };
}, },
conditionallyShowToastSuccess(response) { conditionallyShowSuccessMessage(response) {
const message = this.unescapeMsg(responseMessageFromSuccess(response)); const message = this.unescapeMsg(responseMessageFromSuccess(response));
if (message === '') { if (message === '') {
this.showToastMessageSuccess(); this.showSuccessMessage();
return; return;
} }
...@@ -376,8 +385,12 @@ export default { ...@@ -376,8 +385,12 @@ export default {
this.invalidFeedbackMessage = message; this.invalidFeedbackMessage = message;
this.isLoading = false; this.isLoading = false;
}, },
showToastMessageSuccess() { showSuccessMessage() {
if (this.isOnLearnGitlab) {
eventHub.$emit('showSuccessfulInvitationsAlert');
} else {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
}
this.closeModal(); this.closeModal();
}, },
showInvalidFeedbackMessage(response) { showInvalidFeedbackMessage(response) {
......
...@@ -144,3 +144,5 @@ export const MODAL_LABELS = { ...@@ -144,3 +144,5 @@ export const MODAL_LABELS = {
headerCloseLabel: HEADER_CLOSE_LABEL, headerCloseLabel: HEADER_CLOSE_LABEL,
areasOfFocusLabel: AREAS_OF_FOCUS_LABEL, areasOfFocusLabel: AREAS_OF_FOCUS_LABEL,
}; };
export const LEARN_GITLAB = 'learn_gitlab';
<script> <script>
import { GlProgressBar, GlSprintf } from '@gitlab/ui'; import { GlProgressBar, GlSprintf, GlAlert } from '@gitlab/ui';
import eventHub from '~/invite_members/event_hub'; import eventHub from '~/invite_members/event_hub';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { ACTION_LABELS, ACTION_SECTIONS } from '../constants'; import { ACTION_LABELS, ACTION_SECTIONS } from '../constants';
import LearnGitlabSectionCard from './learn_gitlab_section_card.vue'; import LearnGitlabSectionCard from './learn_gitlab_section_card.vue';
export default { export default {
components: { GlProgressBar, GlSprintf, LearnGitlabSectionCard }, components: { GlProgressBar, GlSprintf, GlAlert, LearnGitlabSectionCard },
i18n: { i18n: {
title: s__('LearnGitLab|Learn GitLab'), title: s__('LearnGitLab|Learn GitLab'),
description: s__( description: s__(
'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.', 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.',
), ),
percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`), percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`),
successfulInvitations: s__(
"LearnGitLab|Your team is growing! You've successfully invited new team members to the %{projectName} project.",
),
}, },
props: { props: {
actions: { actions: {
...@@ -28,12 +31,22 @@ export default { ...@@ -28,12 +31,22 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
project: {
required: true,
type: Object,
},
},
data() {
return {
showSuccessfulInvitationsAlert: false,
actionsData: this.actions,
};
}, },
maxValue: Object.keys(ACTION_LABELS).length, maxValue: Object.keys(ACTION_LABELS).length,
actionSections: Object.keys(ACTION_SECTIONS), actionSections: Object.keys(ACTION_SECTIONS),
computed: { computed: {
progressValue() { progressValue() {
return Object.values(this.actions).filter((a) => a.completed).length; return Object.values(this.actionsData).filter((a) => a.completed).length;
}, },
progressPercentage() { progressPercentage() {
return Math.round((this.progressValue / this.$options.maxValue) * 100); return Math.round((this.progressValue / this.$options.maxValue) * 100);
...@@ -43,14 +56,23 @@ export default { ...@@ -43,14 +56,23 @@ export default {
if (this.inviteMembersOpen) { if (this.inviteMembersOpen) {
this.openInviteMembersModal('celebrate'); this.openInviteMembersModal('celebrate');
} }
eventHub.$on('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert);
},
beforeDestroy() {
eventHub.$off('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert);
}, },
methods: { methods: {
openInviteMembersModal(mode) { openInviteMembersModal(mode) {
eventHub.$emit('openModal', { mode, inviteeType: 'members', source: 'learn-gitlab' }); eventHub.$emit('openModal', { mode, inviteeType: 'members', source: 'learn-gitlab' });
}, },
handleShowSuccessfulInvitationsAlert() {
this.showSuccessfulInvitationsAlert = true;
this.markActionAsCompleted('userAdded');
},
actionsFor(section) { actionsFor(section) {
const actions = Object.fromEntries( const actions = Object.fromEntries(
Object.entries(this.actions).filter( Object.entries(this.actionsData).filter(
([action]) => ACTION_LABELS[action].section === section, ([action]) => ACTION_LABELS[action].section === section,
), ),
); );
...@@ -59,11 +81,34 @@ export default { ...@@ -59,11 +81,34 @@ export default {
svgFor(section) { svgFor(section) {
return this.sections[section].svg; return this.sections[section].svg;
}, },
markActionAsCompleted(completedAction) {
Object.keys(this.actionsData).forEach((action) => {
if (action === completedAction) {
this.actionsData[action].completed = true;
this.modifySidebarPercentage();
}
});
},
modifySidebarPercentage() {
const el = document.querySelector('.sidebar-top-level-items .active .count');
el.textContent = `${this.progressPercentage}%`;
},
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<gl-alert
v-if="showSuccessfulInvitationsAlert"
class="gl-mt-5"
@dismiss="showSuccessfulInvitationsAlert = false"
>
<gl-sprintf :message="$options.i18n.successfulInvitations">
<template #projectName>
<strong>{{ project.name }}</strong>
</template>
</gl-sprintf>
</gl-alert>
<div class="row"> <div class="row">
<div class="gl-mb-7 gl-ml-5"> <div class="gl-mb-7 gl-ml-5">
<h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
......
<script> <script>
import { GlLink, GlIcon } from '@gitlab/ui'; import { GlLink, GlIcon } from '@gitlab/ui';
import { isExperimentVariant } from '~/experimentation/utils';
import eventHub from '~/invite_members/event_hub';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { ACTION_LABELS } from '../constants'; import { ACTION_LABELS } from '../constants';
...@@ -24,6 +26,20 @@ export default { ...@@ -24,6 +26,20 @@ export default {
trialOnly() { trialOnly() {
return ACTION_LABELS[this.action].trialRequired; return ACTION_LABELS[this.action].trialRequired;
}, },
showInviteModalLink() {
return (
this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding')
);
},
},
methods: {
openModal() {
eventHub.$emit('openModal', {
inviteeType: 'members',
source: 'learn_gitlab',
tasksToBeDoneEnabled: true,
});
},
}, },
}; };
</script> </script>
...@@ -33,8 +49,18 @@ export default { ...@@ -33,8 +49,18 @@ export default {
<gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" /> <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" />
{{ $options.i18n.ACTION_LABELS[action].title }} {{ $options.i18n.ACTION_LABELS[action].title }}
</span> </span>
<span v-else>
<gl-link <gl-link
v-else-if="showInviteModalLink"
data-track-action="click_link"
:data-track-label="$options.i18n.ACTION_LABELS[action].title"
data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding"
data-testid="invite-for-help-continuous-onboarding-experiment-link"
@click="openModal"
>
{{ $options.i18n.ACTION_LABELS[action].title }}
</gl-link>
<gl-link
v-else
target="_blank" target="_blank"
:href="value.url" :href="value.url"
data-track-action="click_link" data-track-action="click_link"
...@@ -44,7 +70,6 @@ export default { ...@@ -44,7 +70,6 @@ export default {
> >
{{ $options.i18n.ACTION_LABELS[action].title }} {{ $options.i18n.ACTION_LABELS[action].title }}
</gl-link> </gl-link>
</span>
<span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> <span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
- {{ $options.i18n.trialOnly }} - {{ $options.i18n.trialOnly }}
</span> </span>
......
...@@ -12,17 +12,18 @@ function initLearnGitlab() { ...@@ -12,17 +12,18 @@ function initLearnGitlab() {
const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions)); const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions));
const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections)); const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections));
const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project));
const { inviteMembersOpen } = el.dataset; const { inviteMembersOpen } = el.dataset;
return new Vue({ return new Vue({
el, el,
render(createElement) { render(createElement) {
return createElement(LearnGitlab, { return createElement(LearnGitlab, {
props: { actions, sections, inviteMembersOpen }, props: { actions, sections, project, inviteMembersOpen },
}); });
}, },
}); });
} }
initInviteMembersModal();
initLearnGitlab(); initLearnGitlab();
initInviteMembersModal();
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
class Projects::LearnGitlabController < Projects::ApplicationController class Projects::LearnGitlabController < Projects::ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :check_experiment_enabled? before_action :check_experiment_enabled?
before_action :enable_invite_for_help_continuous_onboarding_experiment
feature_category :users feature_category :users
...@@ -14,4 +15,13 @@ class Projects::LearnGitlabController < Projects::ApplicationController ...@@ -14,4 +15,13 @@ class Projects::LearnGitlabController < Projects::ApplicationController
def check_experiment_enabled? def check_experiment_enabled?
return access_denied! unless helpers.learn_gitlab_enabled?(project) return access_denied! unless helpers.learn_gitlab_enabled?(project)
end end
def enable_invite_for_help_continuous_onboarding_experiment
return unless current_user.can?(:admin_group_member, project.namespace)
experiment(:invite_for_help_continuous_onboarding, namespace: project.namespace) do |e|
e.candidate {}
e.record!
end
end
end end
...@@ -42,7 +42,7 @@ module InviteMembersHelper ...@@ -42,7 +42,7 @@ module InviteMembersHelper
e.candidate { dataset.merge!(areas_of_focus_options: member_areas_of_focus_options.to_json, no_selection_areas_of_focus: ['no_selection']) } e.candidate { dataset.merge!(areas_of_focus_options: member_areas_of_focus_options.to_json, no_selection_areas_of_focus: ['no_selection']) }
end end
if show_invite_members_for_task? if show_invite_members_for_task?(source)
dataset.merge!( dataset.merge!(
tasks_to_be_done_options: tasks_to_be_done_options.to_json, tasks_to_be_done_options: tasks_to_be_done_options.to_json,
projects: projects_for_source(source).to_json, projects: projects_for_source(source).to_json,
...@@ -80,10 +80,12 @@ module InviteMembersHelper ...@@ -80,10 +80,12 @@ module InviteMembersHelper
{} {}
end end
def show_invite_members_for_task? def show_invite_members_for_task?(source)
return unless current_user && experiment(:invite_members_for_task).enabled? return unless current_user
params[:open_modal] == 'invite_members_for_task' invite_members_for_task_experiment = experiment(:invite_members_for_task).enabled? && params[:open_modal] == 'invite_members_for_task'
invite_for_help_continuous_onboarding = source.is_a?(Project) && experiment(:invite_for_help_continuous_onboarding, namespace: source.namespace).variant.name == 'candidate'
invite_members_for_task_experiment || invite_for_help_continuous_onboarding
end end
def tasks_to_be_done_options def tasks_to_be_done_options
......
...@@ -10,7 +10,8 @@ module LearnGitlabHelper ...@@ -10,7 +10,8 @@ module LearnGitlabHelper
def learn_gitlab_data(project) def learn_gitlab_data(project)
{ {
actions: onboarding_actions_data(project).to_json, actions: onboarding_actions_data(project).to_json,
sections: onboarding_sections_data.to_json sections: onboarding_sections_data.to_json,
project: onboarding_project_data(project).to_json
} }
end end
...@@ -56,6 +57,10 @@ module LearnGitlabHelper ...@@ -56,6 +57,10 @@ module LearnGitlabHelper
} }
end end
def onboarding_project_data(project)
{ name: project.name }
end
def action_urls def action_urls
LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) } LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) }
.merge(LearnGitlab::Onboarding::ACTION_DOC_URLS) .merge(LearnGitlab::Onboarding::ACTION_DOC_URLS)
......
...@@ -4,9 +4,10 @@ ...@@ -4,9 +4,10 @@
- data = learn_gitlab_data(@project) - data = learn_gitlab_data(@project)
- invite_members_open = session.delete(:confetti_post_signup) - invite_members_open = session.delete(:confetti_post_signup)
= render 'projects/invite_members_modal', project: @project
- experiment(:confetti_post_signup, actor: current_user) do |e| - experiment(:confetti_post_signup, actor: current_user) do |e|
- e.control do - e.control do
#js-learn-gitlab-app{ data: data } #js-learn-gitlab-app{ data: data }
- e.candidate do - e.candidate do
= render 'projects/invite_members_modal', project: @project
#js-learn-gitlab-app{ data: data.merge(invite_members_open: invite_members_open) } #js-learn-gitlab-app{ data: data.merge(invite_members_open: invite_members_open) }
---
name: invite_for_help_continuous_onboarding
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73846
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/345708
milestone: '14.5'
type: experiment
group: group::activation
default_enabled: false
...@@ -20583,6 +20583,9 @@ msgstr "" ...@@ -20583,6 +20583,9 @@ msgstr ""
msgid "LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:" msgid "LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:"
msgstr "" msgstr ""
msgid "LearnGitLab|Your team is growing! You've successfully invited new team members to the %{projectName} project."
msgstr ""
msgid "LearnGitlab|Creating your onboarding experience..." msgid "LearnGitlab|Creating your onboarding experience..."
msgstr "" msgstr ""
......
...@@ -5,14 +5,15 @@ require 'spec_helper' ...@@ -5,14 +5,15 @@ require 'spec_helper'
RSpec.describe Projects::LearnGitlabController do RSpec.describe Projects::LearnGitlabController do
describe 'GET #index' do describe 'GET #index' do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace) } let_it_be(:project) { create(:project, namespace: create(:group)) }
let(:learn_gitlab_enabled) { true } let(:learn_gitlab_enabled) { true }
let(:params) { { namespace_id: project.namespace.to_param, project_id: project } } let(:params) { { namespace_id: project.namespace.to_param, project_id: project } }
subject { get :index, params: params } subject(:action) { get :index, params: params }
before do before do
project.namespace.add_owner(user)
allow(controller.helpers).to receive(:learn_gitlab_enabled?).and_return(learn_gitlab_enabled) allow(controller.helpers).to receive(:learn_gitlab_enabled?).and_return(learn_gitlab_enabled)
end end
...@@ -32,6 +33,10 @@ RSpec.describe Projects::LearnGitlabController do ...@@ -32,6 +33,10 @@ RSpec.describe Projects::LearnGitlabController do
it { is_expected.to have_gitlab_http_status(:not_found) } it { is_expected.to have_gitlab_http_status(:not_found) }
end end
it_behaves_like 'tracks assignment and records the subject', :invite_for_help_continuous_onboarding, :namespace do
subject { project.namespace }
end
end end
end end
end end
...@@ -100,6 +100,7 @@ describe('experiment Utilities', () => { ...@@ -100,6 +100,7 @@ describe('experiment Utilities', () => {
describe('isExperimentVariant', () => { describe('isExperimentVariant', () => {
describe.each` describe.each`
experiment | variant | input | output experiment | variant | input | output
${ABC_KEY} | ${CANDIDATE_VARIANT} | ${[ABC_KEY]} | ${true}
${ABC_KEY} | ${DEFAULT_VARIANT} | ${[ABC_KEY, DEFAULT_VARIANT]} | ${true} ${ABC_KEY} | ${DEFAULT_VARIANT} | ${[ABC_KEY, DEFAULT_VARIANT]} | ${true}
${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_variant_name']} | ${true} ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_variant_name']} | ${true}
${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_bogus_name']} | ${false} ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_bogus_name']} | ${false}
......
...@@ -28,6 +28,7 @@ import { ...@@ -28,6 +28,7 @@ import {
MEMBERS_MODAL_DEFAULT_TITLE, MEMBERS_MODAL_DEFAULT_TITLE,
MEMBERS_PLACEHOLDER, MEMBERS_PLACEHOLDER,
MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
LEARN_GITLAB,
} from '~/invite_members/constants'; } from '~/invite_members/constants';
import eventHub from '~/invite_members/event_hub'; import eventHub from '~/invite_members/event_hub';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
...@@ -268,6 +269,14 @@ describe('InviteMembersModal', () => { ...@@ -268,6 +269,14 @@ describe('InviteMembersModal', () => {
expect(findTasksToBeDone().exists()).toBe(false); expect(findTasksToBeDone().exists()).toBe(false);
}); });
describe('when opened from the Learn GitLab page', () => {
it('does render the tasks to be done', () => {
setupComponent({ source: LEARN_GITLAB }, {}, []);
expect(findTasksToBeDone().exists()).toBe(true);
});
});
}); });
describe('rendering the tasks', () => { describe('rendering the tasks', () => {
...@@ -465,7 +474,6 @@ describe('InviteMembersModal', () => { ...@@ -465,7 +474,6 @@ describe('InviteMembersModal', () => {
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');
}); });
it('includes the non-default selected areas of focus', () => { it('includes the non-default selected areas of focus', () => {
...@@ -492,7 +500,23 @@ describe('InviteMembersModal', () => { ...@@ -492,7 +500,23 @@ describe('InviteMembersModal', () => {
}); });
it('displays the successful toastMessage', () => { it('displays the successful toastMessage', () => {
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
});
});
describe('when opened from a Learn GitLab page', () => {
it('emits the `showSuccessfulInvitationsAlert` event', async () => {
eventHub.$emit('openModal', { inviteeType: 'members', source: LEARN_GITLAB });
jest.spyOn(eventHub, '$emit').mockImplementation();
clickInviteButton();
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith('showSuccessfulInvitationsAlert');
}); });
}); });
}); });
...@@ -649,7 +673,6 @@ describe('InviteMembersModal', () => { ...@@ -649,7 +673,6 @@ describe('InviteMembersModal', () => {
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
}); });
it('includes the non-default selected areas of focus', () => { it('includes the non-default selected areas of focus', () => {
...@@ -672,7 +695,9 @@ describe('InviteMembersModal', () => { ...@@ -672,7 +695,9 @@ describe('InviteMembersModal', () => {
}); });
it('displays the successful toastMessage', () => { it('displays the successful toastMessage', () => {
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
}); });
}); });
}); });
...@@ -711,13 +736,14 @@ describe('InviteMembersModal', () => { ...@@ -711,13 +736,14 @@ describe('InviteMembersModal', () => {
it('displays the successful toast message when email has already been invited', async () => { it('displays the successful toast message when email has already been invited', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
clickInviteButton(); clickInviteButton();
await waitForPromises(); await waitForPromises();
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
expect(findMembersSelect().props('validationState')).toBe(null); expect(findMembersSelect().props('validationState')).toBe(null);
}); });
...@@ -782,7 +808,6 @@ describe('InviteMembersModal', () => { ...@@ -782,7 +808,6 @@ describe('InviteMembersModal', () => {
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
jest.spyOn(wrapper.vm, 'trackInvite'); jest.spyOn(wrapper.vm, 'trackInvite');
}); });
...@@ -800,7 +825,9 @@ describe('InviteMembersModal', () => { ...@@ -800,7 +825,9 @@ describe('InviteMembersModal', () => {
}); });
it('displays the successful toastMessage', () => { it('displays the successful toastMessage', () => {
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
}); });
}); });
...@@ -855,7 +882,6 @@ describe('InviteMembersModal', () => { ...@@ -855,7 +882,6 @@ describe('InviteMembersModal', () => {
wrapper.setData({ inviteeType: 'group' }); wrapper.setData({ inviteeType: 'group' });
wrapper.vm.$toast = { show: jest.fn() }; wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
clickInviteButton(); clickInviteButton();
}); });
...@@ -865,7 +891,9 @@ describe('InviteMembersModal', () => { ...@@ -865,7 +891,9 @@ describe('InviteMembersModal', () => {
}); });
it('displays the successful toastMessage', () => { it('displays the successful toastMessage', () => {
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
}); });
}); });
...@@ -930,6 +958,13 @@ describe('InviteMembersModal', () => { ...@@ -930,6 +958,13 @@ describe('InviteMembersModal', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(MEMBER_AREAS_OF_FOCUS.view); expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(MEMBER_AREAS_OF_FOCUS.view);
}); });
it('tracks the view for learn_gitlab source', () => {
eventHub.$emit('openModal', { inviteeType: 'members', source: LEARN_GITLAB });
expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name);
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(LEARN_GITLAB);
});
it('tracks the invite for areas_of_focus', () => { it('tracks the invite for areas_of_focus', () => {
eventHub.$emit('openModal', { inviteeType: 'members' }); eventHub.$emit('openModal', { inviteeType: 'members' });
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
exports[`Learn GitLab renders correctly 1`] = ` exports[`Learn GitLab renders correctly 1`] = `
<div> <div>
<!---->
<div <div
class="row" class="row"
> >
...@@ -131,7 +133,6 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -131,7 +133,6 @@ exports[`Learn GitLab renders correctly 1`] = `
<div <div
class="gl-mb-4" class="gl-mb-4"
> >
<span>
<a <a
class="gl-link" class="gl-link"
data-track-action="click_link" data-track-action="click_link"
...@@ -146,14 +147,12 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -146,14 +147,12 @@ exports[`Learn GitLab renders correctly 1`] = `
Set up CI/CD Set up CI/CD
</a> </a>
</span>
<!----> <!---->
</div> </div>
<div <div
class="gl-mb-4" class="gl-mb-4"
> >
<span>
<a <a
class="gl-link" class="gl-link"
data-track-action="click_link" data-track-action="click_link"
...@@ -168,14 +167,12 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -168,14 +167,12 @@ exports[`Learn GitLab renders correctly 1`] = `
Start a free Ultimate trial Start a free Ultimate trial
</a> </a>
</span>
<!----> <!---->
</div> </div>
<div <div
class="gl-mb-4" class="gl-mb-4"
> >
<span>
<a <a
class="gl-link" class="gl-link"
data-track-action="click_link" data-track-action="click_link"
...@@ -190,7 +187,6 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -190,7 +187,6 @@ exports[`Learn GitLab renders correctly 1`] = `
Add code owners Add code owners
</a> </a>
</span>
<span <span
class="gl-font-style-italic gl-text-gray-500" class="gl-font-style-italic gl-text-gray-500"
...@@ -204,7 +200,6 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -204,7 +200,6 @@ exports[`Learn GitLab renders correctly 1`] = `
<div <div
class="gl-mb-4" class="gl-mb-4"
> >
<span>
<a <a
class="gl-link" class="gl-link"
data-track-action="click_link" data-track-action="click_link"
...@@ -219,7 +214,6 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -219,7 +214,6 @@ exports[`Learn GitLab renders correctly 1`] = `
Add merge request approval Add merge request approval
</a> </a>
</span>
<span <span
class="gl-font-style-italic gl-text-gray-500" class="gl-font-style-italic gl-text-gray-500"
...@@ -269,7 +263,6 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -269,7 +263,6 @@ exports[`Learn GitLab renders correctly 1`] = `
<div <div
class="gl-mb-4" class="gl-mb-4"
> >
<span>
<a <a
class="gl-link" class="gl-link"
data-track-action="click_link" data-track-action="click_link"
...@@ -284,14 +277,12 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -284,14 +277,12 @@ exports[`Learn GitLab renders correctly 1`] = `
Create an issue Create an issue
</a> </a>
</span>
<!----> <!---->
</div> </div>
<div <div
class="gl-mb-4" class="gl-mb-4"
> >
<span>
<a <a
class="gl-link" class="gl-link"
data-track-action="click_link" data-track-action="click_link"
...@@ -306,7 +297,6 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -306,7 +297,6 @@ exports[`Learn GitLab renders correctly 1`] = `
Submit a merge request Submit a merge request
</a> </a>
</span>
<!----> <!---->
</div> </div>
...@@ -349,7 +339,6 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -349,7 +339,6 @@ exports[`Learn GitLab renders correctly 1`] = `
<div <div
class="gl-mb-4" class="gl-mb-4"
> >
<span>
<a <a
class="gl-link" class="gl-link"
data-track-action="click_link" data-track-action="click_link"
...@@ -364,7 +353,6 @@ exports[`Learn GitLab renders correctly 1`] = ` ...@@ -364,7 +353,6 @@ exports[`Learn GitLab renders correctly 1`] = `
Run a Security scan using CI/CD Run a Security scan using CI/CD
</a> </a>
</span>
<!----> <!---->
</div> </div>
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { stubExperiments } from 'helpers/experimentation_helper';
import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper';
import eventHub from '~/invite_members/event_hub';
import LearnGitlabSectionLink from '~/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue'; import LearnGitlabSectionLink from '~/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue';
const defaultAction = 'gitWrite'; const defaultAction = 'gitWrite';
...@@ -23,6 +26,9 @@ describe('Learn GitLab Section Link', () => { ...@@ -23,6 +26,9 @@ describe('Learn GitLab Section Link', () => {
}); });
}; };
const openInviteMembesrModalLink = () =>
wrapper.find('[data-testid="invite-for-help-continuous-onboarding-experiment-link"]');
it('renders no icon when not completed', () => { it('renders no icon when not completed', () => {
createWrapper(undefined, { completed: false }); createWrapper(undefined, { completed: false });
...@@ -46,4 +52,54 @@ describe('Learn GitLab Section Link', () => { ...@@ -46,4 +52,54 @@ describe('Learn GitLab Section Link', () => {
expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true); expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true);
}); });
describe('rendering a link to open the invite_members modal instead of a regular link', () => {
it.each`
action | experimentVariant | showModal
${'userAdded'} | ${'candidate'} | ${true}
${'userAdded'} | ${'control'} | ${false}
${defaultAction} | ${'candidate'} | ${false}
${defaultAction} | ${'control'} | ${false}
`(
'when the invite_for_help_continuous_onboarding experiment has variant: $experimentVariant and action is $action, the modal link is shown: $showModal',
({ action, experimentVariant, showModal }) => {
stubExperiments({ invite_for_help_continuous_onboarding: experimentVariant });
createWrapper(action);
expect(openInviteMembesrModalLink().exists()).toBe(showModal);
},
);
});
describe('clicking the link to open the invite_members modal', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation();
stubExperiments({ invite_for_help_continuous_onboarding: 'candidate' });
createWrapper('userAdded');
});
it('calls the eventHub', () => {
openInviteMembesrModalLink().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('openModal', {
inviteeType: 'members',
source: 'learn_gitlab',
tasksToBeDoneEnabled: true,
});
});
it('tracks the click', async () => {
const trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
triggerEvent(openInviteMembesrModalLink().element);
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', {
label: 'Invite your colleagues',
property: 'Growth::Activation::Experiment::InviteForHelpContinuousOnboarding',
});
unmockTracking();
});
});
}); });
import { GlProgressBar } from '@gitlab/ui'; import { GlProgressBar, GlAlert } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import LearnGitlab from '~/pages/projects/learn_gitlab/components/learn_gitlab.vue'; import LearnGitlab from '~/pages/projects/learn_gitlab/components/learn_gitlab.vue';
import eventHub from '~/invite_members/event_hub'; import eventHub from '~/invite_members/event_hub';
import { testActions, testSections } from './mock_data'; import { testActions, testSections, testProject } from './mock_data';
describe('Learn GitLab', () => { describe('Learn GitLab', () => {
let wrapper; let wrapper;
let sidebar;
let inviteMembersOpen = false; let inviteMembersOpen = false;
const createWrapper = () => { const createWrapper = () => {
wrapper = mount(LearnGitlab, { wrapper = mount(LearnGitlab, {
propsData: { actions: testActions, sections: testSections, inviteMembersOpen }, propsData: {
actions: testActions,
sections: testSections,
project: testProject,
inviteMembersOpen,
},
}); });
}; };
beforeEach(() => { beforeEach(() => {
sidebar = document.createElement('div');
sidebar.innerHTML = `
<div class="sidebar-top-level-items">
<div class="active">
<div class="count"></div>
</div>
</div>
`;
document.body.appendChild(sidebar);
createWrapper(); createWrapper();
}); });
...@@ -22,6 +37,7 @@ describe('Learn GitLab', () => { ...@@ -22,6 +37,7 @@ describe('Learn GitLab', () => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
inviteMembersOpen = false; inviteMembersOpen = false;
sidebar.remove();
}); });
it('renders correctly', () => { it('renders correctly', () => {
...@@ -66,4 +82,26 @@ describe('Learn GitLab', () => { ...@@ -66,4 +82,26 @@ describe('Learn GitLab', () => {
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
}); });
describe('when the showSuccessfulInvitationsAlert event is fired', () => {
const findAlert = () => wrapper.findComponent(GlAlert);
beforeEach(() => {
eventHub.$emit('showSuccessfulInvitationsAlert');
});
it('displays the successful invitations alert', () => {
expect(findAlert().exists()).toBe(true);
});
it('displays a message with the project name', () => {
expect(findAlert().text()).toBe(
"Your team is growing! You've successfully invited new team members to the test-project project.",
);
});
it('modifies the sidebar percentage', () => {
expect(sidebar.textContent.trim()).toBe('22%');
});
});
}); });
...@@ -57,3 +57,7 @@ export const testSections = { ...@@ -57,3 +57,7 @@ export const testSections = {
svg: 'plan.svg', svg: 'plan.svg',
}, },
}; };
export const testProject = {
name: 'test-project',
};
...@@ -6,6 +6,7 @@ RSpec.describe InviteMembersHelper do ...@@ -6,6 +6,7 @@ RSpec.describe InviteMembersHelper do
include Devise::Test::ControllerHelpers include Devise::Test::ControllerHelpers
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group, projects: [project]) }
let_it_be(:developer) { create(:user, developer_projects: [project]) } let_it_be(:developer) { create(:user, developer_projects: [project]) }
let(:owner) { project.owner } let(:owner) { project.owner }
...@@ -64,77 +65,93 @@ RSpec.describe InviteMembersHelper do ...@@ -64,77 +65,93 @@ RSpec.describe InviteMembersHelper do
end end
context 'tasks_to_be_done' do context 'tasks_to_be_done' do
subject(:output) { helper.common_invite_modal_dataset(source) } using RSpec::Parameterized::TableSyntax
let_it_be(:source) { project }
before do
stub_experiments(invite_members_for_task: true)
end
context 'when not logged in' do subject(:output) { helper.common_invite_modal_dataset(source) }
before do
allow(helper).to receive(:params).and_return({ open_modal: 'invite_members_for_task' })
end
it "doesn't have the tasks to be done attributes" do shared_examples_for 'including the tasks to be done attributes' do
it 'includes the tasks to be done attributes when expected' do
if expected?
expect(output[:tasks_to_be_done_options]).to eq(
[
{ value: :code, text: 'Create/import code into a project (repository)' },
{ value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' },
{ value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' }
].to_json
)
expect(output[:projects]).to eq(
[{ id: project.id, title: project.title }].to_json
)
expect(output[:new_project_path]).to eq(
source.is_a?(Project) ? '' : new_project_path(namespace_id: group.id)
)
else
expect(output[:tasks_to_be_done_options]).to be_nil expect(output[:tasks_to_be_done_options]).to be_nil
expect(output[:projects]).to be_nil expect(output[:projects]).to be_nil
expect(output[:new_project_path]).to be_nil expect(output[:new_project_path]).to be_nil
end end
end end
end
context 'the invite_members_for_task experiment' do
where(:invite_members_for_task_enabled?, :open_modal_param_present?, :logged_in?, :expected?) do
true | true | true | true
true | true | false | false
true | false | true | false
true | false | false | false
false | true | true | false
false | true | false | false
false | false | true | false
false | false | false | false
end
context 'when logged in but the open_modal param is not present' do with_them do
before do before do
allow(helper).to receive(:current_user).and_return(developer) allow(helper).to receive(:current_user).and_return(developer) if logged_in?
stub_experiments(invite_members_for_task: true) if invite_members_for_task_enabled?
allow(helper).to receive(:params).and_return({ open_modal: 'invite_members_for_task' }) if open_modal_param_present?
end end
it "doesn't have the tasks to be done attributes" do context 'when the source is a project' do
expect(output[:tasks_to_be_done_options]).to be_nil let_it_be(:source) { project }
expect(output[:projects]).to be_nil
expect(output[:new_project_path]).to be_nil it_behaves_like 'including the tasks to be done attributes'
end end
context 'when the source is a group' do
let_it_be(:source) { group }
it_behaves_like 'including the tasks to be done attributes'
end
end
end
context 'the invite_for_help_continuous_onboarding experiment' do
where(:invite_for_help_continuous_onboarding?, :logged_in?, :expected?) do
true | true | true
true | false | false
false | true | false
false | false | false
end end
context 'when logged in and the open_modal param is present' do with_them do
before do before do
allow(helper).to receive(:current_user).and_return(developer) allow(helper).to receive(:current_user).and_return(developer) if logged_in?
allow(helper).to receive(:params).and_return({ open_modal: 'invite_members_for_task' }) stub_experiments(invite_for_help_continuous_onboarding: :candidate) if invite_for_help_continuous_onboarding?
end end
context 'for a group' do context 'when the source is a project' do
let_it_be(:source) { create(:group, projects: [project]) } let_it_be(:source) { project }
it 'has the expected attributes', :aggregate_failures do it_behaves_like 'including the tasks to be done attributes'
expect(output[:tasks_to_be_done_options]).to eq(
[
{ value: :code, text: 'Create/import code into a project (repository)' },
{ value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' },
{ value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' }
].to_json
)
expect(output[:projects]).to eq(
[{ id: project.id, title: project.title }].to_json
)
expect(output[:new_project_path]).to eq(
new_project_path(namespace_id: source.id)
)
end
end end
context 'for a project' do context 'when the source is a group' do
it 'has the expected attributes', :aggregate_failures do let_it_be(:source) { group }
expect(output[:tasks_to_be_done_options]).to eq(
[ let(:expected?) { false }
{ value: :code, text: 'Create/import code into a project (repository)' },
{ value: :ci, text: 'Set up CI/CD pipelines to build, test, deploy, and monitor code' }, it_behaves_like 'including the tasks to be done attributes'
{ value: :issues, text: 'Create/import issues (tickets) to collaborate on ideas and plan work' }
].to_json
)
expect(output[:projects]).to eq(
[{ id: project.id, title: project.title }].to_json
)
expect(output[:new_project_path]).to eq('')
end end
end end
end end
......
...@@ -60,6 +60,7 @@ RSpec.describe LearnGitlabHelper do ...@@ -60,6 +60,7 @@ RSpec.describe LearnGitlabHelper do
let(:onboarding_actions_data) { Gitlab::Json.parse(learn_gitlab_data[:actions]).deep_symbolize_keys } let(:onboarding_actions_data) { Gitlab::Json.parse(learn_gitlab_data[:actions]).deep_symbolize_keys }
let(:onboarding_sections_data) { Gitlab::Json.parse(learn_gitlab_data[:sections]).deep_symbolize_keys } let(:onboarding_sections_data) { Gitlab::Json.parse(learn_gitlab_data[:sections]).deep_symbolize_keys }
let(:onboarding_project_data) { Gitlab::Json.parse(learn_gitlab_data[:project]).deep_symbolize_keys }
shared_examples 'has all data' do shared_examples 'has all data' do
it 'has all actions' do it 'has all actions' do
...@@ -82,6 +83,11 @@ RSpec.describe LearnGitlabHelper do ...@@ -82,6 +83,11 @@ RSpec.describe LearnGitlabHelper do
expect(onboarding_sections_data.keys).to contain_exactly(:deploy, :plan, :workspace) expect(onboarding_sections_data.keys).to contain_exactly(:deploy, :plan, :workspace)
expect(onboarding_sections_data.values.map { |section| section.keys }).to match_array([[:svg]] * 3) expect(onboarding_sections_data.values.map { |section| section.keys }).to match_array([[:svg]] * 3)
end end
it 'has all project data', :aggregate_failures do
expect(onboarding_project_data.keys).to contain_exactly(:name)
expect(onboarding_project_data.values).to match_array([project.name])
end
end end
it_behaves_like 'has all data' it_behaves_like 'has all data'
......
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