Build a Project Management Tool with Vue.js, Node.js and Apollo — Part 2

Kenzo Takahashi
ITNEXT
Published in
9 min readAug 5, 2018

--

In part 1 of this series, we built a simple GraphQL API.

In this part, we are going to build MongoDB schemas and an email form.

In part 3, we will complete the authentication.

Part 4 — building a workspace

Part 5 — CRUD functionality for folders

Let’s begin this tutorial by creating a MongoDB schema. Create a new file models.js inside server/src.

Although you can use MongoDB without creating a Schema, doing so allows you to write code in a more expressive way and helps you visualize your database structure.

First we need a model for Folder, Team, and User. Wrike is more complex than other tools in that folders and tasks are a tree structure — you can create a subfolder of subfolder. So Folder model has a parent reference. Also notice that I’m using dynamic reference for sharing. That’s because you can share a folder with a whole team, groups, or users.

Finally, I wrote buildModel function to abstract boilerplate code. It inserts timestamps(createdAt and updateAt) into every model.

server/src/models.js
const mongoose = require("mongoose")
const moment = require('moment')
const Schema = mongoose.Schema
const ObjectId = Schema.Types.ObjectId
function buildModel(name, schema) {
return mongoose.model(name, new Schema(schema, {timestamps: true}))
}
const Folder = buildModel('Folder', {
name: String,
description: String,
shareWith: [{
kind: String,
item: { type: ObjectId, refPath: 'shareWith.kind' }
}],
parent: { type: ObjectId, ref: 'Folder' },
})
module.exports.Folder = Folder

Team model is exactly the same as Folder. But since Team has distinct characters, I made a separate model for that.

module.exports.Team = Folder.discriminator('Team', new Schema({
}, {timestamps: true}))

User model has lots fields. I will explain each field as we go along.

module.exports.User = buildModel('User', {
name: {
type: String,
default: ''
},
firstname: String,
lastname: String,
email: {
type: String,
required: true,
},
password: {
type: String,
},
jobTitle: {
type: String,
default: ''
},
avatarColor: String,
team: { type: ObjectId, ref: 'Team' },
role: String,
status: String
})

User Object

Usually a tutorial like this deals with authentication after completing a basic functionality, which helps you get a big picture quickly. The downside of this approach is that you end up rewriting some of the code. So I decided to build authentication first. I know it’s not the most exciting part, but let’s get this done now so that we can move onto more interesting stuff!

Here is the new schema.graphql. Instead of requiring a user to signup right away, I want to capture his email first. So we have captureEmail as well as signup and login . We need to define a custom scalar type Date because GraphQL does not have one by default.

// server/src/schema.graphql
scalar Date
type Query {
test: String
}
type Mutation {
captureEmail(email: String!): User
signup(id: String!, firstname: String!, lastname: String!, password: String!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
}
type User {
id: String
name: String
firstname: String
lastname: String
email: String
avatarColor: String
jobTitle: String
team: String
role: String
status: String
createdAt: Date
}
type AuthPayload {
token: String!
user: User!
}

Now that we have the GraphQL schema setup, let’s write corresponding revolver functions. We are going to fill signup and login later.

const { GraphQLScalarType } = require('graphql')
const moment = require('moment')
const { User } = require('./models')
const resolvers = {
Query: {
test (_, args, context) {
return 'Hello World!!'
}
},
Mutation: {
async captureEmail (_, {email}) {
const isEmailTaken = await User.findOne({email})
if (isEmailTaken) {
throw new Error('This email is already taken')
}
const user = await User.create({
email,
role: 'Owner',
status: 'Pending'
})
return user
},
async signup (_, {id, firstname, lastname, password}) {
},
async login (_, {email, password}) {
}
},
Date: new GraphQLScalarType({
name: 'Date',
description: 'Date custom scalar type',
parseValue: (value) => moment(value).toDate(), // value from the client
serialize: (value) => value.getTime(), // value sent to the client
parseLiteral: (ast) => ast
})
}
module.exports = resolvers

captureEmail is straightforward. First it checks if the email is already taken. It not, create a user with Owner role. Status is Pending because the user hasn’t signed up yet.

Below Mutation, we have Date scalar type. Since GraphQL cannot send and receive a date object, we need to parse and serialize it. To serialize it, we call getTime() to get the number of milliseconds since 1970/01/01. This way we can treat a date as a number. To parse it, we use moment.js. It’s an awesome library for dealing with date and time, and I use it extensively throughout the app.

Let’s test captureEmail . Just like before, open up playground by going to localhost:5500/playground and write a mutation. Make sure you provide an email as a query variable.

That should create a new user. To confirm it, take a look at MongoDB. I use free GUI tool Robo 3T, but you can use whatever you want. As you can see, the user object is created.

Setup Vue App

I don’t want to keep you in the backend too long, so let’s create a Vue app and test captureEmail from client.

To scaffold a Vue app, we are going to use Vue cli 3. It’s a fantastic tool. Did you know that it automatically adds babel polyfills based on your code? It makes our lives so much easier. If you haven’t already, install it globally.

yarn global add @vue/cli

Then create a new app under root directory.

vue create client

Here is the features we need. To keep it simple, I only included the bare essentials.

CSS pre-processor is a matter of preference. I use SCSS, but you are welcome to use your favorite.

To minimize the number of files, I place config files in package.json. Again, you can choose whichever option you want.

Confirm the app is working:

cd client
yarn serve

Add the following dependencies to package.json . That’s all we need for client. As you can see, except element-ui and moment, they are all GraphQL/Apollo related.

{
...
"dependencies": {
"apollo-cache-inmemory": "1.2.5",
"apollo-client": "2.3.5",
"apollo-link": "1.2.2",
"apollo-link-error": "1.1.0",
"apollo-link-http": "1.5.4",
"element-ui": "2.4.3",
"graphql": "0.13.2",
"graphql-tag": "2.9.2",
"moment": "2.22.2",
"vue-apollo": "3.0.0-beta.19",
"vue": "^2.5.16",
"vue-router": "^3.0.1",
"vuex": "^3.0.1"
},
...
}

Install the dependencies:

yarn

In main.js , install libraries and add some boilerplate code for Apollo:

import Vue from 'vue'
import { ApolloClient } from 'apollo-client'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { onError } from "apollo-link-error"
import { ApolloLink } from 'apollo-link'
import { HttpLink } from 'apollo-link-http'
import { enableExperimentalFragmentVariables } from 'graphql-tag'
import VueApollo from 'vue-apollo'
import ElementUI from 'element-ui'
import App from './App.vue'
import router from './router'
import store from './store'
import 'element-ui/lib/theme-chalk/index.css'Vue.use(ElementUI)Vue.config.productionTip = falseconst uri = `${process.env.VUE_APP_URI}/graphql`
const httpLink = new HttpLink({uri})
const cache = new InMemoryCache({})const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors)
graphQLErrors.map(({ message, locations, path }) =>
console.log(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
)
)
if (networkError) console.log(`[Network error]: ${networkError}`)
})
const client = new ApolloClient({
link: ApolloLink.from([
errorLink,
httpLink
]),
cache,
connectToDevTools: true,
})
const apolloProvider = new VueApollo({
defaultClient: client,
defaultOptions: {
$loadingKey: 'loading'
}
})
Vue.use(VueApollo)new Vue({
router,
provide: apolloProvider.provide(),
store,
render: h => h(App)
}).$mount('#app')

Create .env.development and define the environment variable for backend API:

VUE_APP_URI=http://localhost:5500

The CSS file is too long to place here. You can copy paste style.scss and variables.scss from the repository.

Create vue.config.js and add the following:

//client/vue.config.js
module.exports = {
devServer: {
port: 8080,
proxy: 'http://localhost:5500'
},
configureWebpack: {
module: {
rules: [
{
test: /\.(graphql|gql)$/,
exclude: /node_modules/,
loader: 'graphql-tag/loader'
}
]
}
},
css: {
loaderOptions: {
sass: {
data: `@import "@/assets/css/variables.scss";`
}
}
}
}

We are going to use graphQL file in client, so we need to define a loader here.

I want scss variables to be accessible from all the components without manually importing, so I import variables.scss here.

Change route mode to history

client/src/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
Vue.use(Router)export default new Router({
mode: 'history',
...
})

Delete the sample code in App.vue and replace with this:

<template>
<div id="app">
<router-view></router-view>
</div>
</template>

Finally, the captureEmail page in Home.vue:

//client/src/views/Home.vue
<template>
<el-container>
<el-header>
</el-header>
<el-main>
<div class="container-center">
<h2>Welcome!</h2>
<div>Enter your email address to start free trial</div>
<div v-if="error" class="error">
{{ error }}
</div>
<el-form ref="form" :model="form">
<el-form-item>
<label>Email</label>
<el-input v-model="form.email" placeholder="Email"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" >Create my enamel account</el-button>
</el-form-item>
</el-form>
<div>
<span>Already have an enamel account?</span>
<router-link :to="{name: 'login'}" class="link">Log in</router-link>
</div>
<div v-if="submitted">
<div>Thank you!</div>
<div>Please check your email.</div>
</div>
</div>
</el-main>
</el-container>
</template><script>export default {
data() {
return {
submitted: false,
error: false,
form: {
email: '',
}
}
}
}
</script>
<style scoped lang="scss">
.el-button {
width: 100%;
}
.error {
padding-top: 10px;
}
</style>

Now, if you go to a root URL, It should look like this:

Phew, that was a lot of code! And it doesn’t even do anything!

Capture Email

Finally it’s time to use Apollo client. Create constants/query.gql and place the same code we used in the playground:

//client/src/constatnts/query.gql
mutation CaptureEmail($email: String!) {
captureEmail(email: $email) {
id
email
}
}

Also we are going to define a validation function in helpers/helpers.js , so create the folder inside src .

//client/src/helpers/helpers.js
export function validateEmail(email) {
const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase())
}

You could use something like Vue-validate for validation. It’s a powerful library, but I want to keep it simple and not reply too much on libraries.

We are almost done. Go back to Home.vue . Add event handler to the submit button:

<el-button type="primary" @click="capture">Create my enamel account</el-button>

Import the query and validation function. capture method looks like this:

<script>
import { CaptureEmail } from '../constants/query.gql'
import { validateEmail } from '@/helpers/helpers'
export default {
...
methods: {
capture() {
const {email} = this.form
if (!email || !validateEmail(email)) {
this.error = 'Please enter a valid email'
return
}
this.$apollo.mutate({
mutation: CaptureEmail,
variables: {email}
}).then(({data}) => {
this.submitted = true
this.error = false
// For development only
console.log(data.captureEmail.id)
}).catch((error) => {
if (error.graphQLErrors.length >= 1) {
this.error = error.graphQLErrors[0].message
} else {
this.error = 'Something went wrong'
}
console.log(error)
})
},
}
}
</script>

First it validates the email. If it’s valid, call Apollo mutation method with the email as a variable. Then if everything is OK, set this.submitted to true and show a message. If not, show an error message. Apollo error is either graphQLErrors or NetworkError. So if the error object has graphQLErrors, show the message. In this case, it’s that the email is already taken. Otherwise it’s a network error, so we just show a generic error message.

You can learn more about error handling here.

If you enter a different email from the one you used in playground, you should see the message below.

You can also try entering an invalid email or the email that’s already taken and confirm that they are handled properly.

That’s it for part 2! Check out the github for reference if you need it.

In part 3, we are going to create authentication.

if you liked this post, please give it some claps! It motivates me to write the next part sooner.

--

--