Navigating the Vue Router
Welcome to the second post on using Vue.js and Flask for full-stack web development. The major topic in this article will be on Vue Router, but I will also cover the v-model directive, as well as Vue methods and computed properties. That being said, grab something caffeinated and consume some Vue goodness. The code for this post is on my GitHub.
Series Contents
- Setup and Getting to Know VueJS
- Navigating Vue Router (you are here)
- State Management with Vuex
- RESTful API with Flask
- AJAX Integration with REST API
- JWT Authentication
- Deployment to a Virtual Private Server
Getting Familiar with Vue Router
Like most other aspects of the Vue.js framework, using Vue Router to navigate the various pages and subsequent components is wickedly easy.
Aside 1 - Stand alone Vue and vue-router Instances
Some of the topics presented in this post will be best described with smaller toy examples, so there will be times when I break out into an aside to do so. In this aside I will demonstrate what is required to drop in a stand alone Vue instance and router. While Vue is absolutely phenomenal for building full fledged SPA applications, there is also real value in the ability to drop it into a regular HTML page.
<!-- index.html -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<div id="app">
<div>
<h3>Cartoon characters</h3>
<div v-for="(character, i) in characters" v-bind:key="i">
<h4>{{ character.name }}</h4>
<p><img v-bind:src="character.imgSrc" v-bind:alt="character.name"/></p>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data: {
characters: [{
name: 'Scooby',
imgSrc: 'https://www.wbkidsgo.com/Portals/4/Images/Content/Characters/Scooby/characterArt-scooby-SD.png'
}, {
name: 'Shaggy',
imgSrc: 'https://upload.wikimedia.org/wikipedia/en/thumb/8/87/ShaggyRogers.png/150px-ShaggyRogers.png'
} ]
}
})
</script>
This displays the cartoon characters Scooby and Shaggy. The example introduces the v-bind:
directive to dynamically bind data from the characters
array to the src
and alt
attributes of the img
element, enabling data to drive the content. This is similar to how text interpolation is done using {{}}
, except v-bind
interpolates the data into attributes. You can find a working example of this here.
Instead of displaying both characters, let's change our approach, allowing us to click a link for each character to display a specific "Scooby" or "Shaggy" component. To accomplish this I will pull in the vue-router library and make the following changes:
<!-- index.html -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
<div id="app">
<p>
<router-link to="/scooby">Scooby</router-link>
<router-link to="/shaggy">Shaggy</router-link>
</p>
<router-view></router-view>
</div>
<script></script>
As you can probably tell, this implementation is driven by hard-coded components, which is not a beneficial change from a reusability standpoint. However, it does show an easy-to-follow use of vue-router. Besides sourcing the vue-router library the HTML contains two new elements, components actually, specific to vue-router.
The first vue-router component is <router-link>
which is a component that receives a route path via the to
attribute, which is actually called a "parameter" in a Vue component. The <router-link>
component produces a hyperlink element that responds to click events and tells Vue to display the component associated with its to
parameter, either "/scooby" or "/shaggy".
Below <router-link>
is another new component, <router-view>
, which is where vue-router tells Vue to inject the UI components, Scooby and Shaggy. The Scooby and Shaggy custom template components are defined in the script
element at the bottom of this example index.html page.
Next a vue-router object is instantiated with a routes
object which defines a routes array similar to what we saw in the first article's Survey app. Here the route paths are mapped to the Scooby and Shaggy components. The last thing to do is instantiate a Vue instance, give it a router object as a property to its options object and bind the instance to the app div
.
You can click on either the Scooby or Shaggy router-link to display them, as shown below. The code for this example can be found here.
Using vue-router to Show an Individual Survey
Back in the Survey App, let us begin our discussion by taking a look at the routes/index.js file.
import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'
Vue.use(Router)
export default new Router({
routes: [
{
path: '/',
name: 'Home',
component: Home
}
]
})
The Home page displays the application's surveys when the root URL is requested at localhost:8080 because it is mapped to the Home
component via the router's routes.
I also need to hook into Vue via the Vue.use(Router)
function for a modular SPA application such as this one. Additionally, I need to include the router in the options object, which is fed to the Vue instance in main.js
similar to the Scooby/Shaggy toy example.
Continuing with the Survey application I add a new route to the routes
object which will map each survey and its questions to a reusable Survey.vue
file-based component. In the routes/index.js file import a Survey
component and then add a route to map each survey by its id to the Survey
component.
// ... omitted for brevity
import Survey from '@/components/Survey'
// ... omitted for brevity
export default new Router({
routes: [
{
// ... omitted for brevity
}, {
path: '/surveys/:id',
name: 'Survey',
component: Survey
}
]
})
Note the :id
portion of the new path /surveys/:id
. This is known as a dynamic segment which you can think of as a variable within a route path. In this case I am saying that the :id
will be used to identify a specific survey to display in the Survey
component to be built next.
In the src/components/
directory create a file called Survey.vue
, then open it and add the standard template, script, and style sections along with the code shown below:
<template>
<div>
<h3>I'm a Survey Component</h3>
</div>
</template>
<script>
export default {
data() {
return {
survey: {}
}
},
beforeMount() {
console.log('Survey.beforeMount -> :id === ', this.$route.params.id)
}
}
</script>
<style>
</style>
Saving all files and I start the dev server with npm run dev
, then enter the following URL in the browser: localhost:8080/#/surveys/23. In the console of my browser's dev tools I see the image below.
So what just happened?
In the template
section I added some nonsense code to make it clear the Survey component is being served by the router. In the script
section I initialized a survey object which will hold survey data eventually. In the beforeMount
lifecycle hook something pretty cool is happening. In this function I am accessing the current window's route and subsequent :id
parameter defined in the route
module.
This last part is possible because the Survey
component's Vue object has reference to the vue-router instance giving access to the route allowing me to access it with this.$route.params.id
. A route can have multiple dynamic segments and all are accessible in their corresponding components via the params
member of the this.$route
object.
Next I will define a mock AJAX function in api/index.js
, which I call from the Survey
component's beforeMount
hook to fetch a survey by :id
. In api/index.js
add the following function:
const surveys = [{
id: 1,
name: 'Dogs',
// ... omitted for brevity
}, {
id: 2,
name: 'Cars',
// ... omitted for brevity
}]
// ... omitted for brevity
export function fetchSurvey (surveyId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const survey = surveys.find(survey => survey.id === surveyId)
if (survey) {
resolve(survey)
} else {
reject(Error('Survey does not exist'))
}
}, 300)
})
}
Now back in the Survey
component I need to import fetchSurvey
and use it in beforeMount
to retrieve the requested survey. Again for visual purposes I will output the survey name in the template as a bulma
hero header.
<template>
<div>
<section class="hero is-primary">
<div class="hero-body">
<div class="container has-text-centered">
<h2 class="title">{{ survey.name }}</h2>
</div>
</div>
</section>
</div>
</template>
<script>
import { fetchSurvey } from '@/api'
export default {
data() {
return {
survey: {}
}
},
beforeMount() {
fetchSurvey(parseInt(this.$route.params.id))
.then((response) => {
this.survey = response
})
}
}
</script>
<style>
</style>
Updating the browser's URL to localhost:8080/surveys/2, I see what is shown below:
Pretty cool right?
Next I would like to do something a little more useful with my Survey
component and include the questions and choices.
<template>
<div>
<!-- omitted survey name header for brevity -->
<section class="section">
<div class="container">
<div class="columns">
<div class="column is-10 is-offset-1">
<div v-for="question in survey.questions" v-bind:key="question.id">
<div class="column is-offset-3 is-6">
<h4 class='title has-text-centered'>{{ question.text }}</h4>
</div>
<div class="column is-offset-4 is-4">
<div class="control">
<div v-for="choice in question.choices" v-bind:key="choice.id">
<label class="radio">
<input type="radio" v-model="question.choice" :value="choice.id">
{{ choice.text }}
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!
Again, saving and refreshing the browser with URL localhost:8080/#/surveys/2 now gives a listing of questions and the available choices for the cars survey.
Let me try to unpack some of the new Vue features that are being used. We are already familiar with using the v-for
directive to drive the generation of the survey questions and choices, so hopefully you're able to track how those are being displayed. However, if you focus in on how the radio buttons for choices of a question are being generated you will notice I'm doing two new, or slightly different, things.
<div v-for="choice in question.choices" v-bind:key="choice.id">
<label class="radio">
<input type="radio" v-model="question.choice" :value="choice.id">
{{ choice.text }}
</label>
</div>
For the radio input I have used the v-model
directive and supplied it with a value of question.choice
. What this does is it creates a new member on the question
object called choice and registers it with the radio input allowing data to flow from the actual radio input into the question.choice
object property. I am also using a shorthand syntax of :value
instead of v-bind:value
to bind the value of this radio input to the value of the question choices that are being iterated over via v-for
.
Aside 2 - Using the v-model Directive
I realize that the v-model
concept is probably a bit fuzzy, so let me step aside and make another simple example to demonstrate the point. Consider the trivial example below. Again, you can see a working example of this code here.
<!-- index.html -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<div id="app">
<div>
<label for="name">What is your name</label>
<input id="name" type="text" v-model="textInput" />
<span>Hello {{ textInput }}</span>
</div>
<h4>Which do you like better?</h4>
<div v-for="choice in radioChoices" :key="choice">
<label>{{ choice }}</label>
<input name="fruit" type="radio" v-model="favoriteFruit" :value="choice"/>
</div>
<h4>So you like {{ favoriteFruit }}</h4>
</div>
<script>
new Vue({
el: '#app',
data: {
textInput: '',
radioChoices: ['apples', 'oranges'],
favoriteFruit: ''
}
})
</script>
The first input is a text input asking for the user's name. This text input has a v-model
registered to it with the data property textInput
attached to it, which keeps the text input in sync with the textInput
data property of the Vue
instance. Take a second to type your name into the text input and watch it update in the <span>Hello {{ textInput }}</span>
HTML's output.
Amazing right?
The second input is a radio input named "fruit" that displays the fruits "apples" and "oranges" and asks the user to select their favorite. The radio input is registered to the favoriteFruit
data property of the Vue
instance via the v-model
, which associates the value associated with each radio input via the :value="choice"
attribute binding syntax to keep favoriteFruit
in sync with the selected radio input. Again, you can watch the value of favoriteFruit
update in the <h4>So you like {{ favoriteFruit }}</h4>
element's output.
Below I show some example output. I encourage you to play around with this example until the notion of v-model
is clear.
Completing the Survey Taking Experience
Ok, back to the survey app. Think about the case where a survey has many more questions displayed below the default screen height. Generally, we want to keep people from having to scroll down to see your most important content. A better choice would be to paginate through the questions displaying one question, and its responses, at a time.
The updated Survey
component accomplishes this below.
<template>
<div>
<!-- omitted for brevity -->
<section class="section">
<div class="container">
<div class="columns">
<div class="column is-10 is-offset-1">
<div
v-for="(question, idx) in survey.questions" <!-- modified v-for -->
v-bind:key="question.id"
v-show="currentQuestion === idx"> <!-- new v-show directive -->
<div class="column is-offset-3 is-6">
<!-- <h4 class='title'>{{ idx }}) {{ question.text }}</h4> -->
<h4 class='title has-text-centered'>{{ question.text }}</h4>
</div>
<div class="column is-offset-4 is-4">
<div class="control">
<div v-for="choice in question.choices" v-bind:key="choice.id">
<label class="radio">
<input type="radio" v-model="question.choice" :value="choice.id">
{{ choice.text }}
</label>
</div>
</div>
</div>
</div>
<!-- new pagination buttons -->
<div class="column is-offset-one-quarter is-half">
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<a class="pagination-previous" @click.stop="goToPreviousQuestion"><i class="fa fa-chevron-left" aria-hidden="true"></i> Back</a>
<a class="pagination-next" @click.stop="goToNextQuestion">Next <i class="fa fa-chevron-right" aria-hidden="true"></i></a>
</nav>
</div>
<!-- new submit button -->
<div class="column has-text-centered">
<a v-if="surveyComplete" class='button is-focused is-primary is-large'
@click.stop="handleSubmit">
Submit
</a>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
import { fetchSurvey, saveSurveyResponse } from '@/api' // new AJAX func
export default {
data() {
return {
survey: {},
currentQuestion: 0 // new data prop
}
},
beforeMount() {
// omitted for brevity
},
methods: { // new Vue obj member
goToNextQuestion() {
if (this.currentQuestion === this.survey.questions.length - 1) {
this.currentQuestion = 0
} else {
this.currentQuestion++
}
},
goToPreviousQuestion() {
if (this.currentQuestion === 0) {
this.currentQuestion = this.survey.questions.lenth - 1
} else {
this.currentQuestion--
}
},
handleSubmit() {
saveSurveyResponse(this.survey)
.then(() => this.$router.push('/'))
}
},
computed: { // new Vue obj member
surveyComplete() {
if (this.survey.questions) {
const numQuestions = this.survey.questions.length
const numCompleted = this.survey.questions.filter(q => q.choice).length
return numQuestions === numCompleted
}
return false
}
}
}
</script>
These changes work together to complete the survey-taking experience. As the question nodes are generated from v-for="(question, idx) in survey.questions"
I am using the v-show="currentQuestion === idx"
directive to test if the value of the data property, currentQuestion
, matches the value of idx
. This makes the question div
visible only if currentQuestion
is equal to that question's idx
value. Since the value of currectQuestion
is initialized to zero, the zeroth question will be displayed by default.
Below the questions and responses the pagination buttons allow the user to paginate through the questions. The "next" button element has @click="goToNextQuestion"
within it, which is a Vue click event handler that responds by calling the goToNextQuestion
function inside the new methods
Vue object property. A Vue component object's methods
section is where functions can be defined to do a number of things, most often to change component state. Here goToNextQuestion
increments currentQuestion
by one, advancing the question being displayed, or it resets it to the first question. The back button and its associated goToPreviousQuestion
method does the exact opposite.
The last change is the functionality to submit the survey response. The button uses v-show
again to determine if the button should be displayed based on the value of a computed property called surveyCompleted
. Computed properties are another awesome trait of Vue. They are properties that usually control how UI components are displayed that come in handy when the logic is a bit more complex than checking a single value of a data property. In this way the template code is clean and able to focus on presentation while the logic remains in the JavaScript code.
A click event listener, @click="handleSubmit"
, is registered on the submit anchor button, which calls the handleSubmit
method. This method calls the mock AJAX function saveSurveyResponse
, which returns a promise and passes control to the "then chain". The "then chain" has a callback, .then(() -> this.$router.push('/'))
, that calls the component's router object and programmatically routes the app back to the root path displaying the Home
component.
In the api/index.js
, add the saveSurveyResponse
function at the bottom of the file. This function receives a survey response object and simply logs "saving survey response..." on the console until I connect the front-end to the REST API in the future.
export function saveSurveyResponse (surveyResponse) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("saving survey response...")
})
resolve()
}, 300)
})
}
Saving all the files and refreshing the browser with the URL localhost:8080:/#/surveys/2 I see what is below. I recommend clicking around in the application and making sure you can logically trace the control flow through the Survey
component.
Aside 3 - Programmatic Routing
Similar to before I want to demonstrate a few things just discussed with a variation of one of the previous toy examples. Below I have altered the navigation example that displays either Scooby or Shaggy to no longer use the <router-link>
component.
<!-- index.html -->
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
<div id="app">
<p>
<a @click="toScooby">Scooby</a>
<a @click="toShaggy">Shaggy</a>
</p>
<router-view></router-view>
</div>
<script>
const Scooby = {
template: `
<div>
<h4>Scooby</h4>
<p>
<img src="https://www.wbkidsgo.com/Portals/4/Images/Content/Characters/Scooby/characterArt-scooby-SD.png" alt="scooby"/>
</p>
</div>`
}
const Shaggy = {
template: `
<div class="character">
<h4>Shaggy</h4>
<p>
<img src="https://upload.wikimedia.org/wikipedia/en/thumb/8/87/ShaggyRogers.png/150px-ShaggyRogers.png" alt="shaggy"/>
</p>
</div>`
}
const router = new vue-router({
routes: [
{ path: '/characters/scooby', component: Scooby },
{ path: '/characters/shaggy', component: Shaggy }
]
})
const app = new Vue({
router: router,
methods: {
toScooby() { this.$router.push('/characters/scooby') },
toShaggy() { this.$router.push('/characters/shaggy') }
}
}).$mount('#app')
</script>
The example behaves in the exact same way as before, but now the routing is done via a combination of click event listeners, Vue methods, and manually calling this.$router.push('/path')
. This is actually what <router-link>
does behind the scenes using the to="/path"
value. I encourage you to play with this live example here.
Adding Router Links to the Home Component
The last thing to do with the Survey
component is provide the ability to navigate to a survey from the Home
component. As shown previously with the Scooby and Shaggy example, vue-router makes this incredibly easy with <router-link>
.
Back in the Home
component make the following modification:
<div class="card" v-for="survey in surveys" v-bind:key="survey.id">
<div class="card-content">
<p class="title">{{ survey.name}}</p>
<p class='subtitle'>{{survey.created_at.toDateString()}}</p>
</div>
<div class="card-foooter">
<router-link :to="`surveys/${survey.id}`" class="card-footer-item">Take Survey</router-link>
</div>
</div>
I added a <router-link>
component inside a bulma
card footer and dynamically constructed the path to each survey. This is different from the literal string paths I provided in my earlier example. To dynamically produce the paths using the JavaScript template strings and the survey IDs being iterated over I prefix the to
parameter with a colon (":"), which is shorthand for the v-bind
directive.
I save all files and pull up the root URL path, localhost:8080, in my browser to make sure it works. You should be able to click on each survey's "Take Survey" link and be displayed the Survey
component UI.
To complete the experience, I add a simple nav bar with a "Home" tab using <router-link>
and a to
parameter pointing to the application's root path within a new component file named Header.vue
in the component's directory.
<template>
<nav class="navbar is-light" role="navigation" aria-label="main navigation">
<div class="navbar-menu">
<div class="navbar-start">
<router-link to="/" class="navbar-item">
Home
</router-link>
</div>
</div>
</nav>
</template>
<script>
</script>
<style>
</style>
To ensure that it is included in every page of the application I place it in the App.vue
component. To do this I first import the Header
component as AppHeader
in the script
section then register it by adding a property called components
on the App component's Vue object and set it equal to an object containing the AppHeader
component.
I then add the component in the template placing <app-header>
right above the <router-view>
component. When naming components it's common to use Pascal case concatenating the words describing it together where each word's first letter is capitalized. Then I include it in the template in all lowercase with hyphens between each word that begins with a capitalized letter.
Saving the files and refreshing the browser I now see the Header
component containing the nav bar in each page of the application.
Conclusion
If you are still reading this you have consumed quite a bit of Vue goodness. We certainly covered a lot of material in this post and if you are new to Vue or single file component SPAs it is probably worth a second read and a trip over to the excellent Vue docs for a deeper dive.
As always, thanks for reading and don't be shy about commenting or critiquing below.