Commit 227a4964 authored by Sean McGivern's avatar Sean McGivern Committed by Clement Ho

Vue salary calculator

parent eba58df8
......@@ -86,20 +86,30 @@ rubocop:
tags:
- gitlab-org
rspec_master:
spec 0 2:
<<: *rspec_job
allow_failure: true
only:
only:
refs:
- master
rspec_branch:
spec 0 2:
<<: *rspec_job
allow_failure: false
except:
except:
refs:
- master
spec 1 2:
cache: {}
before_script: []
stage: build
script:
- yarn install
- yarn run test
tags:
- gitlab-org
enforce_relative_links:
stage: build
image: alpine
......
......@@ -38,6 +38,8 @@ linters:
enabled: false
SelectorDepth:
max_depth: 4
SelectorFormat:
ignored_names: vs__actions
SingleLinePerProperty:
allow_single_line_rule_sets: false
StringQuotes:
......
......@@ -108,4 +108,14 @@ module CustomHelpers
def production?
ENV['MIDDLEMAN_ENV'] == 'production'
end
def add_extra_css(*files)
current_page.data.extra_css ||= []
current_page.data.extra_css |= files
end
def add_extra_js(*files)
current_page.data.extra_js ||= []
current_page.data.extra_js |= files
end
end
......@@ -4,13 +4,16 @@
"description": "GitLab official marketing site",
"scripts": {
"eslint": "eslint --max-warnings 0 .",
"yamllint": "yamllint `find data -type f -name '*.yml'`"
"yamllint": "yamllint `find data -type f -name '*.yml'`",
"test": "jest"
},
"license": "MIT",
"private": true,
"devDependencies": {
"eslint": "^4.10.0",
"eslint-config-es5": "^0.5.0",
"jest": "^23.6.0",
"jsdom": "^13.0.0",
"yaml-lint": "^1.2.3"
}
}
---
layout: markdown_page
title: Compensation Calculator
---
= partial 'includes/salary_calculator_vue'
- current_page.data.extra_css ||= []
- current_page.data.extra_css |= ['salary-calculator.css']
- current_page.data.extra_js ||= []
- current_page.data.extra_js |= ['libs/clipboard.min.js']
- current_page.data.extra_js |= ['salary-calculator.js']
- if role.salary
.salary-container.col-xs-12{data: {salary: role.salary}}
%h2#compensation Compensation
.col-sm-8
.col-sm-6
.dropdown.level
- if role.levels
- default_level = role.levels.detect { |l| l[:is_default] }
%button.dropdown-menu-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'}
%span.label Level
%span.title.title-truncated{'data-selected' => default_level[:factor] }
= default_level[:title]
%span.subtitle
= default_level[:factor]
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-scroll
- role.levels.each do |level|
%li
%span.key
= level[:title]
%span.value
= level[:factor]
- else
%button.dropdown-menu-toggle.btn{type: 'button', disabled: 'disabled', 'data-toggle' => 'dropdown'}
%span.label Level
%span.title{'data-selected' => '1.0'} N/A
%span.subtitle 1.0
.col-sm-6
.dropdown.experience
%button.dropdown-menu-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'}
%span.label Experience
%span.title{data: { 'selected' => '0.9 to 1.1', 'min' => '0.9', 'max' => '1.1'} } Experience range
%span.subtitle 0.9 to 1.1
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-scroll
%li
%span.key Experience range
%span.value{'data': {'min': '0.9', 'max': '1.1'}} 0.9 to 1.1
%li
%span.key Learning the role
%span.value{'data': {'min': '0.9', 'max': '0.949'}} 0.9 to 0.949
%li
%span.key Growing in the role
%span.value{'data': {'min': '0.95', 'max': '0.999'}} 0.95 to 0.999
%li
%span.key Thriving in the role
%span.value{'data': {'min': '1.0', 'max': '1.049'}} 1.0 to 1.049
%li
%span.key Expert in the role
%span.value{'data': {'min': '1.05', 'max': '1.1'}} 1.05 to 1.1
.col-sm-6
.dropdown.country
%button.dropdown-menu-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'}
%span.label Country
%span.title --
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-scroll
%li.filter-container
%input.filter-input.js-country-filter{ type: 'search', placeholder: 'Search country'}
%i.fa.fa-search
- data.location_factors.uniq(&:country).sort_by(&:country).each do |p|
%li
%span.key= p.country
.col-sm-6
.dropdown.area
%button.dropdown-menu-toggle.disabled.btn{type: 'button', 'data-toggle' => 'dropdown'}
%span.label Area
%span.title --
%span.subtitle
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-scroll
%li.filter-container
%input.filter-input.js-area-filter{ type: 'search', placeholder: 'Search area'}
%i.fa.fa-search
- data.location_factors.sort_by(&:area).each do |p|
%li.hidden{'data-country' => p.country}
%span.key= p.area
%span.display-value= sprintf('%0.3f', (p.locationFactor*0.01).round(3))
.col-sm-4
.compensation
.title Annual Compensation
.amount --
.converted-local-currency.hide
%a{href: '/handbook/people-operations/global-compensation/#exchange-rates'}
.col-sm-12.hide.js-country-no-hire
.alert.alert-warning
Unfortunately GitLab is not hiring at your selected country at this time. Please read our
%a{href: '/handbook/hiring/#country-hiring-guidelines'} hiring handbook
for more details.
.col-sm-12.formula-container
%h4 How did we calculate your compensation?
.formula
.variable-group
.variable.benchmark
%span.name SF benchmark
%span.value= "$#{number_with_delimiter(role.salary)}"
.symbol.multiplication x
.variable-group
.variable.locationFactor
%span.name Location Factor
%span.value --
.symbol.multiplication x
.variable-group
.variable.level
%span.name Level
%span.value 1.0
.symbol.multiplication x
.variable-group
.variable.experience
%span.name Experience
%span.value 0.9 to 1.1
.symbol.multiplication x
.variable-group
.variable.contractType
%span.name Contract Type
%span.value --
.explanation
Find out
= succeed '.' do
%a{href: '/handbook/people-operations/global-compensation#compensation-calculator'} how our calculator works
You are also eligible for
%a{href: '/handbook/stock-options/'} stock options
and
= succeed '.' do
%a{href: '/handbook/benefits/'} other benefits
%span.contract-type-container.hidden
Based on the information provided, we expect you will be
%span.grammer a
%strong.contract-type contractor
at GitLab
%span.company-type BV.
<% add_extra_css('salary-calculator.css') %>
<% add_extra_js('libs/vue.min.js', 'libs/vue-select.min.js', 'salary-calculator-vue.js') %>
<div class="salary-container col-xs-12" data-role="<%= locals.fetch(:role, nil) %>">
<h2 id="compensation">Compensation</h2>
<div class="role margin-bottom10" v-if="showRoleSelector">
<h4>Select a role</h4>
<salary-calculator-select
:searchable="true"
:options="sourceData.roles"
v-model="currentRole"
label="Role"
title-field="title"
/>
</div>
<div v-if="renderCalculator">
<div class="col-sm-8">
<div class="col-sm-6">
<div class="level" v-if="roleLevels">
<salary-calculator-select
:options="roleLevels"
v-model="currentLevel"
label="Level"
title-field="title"
:option-value="formatRoleLevel"
/>
</div>
<div class="dropdown level" v-else>
<button class="dropdown-menu-toggle btn" data-toggle="dropdown" disabled type="button">
<span class="label">Level</span>
<span class="title" data-selected="1.0">N/A</span>
<span class="subtitle">1.0</span>
</button>
</div>
</div>
<div class="col-sm-6">
<div class="experience">
<salary-calculator-select
:options="experienceFactors"
v-model="currentExperience"
label="Experience factor"
title-field="label"
:option-value="formatExperienceFactor"
/>
</div>
</div>
<div class="col-sm-6">
<div class="country">
<salary-calculator-select
:searchable="true"
:options="countries"
v-model="currentCountry"
label="Country"
title-field="label"
/>
</div>
</div>
<div class="col-sm-6">
<div class="area">
<salary-calculator-select
:disabled="!currentCountry"
:searchable="true"
:options="areas"
v-model="currentArea"
label="Area"
title-field="area"
:option-value="formatArea"
/>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="compensation">
<div class="title">Annual Compensation</div>
<div class="amount">{{ compensationRange }}</div>
<div class="converted-local-currency" v-if="localCurrencyRange">
<a href="/handbook/people-operations/global-compensation/#exchange-rates">
{{ localCurrencyRange }}
</a>
</div>
</div>
</div>
<div class="col-sm-12 js-country-no-hire" :class="canHireCountry && 'hide'">
<div class="alert alert-warning">
Unfortunately GitLab is not hiring in your selected country at
this time. Please read our
<a href="/handbook/hiring/#country-hiring-guidelines">hiring handbook</a>
for more details.
</div>
</div>
<div class="col-sm-12 formula-container">
<h4>How did we calculate your compensation?</h4>
<div class="formula">
<div class="variable-group">
<div class="variable benchmark">
<span class="name">SF benchmark</span>
<span class="value">{{ formatAmount(currentRole.salary) }}</span>
</div>
</div>
<div class="symbol multiplication">x</div>
<div class="variable-group">
<div class="variable locationFactor">
<span class="name">Location Factor</span>
<span class="value">{{ currentLocationFactor || '--' }}</span>
</div>
</div>
<div class="symbol multiplication">x</div>
<div class="variable-group">
<div class="variable level">
<span class="name">Level</span>
<span class="value">{{ currentLevelFactor }}</span>
</div>
</div>
<div class="symbol multiplication">x</div>
<div class="variable-group">
<div class="variable experience">
<span class="name">Experience</span>
<span class="value">{{ currentExperience.min }} to {{ currentExperience.max }}</span>
</div>
</div>
<div class="symbol multiplication">x</div>
<div class="variable-group">
<div class="variable contractFactor">
<span class="name">Contract Factor</span>
<span class="value">{{ contractTypeFactor || '--' }}</span>
</div>
</div>
</div>
<div class="explanation">
Find out
<a href="/handbook/people-operations/global-compensation#compensation-calculator">how our calculator works</a>.
You are also eligible for
<a href="/handbook/stock-options/">stock options</a>
and
<a href="/handbook/benefits/">other benefits</a>.
<span class="contract-type" v-if="calculateCompensation">
Based on the information provided, we expect you will be
<span v-if="contractType.employee_factor">
an <b>employee</b>
</span>
<span v-else>
a <b>contractor</b>
</span>
of
<a href="/handbook/benefits/#entity-specific-benefits">{{ contractType.entity }}</a>.
</span>
</div>
</div>
</div>
</div>
This diff is collapsed.
This diff is collapsed.
(function($, Vue, VueSelect, module) {
Vue.component('v-select', VueSelect.VueSelect);
var salaryCalculatorSelect = {
props: {
disabled: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
searchable: { type: Boolean, default: false },
options: { type: Array, required: true },
label: { type: String, required: true },
titleField: { type: String, required: true },
optionValue: { type: Function, required: false },
value: { default: null } // wrapper for v-model
},
template: '' +
'<v-select :disabled="disabled" :clearable="clearable" :options="options" :searchable="searchable" :label="titleField" :value="value" @input="$emit(\'input\', $event)">' +
' <template slot-scope="option" slot="selected-option">' +
' <span class="label">' +
' {{ label }}' +
' </span>' +
' <span class="title">' +
' {{ option[titleField] }}' +
' </span>' +
' <span class="subtitle" v-if="optionValue">' +
' {{ optionValue(option) }}' +
' </span>' +
' </template>' +
' <template slot-scope="option" slot="option">' +
' <span class="key">' +
' {{ option[titleField] }}' +
' </span>' +
' <span class="value" v-if="optionValue">' +
' {{ optionValue(option) }}' +
' </span>' +
' </template>' +
'</v-select>'
};
module.exports = new Vue({
el: '.salary-container',
components: {
'salary-calculator-select': salaryCalculatorSelect
},
data: function() {
return {
initialRole: null,
sourceData: {},
countries: [],
allAreas: [],
countryCurrencies: {},
experienceFactors: [
{ min: 0.9, max: 1.1, label: 'Experience range' },
{ min: 0.9, max: 0.949, label: 'Learning the role' },
{ min: 0.95, max: 0.999, label: 'Growing in the role' },
{ min: 1.0, max: 1.049, label: 'Thriving in the role' },
{ min: 1.05, max: 1.1, label: 'Expert in the role' }
],
currentRole: null,
currentLevel: null,
currentExperience: null,
currentCountry: null,
currentArea: null
};
},
beforeMount: function() {
this.initialRole = this.$el.dataset.role;
},
mounted: function() {
this.getSourceData();
this.currentExperience = this.experienceFactors[0];
},
watch: {
currentCountry: function() {
this.currentArea = null;
},
currentRole: function(newRole) {
if (newRole) {
this.setRoleLevels();
}
}
},
computed: {
showRoleSelector: function() {
return !this.initialRole;
},
renderCalculator: function() {
return this.currentRole && !$.isEmptyObject(this.sourceData);
},
currentLevelFactor: function() {
return this.currentLevel ? this.currentLevel.factor : null;
},
currentLocationFactor: function() {
return this.currentArea ? this.currentArea.locationFactor : null;
},
roleLevels: function() {
if (!this.currentRole) { return null; }
return this.sourceData.roleLevels[this.currentRole.levels];
},
contractType: function() {
if (!this.currentCountry) { return null; }
return this.findByCountry(this.sourceData.contractTypes, this.currentCountry);
},
contractTypeFactor: function() {
if (!this.contractType) { return null; }
return this.contractType.employee_factor || this.contractType.contractor_factor;
},
areas: function() {
var currentCountry = this.currentCountry;
return this.allAreas.filter(function(location) {
return location.country === currentCountry;
});
},
calculateCompensation: function() {
return this.currentLevel &&
!$.isEmptyObject(this.currentExperience) &&
this.currentArea &&
this.contractType;
},
compensationRange: function() {
if (!this.calculateCompensation) {
return '--';
}
return this.formatAmount(this.calculateSalary(this.currentExperience.min)) +
' - ' +
this.formatAmount(this.calculateSalary(this.currentExperience.max));
},
localCurrencyRange: function() {
if (!this.calculateCompensation) { return null; }
var currency = this.countryCurrencies[this.currentArea.country];
if (!currency) { return null; }
return this.formatAmount(this.calculateSalary(this.currentExperience.min, currency.rate), currency.code) +
' - ' +
this.formatAmount(this.calculateSalary(this.currentExperience.max, currency.rate), currency.code);
},
canHireCountry: function() {
var currentCountry = this.currentCountry;
return !this.sourceData.countryNoHire.find(function(noHire) {
return noHire === currentCountry;
});
}
},
methods: {
getSourceData: function() {
var vue = this;
$.get('/salary/data.json').then(function(data) {
vue.sourceData = data;
// countries
vue.countries = data.locationFactors.map(function(location) {
return location.country;
}).filter(function(value, index, self) {
return self.indexOf(value) === index;
}).sort();
// areas
vue.allAreas = data.locationFactors.sort(function(location) {
return location.area;
});
// country -> currency mapping
data.currencyExchangeRates.rates_to_usd.forEach(function(currency) {
currency.countries.forEach(function(country) {
vue.countryCurrencies[country] = {
code: currency.currency_code,
rate: currency.rate
};
});
});
vue.currentRole = data.roles.find(function(role) {
return role.title === vue.initialRole;
});
});
},
setRoleLevels: function() {
if (this.roleLevels) {
this.currentLevel = this.roleLevels.find(function(level) {
return level.is_default;
});
} else {
this.currentLevel = { title: 'N/A', factor: 1 };
}
},
formatAmount: function(amount, currencyCode) {
var formattedAmount = amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
if (currencyCode) {
return formattedAmount + ' ' + currencyCode;
}
return '$' + formattedAmount;
},
formatRoleLevel: function(roleLevel) {
return roleLevel.factor;
},
formatExperienceFactor: function(experienceFactor) {
return experienceFactor.min + ' to ' + experienceFactor.max;
},
formatArea: function(area) {
return (area.locationFactor * 0.01).toFixed(3);
},
findByCountry: function(data, country) {
var vue = this;
var fallback = function() {
if (country === '*') { return null; }
return vue.findByCountry(data, '*');
};
return data.find(function(item) { return item.country === country; }) || fallback();
},
calculateSalary: function(experienceFactor, currencyRate) {
if (!this.calculateCompensation) { return null; }
return Math.round(
this.currentRole.salary *
(this.currentLocationFactor * 0.01) *
this.currentLevelFactor *
experienceFactor *
this.contractTypeFactor /
(currencyRate || 1)
);
}
}
});
})(window.$, window.Vue, window.VueSelect, typeof module !== 'undefined' ? module : {});
This diff is collapsed.
......@@ -2,7 +2,7 @@
= yield
- if role = current_role_for_salary_calculator
= partial "includes/salary_calculator", locals: {role: role}
= partial "includes/salary_calculator_vue", locals: {role: role.title}
:markdown
## Apply
......
{
"locationFactors": <%= data.location_factors.to_json %>,
"marketAdjustments": <%= data.hotmarkets.to_json %>,
"contractTypes": <%= data.contract_factors.to_json %>,
"countryNoHire": <%= data.country_no_hire.to_json %>,
"currencyExchangeRates": <%= data.currency_exchange_rates.to_json %>
"currencyExchangeRates": <%= data.currency_exchange_rates.to_json %>,
"locationFactors": <%= data.location_factors.to_json %>,
"marketAdjustments": <%= data.hotmarkets.to_json %>,
"roles": <%= data.roles.select(&:salary).to_json %>,
"roleLevels": <%= data.role_levels.to_json %>
}
......@@ -26,14 +26,95 @@
}
}
.open-indicator {
margin-right: 6px;
}
// Need to override the given vue border style
// scss-lint:disable ImportantRule
.dropdown-toggle {
border: 1px solid $dropdown-border-color !important;
border-radius: $border-radius-large;
.selected-tag {
top: 10px;
left: 2px;
}
}
// Need to override the given vue border style
// scss-lint:disable ImportantRule
.dropdown-menu-toggle,
.dropdown-menu {
width: 100%;
li:not(.no-options):hover {
color: $color-white;
}
}
.v-select.open {
.title,
.subtitle {
display: none;
}
.dropdown-menu li {
margin-top: 0;
}
}
.v-select:not(.open) {
.vs__actions {
position: absolute;
top: 50%;
right: 0;
margin-top: -6px;
}
.subtitle {
color: $color-gray-light;
position: absolute;
right: 34px;
font-size: 15px;
}
.title {
position: absolute;
bottom: 4px;
}
.label {
position: absolute;
font-size: 13px;
top: 7px;