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

Kenzo Takahashi
ITNEXT
Published in
11 min readAug 16, 2018

--

Hello, my fellow Vue.js lovers and Node.js lovers and Apollo lovers! Let’s dive into part 5!

Part 1 — building a simple GraphQL API

Part 2 — building MongoDB schemas and an email form

Part 3 — authentication

Part 4 — building a workspace

Part 5 — CRUD functionality for folders (this part)

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

Create Folder

Add a schema definition to server/src/schema.graphql :

type Mutation {
...
createFolder(parent: String, name: String!): Folder
}

The resolver looks like this:

Mutation: {
...
async createFolder(_, {parent, name}, context) {
const userId = getUserId(context)
const folder = await Folder.create({
name,
parent: parent || undefined,
shareWith: parent ? [] : [{
kind: 'Team',
item: (await User.findById(userId)).team
}]
})
return await Folder.findById(folder.id).populate('shareWith.item')
}
},

If a folder is a root folder(no parent), set the team as shareWith . It the folder is a subfolder, shareWith is just an empty array. Later on, we are going to build a form to change the sharing state of a folder, but you can’t set a sharing state when creating a new folder. Although you can do this in Wrike, I didn’t want to implement the same functionality in multiple places.

That’s it for server.

Add this to client/src/constants/query.gql

mutation CreateFolder($parent: String, $name: String!) {
createFolder(parent: $parent, name: $name) {
...FolderFields
}
}

Here is the updated version of client/src/views/Workspace.vue (except CSS):

<template>
<div>
<div class="container">
<aside class="tree-root">
<div v-if="getTeam.id" class="tree-item"
@click.right.stop.prevent="$store.commit('changeActiveWidget', `folder${getTeam.id}`)"
@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>
<plus-button @click="openModal" color="white"></plus-button>
<div class="dropdown-content left" v-show="activeWidget === `folder${getTeam.id}`">
<div @click="openModal">Add Folder</div>
</div>
</div>
</div>
<FolderTree
v-for="folder in getFolders"
:key="folder.id"
:model="folder"
:team="getTeam.id" >
</FolderTree>
</aside>
<div class="workspace-main">
<router-view :key="$route.fullPath"></router-view>
</div>
<FolderForm v-if="showModal" :config="{parent: ''}" @close="showModal = false"></FolderForm>
</div>
</div></template><script>
import { mapState } from 'vuex'
import FolderTree from '@/components/FolderTree'
import FolderForm from '@/components/FolderForm'
import { GetFolders, GetTeam } from '../constants/query.gql'
export default {
components: {
FolderTree,
FolderForm,
},
computed: mapState(['activeWidget']),
data() {
return {
showModal: false,
getFolders: [],
getTeam: {}
}
},
apollo: {
getTeam: {
query: GetTeam,
},
getFolders: {
query: GetFolders,
error(error) {
console.error(error)
},
}
},
methods: {
openModal() {
this.$store.commit('changeActiveWidget', null)
this.showModal = true
},
}
}
</script>

We have three new components. FolderTree is a tree component for rendering folders recursively. FolderForm is a modal form for creating a new folder. PlusButton is just a SVG icon. We also have a mutation changeActiveWidget .

First, let’s create PlusButton.vue in client/src/components/icons (create icons folder):

//client/src/components/icons/PlusButton.vue
<template>
<div class="plus-button"
@click.stop="$emit('click')">
<svg :height="size || 16" :width="size || 16" viewBox="0 0 15 15">
<g fill-rule="evenodd">
<path :fill="circleColor[color]" d="M7.5 15a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15zm0-1a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13z"></path>
<path :fill="plusColor[color]" d="M7 7H4v1h3v3h1V8h3V7H8V4H7v3z" id="plus_light_icon" ></path>
</g>
</svg>
</div>
</template>
<script>
export default {
name: 'plus-button',
props: ['size', 'color'],
data() {
return {
circleColor: {
'white': 'rgba(255,255,255,.6)',
'grey': 'rgba(0, 0, 0, 0.32)',
},
plusColor: {
'white': '#fff',
'grey': 'rgba(0, 0, 0, 0.32)',
}
}
}
}
</script>
<style scoped>
.plus-button {
display: flex;
cursor: pointer;
width: 16px;
height: 16px;
}
</style>

I want to register this globally, so add this to client/src/main.js :

import PlusButton from '@/components/icons/PlusButton.vue'Vue.component('plus-button', PlusButton)

Next, FolderTree.vue .

//client/src/components/FolderTree.vue
<template name="tree">
<li>
<div class="tree-item"
@click.right.stop.prevent="$store.commit('changeActiveWidget', `folder${model.id}`)"
@click.left.stop="$router.push({name: 'folder', params: {id: model.id}})">
<span @click="toggle" class="fold-button"
v-bind:class="{active: $route.params.id === model.id}"
v-bind:style="{visibility: isFolder ? 'visible' : 'hidden'}">
<i :class="`fas fa-angle-${open ? 'down' : 'right'}`"></i>
</span>
<div class="tree-plate" v-bind:class="{active: $route.params.id === model.id}">
<span class="folder no-select-color">{{ model.name }}</span>
<div class="dropdown-content left" v-show="activeWidget === `folder${model.id}`">
<div @click="openModal">Add Folder</div>
<!-- <div @click="deleteFolder">Delete</div> -->
</div>
</div>
</div>
<ul class="tree" v-show="open" v-if="isFolder">
<tree
v-for="folder in getFolders"
:key="folder.id"
:model="folder"
@open="openArrow"
>
</tree>
</ul>
<FolderForm v-if="showModal" :config="modalConfig" @close="showModal = false"></FolderForm>
</li>
</template>
<script>
import { mapState } from 'vuex'
import FolderTree from './FolderTree'
import FolderForm from './FolderForm'
import { GetFolders } from '../constants/query.gql'
export default {
name: 'tree',
components: {
'tree': FolderTree,
FolderForm
},
props: ['model', 'team'],
data() {
return {
open: false,
showModal: false,
modalConfig: {},
getFolders: []
}
},
mounted() {
if (this.$route.params.id === this.model.id) {
this.$emit('open')
}
},
computed: {
isFolder: function () {
return this.getFolders.length > 0
},
...mapState(['activeWidget'])
},
apollo: {
getFolders: {
query: GetFolders,
variables() {
return { parent: this.model.id }
},
error(error) {
console.error(error)
},
}
},
methods: {
toggle() {
if (this.isFolder) {
this.open = !this.open
}
},
openModal(mode) {
this.$store.commit('changeActiveWidget', null)
this.showModal = true
this.modalConfig = {
parent: this.model.id
}
},
openArrow() {
this.open = true
this.$emit('open')
},
},
watch: {
'$route' (to, from) {
if (to.params.id === this.model.id) {
this.$emit('open')
}
}
}
}
</script>

If you are familiar with how to implement a tree in Vue.js, most of this should be easy to understand. One thing that’s not obvious at first is this.$emit(‘open’) . When you directly enter the URL of a folder or create a new folder, you want to automatically expand the tree and show the folder. To do this, for each folder, check if the current route matches the id of the folder. If so, open the parent tree by calling @open=”openArrow" . I do this both in mounted and watch .

I’m using a font awesome icon, so include the library in client/public/index.html :

<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.1.0/css/all.css" integrity="sha384-lKuwvrZot6UHsBSfcMvOkWwlCMgc0TaWr+30HWe3a4ltaBwTZhyTEggF5tJv8tbt" crossorigin="anonymous">

There is vue-fontawesome, but I found it hard to use.

Next, create FolderForm.vue :

// client/src/components/FolderForm.vue
<template>
<div class="modal-mask white" @click="$emit('close')">
<div class="modal-wrapper">
<div class="modal-container" @click.stop="$store.commit('changeActiveWidget', null)">
<h3>Create folder</h3><el-form :model="form" @submit.native.prevent="createFolder">
<el-input type="text" name="foldername" ref="foldername" v-model="form.name"
placeholder="folder name" @keyup.esc="$emit('close')">
</el-input>
</el-form>
<div class="button-group">
<el-button type="primary" @click="createFolder">Create</el-button>
<el-button type="text" @click="$emit('close')">Cancel</el-button>
</div>
</div>
</div>
</div>
</template>
<script>
import { CreateFolder, GetFolders } from '../constants/query.gql'
export default {
props: ['config'],
data() {
return {
form: {
name: '',
}
}
},
mounted() {
this.$refs.foldername.focus()
},
methods: {
createFolder() {
const { name } = this.form
if (!name) return
const parent = this.config.parent
this.$apollo.mutate({
mutation: CreateFolder,
variables: {name, parent},
update: (store, { data: { createFolder } }) => {
const variables = parent ? { parent } : {}
try {
const data = store.readQuery({
query: GetFolders,
variables
})
data.getFolders.push(createFolder)
store.writeQuery({
query: GetFolders,
variables,
data
})
} catch(err) {
console.log(err)
}
}
}).then(({ data: { createFolder } }) => {
this.$emit('close')
this.$router.push({name: 'folder', params: {id: createFolder.id} })
}).catch((error) => {
console.log(error)
})
}
}
}
</script><style scoped>.modal-container {
width: 400px;
}
.radio-group {
padding: 20px 0;
}
.description {
position: relative;
left: 28px;
font-size: 12px;
color: rgba(0, 0, 0, 0.56);
line-height: 1.67;
padding-bottom: 10px;
}
.button-group {
margin-top: 20px;
}
</style>

I have this.$refs.foldername.focus() on mount to automatically focus the form, because unfortunately, native autofocus only works the first time.

This is the first time we’ve seen update on Apollo. Given the same parameter, Apollo queries data from cache. Which means that after each mutation, you have to explicitly tell Apollo, “Hey, the query with this parameter has changed”. For more information, refer to Apollo and vue-apollo documentation.

One tricky part of update is that if the query specified in readQuery has not been called yet, it will give you a weird error. A typical example of this is where you have a list in /folders and a form in /folders/new . A list of folders is queried in /folders . If you navigate to /folders/new directly, however, folders have not been queried yet. If you call mutation at this point and try to update the query for folders, you will get an error. So it’s good practice to use try & catch on update . As long as you do that, you will be fine.

Finally, update client/src/store.js :

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)export default new Vuex.Store({
state: {
userId: localStorage.getItem('user-id'),
activeWidget: null,
},
mutations: {
changeActiveWidget(state, key) {
state.activeWidget = state.activeWidget === key ? null : key
}
}
})

I should note that there is a state management library in Apollo called apollo-link-state. It lets you manage your local state without using another state management like vuex or redux. This was appealing and I tried it. However, it was rather cumbersome and not as mature as vuex. Managing all the state in one place does have some benefits, but I don’t think they are big enough to replace vuex.

Time to test! You can either click on the plus button or double-click the team, and the modal form will pop up:

Enter a name and submit. You will see a folder being created. You can also create a subfolder, and create a subfolder of that subfolder…

Finally I feel like building an app :)

You might have noticed that the dropdown won’t disappear unless you click on it. To fix this, update client/src/App.vue :

<template>
<div id="app" @click="$store.commit('changeActiveWidget', null)">
<router-view></router-view>
</div>
</template>

Update Folder

Next thing we want to do is updating a folder. As always, let’s start with server/src/schema.graphql :

type Mutation {
...
updateFolder(id: String!, input: FolderInput): Folder
}
input FolderInput {
name: String
parent: String
description: String
shareWith: [ShareInput]
}
input ShareInput {
kind: String
item: String
}

The resolver looks like this:

Mutation {
...
async updateFolder(_, {id, input}, context) {
const userId = getUserId(context)
return await Folder.findOneAndUpdate(
{ _id: id },
{ $set: input },
{ new: true }
).populate('shareWith')
},
}

GraphQL’s input works nicely with MongoDB. You can just set the input, which is much cleaner than setting each field individually.

Add this to client/src/constants/query.gql :

mutation UpdateFolder($id: String!, $input: FolderInput) {
updateFolder(id: $id, input: $input) { ...FolderFields }
}

Here is the updated version of client/src/views/FolderDetail.vue :

<template>
<div class="white card max-height">
<div class="folder-header">
<form @submit.prevent="updateFolder">
<input class="no-outline header-title folder-name" type="text" name="taskname" ref="taskname"
v-model="folderName" @keyup.esc="cancel">
</input>
</form>
</div>

</div>
</template>
<script>
import { UpdateFolder } from '../constants/query.gql'
export default {
data() {
return {
folderName: this.folder.name,
}
},
props: ['folder'],
mounted() {
document.title = `${this.folder.name} - enamel`
},
methods: {
updateFolder(e) {
const name = this.folderName
if (name === this.folder.name) {
this.cancel(e)
return
}
this.$apollo.mutate({
mutation: UpdateFolder,
variables: { id: this.folder.id, input: {name} },
}).then(() => {
this.cancel(e)
}).catch((error) => {
console.log(error)
})
},
cancel(e) {
e.target.blur()
}
}
}
</script>

You may notice that we don’t have update in this mutation. That’s because Apollo is smart enough to know which object is being updated based on the id. So you don’t have to manually update it. Apollo saves you a lot of hustle. Obviously you do need to query id in order for this to work though.

Let’s test. Click the folder name on the right and edit it. When you hit enter, the folder name in other components will get updated as well. Sweet!

Delete Folder

Let’s implement deletion.

server/src/schema.graphql

type Mutation {
deleteFolder(id: String!): Boolean
}

server/src/resolvers.js . I’m deleting subfolders recursively.

async function deleteSubfolders(id) {
const folders = await Folder.find({parent: id})
for (const folder of folders) {
await deleteSubfolders(folder.id)
await Folder.deleteOne({_id: folder.id})
}
}
Mutation {
...
async deleteFolder(_, {id}, context) {
const userId = getUserId(context)
await Folder.deleteOne({_id: id})
deleteSubfolders(id)
return true
},
}

client/src/constants/query.gql

mutation DeleteFolder($id: String!) {
deleteFolder(id: $id)
}

Just like creating a new folder, deleting a folder will be done by right clicking the folder. So uncomment this line<div @click=”deleteFolder”>Delete</div> in client/src/components/FolderTree.vue and add this code:

import { GetFolders, DeleteFolder } from '../constants/query.gql'methods: {
...
deleteFolder() {
const { id, parent } = this.model
this.$apollo.mutate({
mutation: DeleteFolder,
variables: {id},
update: (store) => {
const variables = this.team ? {} : {parent}
const data = store.readQuery({
query: GetFolders,
variables
})
data.getFolders.splice(data.getFolders.findIndex(o => o.id === id), 1)
store.writeQuery({
query: GetFolders,
variables,
data
})
}
}).then(() => {
this.$router.replace({
name: "folder",
params: {id: this.team || parent},
})
}).catch((error) => {
console.log(error)
})
},
},

Here, update deletes the folder from the cache. It does not delete subfolders of the folder, but since there is no way to access the subfolders on UI unless you type the URL, this will suffice.

To test the code, delete a folder that contains a subfolder(s). Check the database to confirm that both the parent and subfolder have been deleted.

Bonus Material: Cache Redirect

I hope you have realized the power of Apollo by now. Let me show you one cool feature of Apollo: cache redirects. When we query getFolder for a specific folder data, actually the data already exists in getFolders cache. So why not get the data from the cache? Apollo allows you to do this with cacheRedirects . Add this code to client/src/main.js :

const cache = new InMemoryCache({
cacheRedirects: {
Query: {
getFolder: (_, args, { getCacheKey }) => {
return getCacheKey({ __typename: 'Folder', id: args.id })
},
},
}
})

This code says, “When I query getFolder , find the folder with the id in Folder cache.” __typename is automatically created by Apollo based on the return type defined in GraphQL schema. Apollo uses the combination of type name and id to uniquely identify each data in the cache.

To test cache redirects, go to localhost:8080/w , which calls getFolders . Then check offline in Chrome dev tool. Then click on one of your folders. You can see that even though getFolder isn’t available, it shows the folder name in other components.

Another way to confirm this is by printing the query result in client/src/views/Folder.vue

apollo: {
getFolder: {
query: GetFolder,
variables() {
return {id: this.$route.params.id}
},
result ({data: { getFolder }}) {
console.log(getFolder)
this.folder = getFolder
this.folderName = this.folder.name
if (this.isTeam) {
document.title = `${this.folder.name} - enamel`
}
},
}
},

This is so cool! The first time I learned this, it blew my mind.

Coming Up: Working with Tasks

Now that we have folders set up, we can create tasks.

Thank you so much for sticking around this far. I’m not sure how many more parts there is going to be, but want to finish it by part 10. I’ve been adding more and more features to enamel. Now you can comment, move folders and tasks, see notifications, reorder tasks, see the workload of team members, and more. But I try to limit the scope of this tutorial to bare minimum, otherwise it would be a never-ending tutorial. Besides, once you have the basic understanding of the tech stack, you can figure out how to build pretty much anything anyway.

If you are curious about what enamel looks like in production, you can go to http://www.enamel.tech/ or look at the source for client and server on github.

I will see you in part 6!

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

--

--