Single-Page Applications (SPAs) are web apps that load a single HTML page and dynamically update that page as the user interacts with the app. SPAs use AJAX and HTML5 to create fluid and responsive Web apps, without constant page reloads.
As stated in the above description taken from Wikipedia, the main advantage of SPAs is that the app can respond to user interactions without fully reloading the page, resulting in a much more fluid user experience.
All the code for this post can be found on Github.
Creating the Home Component
Let's kick things off here by applying a navigation bar to the app. But first, we'll need to add Bootstrap for some styling. A quick way to do this is to grab the CSS from Bootstrap's CDN.
<head>
<meta charset="utf-8">
<title>vue-time-tracker</title>
<link href="https://cdn.bootcss.com/bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet">
</head>
The best place for the navbar is our App.vue
file, so let's edit it to have a new template.
<template>
<div id="wrapper">
<nav class="navbar navbar-default">
<div class="container">
<a class="navbar-brand">
<i class="glyphicon glyphicon-time"></i>
Vue Time Tracker
</a>
<ul class="nav navbar-nav">
<router-link tag="li" to="/home">
<a>Home</a>
</router-link>
<router-link tag="li" to="/time-entries">
<a>Time Entries</a>
</router-link>
</ul>
</div>
</nav>
<div class="container">
<div class="col-sm-3">
<sidebar :time="totalTime"></sidebar>
</div>
<div class="col-sm-9">
<router-view></router-view>
</div>
</div>
</div>
</template>
There's a smaller div
that will be used for our sidebar, and a larger one that will show other components through the router-view
tag, vue-router
does this as well through router-view
.
The Home
component for our app really just needs to show a simple message. Let's create a Home.vue
and give it a template with that message.
<template>
<div class="jumbotron">
<h1>Vue Time Tracker</h1>
<p>
<strong>
Get started by
<router-link to='/time-entries'>creating a time entry</router-link>.
</strong>
</p>
</div>
</template>
<script>
export default {}
</script>
Creating the Time Entries Component
We'll make a component that lists out existing time entries, called TimeEntries.vue
, and then another one for entering new time entries, called LogTime.vue
. We'll add a link from TimeEntries
to LogTime
so the user can jump to the spot for adding new time entries quickly.
<template>
<div>
<router-link to='/time-entries/log-time' v-if="$route.path !== '/time-entries/log-time'">
<button class="btn btn-primary">
Log Time
</button>
</router-link>
<div v-if="$route.path === '/time-entries/log-time'">
<h3>Log Time</h3>
</div>
<hr>
<router-view></router-view>
<div class="time-entries">
<p v-if="!timeEntries.length">
<strong>No time entries yet</strong>
</p>
<div class="list-group">
<a class="list-group-item" v-for="timeEntry in timeEntries">
<div class="row">
<div class="col-sm-2 user-details">
![](timeEntry.user.image)
<p class="text-center">
<strong>
{{ timeEntry.user.firstName }} {{ timeEntry.user.lastName }}
</strong>
</p>
</div>
<div class="col-sm-2 text-center time-block">
<h3 class="list-group-item-text total-time">
<i class="glyphicon glyphicon-time"></i>
{{ timeEntry.totalTime }}
</h3>
<p class="label label-primary text-center">
<i class="glyphicon glyphicon-calendar"></i>
{{ timeEntry.date }}
</p>
</div>
<div class="col-sm-7 comment-section">
<p>{{ timeEntry.comment }}</p>
</div>
<div class="col-sm-1">
<button class="btn btn-xs btn-danger delete-button" @click="deleteTimeEntry(timeEntry)">
X
</button>
</div>
</div>
</a>
</div>
</div>
</div>
</template>
<script>
import store from '../store'
export default {
data() {
return {
timeEntries: store.fetch()
}
},
watch: {
timeEntries(val, oldVal){
store.save(val)
}
},
methods: {
deleteTimeEntry(timeEntry) {
let index = this.timeEntries.indexOf(timeEntry)
if (window.confirm('Are you sure you want to delete this time entry?')) {
this.timeEntries.splice(index, 1)
this.$router.app.$emit('deleteTime', timeEntry)
}
}
},
created() {
this.$router.app.$on('timeUpdate', timeEntry => {
this.timeEntries.push(timeEntry)
})
}
}
</script>
<style>
.avatar {
height: 75px;
margin: 0 auto;
margin-top: 10px;
margin-bottom: 10px;
}
.user-details {
background-color: #f5f5f5;
border-right: 1px solid #ddd;
margin: -10px 0;
}
.time-block {
padding: 10px;
}
.comment-section {
padding: 20px;
}
</style>
We see a router-view
below the hr
tag, and this is because we'll be registering a sub-route for logging time entries. Essentially we're nesting one route within another, so the LogTime
component will be two levels deep. This is the cool thing about routing--we can just keep putting router-view
elements in our templates, and as long as we register a component for them, they will keep nesting further down.
Creating the store.js
we're just working with local data by localStorage
.
/* 使用 localStorage 临时保存数据 */
const STORAGE_KEY = 'vue-time-tracker'
export default {
fetch() {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
},
save(items) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
},
totalTimes() {
var total = 0
this.fetch().forEach(function (item) {
total += parseInt(item.totalTime)
});
return total
}
}
Creating the Log Time Component
We need a component that provides a screen for the user to log time entries.
<template>
<div class="form-horizontal">
<div class="form-group">
<div class="col-sm-6">
<label>Date</label>
<input type="date" class="form-control" v-model="timeEntry.date" placeholder="Date" />
</div>
<div class="col-sm-6">
<label>Hours</label>
<input type="number" class="form-control" v-model="timeEntry.totalTime" placeholder="Hours" />
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label>Comment</label>
<input type="text" class="form-control" v-model="timeEntry.comment" placeholder="Comment" />
</div>
</div>
<button class="btn btn-primary" @click="save">Save</button>
<router-link to="/time-entries">
<button class="btn btn-danger">Cancel</button>
</router-link>
<hr>
</div>
</template>
<script>
export default {
data() {
return {
timeEntry: {}
}
},
methods: {
save() {
let user = {
firstName: 'Zhang',
lastName: 'Yongjie',
email: '1091354206@qq.com',
image: 'http://s.gravatar.com/avatar/c93ccb8362ae464812afc1927c60a90e?s=250'
}
this.timeEntry['user'] = user
this.$router.app.$emit('timeUpdate', this.timeEntry)
this.timeEntry = {}
}
}
}
</script>
We're initializing the timeEntry
model with some data for the user so that we can have a name and profile photo.
When our app grows and needs to communicate a lot of data between many components. We'll see how to fix this by using Vuex for state management.
Creating the router.js
We need to add the LogTime
component as a sub-route of TimeEntries
in our router configuration. By doing this, the router will know that LogTime
is a child of TimeEntries
, and the appropriate URI structure will be generated when we navigate to it.
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from './components/Home'
import TimeEntries from './components/TimeEntries'
import LogTime from './components/LogTime'
Vue.use(VueRouter)
const routes = [{
path: '/home',
component: Home
},
{
path: '/time-entries',
component: TimeEntries,
children: [{
path: 'log-time',
component: LogTime
}]
},
{
path: '*',
redirect: '/home'
}
]
export default new VueRouter({
mode: 'history',
routes
})
Creating the Sidebar Component
Sidebar
component will hold the total number of hours for all of our time entries.
<template>
<div class="panel panel-default">
<div class="panel-heading">
<h1 class="text-center">Total Time</h1>
</div>
<div class="panel-body">
<h1 class="text-center">{{ time }} hours</h1>
</div>
</div>
</template>
<script>
export default {
props: ['time']
}
</script>
The props
array is where we can specify any properties that we want to use which are passed into the component, and here we are getting the time
prop which is placed on the sidebar
element in App.vue
.