Commit 50c72b5c authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '334211-implement-hand-raise-pqls-in-saas-app' into 'master'

Implement hand-raise PQLs in SaaS app

See merge request gitlab-org/gitlab!70044
parents 9860d017 554183a0
<script>
import {
GlButton,
GlFormGroup,
GlFormInput,
GlFormSelect,
GlFormTextarea,
GlModal,
GlModalDirective,
} from '@gitlab/ui';
import * as SubscriptionsApi from 'ee/api/subscriptions_api';
import createFlash, { FLASH_TYPES } from '~/flash';
import { sprintf } from '~/locale';
import countriesQuery from 'ee/subscriptions/graphql/queries/countries.query.graphql';
import statesQuery from 'ee/subscriptions/graphql/queries/states.query.graphql';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { i18n, companySizes, COUNTRIES_WITH_STATES_ALLOWED } from '../constants';
export default {
name: 'HandRaiseLeadButton',
components: {
GlButton,
GlFormGroup,
GlFormInput,
GlFormSelect,
GlFormTextarea,
GlModal,
},
directives: {
GlModal: GlModalDirective,
autofocusonshow,
},
props: {
namespaceId: {
type: Number,
required: true,
},
userName: {
type: String,
required: true,
},
},
data() {
return {
isLoading: false,
firstName: '',
lastName: '',
companyName: '',
companySize: null,
phoneNumber: '',
country: null,
state: null,
countries: [],
states: [],
comment: '',
};
},
apollo: {
countries: {
query: countriesQuery,
},
states: {
query: statesQuery,
skip() {
return !this.country;
},
variables() {
return {
countryId: this.country,
};
},
},
},
computed: {
modalHeaderText() {
return sprintf(this.$options.i18n.modalHeaderText, {
userName: this.userName,
});
},
mustEnterState() {
return COUNTRIES_WITH_STATES_ALLOWED.includes(this.country);
},
canSubmit() {
return (
this.firstName &&
this.lastName &&
this.companyName &&
this.companySize &&
this.phoneNumber &&
this.country &&
(this.mustEnterState ? this.state : true)
);
},
actionPrimary() {
return {
text: this.$options.i18n.modalPrimary,
attributes: [{ variant: 'success' }, { disabled: !this.canSubmit }],
};
},
actionCancel() {
return {
text: this.$options.i18n.modalCancel,
};
},
showState() {
return !this.$apollo.loading.states && this.states && this.country && this.mustEnterState;
},
companySizeOptionsWithDefault() {
return [
{
name: this.$options.i18n.companySizeSelectPrompt,
id: null,
},
...companySizes,
];
},
countryOptionsWithDefault() {
return [
{
name: this.$options.i18n.countrySelectPrompt,
id: null,
},
...this.countries,
];
},
stateOptionsWithDefault() {
return [
{
name: this.$options.i18n.stateSelectPrompt,
id: null,
},
...this.states,
];
},
formParams() {
return {
namespaceId: this.namespaceId,
companyName: this.companyName,
companySize: this.companySize,
firstName: this.firstName,
lastName: this.lastName,
phoneNumber: this.phoneNumber,
country: this.country,
state: this.mustEnterState ? this.state : null,
comment: this.comment,
};
},
},
methods: {
clearForm() {
this.firstName = '';
this.lastName = '';
this.companyName = '';
this.companySize = '';
this.phoneNumber = '';
this.country = null;
this.state = null;
this.comment = '';
},
async submit() {
this.isLoading = true;
await SubscriptionsApi.sendHandRaiseLead(this.formParams)
.then(() => {
createFlash({
message: this.$options.i18n.handRaiseActionSuccess,
type: FLASH_TYPES.SUCCESS,
});
this.clearForm();
})
.catch((error) => {
createFlash({
message: this.$options.i18n.handRaiseActionError,
captureError: true,
error,
});
})
.finally(() => {
this.isLoading = false;
});
},
},
i18n,
};
</script>
<template>
<div>
<gl-button
v-gl-modal.hand-raise-lead
:loading="isLoading"
category="secondary"
variant="success"
>
{{ $options.i18n.buttonText }}
</gl-button>
<gl-modal
ref="modal"
modal-id="hand-raise-lead"
size="sm"
:title="$options.i18n.modalTitle"
:action-primary="actionPrimary"
:action-cancel="actionCancel"
@primary="submit"
>
{{ modalHeaderText }}
<div class="combined d-flex">
<gl-form-group
:label="$options.i18n.firstNameLabel"
label-size="sm"
label-for="firstName"
class="mr-3 w-50"
>
<gl-form-input
id="first-Name"
v-model="firstName"
type="text"
class="form-control"
data-testid="first-name"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.lastNameLabel"
label-size="sm"
label-for="lastName"
class="w-50"
>
<gl-form-input
id="last-Name"
v-model="lastName"
type="text"
class="form-control"
data-testid="last-name"
/>
</gl-form-group>
</div>
<div class="combined d-flex">
<gl-form-group
:label="$options.i18n.companyNameLabel"
label-size="sm"
label-for="companyName"
class="mr-3 w-50"
>
<gl-form-input
id="company-name"
v-model="companyName"
type="text"
class="form-control"
data-testid="company-name"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.companySizeLabel"
label-size="sm"
label-for="companySize"
class="w-50"
>
<gl-form-select
v-model="companySize"
v-autofocusonshow
:options="companySizeOptionsWithDefault"
value-field="id"
text-field="name"
data-testid="company-size"
/>
</gl-form-group>
</div>
<gl-form-group
:label="$options.i18n.phoneNumberLabel"
label-size="sm"
label-for="phoneNumber"
>
<gl-form-input
id="phone-number"
v-model="phoneNumber"
type="text"
class="form-control"
data-testid="phone-number"
/>
</gl-form-group>
<gl-form-group
v-if="!$apollo.loading.countries"
:label="$options.i18n.countryLabel"
label-size="sm"
label-for="country"
>
<gl-form-select
v-model="country"
v-autofocusonshow
:options="countryOptionsWithDefault"
value-field="id"
text-field="name"
data-testid="country"
/>
</gl-form-group>
<gl-form-group
v-if="showState"
:label="$options.i18n.stateLabel"
label-size="sm"
label-for="state"
>
<gl-form-select
v-model="state"
v-autofocusonshow
:options="stateOptionsWithDefault"
value-field="id"
text-field="name"
data-testid="state"
/>
</gl-form-group>
<gl-form-group :label="$options.i18n.commentLabel" label-size="sm" label-for="comment">
<gl-form-textarea v-model="comment" />
</gl-form-group>
<p class="gl-text-gray-400">
{{ $options.i18n.modalFooterText }}
</p>
</gl-modal>
</div>
</template>
import { __, s__ } from '~/locale';
export const i18n = Object.freeze({
firstNameLabel: __('First Name'),
lastNameLabel: __('Last Name'),
companyNameLabel: __('Company Name'),
companySizeLabel: __('Number of employees'),
companySizeSelectPrompt: __('- Select -'),
phoneNumberLabel: __('Telephone number'),
countryLabel: __('Country'),
countrySelectPrompt: __('Please select a country'),
stateLabel: __('State/Province/City'),
stateSelectPrompt: s__('PQL|Please select a city or state'),
commentLabel: s__('PQL|Message for the Sales team (optional)'),
buttonText: s__('PQL|Contact sales'),
modalTitle: s__('PQL|Contact our Sales team'),
modalPrimary: s__('PQL|Submit information'),
modalCancel: s__('PQL|Cancel'),
modalHeaderText: s__(
'PQL|Hello %{userName}. Before putting you in touch with our sales team, we would like you to verify and complete the information below.',
),
modalFooterText: s__(
'PQL|By providing my contact information, I agree GitLab may contact me via email about its product, services and events. You may opt-out at any time by unsubscribing in emails or visiting our communication preference center.',
),
handRaiseActionError: s__('PQL|An error occurred while sending hand raise lead.'),
handRaiseActionSuccess: s__(
'PQL|Thank you for reaching out! Our sales team will bet back to you soon.',
),
});
export const companySizes = Object.freeze([
{
name: '1 - 99',
id: '1-99',
},
{
name: '100 - 499',
id: '100-499',
},
{
name: '500 - 1,999',
id: '500-1,999',
},
{
name: '2,000 - 9,999',
id: '2,000-9,999',
},
{
name: '10,000 +',
id: '10,000+',
},
]);
export const COUNTRIES_WITH_STATES_ALLOWED = ['US', 'CA'];
export const shouldHandRaiseLeadButtonMount = async () => {
const elements = document.querySelectorAll('.js-hand-raise-lead-button');
if (elements.length > 0) {
const { initHandRaiseLeadButton } = await import(
/* webpackChunkName: 'init_hand_raise_lead_button' */ './init_hand_raise_lead_button'
);
elements.forEach(async (el) => {
initHandRaiseLeadButton(el);
});
}
};
import Vue from 'vue';
import HandRaiseLeadButton from 'ee/hand_raise_leads/hand_raise_lead/components/hand_raise_lead_button.vue';
import apolloProvider from 'ee/subscriptions/buy_addons_shared/graphql';
export const initHandRaiseLeadButton = (el) => {
const { namespaceId, userName } = el.dataset;
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(HandRaiseLeadButton, {
props: {
namespaceId: Number(namespaceId),
userName,
},
});
},
});
};
import { shouldQrtlyReconciliationMount } from 'ee/billings/qrtly_reconciliation'; import { shouldQrtlyReconciliationMount } from 'ee/billings/qrtly_reconciliation';
import initSubscriptions from 'ee/billings/subscriptions'; import initSubscriptions from 'ee/billings/subscriptions';
import { shouldExtendReactivateTrialButtonMount } from 'ee/trials/extend_reactivate_trial'; import { shouldExtendReactivateTrialButtonMount } from 'ee/trials/extend_reactivate_trial';
import { shouldHandRaiseLeadButtonMount } from 'ee/hand_raise_leads/hand_raise_lead';
import PersistentUserCallout from '~/persistent_user_callout'; import PersistentUserCallout from '~/persistent_user_callout';
PersistentUserCallout.factory(document.querySelector('.js-gold-trial-callout')); PersistentUserCallout.factory(document.querySelector('.js-gold-trial-callout'));
initSubscriptions(); initSubscriptions();
shouldExtendReactivateTrialButtonMount(); shouldExtendReactivateTrialButtonMount();
shouldHandRaiseLeadButtonMount();
shouldQrtlyReconciliationMount(); shouldQrtlyReconciliationMount();
import { GlButton, GlModal } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { sprintf } from '~/locale';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import HandRaiseLeadButton from 'ee/hand_raise_leads/hand_raise_lead/components/hand_raise_lead_button.vue';
import { i18n } from 'ee/hand_raise_leads/hand_raise_lead/constants';
import * as SubscriptionsApi from 'ee/api/subscriptions_api';
import { formData, states, countries } from './mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('HandRaiseLeadButton', () => {
let wrapper;
let fakeApollo;
const createComponent = (props = {}) => {
const mockResolvers = {
Query: {
countries() {
return [{ id: 'US', name: 'United States' }];
},
states() {
return [{ countryId: 'US', id: 'CA', name: 'California' }];
},
},
};
fakeApollo = createMockApollo([], mockResolvers);
return shallowMountExtended(HandRaiseLeadButton, {
localVue,
apolloProvider: fakeApollo,
propsData: {
namespaceId: 1,
userName: 'Joe',
...props,
},
});
};
const findButton = () => wrapper.findComponent(GlButton);
const findModal = () => wrapper.findComponent(GlModal);
afterEach(() => {
wrapper.destroy();
wrapper = null;
fakeApollo = null;
});
describe('rendering', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('does not have loading icon', () => {
expect(findButton().props('loading')).toBe(false);
});
it('has the "Contact sales" text on the button', () => {
expect(findButton().text()).toBe(i18n.buttonText);
});
it('has the correct form input in the form content', () => {
const visibleFields = [
'first-name',
'last-name',
'company-name',
'company-size',
'phone-number',
'country',
];
visibleFields.forEach((f) => expect(wrapper.findByTestId(f).exists()).toBe(true));
expect(wrapper.findByTestId('state').exists()).toBe(false);
});
it('has the correct text in the modal content', () => {
expect(findModal().text()).toContain(sprintf(i18n.modalHeaderText, { userName: 'Joe' }));
expect(findModal().text()).toContain(i18n.modalFooterText);
});
it('has the correct modal props', () => {
expect(findModal().props('actionPrimary')).toStrictEqual({
text: i18n.modalPrimary,
attributes: [{ variant: 'success' }, { disabled: true }],
});
expect(findModal().props('actionCancel')).toStrictEqual({
text: i18n.modalCancel,
});
});
});
describe('submit button', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('becomes enabled when required info is there', async () => {
wrapper.setData({ countries, states, ...formData });
await wrapper.vm.$nextTick();
expect(findModal().props('actionPrimary')).toStrictEqual({
text: i18n.modalPrimary,
attributes: [{ variant: 'success' }, { disabled: false }],
});
});
});
describe('country & state handling', () => {
beforeEach(() => {
wrapper = createComponent();
});
it.each`
state | display
${'US'} | ${true}
${'CA'} | ${true}
${'NL'} | ${false}
`('displayed $display', async ({ state, display }) => {
wrapper.setData({ countries, states, country: state });
await wrapper.vm.$nextTick();
expect(wrapper.findByTestId('state').exists()).toBe(display);
});
});
describe('form submission', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('primary submits the valid form', async () => {
jest.spyOn(SubscriptionsApi, 'sendHandRaiseLead').mockResolvedValue(1);
wrapper.setData({ countries, states, country: 'US', ...formData, comment: 'comment' });
await wrapper.vm.$nextTick();
findModal().vm.$emit('primary');
await wrapper.vm.$nextTick();
expect(SubscriptionsApi.sendHandRaiseLead).toHaveBeenCalledWith({
namespaceId: 1,
comment: 'comment',
...formData,
});
});
});
});
export const countries = [
{ id: 'US', name: 'United States' },
{ id: 'CA', name: 'Canada' },
{ id: 'NL', name: 'Netherlands' },
];
export const states = [
{ countryId: 'US', id: 'CA', name: 'California' },
{ countryId: 'CA', id: 'BC', name: 'British Columbia' },
];
export const formData = {
firstName: 'Joe',
lastName: 'Doe',
companyName: 'ACME',
companySize: '1-99',
phoneNumber: '192919',
country: 'US',
state: 'CA',
};
...@@ -1199,6 +1199,9 @@ msgstr[1] "" ...@@ -1199,6 +1199,9 @@ msgstr[1] ""
msgid "- Not available to run jobs." msgid "- Not available to run jobs."
msgstr "" msgstr ""
msgid "- Select -"
msgstr ""
msgid "- User" msgid "- User"
msgid_plural "- Users" msgid_plural "- Users"
msgstr[0] "" msgstr[0] ""
...@@ -8391,6 +8394,9 @@ msgstr "" ...@@ -8391,6 +8394,9 @@ msgstr ""
msgid "Company" msgid "Company"
msgstr "" msgstr ""
msgid "Company Name"
msgstr ""
msgid "Compare" msgid "Compare"
msgstr "" msgstr ""
...@@ -9513,6 +9519,9 @@ msgstr "" ...@@ -9513,6 +9519,9 @@ msgstr ""
msgid "Couldn't assign policy to project" msgid "Couldn't assign policy to project"
msgstr "" msgstr ""
msgid "Country"
msgstr ""
msgid "Coverage" msgid "Coverage"
msgstr "" msgstr ""
...@@ -14644,6 +14653,9 @@ msgstr "" ...@@ -14644,6 +14653,9 @@ msgstr ""
msgid "Finished at" msgid "Finished at"
msgstr "" msgstr ""
msgid "First Name"
msgstr ""
msgid "First Seen" msgid "First Seen"
msgstr "" msgstr ""
...@@ -19917,6 +19929,9 @@ msgstr "" ...@@ -19917,6 +19929,9 @@ msgstr ""
msgid "Last Activity" msgid "Last Activity"
msgstr "" msgstr ""
msgid "Last Name"
msgstr ""
msgid "Last Pipeline" msgid "Last Pipeline"
msgstr "" msgstr ""
...@@ -23456,6 +23471,9 @@ msgstr "" ...@@ -23456,6 +23471,9 @@ msgstr ""
msgid "Number of commits per MR" msgid "Number of commits per MR"
msgstr "" msgstr ""
msgid "Number of employees"
msgstr ""
msgid "Number of events" msgid "Number of events"
msgstr "" msgstr ""
...@@ -24031,6 +24049,36 @@ msgstr "" ...@@ -24031,6 +24049,36 @@ msgstr ""
msgid "Owner" msgid "Owner"
msgstr "" msgstr ""
msgid "PQL|An error occurred while sending hand raise lead."
msgstr ""
msgid "PQL|By providing my contact information, I agree GitLab may contact me via email about its product, services and events. You may opt-out at any time by unsubscribing in emails or visiting our communication preference center."
msgstr ""
msgid "PQL|Cancel"
msgstr ""
msgid "PQL|Contact our Sales team"
msgstr ""
msgid "PQL|Contact sales"
msgstr ""
msgid "PQL|Hello %{userName}. Before putting you in touch with our sales team, we would like you to verify and complete the information below."
msgstr ""
msgid "PQL|Message for the Sales team (optional)"
msgstr ""
msgid "PQL|Please select a city or state"
msgstr ""
msgid "PQL|Submit information"
msgstr ""
msgid "PQL|Thank you for reaching out! Our sales team will bet back to you soon."
msgstr ""
msgid "Package Registry" msgid "Package Registry"
msgstr "" msgstr ""
...@@ -32309,6 +32357,9 @@ msgstr "" ...@@ -32309,6 +32357,9 @@ msgstr ""
msgid "State your message to activate" msgid "State your message to activate"
msgstr "" msgstr ""
msgid "State/Province/City"
msgstr ""
msgid "Static Application Security Testing (SAST)" msgid "Static Application Security Testing (SAST)"
msgstr "" msgstr ""
...@@ -33311,6 +33362,9 @@ msgstr "" ...@@ -33311,6 +33362,9 @@ msgstr ""
msgid "TeamcityIntegration|Trigger TeamCity CI after every push to the repository, except branch delete" msgid "TeamcityIntegration|Trigger TeamCity CI after every push to the repository, except branch delete"
msgstr "" msgstr ""
msgid "Telephone number"
msgstr ""
msgid "Tell us your experiences with the new Markdown editor %{linkStart}in this feedback issue%{linkEnd}." msgid "Tell us your experiences with the new Markdown editor %{linkStart}in this feedback issue%{linkEnd}."
msgstr "" msgstr ""
......
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