There is great support for Material Design in Vue.js. One of the libraries available for Vue.js is Vuetify. It is easy to incorporate into your Vue.js app and the result is appealing to the users’ eyes.
In this piece, we will build an app that displays data from the New York Times API. You can register for an API key at https://developer.nytimes.com/. After that, we can start building the app.
To start building the app, we have to install the Vue CLI. We do this by running:
npm install -g @vue/cli
Node.js 8.9 or later is required for Vue CLI to run. I did not have success getting the Vue CLI to run with the Windows version of Node.js. Ubuntu had no problem running Vue CLI for me.
Then, we run:
vue create vuetify-nyt-app
To create the project folder and create the files. In the wizard, instead of using the default options, we choose ‘Manually select features’. We select Babel, Router, and Vuex from the list of options by pressing space on each. If they are green, it means they’re selected.
Now we need to install a few libraries. We need to install an HTTP client, a library for formatting dates, one for generating GET
query strings from objects, and another one for form validation.
Also, we need to install the Vue Material library itself. We do this by running:
npm i axios moment querystring vee-validate
axios
is our HTTP client, moment
is for manipulating dates, querystring
is for generating query strings from objects, and vee-validate
is an add-on package for Vue.js to do validation.
Then, we have to add the boilerplate for vuetify
. We do this by running vue add vuetify
. This adds the library and the references to it in our app, in their appropriate locations in our code.
Now that we have all the libraries installed, we can start building our app.
First, we create some components. In the views
folder, we create Home.vue
and Search.vue
. Those are the code files for our pages. Then, create a mixins
folder and create a file, called nytMixin.js
.
Mixins are code snippets that can be incorporated directly into Vue.js components and used as if they are directly in the component. Then, we add some filters.
Filters are Vue.js code that map from one thing to another. We create a filters
folder and add capitalize.js
and formatDate.js
.
In the components
folder, we create a file, called SearchResults.vue
. The components
folder contains Vue.js components that aren’t pages.
To make passing data between components easier and more organized, we use Vuex for state management. As we selected Vuex when we ran vue create
, we should have a store.js
in our project folder. If not, create it.
In store.js
, we put:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
searchResults: []
},
mutations: {
setSearchResults(state, payload) {
state.searchResults = payload;
}
},
actions: {}
})
The state
object is where the state is stored. The mutations
object is where we can manipulate our state.
When we call this.$store.commit(“setSearchResults”, searchResults)
in our code, given that searchResults
is defined, state.searchResults
will be set to searchResults
.
We can then get the result by using this.$store.state.searchResults
.
We need to add some boilerplate code to our app. First, we add our filter. In capitalize.js
, we put:
export const capitalize = (str) => {
if (typeof str == 'string') {
if (str == 'realestate') {
return 'Real Estate';
}
if (str == 'sundayreview') {
return 'Sunday Review';
}
if (str == 'tmagazine') {
return 'T Magazine';
}
return `${str[0].toUpperCase()}${str.slice(1)}`;
}
}
This allows us to map capitalize our New York Times section names, listed in the New York Times developer pages. Then, in formatDate.js
, we put:
import * as moment from 'moment';
export const formatDate = (date) => {
if (date) {
return moment(date).format('YYYY-MM-DD hh:mm A');
}
}
To format our dates into a human-readable format.
In main.js
, we put:
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import { formatDate } from './filters/formatDate';
import { capitalize } from './filters/capitalize';
import VeeValidate from 'vee-validate';
import Vuetify from 'vuetify/lib'
import vuetify from './plugins/vuetify';
import '@mdi/font/css/materialdesignicons.css'
Vue.config.productionTip = false;
Vue.use(VeeValidate);
Vue.use(Vuetify);
Vue.filter('formatDate', formatDate);
Vue.filter('capitalize', capitalize);
new Vue({
router,
store,
vuetify,
render: h => h(App)
}).$mount('#app')
Notice that, in the file above, we have to register the libraries we use with Vue.js by calling Vue.use
on them, so they can be used in our app templates.
We call Vue.filter
on our filter functions so we can use them in our templates by adding a pipe and the filter name to the right of our variable.
Then, in router.js
, we put:
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue';
import Search from './views/Search.vue';
Vue.use(Router)
export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/search',
name: 'search',
component: Search
}
]
})
So that we can go to the pages when we enter the URLs listed.
mode: ‘history’
means that we won’t have a hash sign between the base URL and our routes.
If we deploy our app, we need to configure our web server so that all requests will be redirected to index.html
so we won’t have errors when we reload the app.
For example, in Apache, we do:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
And, in NGINX, we put:
location / {
try_files $uri $uri/ /index.html;
}
See your web server’s documentation for info on how to do the same thing in your web server.
Now, we write the code for our components. In SearchResult.vue
, we put:
<template>
<v-container>
<v-card v-for="s in searchResults" :key="s.id" class="mx-auto">
<v-card-title>{{s.headline.main}}</v-card-title><v-list-item>
<v-list-item-content>Date: {{s.pub_date | formatDate}}</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<a :href="s.web_url">Link</a>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="s.byline.original">
<v-list-item-content>{{s.byline.original}}</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>{{s.lead_paragraph}}</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>{{s.snippet}}</v-list-item-content>
</v-list-item>
</v-card>
</v-container>
</template>
<script>
export default {
computed: {
searchResults() {
return this.$store.state.searchResults;
}
}
};
</script>
<style scoped>
.title {
margin: 0 15px !important;
}
#search-results {
margin: 0 auto;
width: 95vw;
}
</style>
This is where get our search results from the Vuex store and display them.
We return this.$store.state.searchResults
in a function in the computed
property in our app so the search results will be automatically refreshed when the store’s searchResults
state is updated.
md-card
is a card widget for displaying data in a box. v-for
is for looping the array entries and displaying everything. md-list
is a list widget for displaying items in a list, neatly on the page. {{s.pub_date | formatDate}}
is where our formatDate
filter is applied.
Next, we write our mixin. We will add code for our HTTP calls in our mixin.
In nytMixin.js
, we put:
const axios = require('axios');
const querystring = require('querystring');
const apiUrl = 'https://api.nytimes.com/svc';
const apikey = 'your api key';
export const nytMixin = {
methods: {
getArticles(section) {
return axios.get(`${apiUrl}/topstories/v2/${section}.json?api-key=${apikey}`);
},
searchArticles(data) {
let params = Object.assign({}, data);
params['api-key'] = apikey;
Object.keys(params).forEach(key => {
if (!params\[key]) {
delete params[key];
}
})
const queryString = querystring.stringify(params);
return axios.get(`${apiUrl}/search/v2/articlesearch.json?${queryString}`);
}
}
}
We return the promises for HTTP requests to get articles in each function. In the searchArticles
function, we message the object that we pass in into a query string that we pass into our request.
Make sure you put your API key into your app into the apiKey
constant and delete anything that is undefined, with:
Object.keys(params).forEach(key => {
if (!params[key]) {
delete params[key];
}
})
In Home.vue
, we put:
<template>
<div>
<div class="text-center" id="header">
<h1>{{selectedSection | capitalize}}</h1>
<v-spacer></v-spacer>
<v-menu offset-y>
<template v-slot:activator="{ on }">
<v-btn color="primary" dark v-on="on">Sections</v-btn>
</template>
<v-list>
<v-list-item v-for="(s, index) in sections" :key="index"@click="selectSection(s)">
<v-list-item-title>{{ s | capitalize}}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-spacer></v-spacer>
</div>
<v-spacer></v-spacer>
<v-card v-for="a in articles" :key="a.id" class="mx-auto">
<v-card-title>{{a.title}}</v-card-title>
<v-card-text>
<v-list-item>
<v-list-item-content>Date: {{a.published_date | formatDate}}</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<a :href="a.url">Link</a>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="a.byline">
<v-list-item-content>{{a.byline}}</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>{{a.abstract}}</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<img
v-if="a.multimedia[a.multimedia.length - 1]"
:src="a.multimedia[a.multimedia.length - 1].url"
:alt="a.multimedia[a.multimedia.length - 1].caption"
class="image"
/>
</v-list-item-content>
</v-list-item>
</v-card-text>
</v-card>
</div>
</template>
<script>
import { nytMixin } from "../mixins/nytMixin";
export default {
name: "home",
mixins: [nytMixin],
computed: {},
data() {
return {
selectedSection: "home",
articles: [],
sections: `arts, automobiles, books, business, fashion, food, health,
home, insider, magazine, movies, national, nyregion, obituaries,
opinion, politics, realestate, science, sports, sundayreview,
technology, theater, tmagazine, travel, upshot, world\`
.replace(/ /g, "")
.split(",")
};
},
beforeMount() {
this.getNewsArticles(this.selectedSection);
},
methods: {
async getNewsArticles(section) {
const response = await this.getArticles(section);
this.articles = response.data.results;
},selectSection(section) {
this.selectedSection = section;
this.getNewsArticles(section);
}
}
};
</script>
<style scoped>
.image {
width: 100%;
}
.title {
color: rgba(0, 0, 0, 0.87) !important;
margin: 0 15px !important;
}
.md-card {
width: 95vw;
margin: 0 auto;
}
#header {
margin-bottom: 10px;
}
</style>
This page component is where we get the articles for the selected section, defaulting to the home
section. We also have a menu to select the section we want to see, by adding:
<v-menu offset-y>
<template v-slot:activator="{ on }">
<v-btn color="primary" dark v-on="on">Sections</v-btn>
</template>
<v-list>
<v-list-item v-for="(s, index) in sections" :key="index" @click="selectSection(s)">
<v-list-item-title>{{ s | capitalize}}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
Notice that we use the async
and await
keywords in our promises code, instead of using then
.
It is much shorter and the functionality between then
, await
, and async
is equivalent. However, it is not supported in Internet Explorer. In the beforeMount
block, we run the this.getNewsArticles
to get the articles as the page loads.
Note that the Vuetify library uses the slots of the features of Vue.js extensively. The elements with nesting, like the v-slot
prop, are in:
<v-menu offset-y>
<template v-slot:activator="{ on }">
<v-btn color="primary" dark v-on="on">Sections</v-btn>
</template>
<v-list>
<v-list-item v-for="(s, index) in sections" :key="index" @click="selectSection(s)">
<v-list-item-title>{{ s | capitalize}}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
See the Vue.js guide for details.
In Search.vue
, we put:
<template>
<div>
<form>
<v-text-field
v-model="searchData.keyword"
v-validate="'required'"
:error-messages="errors.collect('keyword')"
label="Keyword"
data-vv-name="keyword"
required
></v-text-field> <v-menu
ref="menu"
v-model="toggleBeginDate"
:close-on-content-click="false"
transition="scale-transition"
offset-y
full-width
min-width="290px"
>
<template v-slot:activator="{ on }">
<v-text-field
v-model="searchData.beginDate"
label="Begin Date"
prepend-icon="event"
readonly
v-on="on"
></v-text-field>
</template>
<v-date-picker
v-model="searchData.beginDate"
no-title
scrollable
:max="new Date().toISOString()"
>
<v-spacer></v-spacer>
<v-btn text color="primary" @click="toggleBeginDate = false">Cancel</v-btn>
<v-btn
text
color="primary"
@click="$refs.menu.save(searchData.beginDate); toggleBeginDate = false"
>OK</v-btn>
</v-date-picker>
</v-menu> <v-menu
ref="menu"
v-model="toggleEndDate"
:close-on-content-click="false"
transition="scale-transition"
offset-y
full-width
min-width="290px"
>
<template v-slot:activator="{ on }">
<v-text-field
v-model="searchData.endDate"
label="End Date"
prepend-icon="event"
readonly
v-on="on"
></v-text-field>
</template>
<v-date-picker
v-model="searchData.endDate"
no-title
scrollable
:max="new Date().toISOString()"
>
<v-spacer></v-spacer>
<v-btn text color="primary" @click="toggleEndDate = false">Cancel</v-btn>
<v-btn
text
color="primary"
@click="$refs.menu.save(searchData.endDate); toggleEndDate = false"
>OK</v-btn>
</v-date-picker>
</v-menu>
<v-select
v-model="searchData.sort"
:items="sortChoices"
label="Sort By"
data-vv-name="sort"
item-value="value"
item-text="name"
>
<template slot="selection" slot-scope="{ item }">{{ item.name }}</template>
<template slot="item" slot-scope="{ item }">{{ item.name }}</template>
</v-select>
<v-btn class="mr-4" type="submit" @click="search">Search</v-btn>
</form>
<SearchResults />
</div>
</template>
<script>
import { nytMixin } from "../mixins/nytMixin";
import SearchResults from "@/components/SearchResults.vue";
import * as moment from "moment";
import { capitalize } from "@/filters/capitalize";export default {
name: "search",
mixins: [nytMixin],
components: {
SearchResults
},
computed: {
isFormDirty() {
return Object.keys(this.fields).some(key => this.fields[key].dirty);
}
},
data: () => {
return {
searchData: {
sort: "newest"
},
disabledDates: date => {
return +date >= +new Date();
},
sortChoices: [
{
value: "newest",
name: "Newest"
},
{
value: "oldest",
name: "Oldest"
},
{
value: "relevance",
name: "Relevance"
}
],
toggleBeginDate: false,
toggleEndDate: false
};
},
methods: {
async search(evt) {
evt.preventDefault();
if (!this.isFormDirty || this.errors.items.length > 0) {
return;
}
const data = {
q: this.searchData.keyword,
begin_date: moment(this.searchData.beginDate).format("YYYYMMDD"),
end_date: moment(this.searchData.endDate).format("YYYYMMDD"),
sort: this.searchData.sort
};
const response = await this.searchArticles(data);
this.$store.commit("setSearchResults", response.data.response.docs);
}
}
};
</script>
This is where we have a form to search for articles. We also have two date-pickers to label users to set the start and end dates. We only restrict the dates to today and earlier so that the search query makes sense.
In this block:
<v-text-field
v-model="searchData.keyword"
v-validate="'required'"
:error-messages="errors.collect('keyword')"
label="Keyword"
data-vv-name="keyword"
required
></v-text-field>
We use vee-validate
to check if the required search keyword field is filled in. If it’s not, it’ll display an error message and prevent the query from proceeding.
We also nested our SearchResults
component into the Search
page component, by including:
components: {
SearchResults
}
Between the script
tag and <SearchResults />
in the template.
Finally, we add our top bar and menu by putting the following in App.vue
:
<template>
<v-app>
<v-navigation-drawer v-model="drawer" app>
<v-list nav dense>
<v-list-item-group v-model="group" active-class="deep-purple--text text--accent-4">
<v-list-item>
<v-list-item-title>New Yourk Times Vuetify App</v-list-item-title>
</v-list-item><v-list-item>
<v-list-item-title>
<router-link to="/">Home</router-link>
</v-list-item-title>
</v-list-item><v-list-item>
<v-list-item-title>
<router-link to="/search">Search</router-link>
</v-list-item-title>
</v-list-item>
</v-list-item-group>
</v-list>
</v-navigation-drawer><v-app-bar app>
<v-toolbar-title class="headline text-uppercase">
<v-app-bar-nav-icon @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
<span>New York Times Vuetify App</span>
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar><v-content>
<v-container fluid>
<router-view />
</v-container>
</v-content>
</v-app>
</template>
<script>
export default {
name: "app",
data: () => {
return {
showNavigation: false,
drawer: false,
group: null
};
}
};
</script>
<style>
.center {
text-align: center;
}
form {
width: 95vw;
margin: 0 auto;
}
.md-toolbar.md-theme-default {
background: #009688 !important;
height: 60px;
}
.md-title,
.md-toolbar.md-theme-default .md-icon {
color: #fff !important;
}
</style>
If you want a top bar with a left navigation drawer, you have to follow the code structure above precisely.