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 initSubscriptions from 'ee/billings/subscriptions';
import { shouldExtendReactivateTrialButtonMount } from 'ee/trials/extend_reactivate_trial';
import { shouldHandRaiseLeadButtonMount } from 'ee/hand_raise_leads/hand_raise_lead';
import PersistentUserCallout from '~/persistent_user_callout';
PersistentUserCallout.factory(document.querySelector('.js-gold-trial-callout'));
initSubscriptions();
shouldExtendReactivateTrialButtonMount();
shouldHandRaiseLeadButtonMount();
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] ""
msgid "- Not available to run jobs."
msgstr ""
msgid "- Select -"
msgstr ""
msgid "- User"
msgid_plural "- Users"
msgstr[0] ""
......@@ -8391,6 +8394,9 @@ msgstr ""
msgid "Company"
msgstr ""
msgid "Company Name"
msgstr ""
msgid "Compare"
msgstr ""
......@@ -9513,6 +9519,9 @@ msgstr ""
msgid "Couldn't assign policy to project"
msgstr ""
msgid "Country"
msgstr ""
msgid "Coverage"
msgstr ""
......@@ -14644,6 +14653,9 @@ msgstr ""
msgid "Finished at"
msgstr ""
msgid "First Name"
msgstr ""
msgid "First Seen"
msgstr ""
......@@ -19917,6 +19929,9 @@ msgstr ""
msgid "Last Activity"
msgstr ""
msgid "Last Name"
msgstr ""
msgid "Last Pipeline"
msgstr ""
......@@ -23456,6 +23471,9 @@ msgstr ""
msgid "Number of commits per MR"
msgstr ""
msgid "Number of employees"
msgstr ""
msgid "Number of events"
msgstr ""
......@@ -24031,6 +24049,36 @@ msgstr ""
msgid "Owner"
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"
msgstr ""
......@@ -32309,6 +32357,9 @@ msgstr ""
msgid "State your message to activate"
msgstr ""
msgid "State/Province/City"
msgstr ""
msgid "Static Application Security Testing (SAST)"
msgstr ""
......@@ -33311,6 +33362,9 @@ msgstr ""
msgid "TeamcityIntegration|Trigger TeamCity CI after every push to the repository, except branch delete"
msgstr ""
msgid "Telephone number"
msgstr ""
msgid "Tell us your experiences with the new Markdown editor %{linkStart}in this feedback issue%{linkEnd}."
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