In this post we are going to build complete SPA (Single Page Application) using Vue.js v2 and for state management, we will use Vuex which is very similar to redux but it’s for vue.js so out of the box it utilizes vue.js reactivity system. This app will have the option to add, remove and edit RSS feed channel, search and favorite a post, for persistence am using local storage so the app can be usable even in offline scenario, earlier we have already built our QRss API to fetch RSS feed and parse it into JSON so we can consume it in this app.
Create q-reader app with Vue-CLI
Vue comes with awesome tooling, one of them is the CLI, lets quickly create our project and pull all the dependencies we will need for this app.
vue init webpack q-reader
...
? Project name q-reader
? Project description Feed reader using vue.js and vuex with element as UI library
? Vue build standalone
? Install vue-router? Yes
? Setup unit tests with Karma + Mocha? No
? Setup e2e tests with Nightwatch? No
After this cd into created app folder and run npm install
. Once it’s completed now you can run your scaffolded app.
cd q-reader
npm install
npm run dev
You should see default app opened in the browser, in another terminal window lets pull other dependencies for our app.
npm install axios element-ui store vuex --save
Axios for HTTP request, Store.js for localStorage, element-ui for UI and Vuex for centralized state management.
Since I will use scss for css preprocessing I am running npm install node-sass sass-loader --only=dev
get the loader and node sass.
Element for UI
Everyone knows about Bootstrap, great frontend library, but recently I stumble upon this hidden gem Element UI, its very well designed and has everything a Vue.js developer can ask for, from the Layout (Like bootstrap grid) to Form, Buttons Date Picker and many other components. I will be using this for our app UI.
Once everything is Installed let’s organize our app in folders.
Folder Structure of app
Create all the remaining folder inside src
directory, for assets grab the content from here. I won’t be covering CSS styling part in this post, just copy and paste provided assets.
One more thing you need to do to setup the API in the root folder, we already have QRss API build in the earlier part, to use it, clone the repo.
git clone https://github.com/saqueib/qrss.git api
Edit the api/index.php and add below code to use it.
// CORS for development, you can turn off it for production
enable_cors();
// Require the QRss class
require 'src/QRss.php';
// Get the feed and cache for 4 hours
(new QRss($_REQUEST['url']))->cache_for('4 hours')->json();
/**
* Enable CORS support
*/
function enable_cors() {
// Allow from any origin
if (isset($_SERVER['HTTP_ORIGIN'])) {
header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Max-Age: 86400'); // cache for 1 day
}
// Access-Control headers are received during OPTIONS requests
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']))
header("Access-Control-Allow-Methods: GET, PUT, PATCH, DELETE, POST, OPTIONS");
if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']))
header("Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}");
exit(0);
}
}
Now we have API ready. Lets setup the Vuex store next.
What is Vuex?
Vuex is a state management pattern + library for Vue.js applications. It serves as a centralized store for all the components in an application, with rules ensuring that the state can only be mutated in a predictable fashion.
Vuex provide a way to manage your app state which is going to be shared throughout the app in different components, it doesn’t necessarily mean you should put everything in state and ignore props
for data sharing provided by vue between component, yes vue.js also provides event system where you can emit an event which can listen by parent component, but in larger and more maintainable app Vuex is ideal choice.
Benefits of isolating state of app
The biggest benefit is the reactivity of vuex, since our app state lives outside of our components every change you make will be reflected in all places. If you need to change any implementation you can, by swapping your state getters, actions methods. It provides an extra layer of separation so our components are loosely coupled with data.
Vuex state, getters, mutations & actions
Let’s get started to build our vuex store.
State
One source of truth for your app state.
Open the store folder and add state.js
, now let’s add all the states we will be using.
import { storage } from '../utils'
// State of app
const state = {
channels: storage.get('qr_channels') || [],
favs: storage.get('qr_favs') || [],
showAddChannel: false,
quotes: getQuotes(),
collapseSideBar: storage.get('qr_sidebar_collapse') || false
}
// some quotes for dashboard
getQuotes(){
return [...]
}
We are importing storage from utils folder which is just a wrapper for Store.js and in state constant assigning it to vuex store.
Now our state is ready, let’s create getters so we can access state in components.
Getters
You need this to read the app state. In store folder and add getters.js
with following:
// Get the channels
export const channels = state => state.channels
// Get the favs
export const favs = state => state.favs
// Add Channel Dialog
export const showAddChannel = state => state.showAddChannel
// get random quote
export const quotes = state => state.quotes
// Check if post is faved
export const isFaved = (state, getters) => (link) => {
return state.favs.find(fav => fav.link === link)
}
// Fave count
export const favCount = state => state.favs.length
// Get toggle status of sidebar
export const collapse = state => state.collapseSideBar
As you can see this is pretty simple, we just returning the state properties from getters, it also allows you to compute a property before returning the result, as you can see favCount and isFaved.
Mutation
Any changes you wanted to make in state of app synchronously done using mutations. In the same folder and add mutations.js
with following:
import { storage } from '../utils'
// Show add channel box
export const TOGGLE_ADD_CAHNNEL = state => {
state.showAddChannel = !state.showAddChannel
}
// Add a channel
export const ADD_CAHNNEL = (state, newChannel) => {
let allChannel = state.channels;
// ceck if already exists
let index = allChannel.findIndex( (data) => {
return newChannel.channel.link == data.channel.link
});
// if alreday have it just update it
if( index !== -1 ) {
// dont update title and color
newChannel.channel.title = allChannel[index].channel.title;
allChannel[index] = newChannel;
state.channels.splice(index, 1, newChannel);
} else {
allChannel.unshift(newChannel);
}
// persist it
storage.set('qr_channels', allChannel);
}
// Rename a channel by url
export const RENAME_CHANNEL = (state, data) => {
// check if already exists
let index = state.channels.findIndex( (channel) => {
return data.url == channel.url
});
// if alreday have it just update it
if( index !== -1 ) {
let channel = state.channels[index];
channel.channel.title = data.title;
channel.color = data.color;
// update channel
state.channels.splice(index, 1, channel);
// persist it
storage.set('qr_channels', state.channels);
}
}
// Delete a channel by url
export const DELETE_CHANNEL = (state, url) => {
// find channel
let index = state.channels.findIndex( (channel) => {
return url == channel.url
});
if( index !== -1 ) {
state.channels.splice(index, 1);
// persist it
storage.set('qr_channels', state.channels);
}
}
// Toggle fav post
export const TOGGLE_FAV = (state, post) => {
// find channel
let index = state.favs.findIndex( (item) => {
return item.link == post.link
});
if( index !== -1 ) {
state.favs.splice(index, 1);
} else {
state.favs.unshift(post);
}
// persist it
storage.set('qr_favs', state.favs);
}
// Collapse sidebar
export const TOGGLE_SIDEBAR = (state) => {
state.collapseSideBar = !state.collapseSideBar;
// persist it
storage.set('qr_sidebar_collapse', state.collapseSideBar);
}
Mutation gives you a way to manipulate the state and once you change it, every component attached with the state will be updated accordingly.
Actions
Actions are used to mutate the state asynchronously, like getting the list of feed using ajax for a channel will be an action. Add actions.js
with following:
import { axios } from '../utils'
// Add a channel
export const addChannel = ({ commit }, { channel }) => {
return axios.get( '/?url=' + channel.url ).then(res => {
let newChannel = res.data;
// append other info
if( newChannel.channel ) {
newChannel.color = channel.color;
newChannel.updated = new Date();
newChannel.url = channel.url;
commit('ADD_CAHNNEL', newChannel );
}
return res.data;
})
}
In above code to add or update a channel, we fetch the feed from API and use our mutation ADD_CAHNNEL
to commit the changes. Also, you noticed we always persist app state to keep channels and fav with some app related meta in local storage so that we can come back to the app and get our channels and favorites posts back.
import { axios } from ../utils is giving me the instance of axios library with a baseURL for api.
Our Vuex store is ready now you can add one final module file index.js
, inside store folder and initialize our vuex store here:
import Vue from 'vue'
import Vuex from 'vuex'
import state from './state'
import * as getters from './getters'
import * as mutations from './mutations'
import * as actions from './actions'
Vue.use(Vuex)
const store = new Vuex.Store({ state, getters, mutations, actions})
export default store
Let’s work on components now.
Components Hierarchy
In below figure you can see all components we are going to need in this app.
It’s clear now that App component is the root and main layout of our app. So let’s build up all the components one by one. But before that let’s add routes in router/index.js
file and kick start the app in main.js
.
Routes
For this app, we are going to need 3 Routes, root page, channel details and favorites page.
import Vue from 'vue'
import Router from 'vue-router'
// Components
import Dashboard from '@/components/Dashboard'
import Channel from '@/components/Channel'
import Favs from '@/components/Favs'
// Use the router
Vue.use(Router)
export default new Router({
routes: [
{
path: '/',
name: 'Dashboard',
component: Dashboard
},
{
path: '/channel/:index/:title',
name: 'Channel',
component: Channel,
props: true
},
{
path: '/favorites',
name: 'Favs',
component: Favs
}
]
})
Let’s bootstrap the app in src/main.js file.
import Vue from 'vue'
import element from 'element-ui'
import router from './router'
import store from './store'
// Use plugins
Vue.use(element)
// Components
import App from './App'
// Configs
Vue.config.devtools = true
Vue.config.productionTip = false
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
store,
render: h => h(App)
})
App Component
This is the main component which is going to be loaded by root instance of Vue on app bootstrap.
<template>
<div id="app" class="wrapper">
<section class="main">
<!-- sidebar -->
<sidebar></sidebar>
<div class="content">
<header :class="{ collapse: collapse }">
<!-- auto-completed search from element -->
<!-- http://element.eleme.io/#/en-US/component/input#autocomplete -->
</header>
<!-- end header -->
<transition name="content">
<router-view></router-view>
</transition>
<!-- main router view outlet -->
</div>
<!-- end content -->
</section>
<!-- end main section -->
<el-dialog title="Add a Channel" v-model="showChannelDialog">
<el-form :model="form" :rules="rules" ref="channelForm">
<el-form-item label="Channel Feed Url" prop="url">
<el-input :required="true" type="url" placeholder="ex. http://www.wordpress.com/feed" v-model="form.url" auto-complete="on"></el-input>
</el-form-item>
<el-form-item label="Channel color" prop="color">
<el-input type="color" placeholder="Pick a color for channel" v-model="form.color"></el-input>
</el-form-item>
</el-form>
<!-- end add channelForm -->
<span slot="footer" class="dialog-footer">
<el-button @click="toggleChannelDialog()">Cancel</el-button>
<el-button :loading="loading" type="primary" @click.prevent="submit('channelForm')">{{ loading ? 'Adding' : 'Add' }}</el-button>
</span>
</el-dialog>
<!-- end add channel dialog -->
</div>
<!-- end wrapper -->
</template>
<script>
import Sidebar from './components/Sidebar.vue';
import { common } from './utils';
export default {
name: 'app',
computed: {
showChannelDialog() {
return this.$store.getters.showAddChannel;
},
channels() {
return this.$store.getters.channels
},
collapse() {
return this.$store.getters.collapse;
}
},
data() {
return {
loading: false,
form: { ... },
rules: { ... },
query: ''
}
},
components: { Sidebar },
methods: {
toggleChannelDialog() {
this.$store.commit('TOGGLE_ADD_CAHNNEL');
},
submit(form) {
this.$refs[form].validate((valid) => {
if (!valid) return false;
this.loading = true;
this.$store.dispatch('addChannel', { channel: this.form }).then(res => {
// hide the add channel box and show message
this.toggleChannelDialog();
this.$message({
message: 'Congrats! ' + res.channel.title + ' channel has been added.',
type: 'success'
});
// reset form
this.form.url = '';
this.loading = false;
this.form.color = common.randomColor();
// navigate to new channel
this.$router.push({ name: 'Channel', params: {
index: 0, title: res.channel.title.toLowerCase().replace(' ', '-')}
});
}).catch( (error) => {
this.loading = false;
let errorMsg = error.response ? error.response.data.msg : error;
this.$message.error('Oops, ' + errorMsg);
});
});
},
clearSearchInput() {
this.query = '';
},
querySearch(queryString, cb) {
// get all channels
var feeds = this.channels;
// result holder
let results = [];
if( queryString ) {
// search in all channels post titles
feeds.forEach( (channel, channelIndex) => {
let matched = channel.items.forEach( (post) => {
if(post.title.toLowerCase().indexOf(queryString.toLowerCase()) !== -1) {
results.push( {
value : post.title,
link: post.link,
channel: channel.channel.title,
channelIndex: channelIndex
});
}
})
})
// call callback function to return suggestions
cb(results);
}
},
handleSelect(item) {
// navigate to chosen result
this.$router.push({ name: 'Channel', params: {
index: item.channelIndex,
title: common.slug(item.channel)
}});
}
}
}
</script>
<style lang="scss">
@import './assets/scss/app';
</style>
To access the Vuex state we used computed property on component by this.$store.geeters.name_of_geeter
. Because we need the Add Channel dialog on two places Sidebar and Dashboard in our app, I kept it on the vuex store so channel add box can be opened from anywhere in app.
To mutate a state we have used commit(‘MUTATION_NAME’) method on vuex store. So to toggle the Add Channel dialog we use this.$store.commit('TOGGLE_ADD_CAHNNEL');
In the submit() method, we have used action of vuex store using this.$store.dispatch('addChannel', { channel: this.form })
, first argument is the name of the action and second any payload you wanted to send through. It’s an ajax call so we have returned the promise from action to handle the success and error cases.
Search is also global, putting this on the parent component allowed us to have search work everywhere in the app. In style block we have imported the scss file to style our app and set lang=”scss” to pre-process them using sass-loader and node-sass.
In template, we also added <router-view></router-view>
where all the content of app will come, like channel page, favs page and dashboard. I tried to keep the only relevant part in this code, for full code visit here.
Sidebar Component
Sidebar holds the branding and navigation of app, it will be collapsible, to give a user more real estate for feed post list.
<template>
<aside class="sidebar" :class="{ collapse: collapse }">
<h1 class="brand">
<a href="/">
<svg>...</svg>
<span class="brand-name">
<span class="brand-primary">Q</span>Reader
</span>
</a>
</h1>
<!-- end brand -->
<el-menu :router="true" mode="vertical" theme="dark" :default-active="currentIndex">
...
<el-menu-item-group title="Channels">
...
<el-menu-item exact v-for="(item, index) in channels"
:index="index.toString()"
:key="index"
:route="{ name: 'Channel', params: { title: slug(item.channel.title), index: index }}">
<el-tooltip class="item" effect="dark" :content="item.channel.title" placement="right">
<img :src="getFavicon(item.channel.link)" alt="">
</el-tooltip> <span>{{ item.channel.title }}</span>
</el-menu-item>
</el-menu-item-group>
</el-menu>
<!-- end side menu -->
....
</aside>
<!-- end sidebar -->
</template>
<script>
import { common } from '../utils';
export default {
methods: {
toggleChannelDialog() {
this.$store.commit('TOGGLE_ADD_CAHNNEL');
},
slug(str) {
return common.slug(str);
},
getFavicon(url) {
return 'https://www.google.com/s2/favicons?domain_url=' + url
},
collaseSideBar() {
this.$store.commit('TOGGLE_SIDEBAR')
}
},
computed: {
channels () {
return this.$store.getters.channels;
},
favCount() {
return this.$store.getters.favCount;
},
currentIndex() {
if( this.$route.name === 'Favs' ) return 'favorites';
return this.$route.params.hasOwnProperty('index') ? this.$route.params.index.toString() : '/';
},
collapse() {
return this.$store.getters.collapse;
}
}
}
</script>
Sidebar handles the toggle of Add channel dialog, listing of all channel, favs, toggle of collapse and favicon of the channels.
Dashboard Component
I am keeping dashboard very minimal, just greeting and a random quote from vuex store. Of course, you can add anything you want, like most reset posts from all channels, for that you should define a getter in vuex with some filtering applied like by date etc.
<template>
<div class="inner full-height">
<div class="jumbotron">
<h2 class="greet">Hi, QReader!</h2>
<blockquote>
{{ randomQuote[0] }}
<br>
<em>{{ randomQuote[1] }}</em>
</blockquote>
<!-- quote ends -->
<p>Lets get started by adding a channel</p>
<el-button
:plain="true"
type="text"
@click="openChannelBox()">
<i style="color:green" class="el-icon-plus"></i>
Add a Channel
</el-button>
</div>
<!-- end jumbotron -->
</div>
</template>
<script>
export default {
data () {
return {
showChannelDialog: false,
}
},
methods: {
openChannelBox() {
this.$store.commit('TOGGLE_ADD_CAHNNEL');
}
},
computed: {
randomQuote() {
let quote = this.$store.getters.quotes[Math.floor((Math.random()*this.$store.getters.quotes.length))];
return quote.split('--');
}
}
}
</script>
Let’s build next component.
Channel Component
This component is responsible for displaying channel info and posts in it.
<template>
<div class="inner">
<div class="channel-info" :style="{'border-bottom-color': channel.color }">
<div class="channel-img" :style="{
'background-color': channel.color,
'background-image': (channel.channel.img) ? 'url(' + channel.channel.img + ')' : 'none'
}">
<h1 class="text-thumb" v-if="!channel.channel.img">{{ channel.channel.title[0] }}</h1>
</div>
<!-- end channel logo -->
<h3>
<span v-if="!editing">{{ channel.channel.title }}</span>
<span v-if="editing">
<input class="edit-title-input" v-if="editing" placeholder="Rename channel title" v-model="editing"></input>
<input type="color" v-model="channel.color" v-if="editing">
<el-button @click.prevent="renameChannel()" type="success" size="small" icon="circle-check">Save</el-button>
<el-button @click.prevent="editing = null" size="small" type="text">Cancel</el-button>
</span>
<span v-if="!editing">
<el-button @click="editing = channel.channel.title" size="mini" icon="edit"></el-button>
<el-button @click="deleteChannel(channel.url)" size="mini" type="danger" icon="delete"></el-button>
</span>
</h3>
<!-- end editable channel title and color -->
<p>
{{ channel.channel.description }}
- <a :href="channel.channel.link" target="_blank">{{ channel.channel.link }}</a>
</p>
<el-row>
<el-col :sm="18" :xs="16">
<p class="channel-meta">
<el-badge class="fav-count" :value="channel.items.length" /> posts found
<i class="el-icon-date"></i> Blog updated <span class="text-dark">{{ updatedTime(channel.channel.lastBuildDate) }}</span>
<i class="el-icon-time"></i> Last synced <span class="text-dark">{{ updatedTime(channel.updated) }}</span>
<transition v-if="loading" name="fade">
<span class="loading-feed">
<i class="el-icon-loading"></i> checking feed...
</span>
</transition>
<!-- end checking feed -->
</p>
</el-col>
</el-row>
<!-- end channel meta -->
</div>
<!-- end channel info -->
<feed :items="channel.items" :query="$parent.query"></feed>
</div>
</template>
<script>
import Feed from './Feed.vue';
import { common } from '../utils';
export default {
props: ['index', 'title'],
mounted() {
this.fetchFeed();
},
data() {
return {
loading: false,
editing: null
}
},
components: { Feed },
computed: {
channel () {
return this.$store.getters.channels[this.index];
}
},
methods: {
updatedTime(time) {
return common.timeAgo(time);
},
fetchFeed() {
this.loading = true;
this.$store.dispatch('addChannel', { channel: this.channel }).then((res) => {
this.loading = false;
}).catch((error) => {
this.loading = false;
let errorMsg = error.response ? error.response.data.msg : error;
this.$message.error('Oops, ' + errorMsg);
});
},
deleteChannel(url) {
this.$confirm( this.channel.channel.title + ' channel will permanently deleted?', 'Confirm Delete', {
confirmButtonText: 'Delete it',
cancelButtonText: 'Cancel',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}).then(() => {
this.$message.success( this.channel.channel.title + ' channel has been deleted.');
this.$store.commit('DELETE_CHANNEL', url);
}).catch(() => console.info('Delete cancel'))
},
renameChannel() {
if( this.editing.trim() == '' ) {
this.$message.error('Enter a title for channel');
return;
}
this.$store.dispatch('renameChannel', {
title: this.editing,
color: this.channel.color,
url: this.channel.url
});
this.channel.channel.title = this.editing;
this.editing = null;
}
},
watch: {
'$route' (to) {
this.fetchFeed();
}
}
}
</script>
This component when first created, it fetches feed, because we are using single Channel component to display all the channel, lifecycle hook of a component will not fire after first-time initializations, vue just reusage same component, but we need the different channel info? to solve this we have added a watch on $route
change, whenever route changes we re-fetch feed and update the component data.
This component also allowed a user to change the name of channel and color and you can delete it all together.
You can see I have a used Feed as a child component, I know I will be using the same listing of posts UI in favorites component so I decided to create a separate component to make it reusable with a slight variation which is handled by passing props to feed component.
Feed Component
This is very simple component responsible for displaying a list of post and user can favourite a post.
<template>
<div class="feed-list">
<el-row :gutter="14">
<el-col v-for="(feed, index) in items" :key="index" :sm="12" :md="12" :lg="8">
<div class="feed-item" :class="{ 'query-match': query == feed.title }">
<div class="feed-thumb" v-if="feed.img">
<img :src="feed.img" alt="">
</div>
<i class="favebtn" @click.prevent="faveIt(feed)" :class="{'el-icon-star-on': isFaved(feed.link), 'el-icon-star-off': !isFaved(feed.link) }"></i>
<h3> <a target="_blank" :title="feed.title" :href="feed.link">{{ feed.title }}</a></h3>
<p class="feed-meta">
<span v-if="feed.pubDate"> Posted <i class="el-icon-date"></i> <span class="text-dark">{{ new Date(feed.pubDate).toDateString() }}</span></span>
<span v-if="feed.favedAt && fav"> <i class="el-icon-star-on"></i> Faved <span class="text-dark">{{ faveTime(feed.favedAt) }} </span></span>
</p>
<div class="feed-desc">
<p>{{ feed.description_text }}</p>
</div>
</div>
</el-col>
<!-- end feed item -->
</el-row>
<!-- end feed row -->
</div>
<!-- end feed list -->
</template>
<script>
import { common } from '../utils';
export default {
props: {
items: { default: [] },
fav: false,
query: ''
},
methods: {
isFaved(link) {
return this.$store.getters.isFaved(link);
},
faveIt(item) {
item.favedAt = new Date();
this.$store.commit('TOGGLE_FAV', item);
},
faveTime(time) {
return common.timeAgo(time);
}
}
}
</script>
I used Element Layout grid to achieve the grid of posts and with some CSS tricks its looks pretty good.
Axios Instance
As said earlier in the post we have kept the axios instance inside utils module, here is the code for configuration. You might need to change the url in order to make it work for your setup.
// axios instance
import axios from 'axios'
const instance = axios.create({
// change this url to your api
baseURL: '//localhost:8889/',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
export default instance
Now to run the app you will need to serve your RSS to JSON API endpoint, I have PHP installed so I can do it using PHP’s built-in dev server, cd into api folder and run.
php -S localhost:8889
With that we have completed the app, I have not covered Favs Component since it very similar and simpler than Channel component, now you can run npm run dev
in terminal and you should see the working app. I didn’t share all the code in the post for brevity, please check the GitHub repo for the full codebase.
Also let me know if you liked this long post, covering a complete working app in a single post is very difficult but I tried it. If you have any question or suggestion leave in comments I will be more than happy to hear from you. until next time, keep creating amazing things…✌️
Thank you very much, very useful!