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

Kenzo Takahashi
ITNEXT
Published in
9 min readAug 16, 2018

--

Welcome back! Thanks to your support, now this tutorial series is part of ITNEXT! I’m excited to reach wider audience and help more people.

In this part, we are going to build a workspace.

Part 1 — building a simple GraphQL API

Part 2 — building MongoDB schemas and an email form

Part 3 — authentication

Part 4 — building a workspace (this part)

Part 5 — CRUD functionality for folders

The code for this tutorial is available here. Or if you want to follow along, you can clone part3 branch here.

Show Team

The first thing we are going to do is to get the user’s team. We don’t need the test query anymore, so delete it and add getTeam and Team type in server/src/schema.graphql:

type Query {
getTeam: Team
}
type Team {
id: String
name: String
}

Since workspace requires login, we need to check the request header to make sure the user is logged in. Create server/src/utils.js :

// server/src/utils.js
const jwt = require('jsonwebtoken')
require('dotenv').config()
function getUserId(context) {
const Authorization = context.request.get('Authorization')
if (Authorization) {
const token = Authorization.replace('Bearer ', '')
const {id} = jwt.verify(token, process.env.JWT_SECRET)
return id
}
throw new Error('Not authenticated')
}
module.exports = {
getUserId,
}

Import the function to resolvers.js. After we authorize the user, we get the team id from the user and return the Team object:

const { getUserId } = require('./utils')const resolvers = {
Query: {
async getTeam (_, args, context) {
const userId = getUserId(context)
const user = await User.findById(userId)
return await Team.findById(user.team)
},
},
...
}

Let’s test it in playground. First, login with the account you have previously created. If you don’t have one, you can create a new one using captureEmail and signup.

Then copy the token and open a new tab in playground. Here is what getTeam query looks like:

Paste the token after Bearer in the header option.

"Authorization": "Bearer [TOKEN]"

If you do it correctly, you should get the response like shown above.

This is nice, so how do we do this in client? Using ApolloLink, you can automatically add headers to each request. Add the following to client/src/main.js. The order of the links is important, so authMiddleware should be after errorLink and before httpLink.

const authMiddleware = new ApolloLink((operation, forward) => {
const token = localStorage.getItem('user-token')
operation.setContext({
headers: {
authorization: token ? `Bearer ${token}` : null
}
})
return forward(operation)
})
const client = new ApolloClient({
link: ApolloLink.from([
errorLink,
authMiddleware,
httpLink
]),
cache,
connectToDevTools: true,
})

Add getTeam query to client/src/constants/query.gpl:

query GetTeam {
getTeam {
id
name
}
}

Create a new file client/src/views/Workspace.vue and paste this code:

<template>
<div>
<div class="container">
<aside class="tree-root">
<div v-if="getTeam.id" class="tree-item"
@click.left.stop="$router.push({name: 'folder', params: {id: getTeam.id}})">
<div class="tree-plate" v-bind:class="{active: $route.params.id === getTeam.id}">
<div class="circle"></div>
<span class="folder no-select-color teamname">{{ getTeam.name }}</span>
</div>
</div>
</aside><div class="workspace-main">
<router-view :key="$route.fullPath"></router-view>
</div>
</div></div></template><script>
import { GetTeam } from '../constants/query.gql'
export default {
data() {
return {
getTeam: {},
}
},
apollo: {
getTeam: {
query: GetTeam,
},
},
}
</script>
<style scoped>
.container {
width: 100%;
height: calc(100% - 52px);
}
.plus-button {
position: absolute;
right: 0;
top: 7px;
margin: 0 2px;
}
aside {
width: 220px;
height: 100%;
display: inline-block;
}
.workspace-main {
flex: 1 1;
}
</style>

This is the main view that displays a folder tree on the left. It also renders child components. But for now, it just queries the current user’s team and shows the name.

There are many changes to client/src/router.js :

import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import Signup from './views/Signup.vue'
import Login from './views/Login.vue'
import Workspace from './views/Workspace.vue'
Vue.use(Router)const login = {
path: '/login',
name: 'login',
component: Login,
meta: { title: 'Login - enamel' }
}
const workspace = {
path: '/w',
name: 'workspace',
component: Workspace,
meta: { title: 'Workspace - enamel', requiresAuth: true },
}
const router = new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'home',
component: Home,
meta: { title: 'enamel', redirect: true}
},
{
path: '/signup/:id',
name: 'signup',
component: Signup,
meta: { title: 'Signup - enamel' }
},
login,
workspace
]
})
router.beforeEach((to, from, next) => {
const auth = localStorage.getItem('user-id')
if (to.matched.some(record => record.meta.requiresAuth)) {
if(!auth) {
next(login)
}
} else if (to.matched.some(record => record.meta.redirect)) {
if(auth) {
next(workspace)
}
}
next()
})
router.afterEach((to, from) => {
document.title = to.meta.title
})
export default router

First, I added a route for workspace. I also added beforeEach to redirect the user based on whether or not he’s logged in. If he goes to the root page(email form) while he is logged in, he will be redirected to workspace. If he goes to workspace while not logged in, then he will be redirected to login page.

Finally, we need small changes to Login.vue and Signup.vue. Right now, we just print “success” after login or signup. Replace that with this.$router.push({name: ‘workspace’}). Also, we need to clear Apollo cache each time a user logs in, otherwise if a different user logs in on the same computer, she can see the previous user’s data. Clearing cache in VueApollo is this.$apollo.provider.clients.defaultClient.cache.reset() . This was not easy to find, by the way. And it’s rather long.

The updated version of login in client/src/views/Login.vue is as follows:

methods: {
async login() {
this.$apollo.provider.clients.defaultClient.cache.reset()
const { email, password } = this.form
if (email && password) {
this.$apollo.mutate({
mutation: Login,
variables: { email, password }
}).then(async (data) => {
const login = data.data.login
const id = login.user.id
const token = login.token
this.saveUserData(id, token)
this.$router.push({name: 'workspace'})
}).catch((error) => {
this.error = 'Invalid email or password'
console.log(error)
})
}
},
...
}

and client/src/views/Signup.vue

methods: {
async signup() {
this.$apollo.provider.clients.defaultClient.cache.reset()
const { firstname, lastname, password } = this.form
if (!(firstname && lastname && password)) {
this.error = 'Please complete the form'
return
}
this.$apollo.mutate({
mutation: Signup,
variables: {
id: this.$route.params.id,
firstname,
lastname,
password
}
}).then(({data: {signup}}) => {
const id = signup.user.id
const token = signup.token
this.saveUserData(id, token)
this.$router.push({name: 'workspace'})
}).catch((error) => {
this.error = 'Something went wrong'
console.log(error)
})
},
}

Now we are ready to test! After you login, you should see this:

Wow, that’s boring.

Get Folders

Next, we are going to create getFolders and getFolder query. Add this to server/src/schema.graphql :

scalar Date
scalar JSON
type Query {
getTeam: Team
getFolders(parent: String): [Folder]
getFolder(id: String!): Folder
}
type Folder {
id: String
name: String
parent: String
description: String
shareWith: [JSON]
}

Remember, shareWith in folder can be a team, groups or members, so we can’t define the type because there is no polymorphism in GraphQL. In such case, we need to define a new scaler. I call it JSON, but you can name it whatever you want.

resolvers for getFolders and getFolder are as follows:

const mongoose = require('mongoose')
const ObjectId = mongoose.Types.ObjectId
const { User, Team, Folder, Group } = require('./models')
const resolvers = {
Query: {
...
async getFolders (_, {parent}, context) {
const userId = getUserId(context)
if (parent) {
return await Folder.find({parent})
} else {
const user = await User.findById(userId)
const groups = await Group.find({users: ObjectId(userId)}, '_id')
const ids = groups.map(o => o._id).concat(
['External User', 'Collaborator'].includes(user.role)
? [ObjectId(userId)]
: [ObjectId(userId), user.team]
)
return await Folder.find({ 'shareWith.item': ids }).populate('shareWith')
}
},
async getFolder (_, {id}, context) {
const userId = getUserId(context)
return await Folder.findById(id).populate('shareWith')
},
},
}

getFolder is straightforward. getFolders is used for both querying root folders and sub folders, so it’s a bit more complicated. If parent parameter is provided, it queries subfolders. If not, it queries root folders that are shared with the user. So we need to get the user id, the groups the user belongs to, and the user’s team. Only Administrator(+Owner) and Regular User belong to the team. So Regular User is used for full time employees, and External User and Collaborator are used for contractors. You might not want to automatically share your company’s all projects to a contractor, right?

External User and Collaborator are exactly the same. I have both because Wrike has them. In Wrike, Collaborator is even more restricted. He cannot create a new task or folder. He can only comment it and update the status. I was initially going to build it, but I thought building new features is more important than building a restriction.

We don’t have Group model yet, so add this to server/src/models.js :

module.exports.Group = buildModel('Group', {
team: { type: ObjectId, ref: 'Team' },
name: String,
initials: String,
avatarColor: String,
users: [{ type: ObjectId, ref: 'User' }],
})

A group belongs to a team and has users. We won’t use it soon, so don’t worry about it.

Moving on to client, add this code to client/src/constants/query.gql

fragment FolderFields on Folder {
id
name
parent
description
shareWith
}
query GetFolders($parent: String) {
getFolders(parent: $parent) { ...FolderFields }
}
query GetFolder($id: String!) {
getFolder(id: $id) { ...FolderFields }
}

Fragment is not supported by default, so we need to import enableExperimentalFragmentVariables , which we already did in client/src/main.js.

Update the router as follows:

import Folder from './views/Folder.vue'
import FolderDetail from './views/FolderDetail.vue'
const workspace = {
path: '/w',
name: 'workspace',
component: Workspace,
meta: { title: 'Workspace - enamel', requiresAuth: true },
children: [
{
path: 'folder/:id',
component: Folder,
props: true,
children: [
{
path: '',
name: 'folder',
component: FolderDetail
},
// {
// path: 'task/:taskId',
// name: 'task',
// component: Task,
// props: true
// }
]
}
]
}

Workspace has two white panels, or cards if you will. The left side shows a list of tasks in the selected folder. The right side shows the folder info by default, and it shows the task info when one the tasks are clicked. I’m sure there is more than one way to do this. My approach is to treat the right side as a child component of the left side. As you can see, FolderDetail is a child of Folder , the left side. Later in this series we are going to implement Task component.

Create client/src/views/Folder.vue :

<template>
<el-row class="max-height">
<el-col :span="12" class="max-height">
<div class="white card max-height">
<div class="folder-header">
<div class="header-title folder-name">{{folder.name}}</div>
</div>
</div>
</el-col>
<el-col v-if="!isTeam(folder) && subRoute==='folder'" :span="12" class="max-height">
<FolderDetail :folder="folder"></FolderDetail>
</el-col>
</el-row>
</template>
<script>
import { GetFolder } from '../constants/query.gql'
import FolderDetail from './FolderDetail.vue'
export default {
components: {
FolderDetail
},
beforeRouteUpdate (to, from, next) {
this.subRoute = to.name
next()
},
data() {
return {
subRoute: 'folder',
folderName: '',
folder: {
shareWith: []
},
}
},
apollo: {
getFolder: {
query: GetFolder,
variables() {
return {id: this.$route.params.id}
},
result ({data: { getFolder }}) {
this.folder = getFolder
this.folderName = this.folder.name
if (this.isTeam) {
document.title = `${this.folder.name} - enamel`
}
},
}
},
methods: {
isTeam(folder) {
return !folder.parent && folder.shareWith.length === 0
}
}
}
</script>
<style>
.folder-header {
padding: 15px 24px 0;
line-height: 21px;
min-height: 40px;
}
.folder-name {
padding: 0;
margin: 5px 0;
height: 32px;
width: 100%;
}
.menu-title {
margin: 0 5px;
font-size: 12px;
}
.max-height {
height: 100%;
}
.white.card {
display: flex;
flex-direction: column;
}
.task-container {
flex-grow: 1;
overflow: scroll;
}
</style>

It queries the folder and displays the name. It the folder is not a team, it renders FolderDetail on the right.

Create client/src/views/FolderDetail.vue . It doesn’t do much right now. You can ignore the long css, which will be used later.

<template>
<div class="white card max-height">
</div>
</template>
<script>export default {
props: ['folder'],
mounted() {
document.title = `${this.folder.name} - enamel`
},
}
</script>
<style scoped>
.folder-header {
padding: 15px 24px 0;
line-height: 21px;
min-height: 40px;
}
.folder-statebar {
display: flex;
height: 48px;
position: relative;
padding: 0 24px;
border-bottom: solid 1px;
border-color: rgba(0,0,0,.16);
}
.folder-name {
padding: 0;
margin: 5px 0;
height: 32px;
width: 100%;
}
.shared-with {
padding-left: 7px;
}
.subfolder:hover {
color: initial;
cursor: default;
}
/*tooltip*/.member-avatar {
margin-right: 8px;
cursor: pointer;
}
.member-avatar:hover .remove-button {
visibility: visible;
}
.tooltip .tooltip-content {
width: 278px;
left: 50%;
margin-left: -139px;
}
.search-input {
padding: 15px;
}
.contact-picker-item-list {
padding-bottom: 24px;
max-height: 295px;
overflow: auto;
}
.group-field {
box-sizing: border-box;
padding: 15px;
}
.add-additional {
display: flex;
flex-direction: row;
}
.label {
text-align: left;
}
</style>

Okay, let’s test what we have so far. Go to workspace and click on the team. You should see Folder component on the left. Since it’s a team, FolderDetail is not shown.

Still boring, I know.

Coming Up: Create, Update, and Delete Folder

This part turned out to be longer than I expected, so I’m going to stop here. In part 5, we are going to implement a CRUD functionality for folders.

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

--

--