web/satellite: onboarding tour: create project step

Change-Id: If091fd7f703e61c402a6bbdd80d98b0ce7b2bfd6
This commit is contained in:
VitaliiShpital 2020-04-28 15:26:32 +03:00
parent f54a4960a8
commit 4e5e6c8e5c
14 changed files with 450 additions and 17 deletions

View File

@ -21,6 +21,7 @@ import { Component, Vue } from 'vue-property-decorator';
import VInfo from '@/components/common/VInfo.vue';
import NewProjectPopup from '@/components/project/NewProjectPopup.vue';
import { RouteConfig } from '@/router';
import { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
import { ProjectOwning } from '@/utils/projectOwning';
@ -37,7 +38,7 @@ export default class NewProjectArea extends Vue {
* Toggles new project button visibility depending on user having his own project or payment method.
*/
public beforeMount(): void {
if (this.userHasOwnProject || !this.$store.getters.canUserCreateFirstProject) {
if (this.userHasOwnProject || !this.$store.getters.canUserCreateFirstProject || this.isOnboardingTour) {
this.$store.dispatch(APP_STATE_ACTIONS.HIDE_CREATE_PROJECT_BUTTON);
return;
@ -67,6 +68,13 @@ export default class NewProjectArea extends Vue {
return this.$store.state.appStateModule.appState.isCreateProjectButtonShown;
}
/**
* Indicates if current route is onboarding tour.
*/
public get isOnboardingTour(): boolean {
return this.$route.name === RouteConfig.OnboardingTour.name;
}
/**
* Indicates if user has own project.
*/

View File

@ -3,13 +3,19 @@
<template>
<div class="tour-area">
<ProgressBar/>
<ProgressBar
:is-create-project-step="isCreateProjectState"
/>
<OverviewStep
v-if="isDefaultState"
@setAddPaymentState="setAddPaymentState"
/>
<AddPaymentStep
v-if="isAddPaymentState"
@setProjectState="setCreateProjectState"
/>
<CreateProjectStep
v-if="isCreateProjectState"
/>
</div>
</template>
@ -19,6 +25,7 @@ import { Component, Vue } from 'vue-property-decorator';
import ProgressBar from '@/components/onboardingTour/ProgressBar.vue';
import AddPaymentStep from '@/components/onboardingTour/steps/AddPaymentStep.vue';
import CreateProjectStep from '@/components/onboardingTour/steps/CreateProjectStep.vue';
import OverviewStep from '@/components/onboardingTour/steps/OverviewStep.vue';
import CheckedImage from '@/../static/images/common/checked.svg';
@ -28,6 +35,7 @@ import { TourState } from '@/utils/constants/onboardingTourEnums';
@Component({
components: {
CreateProjectStep,
AddPaymentStep,
ProgressBar,
OverviewStep,
@ -53,6 +61,10 @@ export default class OnboardingTourArea extends Vue {
return;
}
if (this.$store.state.paymentsModule.creditCards.length > 0) {
this.setCreateProjectState();
}
if (this.$store.getters.isTransactionProcessing || this.$store.getters.isTransactionCompleted) {
this.setAddPaymentState();
}
@ -66,18 +78,32 @@ export default class OnboardingTourArea extends Vue {
}
/**
* Indicates if area is adding payment method state.
* Indicates if area is in adding payment method state.
*/
public get isAddPaymentState(): boolean {
return this.areaState === TourState.ADDING_PAYMENT;
}
/**
* Indicates if area is in creating project state.
*/
public get isCreateProjectState(): boolean {
return this.areaState === TourState.PROJECT;
}
/**
* Sets area's state to adding payment method state.
*/
public setAddPaymentState(): void {
this.areaState = TourState.ADDING_PAYMENT;
}
/**
* Sets area's state to creating project state.
*/
public setCreateProjectState(): void {
this.areaState = TourState.PROJECT;
}
}
</script>

View File

@ -4,7 +4,7 @@
<template>
<div class="progress-bar-container">
<div class="progress-bar-container__progress-area">
<div class="progress-bar-container__progress-area__circle">
<div class="progress-bar-container__progress-area__circle" :class="{ 'completed-step': isCreateProjectStep }">
<CheckedImage/>
</div>
<div class="progress-bar-container__progress-area__bar"/>
@ -17,7 +17,9 @@
</div>
</div>
<div class="progress-bar-container__titles-area">
<span class="progress-bar-container__titles-area__title">Name Your Project</span>
<span class="progress-bar-container__titles-area__title" :class="{ 'completed-font-color': isCreateProjectStep }">
Name Your Project
</span>
<span class="progress-bar-container__titles-area__title api-key-title">Create an API Key</span>
<span class="progress-bar-container__titles-area__title">Upload Data</span>
</div>
@ -25,7 +27,7 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Component, Prop, Vue } from 'vue-property-decorator';
import CheckedImage from '@/../static/images/common/checked.svg';
@ -35,7 +37,10 @@ import CheckedImage from '@/../static/images/common/checked.svg';
},
})
export default class ProgressBar extends Vue {}
export default class ProgressBar extends Vue {
@Prop({ default: false })
public readonly isCreateProjectStep: boolean;
}
</script>
<style scoped lang="scss">
@ -77,6 +82,7 @@ export default class ProgressBar extends Vue {}
font-size: 10px;
line-height: 15px;
color: rgba(0, 0, 0, 0.4);
text-align: center;
}
}
}
@ -85,6 +91,14 @@ export default class ProgressBar extends Vue {}
padding: 0 15px 0 0;
}
.completed-step {
background-color: #2683ff;
}
.completed-font-color {
color: #2683ff;
}
@media screen and (max-width: 800px) {
.progress-bar-container {

View File

@ -32,10 +32,12 @@
<AddCardState
v-if="isAddCardState"
@toggleIsLoading="toggleIsLoading"
@setProjectState="setProjectState"
/>
<AddStorjState
v-if="isAddStorjState"
@toggleIsLoading="toggleIsLoading"
@setProjectState="setProjectState"
/>
</div>
</template>
@ -103,6 +105,13 @@ export default class AddPaymentStep extends Vue {
public toggleIsLoading(): void {
this.isLoading = !this.isLoading;
}
/**
* Sets tour area to creating project state.
*/
public setProjectState(): void {
this.$emit('setProjectState');
}
}
</script>

View File

@ -0,0 +1,291 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="new-project-step">
<h1 class="new-project-step__title">Name Your Project</h1>
<p class="new-project-step__sub-title">
Projects are where buckets are created for storing data. Within a Project, usage is tracked at the bucket
level and aggregated for billing.
</p>
<div class="new-project-step__container">
<div class="new-project-step__container__title-area">
<h2 class="new-project-step__container__title-area__title">Project Details</h2>
<img
v-if="isLoading"
class="new-project-step__container__title-area__loading-image"
src="@/../static/images/account/billing/loading.gif"
alt="loading gif"
>
</div>
<HeaderedInput
label="Project Name"
additional-label="Up To 20 Characters"
placeholder="Enter Project Name"
class="full-input"
width="100%"
max-symbols="20"
:error="nameError"
@setData="setProjectName"
/>
<HeaderedInput
label="Description"
placeholder="Enter Project Description"
additional-label="Optional"
class="full-input"
is-multiline="true"
height="100px"
width="100%"
@setData="setProjectDescription"
/>
<div class="new-project-step__container__blur" v-if="isLoading"/>
</div>
<VButton
class="create-project-button"
width="156px"
height="48px"
label="Create Project"
:on-press="createProjectClick"
:is-disabled="!projectName"
/>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import HeaderedInput from '@/components/common/HeaderedInput.vue';
import VButton from '@/components/common/VButton.vue';
import { RouteConfig } from '@/router';
import { PAYMENTS_ACTIONS } from '@/store/modules/payments';
import { PROJECTS_ACTIONS } from '@/store/modules/projects';
import { CreateProjectModel, Project } from '@/types/projects';
import { PM_ACTIONS } from '@/utils/constants/actionNames';
import { SegmentEvent } from '@/utils/constants/analyticsEventNames';
import { anyCharactersButSlash } from '@/utils/validation';
@Component({
components: {
VButton,
HeaderedInput,
},
})
export default class CreateProjectStep extends Vue {
private description: string = '';
public projectName: string = '';
public isLoading: boolean = false;
public nameError: string = '';
/**
* Sets project name from input value.
*/
public setProjectName(value: string): void {
this.projectName = value;
this.nameError = '';
}
/**
* Sets project description from input value.
*/
public setProjectDescription(value: string): void {
this.description = value;
}
/**
* Creates project and refreshes store.
*/
public async createProjectClick(): Promise<void> {
if (this.isLoading) {
return;
}
this.isLoading = true;
if (!this.isProjectNameValid()) {
this.isLoading = false;
return;
}
let createdProjectId: string = '';
try {
const project = await this.createProject();
createdProjectId = project.id;
this.$segment.track(SegmentEvent.PROJECT_CREATED, {
project_id: createdProjectId,
});
await this.$notify.success('Project created successfully!');
} catch (error) {
this.isLoading = false;
await this.$notify.error(error.message);
return;
}
await this.$store.dispatch(PROJECTS_ACTIONS.SELECT, createdProjectId);
try {
await this.$store.dispatch(PM_ACTIONS.FETCH, 1);
} catch (error) {
await this.$notify.error(`Unable to get project members. ${error.message}`);
}
try {
await this.$store.dispatch(PAYMENTS_ACTIONS.GET_BILLING_HISTORY);
} catch (error) {
await this.$notify.error(`Unable to get billing history. ${error.message}`);
}
try {
await this.$store.dispatch(PAYMENTS_ACTIONS.GET_BALANCE);
} catch (error) {
await this.$notify.error(`Unable to get account balance. ${error.message}`);
}
try {
await this.$store.dispatch(PAYMENTS_ACTIONS.GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP);
} catch (error) {
await this.$notify.error(`Unable to get project usage and charges for current rollup. ${error.message}`);
}
try {
await this.$store.dispatch(PROJECTS_ACTIONS.GET_LIMITS, createdProjectId);
} catch (error) {
await this.$notify.error(`Unable to get project limits. ${error.message}`);
}
this.isLoading = false;
// TODO: rework after adding third step of onboarding tour
await this.$router.push(RouteConfig.ApiKeys.path);
}
/**
* Validates input value to satisfy project name rules.
*/
private isProjectNameValid(): boolean {
this.projectName.trim();
if (!anyCharactersButSlash(this.projectName)) {
this.nameError = 'Name for project is invalid!';
return false;
}
if (this.projectName.length > 20) {
this.nameError = 'Name should be less than 21 character!';
return false;
}
return true;
}
/**
* Makes create project request.
*/
private async createProject(): Promise<Project> {
const project: CreateProjectModel = {
name: this.projectName,
description: this.description,
ownerId: this.$store.getters.user.id,
};
return await this.$store.dispatch(PROJECTS_ACTIONS.CREATE, project);
}
}
</script>
<style scoped lang="scss">
h1,
h2,
p {
margin: 0;
}
.new-project-step {
font-family: 'font_regular', sans-serif;
margin-top: 75px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 0 200px;
&__title {
font-size: 32px;
line-height: 39px;
color: #1b2533;
margin-bottom: 25px;
}
&__sub-title {
font-size: 16px;
line-height: 19px;
color: #354049;
margin-bottom: 35px;
text-align: center;
word-break: break-word;
}
&__container {
padding: 50px;
width: calc(100% - 100px);
border-radius: 8px;
background-color: #fff;
position: relative;
margin-bottom: 30px;
&__title-area {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 10px;
&__title {
font-family: 'font_medium', sans-serif;
font-size: 22px;
line-height: 27px;
color: #354049;
margin-right: 15px;
}
&__loading-image {
width: 18px;
height: 18px;
}
}
&__blur {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-color: rgba(229, 229, 229, 0.2);
z-index: 100;
}
}
}
.full-input {
width: 100%;
}
@media screen and (max-width: 1450px) {
.new-project-step {
padding: 0 150px;
}
}
@media screen and (max-width: 900px) {
.new-project-step {
padding: 0 50px;
}
}
</style>

View File

@ -36,7 +36,6 @@ import StripeCardInput from '@/components/account/billing/paymentMethods/StripeC
import LockImage from '@/../static/images/account/billing/lock.svg';
import { PAYMENTS_ACTIONS } from '@/store/modules/payments';
import { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
import { SegmentEvent } from '@/utils/constants/analyticsEventNames';
const {
@ -104,8 +103,7 @@ export default class AddCardState extends Vue {
this.setDefaultState();
// TODO: rework after adding next step
await this.$store.dispatch(APP_STATE_ACTIONS.TOGGLE_NEW_PROJ);
this.$emit('setProjectState');
}
/**

View File

@ -147,7 +147,7 @@ export default class AddStorjState extends Vue {
* Starts creating project process.
*/
public createProject(): void {
this.$store.dispatch(APP_STATE_ACTIONS.TOGGLE_NEW_PROJ);
this.$emit('setProjectState');
}
}
</script>

View File

@ -63,7 +63,6 @@ import VButton from '@/components/common/VButton.vue';
import CloseCrossIcon from '@/../static/images/common/closeCross.svg';
import SuccessIcon from '@/../static/images/project/success.svg';
import { RouteConfig } from '@/router';
import { BUCKET_ACTIONS } from '@/store/modules/buckets';
import { PAYMENTS_ACTIONS } from '@/store/modules/payments';
import { PROJECTS_ACTIONS } from '@/store/modules/projects';
@ -86,10 +85,11 @@ import { SegmentEvent } from '@/utils/constants/analyticsEventNames';
export default class NewProjectPopup extends Vue {
private projectName: string = '';
private description: string = '';
private nameError: string = '';
private createdProjectId: string = '';
private isLoading: boolean = false;
public nameError: string = '';
/**
* Indicates if popup is shown.
*/
@ -175,9 +175,6 @@ export default class NewProjectPopup extends Vue {
this.checkIfUsersFirstProject();
this.isLoading = false;
// TODO: remove after adding second step of onboarding tour
await this.$router.push(RouteConfig.ProjectDashboard.path);
}
/**

View File

@ -11,3 +11,9 @@ export function validateEmail(email: string): boolean {
export function validatePassword(password: string): boolean {
return typeof password !== 'undefined' && password.length >= 6;
}
export function anyCharactersButSlash(string: string): boolean {
const rgx = /^[^\/]+$/;
return rgx.test(string);
}

View File

@ -5,6 +5,7 @@ import Vuex from 'vuex';
import NewProjectArea from '@/components/header/NewProjectArea.vue';
import { router } from '@/router';
import { appStateModule } from '@/store/modules/appState';
import { makePaymentsModule, PAYMENTS_MUTATIONS } from '@/store/modules/payments';
import { makeProjectsModule, PROJECTS_MUTATIONS } from '@/store/modules/projects';
@ -36,6 +37,7 @@ describe('NewProjectArea', () => {
const wrapper = mount(NewProjectArea, {
store,
localVue,
router,
});
expect(wrapper).toMatchSnapshot();
@ -50,6 +52,7 @@ describe('NewProjectArea', () => {
const wrapper = mount(NewProjectArea, {
store,
localVue,
router,
});
expect(wrapper).toMatchSnapshot();
@ -70,6 +73,7 @@ describe('NewProjectArea', () => {
const wrapper = mount(NewProjectArea, {
store,
localVue,
router,
});
expect(wrapper.findAll('.new-project-button-container').length).toBe(1);
@ -85,6 +89,7 @@ describe('NewProjectArea', () => {
const wrapper = mount(NewProjectArea, {
store,
localVue,
router,
});
expect(wrapper.findAll('.new-project-button-container').length).toBe(0);

View File

@ -15,6 +15,8 @@ exports[`ProgressBar.vue renders correctly 1`] = `
<path d="M7.61854 0.302314C8.02258 -0.100771 8.67764 -0.100771 9.08163 0.302314C9.48569 0.705397 9.48569 1.35893 9.08163 1.76202L4.20463 6.62768C3.8006 7.03077 3.14555 7.03077 2.74152 6.62768L0.303018 4.19485C-0.101006 3.79177 -0.101006 3.13823 0.303018 2.73515C0.707044 2.33206 1.3621 2.33206 1.76612 2.73515L3.47307 4.43813L7.61854 0.302314Z" fill="white"></path>
</svg></div>
</div>
<div class="progress-bar-container__titles-area"><span class="progress-bar-container__titles-area__title">Name Your Project</span> <span class="progress-bar-container__titles-area__title api-key-title">Create an API Key</span> <span class="progress-bar-container__titles-area__title">Upload Data</span></div>
<div class="progress-bar-container__titles-area"><span class="progress-bar-container__titles-area__title">
Name Your Project
</span> <span class="progress-bar-container__titles-area__title api-key-title">Create an API Key</span> <span class="progress-bar-container__titles-area__title">Upload Data</span></div>
</div>
`;

View File

@ -5,5 +5,6 @@ exports[`OnboardingTourArea.vue renders correctly 1`] = `
<progressbar-stub></progressbar-stub>
<overviewstep-stub></overviewstep-stub>
<!---->
<!---->
</div>
`;

View File

@ -0,0 +1,55 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
import sinon from 'sinon';
import Vuex from 'vuex';
import CreateProjectStep from '@/components/onboardingTour/steps/CreateProjectStep.vue';
import { PaymentsHttpApi } from '@/api/payments';
import { makePaymentsModule } from '@/store/modules/payments';
import { makeProjectsModule } from '@/store/modules/projects';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import { ProjectsApiMock } from '../../mock/api/projects';
const localVue = createLocalVue();
localVue.use(Vuex);
const paymentsApi = new PaymentsHttpApi();
const paymentsModule = makePaymentsModule(paymentsApi);
const projectsApi = new ProjectsApiMock();
const projectsModule = makeProjectsModule(projectsApi);
const store = new Vuex.Store({ modules: { paymentsModule, projectsModule }});
describe('CreateProjectStep.vue', () => {
it('renders correctly', (): void => {
const wrapper = shallowMount(CreateProjectStep, {
store,
localVue,
});
expect(wrapper).toMatchSnapshot();
});
it('click works correctly', async (): Promise<void> => {
const clickSpy = sinon.spy();
const wrapper = mount(CreateProjectStep, {
store,
localVue,
methods: {
createProjectClick: clickSpy,
},
});
expect(wrapper.findAll('.disabled').length).toBe(1);
await wrapper.vm.setProjectName('test');
expect(wrapper.findAll('.disabled').length).toBe(0);
await wrapper.find('.create-project-button').trigger('click');
expect(clickSpy.callCount).toBe(1);
});
});

View File

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CreateProjectStep.vue renders correctly 1`] = `
<div class="new-project-step">
<h1 class="new-project-step__title">Name Your Project</h1>
<p class="new-project-step__sub-title">
Projects are where buckets are created for storing data. Within a Project, usage is tracked at the bucket
level and aggregated for billing.
</p>
<div class="new-project-step__container">
<div class="new-project-step__container__title-area">
<h2 class="new-project-step__container__title-area__title">Project Details</h2>
<!---->
</div>
<headeredinput-stub label="Project Name" placeholder="Enter Project Name" height="48px" width="100%" error="" maxsymbols="20" initvalue="" additionallabel="Up To 20 Characters" class="full-input"></headeredinput-stub>
<headeredinput-stub label="Description" placeholder="Enter Project Description" height="100px" width="100%" error="" maxsymbols="9007199254740991" initvalue="" additionallabel="Optional" ismultiline="true" class="full-input"></headeredinput-stub>
<!---->
</div>
<vbutton-stub label="Create Project" width="156px" height="48px" isdisabled="true" onpress="function () { [native code] }" class="create-project-button"></vbutton-stub>
</div>
`;