Project: Visual Feedback and Enhancement
This project uses the Visual Enhancement starter repository. Be sure to fork and clone this repository to get started.
For this project, we will revisit the Datamuse API to enhance the search tools we worked with a few projects ago. We will present one search with multiple options, and we will allow users to save their results to a "Word List" so they can more effectively browse for words.
This project is mostly built, but it requires some love and affection by way of visual enhancements to make it more useful. With such a complex search tool, it is possible for users to get no results. When that happens, we don't want them to think the application has malfunctioned.
We are also updating information in the search results and in the sidebar Word List. We want to use messages and animation to help users identify when data in these areas of the page has changed.
To make it a little easier to create professional quality animations, we will
rely on the vue2-animate
project,
which makes a set of common animations available for use with Vue.js transition
components. We will also use a load spinner from Spinkit.
Review the Requirements
In order to complete this project, we will mainly be adding elements to enhance the messaging and visual presentation of the application. We must complete the following requirements, which will have us editing the src/components/WordSearch.vue
file. (Each requirement corresponds to the TODO
notes, so look for those.) Here are the basic requirements:
- Use the
showSpinner
value to modulate the display of theCubeSpinner
component when appropriate - Add an animation to the items of the results list when a search is completed
- Add an animation to the items of the WordList for when new items are added and removed
- Add messaging to results display area let the user know when no results are found
- Add global messaging child component (
MessageContainer
) toWordSearch
component - Add a global "success" message to let the user know that a word has been successfully added to the WordList
- Add a global "info" message to let the user know when they try to add a word to the WordList that has already been added
- Add a global "success" message to let the user know that a word has been successfully removed from the WordList
- Add a global "error" message to display any errors from the API request (aside from "no results found")
Working the Project
The following guide offers a walkthrough of how to complete almost every aspect of the project.
Add CubeSpinner
to Indicate Loading
Like many of the tasks in this project, this one should be familiar based on the previous project. We will add the import
statement for the CubeSpinner
component, and then will add the component to the list of components. Once we have done that, we will add the <spinner>
element to our template. We will use a regular v-if
directive on the <spinner>
element to determine whether or not to show the spinner. Here is what the basic implementation looks like:
template
<spinner v-if="showSpinner"></spinner>
script
import CubeSpinner from '@/components/CubeSpinner';
export default {
name: 'WordSearch',
components: {
spinner: CubeSpinner
},
// ... additional code ... //
This is just like how we added the child components in the previous project. Now we must modulate the value of this.showSpinner
from within the findWords
method in order to control the show of the loading spinner. Here is how we can do that:
findWords: function() {
// Show spinner when API request begins here.
this.showSpinner = true;
this.results = null;
axios.get('https://api.datamuse.com/words', {
params: {
ml: this.phrase,
sl: this.soundsLike,
sp: `${this.startLetter}*${this.endLetter}`
}
})
.then( response => {
// Turn off spinner.
this.showSpinner = false;
this.results = response.data;
})
.catch( error => {
// Turn off spinner.
this.showSpinner = false;
})
}
As we can see here, we can easily use the structure of the API request to turn on and off the spinner animation to indicate loading. Usually, the Datamuse API loads very quickly, but on slower connections or for more complex searches this loading spinner will help our users know exactly what is happening in the app.
Load Spinner
Now we should be able to test and see that we have a load spinner showing up whenever the application is requesting data from the server.
Animate the Items in the Search Results
To animate the items in the search results, we can wrap the list items in a <transition-group>
element. We must also add a key
attribute to each list item (key
attributes are required whenever we use a <transition-group>
).
<transition-group name="fade" tag="div" appear>
<li v-for="item in results" class="item" v-bind:key="item.word">
<p class="result-word">{{ item.word }}</p>
<p><button v-on:click="addWord(item.word)" class="add-word">Add to WordList</button></p>
</li>
</transition-group>
Here we are using the item.word
value as the key
, and we have set the tag
attribute on the <transition-group>
to "div"
. This will wrap the set of list items in a div
tag (as opposed to the default <span>
tag that the <transition-group>
uses).
At this point, we should notice that this project has the vue2-animate
module added. We can see the module listed in our package.json
file. And we can notice at the top of the logic in our WordSearch
component is a somewhat odd-looking line:
require('vue2-animate/dist/vue2-animate.min.css');
The vue2-animate
module is unusual because it is only a CSS file. It contains style classes that have been defined to work with the conventions of the Vue.js
transition system. This means that we can access a large range of premade animations by simply naming our <transition-group>
with the name of the animation. In the case above, we will see the items in the results list fade in. We can get a better idea of all the animations vue2-animate
makes available by looking at the vue2-animate
Demo Page.
Experiment with a different animation and see what it looks like. It is easy to switch between animations and trying different animations will help us get an idea for how the system works.
Animate the items in the Word List
Animating the items in the Word List works almost exactly the same way as the items in the results list. We can wrap the list items in a very similar <transition-group>
element. We make the same changes to add a key
to the list items and set the name
and tag
attributes of the <transition-group>
.
<transition-group name="slideRight" tag="div" appear>
<li v-for="word in wordList" v-bind:key="word">{{ word }} <button v-on:click="removeWord(word)" class="remove-word">x</button></li>
</transition-group>
Notice that this time we have used the slideRight
animation. We also have the appear
attribute on the <transition-group>
to make sure the animation will be shown even when the first item is added to the list. We are using the word
value as the key
for each list item.
It's worthwhile to notice that when we remove a word from the list, the opposite animation happens. This is a feature of the way the vue2-animate
styles have been defined. They combine animations to work for both entry and exit. Try different animations and see what it looks like when words are added to the list or removed.
Add MessageContainer
for Global Messages
A MessageContainer
component has been provided to handle display of messages. These components can be easy to create for basic purposes, but it's also very common to use third-party components to display messages in our applications. It all depends on our specific project needs.
To use the MessageContainer
, we need to import it into the WordSearch
component and add it to the list of components used in WordSearch
. This is very similar to how we previously imported and set up the CubeSpinner
component.
<script>
import axios from 'axios';
require('vue2-animate/dist/vue2-animate.min.css');
import CubeSpinner from '@/components/CubeSpinner';
import MessageContainer from '@/components/MessageContainer';
export default {
name: 'WordSearch',
components: {
spinner: CubeSpinner,
'message-container': MessageContainer
},
// ... more script ... //
</script>
Once we have added the child component to WordSearch
, we can use it in the template:
<message-container v-bind:messages="messages"></message-container>
The MessageContainer
component looks for a property called messages
, which we want to bind to the messages
data value in the WordSearch
component. Now we can cause messages to be displayed by adding or removing messages to the messages
array inside of WordSearch
.
Add Messages to addWord
Method
Adding messages is just a matter of pushing a message
object into the messages
array. The message
object expects two properties: type
and text
. This allows the MessageContainer
, and the MessageItem
child component it uses, to properly display each message. Here is how we can add a message to indicate that a word has been added to the Word List:
addWord: function (word) {
if (this.wordList.indexOf(word) === -1) {
this.wordList.push(word);
this.messages.push({
type: 'success',
text: `${word} added to WordList.`
});
} else {
this.messages.push({
type: 'info',
text: `${word} is already on the WordList.`
});
}
}
In the addWord
method, we receive word
as an argument. This word
is compared to the existing this.wordList
array to see if it already exists in the list. If the word
is not found in the array, then it is a new word, and it is added to this.wordList
. We also add a message
object to the this.messages
array with the type
set to "success"
and the text
set to a helpful message.
If the word
already exists in the this.messages
array, then we do not re-add the word, and we instead create an "info" message that lets the user know why the word was not added. This is a common situation when allowing users to add items to a list of things.
Add Messages to removeWord
Method
Adding the message creation to the removeWord
method is even easier than adding it to the addWord
method because there is only one case. We can add it like so:
removeWord: function (word) {
this.wordList.splice(this.wordList.indexOf(word), 1);
this.messages.push({
type: 'success',
text: `${word} removed from WordList.`
});
}
We use the splice()
method, which exists on every JavaScript Array, to remove the word we're after. Once we've done that, we alert the user. Once we get the hang of this, it becomes very easy to add messages to all the parts of our application.
Now we should see pretty much all of these features at play in our application:
Add/Remove Words
Display Errors with Messages
The last place we need to add message display is to our possible API errors. We want the user to know if our application is malfunctioning because of the API; otherwise, they will assume that our application is broken and never realize that the API we depend upon is having troubles. It might be cold comfort, but it makes us feel better as developers to let our users know that we're still looking out for them even when things are going wrong beyond our control.
Adding messages to the error clause of our API request is almost the same as adding them everywhere else:
.catch( error => {
this.showSpinner = false;
this.messages.push({
type: 'error',
text: error.message
});
})
The only unique thing here is that we are using the "error"
type of message, and our message content is coming from the error
object returned by axios
when a request goes wrong. If we want to test these messages we can do so by adding some bogus characters to the domain of our API server and then viewing our site. When we click "search" we should see a "Network Error" show up.
Wrapping Up
Now that we've completed the project, here is what the WordSearch.vue
file looks like:
<template>
<div>
<div class="messages">
<message-container v-bind:messages="messages"></message-container>
</div>
<div class="word-search">
<form v-on:submit.prevent="findWords">
<p><label>Find synonyms for <input type="text" v-model="phrase" placeholder="word or phrase"> that:</label></p>
<ul>
<li><label>sounds like <input type="text" v-model="soundsLike" placeholder="word or phrase"></label></li>
<li><label>start with the letter <input type="text" v-model="startLetter" placeholder="single letter"></label></li>
<li><label>end with the letter <input type="text" v-model="endLetter" placeholder="single letter"></label></li>
</ul>
<p><button type="submit">Search</button></p>
</form>
</div>
<div class="word-list-container">
<h2>Word List</h2>
<ul class="word-list">
<transition-group name="slideRight" tag="div" appear>
<li v-for="word in wordList" v-bind:key="word">{{ word }} <button v-on:click="removeWord(word)" class="remove-word">x</button></li>
</transition-group>
</ul>
</div>
<div class="results-container">
<spinner v-if="showSpinner"></spinner>
<h2 v-if="results && results.length > 0">{{ results.length }} Words Found</h2>
<ul v-if="results && results.length > 0" class="results">
<transition-group name="fade" tag="div" appear>
<li v-for="item in results" class="item" v-bind:key="item.word">
<p class="result-word">{{ item.word }}</p>
<p><button v-on:click="addWord(item.word)" class="add-word">Add to WordList</button></p>
</li>
</transition-group>
</ul>
<div v-else-if="results && results.length === 0" class="no-results">
<h2>No Words Found</h2>
<p>Please adjust your search to find more words.</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
require('vue2-animate/dist/vue2-animate.min.css');
import CubeSpinner from '@/components/CubeSpinner';
import MessageContainer from '@/components/MessageContainer';
export default {
name: 'WordSearch',
components: {
spinner: CubeSpinner,
'message-container': MessageContainer
},
data () {
return {
results: null,
wordList: [],
messages: [],
phrase: '',
soundsLike: '',
startLetter: '',
endLetter: '',
showSpinner: false
}
},
methods: {
addWord: function (word) {
if (this.wordList.indexOf(word) === -1) {
this.wordList.push(word);
console.log(`Added ${word} to wordList.`);
this.messages.push({
type: 'success',
text: `${word} added to WordList.`
});
} else {
console.log('Word is already on wordlist.');
this.messages.push({
type: 'info',
text: `${word} is already on the WordList.`
});
}
},
removeWord: function (word) {
this.wordList.splice(this.wordList.indexOf(word), 1);
this.messages.push({
type: 'success',
text: `${word} removed from WordList.`
});
},
findWords: function() {
this.showSpinner = true;
this.results = null;
axios.get('https://api.datamuse.com/words', {
params: {
ml: this.phrase,
sl: this.soundsLike,
sp: `${this.startLetter}*${this.endLetter}`
}
})
.then( response => {
this.showSpinner = false;
this.results = response.data;
})
.catch( error => {
this.showSpinner = false;
this.messages.push({
type: 'error',
text: error.message
});
})
}
}
}
</script>
<style scoped>
.word-search {
font-size: 1.2rem;
white-space: nowrap;
display: inline-block;
width: 70%;
float: left;
}
.word-list-container {
display: inline-block;
width: 25%;
background: #e8e8e8;
padding: 0.5rem;
}
.results-container {
clear: both;
}
input[type="text"]{
border-top: none;
border-left: none;
border-right: none;
border-bottom: 1px solid #333;
width: 300px;
font-size: 1.2rem;
color: #2c3e50;
font-weight: 300;
background: rgba(0,0,0,0.02);
padding: 0.5rem;
}
button{
background: #333;
padding: 0.5rem;
font-weight: 300;
color: #fff;
border: none;
cursor: pointer;
font-size: 1.4rem;
border-radius: 0;
}
button.add-word {
background: #e8e8e8;
color: #333;
font-size: 0.8rem;
}
button.add-word:hover {
background: #fde300;
}
button.remove-word {
font-size: 0.5rem;
padding: 2px;
display: inline-block;
color: #333;
background: none;
}
button.remove-word:hover {
background: #aa0000;
color: #fde300;
}
h1, h2 {
font-weight: normal;
}
ul.results, ul.word-list {
list-style-type: none;
padding: 0;
}
.word-list li {
margin: 5px 0;
padding: 5px 0;
border-bottom: 1px solid #333;
}
.results li {
display: inline-block;
margin: 10px;
border: solid 1px #333;
padding: 0.5rem;
width: 200px;
min-height: 100px;
color: #fff;
font-weight: 300;
font-size: 1.2rem;
background: rgba(0,0,0,0.7);
}
ul.errors {
list-style-type: none;
}
.errors li {
border: 1px solid red;
color: red;
padding: 0.5rem;
margin: 10px 0;
}
a {
color: #42b983;
}
</style>
Build and Deploy
Once we've finished our work, we can build and deploy the project. This project has been configured to build to the docs/
directory, so we can follow the same pattern we used before:
- Execute the
npm run build
command to build the files into thedocs/
directory. - Commit all of our code.
- Push the code up to GitHub.
- Go into the repository settings and set the GH Pages section to publish from the
docs/
directory.
The project should now be up and available to the public through GH Pages.
Stretch Goals
If we crave more challenges, try tackling some of these suggestions.
- Try making custom animations for everything, and don't use
vue2-animate
animations at all - Add a shuffle feature to the wordlist along with the accompanying animation
- Make other layout or design improvements that help the application be more helpful to our users