Subscribe to my email list now at http://jauyeung.net/subscribe/
Follow me on Twitter at https://twitter.com/AuMayeung
Many more articles at https://medium.com/@hohanga
Progressive web app (PWAs) is a web app that does somethings that a native app does. It can work offline and you can install it from a browser with one click.
PWAs should run well on any device. They should be responsive. Performance should be good in any device. People can link to it easily, and it should have an icon for different size devices.
To build a PWA, we have to register service workers for handling installation and adding offline capabilities to make a regular web app a PWA. Service works also lets PWAs use some native capabilities like notifications, caching for when the device is offline, and app updates.
Building a PWA with Vue CLI 3.x is easy. There is the @vue/cli-plugin-pwa plugin. All we have to do is to run vue add pwa
to convert our web app to a progressive web app. Optionally, we can configure our app with the settings listed at https://www.npmjs.com/package/@vue/cli-plugin-pwa.
In this article, we will build a progressive web app that displays article snippets from the New York Times API and a search page where users can enter a keyword to search the API.
The desktop layout will have a list of item names on the left and the article snippets on the right. The mobile layout will have a drop down for selecting the section to display and the cards displaying the article snippets below it.
The search page will have a search form on top and the article snippets below it regardless of screen size.
To start building the app, we start by running the Vue CLI. We run:
npx @vue/cli create nyt-app
to create the Vue.js project. When the wizard shows up, we choose ‘Manually select features’. Then we choose to include Vue Router and Babel in our project.
Next we add our own libraries for styling and making HTTP requests. We use BootstrapVue for styling, Axios for making requests, VueFilterDateFormat for formatting dates and Vee-Validate for form validation.
To install all the libraries, we run:
npm i axios bootstrap-vue vee-validate vue-filter-date-format
After all the libraries are installed, we can start building our app.
First, we use slots yo build our layouts for our pages. Create BaseLayout.vue
in the components
folder and add:
<template>
<div>
<div class="row">
<div class="col-md-3 d-none d-lg-block d-xl-none d-xl-block">
<slot name="left"></slot>
</div>
<div class="col">
<div class="d-block d-sm-none d-none d-sm-block d-md-block d-lg-none">
<slot name="section-dropdown"></slot>
</div>
<slot name="right"></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: "BaseLayout"
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
In this file, we make use of Vue slots to create the responsive layout for the home page. We have the left
, right
, and section-dropdown
slots in this file. The left
slot only displays when the screen is large since we added the d-none d-lg-block d-xl-none d-xl-block
classes to the left
slot. The section-dropdown
slot only shows on small screens since we added the d-block d-sm-none d-none d-sm-block d-md-block d-lg-none
classes to it. These classes are the responsive utility classes from Bootstrap.
We make use of Vue.js slots in this file. Slots is a useful feature of Vue.js that allows you to separate different parts of a component into an organized unit. With your component compartmentalized into slots, you can reuse components by putting them into the slots you defined. It also makes your code cleaner since it lets you separate the layout from the logic of the app.
Also, if you use slots, you no longer have to compose components with the parent-child relationship since you can put any components into your slots.
The full list of responsive utility classes are at https://getbootstrap.com/docs/4.0/utilities/display/
Next, create a SearchLayout.vue
file in the components
folder and add:
<template>
<div class="row">
<div class="col-12">
<slot name="top"></slot>
</div>
<div class="col-12">
<slot name="bottom"></slot>
</div>
</div>
</template>
<script>
export default {
name: "SearchLayout"
};
</script>
to create another layout for our search page. We have the top
and bottom
slots taking up the whole width of the screen.
Then we create a mixins
folder and in it, create a requestsMixin.js
file and add:
const axios = require("axios");
const APIURL = "https://api.nytimes.com/svc";
export const requestsMixin = {
methods: {
getArticles(section) {
return axios.get(
`${APIURL}/topstories/v2/${section}.json?api-key=${process.env.VUE_APP_API_KEY}`
);
},
searchArticles(keyword) {
return axios.get(
`${APIURL}/search/v2/articlesearch.json?api-key=${process.env.VUE_APP_API_KEY}&q=${keyword}`
);
}
}
};
to create a mixin for making HTTP requests to the New York Times API. process.env.VUE_APP_API_KEY
is the API key for the New York Times API, and we get it from the .env
file in the project’s root folder, with the key of the environment variable being VUE_APP_API_KEY
.
We can get the New York Times API key from https://developer.nytimes.com/get-started.
Next in Home.vue
, replace the existing code with:
<template>
<div class="page">
<h1 class="text-center">Home</h1>
<BaseLayout>
<template v-slot:left>
<b-nav vertical pills>
<b-nav-item
v-for="s in sections"
:key="s"
:active="s == selectedSection"
@click="selectedSection = s; getAllArticles()"
>{{s}}</b-nav-item>
</b-nav>
</template>
<template v-slot:section-dropdown>
<b-form-select
v-model="selectedSection"
:options="sections"
@change="getAllArticles()"
id="section-dropdown"
></b-form-select>
</template>
<template v-slot:right>
<b-card
v-for="(a, index) in articles"
:key="index"
:title="a.title"
:img-src="(Array.isArray(a.multimedia) && a.multimedia.length > 0 && a.multimedia[a.multimedia.length-1].url) || ''"
img-bottom
>
<b-card-text>
<p>{{a.byline}}</p>
<p>Published on: {{new Date(a.published_date) | dateFormat('YYYY.MM.DD hh:mm a')}}</p>
<p>{{a.abstract}}</p>
</b-card-text>
<b-button :href="a.short_url" variant="primary" target="_blank">Go</b-button>
</b-card>
</template>
</BaseLayout>
</div>
</template>
<script>
// @ is an alias to /src
import BaseLayout from "@/components/BaseLayout.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
name: "home",
components: {
BaseLayout
},
mixins: [requestsMixin],
data() {
return {
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`
.split(",")
.map(s => s.trim()),
selectedSection: "arts",
articles: []
};
},
beforeMount() {
this.getAllArticles();
},
methods: {
async getAllArticles() {
const response = await this.getArticles(this.selectedSection);
this.articles = response.data.results;
},
setSection(ev) {
this.getAllArticles();
}
}
};
</script>
<style scoped>
#section-dropdown {
margin-bottom: 10px;
}
</style>
We use the slots defined in BaseLayout.vue
in this file. In the left
slot, we put the list of section names in there to display the list on the left when we have a desktop-sized screen.
In the section-dropdown
slot, we put the drop-down that only shows in mobile screens as defined in BaseLayout
.
Then in the right
slot, we put the Bootstrap cards for displaying the article snippets, also as defined in BaseLayout
.
We put all the slot contents inside BaseLayout
and we use v-slot
outside the items we want to put into the slots to make the items show in the designated slot.
In the script
section, we get the articles by section by defining the getAllArticles
function from requestsMixin
.
Next create a Search.vue
file and add:
<template>
<div class="page">
<h1 class="text-center">Search</h1>
<SearchLayout>
<template v-slot:top>
<ValidationObserver ref="observer" v-slot="{ invalid }">
<b-form @submit.prevent="onSubmit" novalidate id="form">
<b-form-group label="Keyword" label-for="keyword">
<ValidationProvider name="keyword" rules="required" v-slot="{ errors }">
<b-form-input
:state="errors.length == 0"
v-model="form.keyword"
type="text"
required
placeholder="Keyword"
name="keyword"
></b-form-input>
<b-form-invalid-feedback :state="errors.length == 0">Keyword is required</b-form-invalid-feedback>
</ValidationProvider>
</b-form-group>
<b-button type="submit" variant="primary">Search</b-button>
</b-form>
</ValidationObserver>
</template>
<template v-slot:bottom>
<b-card v-for="(a, index) in articles" :key="index" :title="a.headline.main">
<b-card-text>
<p>By: {{a.byline.original}}</p>
<p>Published on: {{new Date(a.pub_date) | dateFormat('YYYY.MM.DD hh:mm a')}}</p>
<p>{{a.abstract}}</p>
</b-card-text>
<b-button :href="a.web_url" variant="primary" target="_blank">Go</b-button>
</b-card>
</template>
</SearchLayout>
</div>
</template>
<script>
// @ is an alias to /src
import SearchLayout from "@/components/SearchLayout.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
name: "home",
components: {
SearchLayout
},
mixins: [requestsMixin],
data() {
return {
articles: [],
form: {}
};
},
methods: {
async onSubmit() {
const isValid = await this.$refs.observer.validate();
if (!isValid) {
return;
}
const response = await this.searchArticles(this.form.keyword);
this.articles = response.data.response.docs;
}
}
};
</script>
<style scoped>
</style>
It’s very similar to Home.vue
. We put the search form in the top
slot by putting it inside the SearchLayour
, and we put our slot content for the top
slot by putting our form inside the <template v-slot:top>
element.
We use the ValidationObserver
to validate the whole form, and ValidationProvider
to validate the keyword
input. They are both provided by Vee-Validate.
Once the Search button is clicked, we call this.$refs.observer.validate();
to validate the form. We get the this.$refs.observer
since we wrapped the ValidationObserver
outside the form.
Then once form validation succeeds, by this.$refs.observer.validate()
resolving to true
, we call searchArticles
from requestsMixin
to search for articles.
In the bottom
slot, we put the cards for displaying the article search results. It works the same way as the other slots.
Next in App.vue
, we put:
<template>
<div>
<b-navbar toggleable="lg" type="dark" variant="info">
<b-navbar-brand href="#">New York Times App</b-navbar-brand>
<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
<b-collapse id="nav-collapse" is-nav>
<b-navbar-nav>
<b-nav-item to="/" :active="path == '/'">Home</b-nav-item>
<b-nav-item to="/search" :active="path == '/search'">Search</b-nav-item>
</b-navbar-nav>
</b-collapse>
</b-navbar>
<router-view />
</div>
</template>
<script>
export default {
data() {
return {
path: this.$route && this.$route.path
};
},
watch: {
$route(route) {
this.path = route.path;
}
}
};
</script>
<style>
.page {
padding: 20px;
}
</style>
to add the BootstrapVue b-navbar
here and watch the route as it changes so that we can set the active
prop to the link of the page the user is currently in.
Next we change main.js
‘s code to:
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import VueFilterDateFormat from "vue-filter-date-format";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
Vue.use(VueFilterDateFormat);
Vue.use(BootstrapVue);
extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.config.productionTip = false;
new Vue({
router,
store,
render: h => h(App)
}).$mount("#app");
We import all the app-wide packages we use here, like BootstrapVue, Vee-Validate and the calendar and date-time picker widgets.
The styles are also imported here so we can see them throughout the app.
Next in router.js
, replace the existing code with:
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
}
]
});
to set the routes for our app, so that when users enter the given URL or click on a link with it, they can see our page.
Then we change store.js
to:
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
state: {},
mutations: {},
actions: {}
});
Finally, we replace the code in index.html
with:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<title>New York Times App</title>
</head>
<body>
<noscript>
<strong
>We're sorry but vue-slots-tutorial-app doesn't work properly without
JavaScript enabled. Please enable it to continue.</strong
>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
to change the app’s title.
Next to make our app a progressive web app by running vue add pwa
. Then we run npm run build
to build the app. After it runs we can use browser-sync
package to serve our app. Run npm i -g browser-sync
to install the server. Then we run browser-sync start — server
from the dist
folder of our project folder, which should be created when we run npm run build
.
Then on the top right corner of your browser, you should get an install button on the right side of the URL input in Chrome.
You should also get an entry in the chrome://apps/ page for this app.