Commit 981c7ed7 authored by Mike Greiling's avatar Mike Greiling

Merge branch 'growth-87-step-component' into 'master'

Step component for paid signup flow

See merge request gitlab-org/gitlab!21568
parents 3f85a5a3 66114837
...@@ -14,7 +14,7 @@ export default { ...@@ -14,7 +14,7 @@ export default {
<div class="full-width"> <div class="full-width">
<progress-bar :step="2" /> <progress-bar :step="2" />
<div class="flash-container"></div> <div class="flash-container"></div>
<h2 class="header-title">{{ $options.i18n.checkout }}</h2> <h2 class="mt-4 mb-3 mb-lg-5">{{ $options.i18n.checkout }}</h2>
</div> </div>
</div> </div>
</template> </template>
<script>
import { mapActions, mapGetters } from 'vuex';
import { GlFormGroup, GlButton } from '@gitlab/ui';
import StepHeader from './step_header.vue';
import StepSummary from './step_summary.vue';
export default {
components: {
GlFormGroup,
GlButton,
StepHeader,
StepSummary,
},
props: {
step: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
isValid: {
type: Boolean,
required: true,
},
nextStepButtonText: {
type: String,
required: false,
default: '',
},
},
computed: {
isActive() {
return this.currentStep === this.step;
},
isFinished() {
return this.isValid && !this.isActive;
},
editable() {
return this.isFinished && this.stepIndex(this.step) < this.activeStepIndex;
},
...mapGetters(['currentStep', 'stepIndex', 'activeStepIndex']),
},
methods: {
...mapActions(['activateStep', 'activateNextStep']),
nextStep() {
if (this.isValid) {
this.activateNextStep();
}
},
edit() {
this.activateStep(this.step);
},
},
};
</script>
<template>
<div class="mb-3 mb-lg-5">
<step-header :title="title" :is-finished="isFinished" />
<div class="card">
<div v-show="isActive" @keyup.enter="nextStep">
<slot name="body" :active="isActive"></slot>
<gl-form-group v-if="nextStepButtonText" class="prepend-top-8 append-bottom-0">
<gl-button variant="success" :disabled="!isValid" @click="nextStep">
{{ nextStepButtonText }}
</gl-button>
</gl-form-group>
</div>
<step-summary v-if="isFinished" :editable="editable" :edit="edit">
<slot name="summary"></slot>
</step-summary>
</div>
</div>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
props: {
isFinished: {
type: Boolean,
required: true,
},
title: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="d-flex">
<icon
v-show="isFinished"
class="checkmark append-right-8"
:size="18"
:aria-label="title"
name="check-circle"
/>
<h5 class="prepend-top-0 append-bottom-default">{{ title }}</h5>
</div>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
components: {
GlButton,
},
props: {
editable: {
type: Boolean,
required: true,
},
edit: {
type: Function,
required: true,
},
},
i18n: {
edit: s__('Checkout|Edit'),
},
};
</script>
<template>
<div class="overview d-flex justify-content-between">
<div>
<slot></slot>
</div>
<div v-if="editable" class="d-flex flex-column justify-content-center">
<gl-button @click="edit">{{ $options.i18n.edit }}</gl-button>
</div>
</div>
</template>
/* eslint-disable import/prefer-default-export */
export const STEPS = [];
import Vue from 'vue'; import Vue from 'vue';
import store from './store';
import Checkout from './components/checkout.vue'; import Checkout from './components/checkout.vue';
export default () => { export default () => {
...@@ -6,7 +7,10 @@ export default () => { ...@@ -6,7 +7,10 @@ export default () => {
return new Vue({ return new Vue({
el: checkoutEl, el: checkoutEl,
components: { Checkout }, store,
components: {
Checkout,
},
render(createElement) { render(createElement) {
return createElement('checkout', {}); return createElement('checkout', {});
}, },
......
import * as types from './mutation_types';
import { STEPS } from '../constants';
export const activateStep = ({ commit }, step) => {
if (STEPS.includes(step)) {
commit(types.ACTIVATE_STEP, step);
}
};
export const activateNextStep = ({ commit, getters }) => {
const { activeStepIndex } = getters;
if (activeStepIndex < STEPS.length - 1) {
const nextStep = STEPS[activeStepIndex + 1];
commit(types.ACTIVATE_STEP, nextStep);
}
};
import { STEPS } from '../constants';
export const currentStep = state => state.currentStep;
export const stepIndex = () => step => STEPS.findIndex(el => el === step);
export const activeStepIndex = (state, getters) => getters.stepIndex(state.currentStep);
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
const createStore = () =>
new Vuex.Store({
actions,
getters,
mutations,
state,
});
export default createStore();
/* eslint-disable import/prefer-default-export */
export const ACTIVATE_STEP = 'ACTIVATE_STEP';
import * as types from './mutation_types';
export default {
[types.ACTIVATE_STEP](state, step) {
state.currentStep = step;
},
};
import { STEPS } from '../constants';
export default () => ({
currentStep: STEPS[0],
});
/* Checkout Page */ /* Checkout Page */
.subscriptions-layout-html { .subscriptions-layout-html {
.container { .container {
margin: 0;
max-width: none; max-width: none;
padding-top: 40px; padding-top: 40px;
} }
...@@ -10,8 +9,6 @@ ...@@ -10,8 +9,6 @@
border-bottom: 1px solid $gray-200; border-bottom: 1px solid $gray-200;
flex-grow: 1; flex-grow: 1;
flex-shrink: 0; flex-shrink: 0;
padding-right: $gl-padding;
padding-left: $gl-padding;
@media(min-width: map-get($grid-breakpoints, lg)) { @media(min-width: map-get($grid-breakpoints, lg)) {
border-bottom: 0; border-bottom: 0;
...@@ -19,17 +16,6 @@ ...@@ -19,17 +16,6 @@
} }
} }
.summary-pane {
padding-bottom: $gl-padding;
padding-right: $gl-padding;
padding-left: $gl-padding;
@media(min-width: map-get($grid-breakpoints, lg)) {
padding-left: $gl-padding * 3;
padding-right: $gl-padding * 3;
}
}
.checkout { .checkout {
align-items: center; align-items: center;
flex-grow: 1; flex-grow: 1;
...@@ -72,11 +58,71 @@ ...@@ -72,11 +58,71 @@
} }
} }
.header-title { .checkmark {
margin: 24px 0 16px; color: $green-500;
margin-top: -1px;
}
.card {
margin-bottom: 0;
min-height: 32px;
padding: $gl-padding;
max-width: 541px;
@media(min-width: map-get($grid-breakpoints, lg)) {
width: 541px;
}
}
.gl-form-group,
.combined {
flex-grow: 1;
max-width: 420px;
.gl-form-input,
.gl-form-select {
height: 32px;
}
}
.number {
width: 50%;
@media(min-width: map-get($grid-breakpoints, lg)) { @media(min-width: map-get($grid-breakpoints, lg)) {
margin-bottom: 32px; max-width: 202px;
}
}
.label {
padding: 0;
width: 50%;
.gl-link {
font-size: inherit;
}
}
.btn {
line-height: $gl-line-height-14;
padding: $gl-padding-8 $gl-padding-12;
width: 100%;
@media(min-width: map-get($grid-breakpoints, lg)) {
width: initial;
}
}
.overview {
line-height: $gl-line-height-20;
.btn {
height: 24px;
padding: 1px 8px;
width: 48px;
@media(min-width: map-get($grid-breakpoints, lg)) {
width: initial;
}
} }
} }
} }
...@@ -4,5 +4,5 @@ ...@@ -4,5 +4,5 @@
%body.ui-indigo.d-flex.vh-100 %body.ui-indigo.d-flex.vh-100
= render "layouts/header/logo_with_title" = render "layouts/header/logo_with_title"
= render "layouts/broadcast" = render "layouts/broadcast"
.container.d-flex.flex-grow-1 .container.d-flex.flex-grow-1.m-0
= yield = yield
- page_title _('Checkout') - page_title _('Checkout')
.row.flex-grow-1.flex-column.flex-nowrap.flex-lg-row.flex-xl-row.flex-lg-wrap.flex-xl-wrap .row.flex-grow-1.flex-column.flex-nowrap.flex-lg-row.flex-xl-row.flex-lg-wrap.flex-xl-wrap
.checkout-pane.align-items-center.bg-gray-light.col-lg-7.d-flex.flex-column.flex-grow-1 .checkout-pane.px-3.align-items-center.bg-gray-light.col-lg-7.d-flex.flex-column.flex-grow-1
#checkout{ data: subscription_data } #checkout{ data: subscription_data }
.summary-pane.col-lg-5.d-flex.flex-row.justify-content-center .pb-3.px-3.px-lg-7.col-lg-5.d-flex.flex-row.justify-content-center
#summary #summary
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import store from 'ee/subscriptions/new/store';
import * as constants from 'ee/subscriptions/new/constants';
import component from 'ee/subscriptions/new/components/checkout/components/step.vue';
describe('Step', () => {
const localVue = createLocalVue();
localVue.use(Vuex);
let wrapper;
const initialData = {
step: 'secondStep',
isValid: true,
title: 'title',
nextStepButtonText: 'next',
};
const factory = propsData => {
wrapper = shallowMount(localVue.extend(component), {
store,
propsData: { ...initialData, ...propsData },
localVue,
sync: false,
});
};
const activatePreviousStep = () => {
store.dispatch('activateStep', 'firstStep');
};
constants.STEPS = ['firstStep', 'secondStep'];
beforeEach(() => {
store.dispatch('activateStep', 'secondStep');
});
afterEach(() => {
wrapper.destroy();
});
describe('isActive', () => {
it('should return true when this step is the current step', () => {
factory();
expect(wrapper.vm.isActive).toEqual(true);
});
it('should return false when this step is not the current step', () => {
activatePreviousStep();
factory();
expect(wrapper.vm.isActive).toEqual(false);
});
});
describe('isFinished', () => {
it('should return true when this step is valid and not active', () => {
activatePreviousStep();
factory();
expect(wrapper.vm.isFinished).toEqual(true);
});
it('should return false when this step is not valid and not active', () => {
activatePreviousStep();
factory({ isValid: false });
expect(wrapper.vm.isFinished).toEqual(false);
});
it('should return false when this step is valid and active', () => {
factory();
expect(wrapper.vm.isFinished).toEqual(false);
});
it('should return false when this step is not valid and active', () => {
factory({ isValid: false });
expect(wrapper.vm.isFinished).toEqual(false);
});
});
describe('editable', () => {
it('should return true when this step is finished and comes before the current step', () => {
factory({ step: 'firstStep' });
expect(wrapper.vm.editable).toEqual(true);
});
it('should return false when this step is not finished and comes before the current step', () => {
factory({ step: 'firstStep', isValid: false });
expect(wrapper.vm.editable).toEqual(false);
});
it('should return false when this step is finished and does not come before the current step', () => {
activatePreviousStep();
factory({ step: 'firstStep' });
expect(wrapper.vm.editable).toEqual(false);
});
});
describe('Showing the summary', () => {
it('shows the summary when this step is finished', () => {
activatePreviousStep();
factory();
expect(wrapper.find('stepsummary-stub').exists()).toBe(true);
});
it('does not show the summary when this step is not finished', () => {
factory();
expect(wrapper.find('stepsummary-stub').exists()).toBe(false);
});
});
describe('Next button', () => {
it('shows the next button when the text was passed', () => {
factory();
expect(wrapper.text()).toEqual('next');
});
it('does not show the next button when no text was passed', () => {
factory({ nextStepButtonText: '' });
expect(wrapper.text()).toEqual('');
});
it('is disabled when this step is not valid', () => {
factory({ isValid: false });
expect(wrapper.find('glbutton-stub').attributes('disabled')).toBe('true');
});
it('is enabled when this step is valid', () => {
factory();
expect(wrapper.find('glbutton-stub').attributes('disabled')).toBeUndefined();
});
});
});
import testAction from 'helpers/vuex_action_helper';
import * as actions from 'ee/subscriptions/new/store/actions';
import * as constants from 'ee/subscriptions/new/constants';
constants.STEPS = ['firstStep', 'secondStep'];
describe('Subscriptions Actions', () => {
describe('activateStep', () => {
it('set the currentStep to the provided value', done => {
testAction(
actions.activateStep,
'secondStep',
{},
[{ type: 'ACTIVATE_STEP', payload: 'secondStep' }],
[],
done,
);
});
it('does not change the currentStep if provided value is not available', done => {
testAction(actions.activateStep, 'thirdStep', {}, [], [], done);
});
});
describe('activateNextStep', () => {
it('set the currentStep to the next step in the available steps', done => {
testAction(
actions.activateNextStep,
{},
{ activeStepIndex: 0 },
[{ type: 'ACTIVATE_STEP', payload: 'secondStep' }],
[],
done,
);
});
it('does not change the currentStep if the current step is the last step', done => {
testAction(actions.activateNextStep, {}, { activeStepIndex: 1 }, [], [], done);
});
});
});
import * as getters from 'ee/subscriptions/new/store/getters';
import * as constants from 'ee/subscriptions/new/constants';
constants.STEPS = ['firstStep', 'secondStep'];
const state = {
currentStep: 'secondStep',
};
describe('Subscriptions Getters', () => {
describe('currentStep', () => {
it('returns the states currentStep', () => {
expect(getters.currentStep(state)).toEqual('secondStep');
});
});
describe('stepIndex', () => {
it('returns a function', () => {
expect(getters.stepIndex()).toBeInstanceOf(Function);
});
it('returns a function that returns the index of the given step', () => {
expect(getters.stepIndex()('secondStep')).toEqual(1);
});
});
describe('activeStepIndex', () => {
it('returns a function', () => {
expect(getters.activeStepIndex(state, getters)).toBeInstanceOf(Function);
});
it('calls the stepIndex function with the current step name', () => {
const stepIndexSpy = jest.spyOn(getters, 'stepIndex');
getters.activeStepIndex(state, getters);
expect(stepIndexSpy).toHaveBeenCalledWith('secondStep');
});
});
});
import mutations from 'ee/subscriptions/new/store/mutations';
import * as types from 'ee/subscriptions/new/store/mutation_types';
const state = () => ({
currentStep: 'firstStep',
});
let stateCopy;
beforeEach(() => {
stateCopy = state();
});
describe('ACTIVATE_STEP', () => {
it('should set the currentStep to the given step', () => {
mutations[types.ACTIVATE_STEP](stateCopy, 'secondStep');
expect(stateCopy.currentStep).toEqual('secondStep');
});
});
...@@ -3256,6 +3256,9 @@ msgstr "" ...@@ -3256,6 +3256,9 @@ msgstr ""
msgid "Checkout|Checkout" msgid "Checkout|Checkout"
msgstr "" msgstr ""
msgid "Checkout|Edit"
msgstr ""
msgid "Cherry-pick this commit" msgid "Cherry-pick this commit"
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