A timeline is great for displaying chronological items. It looks good and it’s easy to read. We can easily add one to a Vue.js with the timeline-vuejs package, located at https://github.com/pablosirera/timeline-vuejs. It displays a vertical timeline with the year and the content of your choice.
In this article, we will build a journal entry app that lets users enter journal entries in and save them. They can also delete them as they wish. The saved entries will be displayed in a vertical timeline with the year on the left and the content on the right. To start, we will run the Vue CLI by running:
npx @vue/cli create journal-app
Vuex, and Babel.
Next we install some packages. We will use Axios for making HTTP requests, BootstrapVue for styling, Timeline-Vuejs for displaying the user’s diary in a timeline, VueFilterDateFormat for formatting dates in templates, and Vee-Validate for form validation. To install them, we run:
npm i axios bootstrap-vue timeline-vuejs vee-validate vue-filter-date-format
Now we can being building our app. Create a file called JournalForm.vue
in the components
folder and add:
<template>
<ValidationObserver ref="observer" v-slot="{ invalid }">
<b-form @submit.prevent="onSubmit" novalidate>
<b-form-group label="Date">
<ValidationProvider name="date" rules="required|date" v-slot="{ errors }">
<b-form-input
type="text"
:state="errors.length == 0"
v-model="form.date"
required
placeholder="Date"
name="date"
></b-form-input>
<b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
</ValidationProvider>
</b-form-group>
<b-form-group label="Diary">
<ValidationProvider name="diary" rules="required" v-slot="{ errors }">
<b-form-textarea
:state="errors.length == 0"
v-model="form.diary"
required
placeholder="Diary"
name="diary"
rows="3"
></b-form-textarea>
<b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
</ValidationProvider>
</b-form-group>
<b-button type="submit" variant="primary" style="margin-right: 10px">Submit</b-button>
<b-button type="reset" variant="danger" @click="cancel()">Cancel</b-button>
</b-form>
</ValidationObserver>
</template>
<script>
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
name: "JournalForm",
mixins: [requestsMixin],
props: {
edit: Boolean,
journal: Object
},
data() {
return {
form: {}
};
},
methods: {
async onSubmit() {
const isValid = await this.$refs.observer.validate();
if (!isValid) {
return;
}
const offDate = new Date(this.form.date);
const correctedDate = new Date(
offDate.getTime() + Math.abs(offDate.getTimezoneOffset() * 60000)
);
const params = {
...this.form,
date: correctedDate
};
if (this.edit) {
await this.editJournal(params);
} else {
await this.addJournal(params);
}
const { data } = await this.getJournals();
this.$store.commit("setJournals", data);
this.$emit("saved");
},
cancel() {
this.$emit("cancelled");
}
},
watch: {
journal: {
handler(val) {
this.form = JSON.parse(JSON.stringify(val || {}));
},
deep: true,
immediate: true
}
}
};
</script>
The file is the form for letting users entering their journal entries.
In the onSubmit
function, we validate our form with Vee-Validate by calling this.$refs.observer.validate();
to make sure diary
is entered and that it’s entered before saving it, and the date field is entered in the YYYY-MM-DD format. We use Vee-Validate to validate the form fields. The ValidationObserver
component is for validating the whole form, while the ValidationProvider
component is for validating the form fields that it wraps around. The validation rule is specified by the rule
prop of the date
and diary
fields. The state
prop is for setting the validation state which shows the green when errors
has length 0 and red otherwise. The error messages are shown in the b-form-invalid-feedback
component. This page only has the countries drop down.
We correct the dates before submitting since dates in YYYY-MM-DD format has to be corrected by adding an offset for the time zone to save the date in UTC. Once the data is saved, we get the latest data and then put the data in the store. Then we emit the saved
event to the HomePage
so that we can close the modal.
Next we create a mixins
folder in the src
folder and create a file called requestsMixin.js
file. In there, we add:
const APIURL = "[http://localhost:3000](http://localhost:3000)";
const axios = require("axios");
export const requestsMixin = {
methods: {
getJournals() {
return axios.get(`${APIURL}/journals`);
},
addJournal(data) {
return axios.post(`${APIURL}/journals`, data);
},
editJournal(data) {
return axios.put(`${APIURL}/journals/${data.id}`, data);
},
deleteJournal(id) {
return axios.delete(`${APIURL}/journals/${id}`);
}
}
};
These are the functions to get and save our journal data to back end.
Next in the views
folder, we replace the code in the Home.vue
file with:
<template>
<div class="page">
<h1 class="text-center">Journal</h1>
<div class="text-center">
<b-button @click="openAddModal()" variant="primary">Add Journal Entry</b-button>
<b-button @click="openDeleteModal()" variant="primary">Delete Journal Entries</b-button>
</div>
<br />
<Timeline
:timeline-items="journals"
message-when-no-items="No Entries Found"
:unique-year="true"
order="desc"
/>
<b-modal id="add-modal" title="Add Journal" hide-footer>
<JournalForm [@saved](http://twitter.com/saved "Twitter profile for @saved")="closeModal()" [@cancelled](http://twitter.com/cancelled "Twitter profile for @cancelled")="closeModal()" :edit="false" />
</b-modal>
<b-modal id="delete-modal" title="Delete Journal Entries" hide-footer>
<b-card v-for="(j, i) of journals" :key="i">
<b-card-text>
<h2>{{j.from | dateFormat('YYYY-MM-DD')}}</h2>
<p>{{j.description}}</p>
</b-card-text>
<b-button @click="deleteOneJournal(j.id)" variant="primary">Delete</b-button>
</b-card>
</b-modal>
</div>
</template>
<script>
// @ is an alias to /src
import JournalForm from "@/components/JournalForm.vue";
import Timeline from "timeline-vuejs";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
name: "home",
components: {
JournalForm,
Timeline
},
mixins: [requestsMixin],
computed: {
journals() {
return this.$store.state.journals
.sort((a, b) => +new Date(b.date) - +new Date(a.date))
.map(j => ({
id: j.id,
from: new Date(j.date),
title: j.title,
description: j.diary
}));
}
},
beforeMount() {
this.getAllJournals();
},
data() {
return {
selectedJournal: {}
};
},
methods: {
openAddModal() {
this.$bvModal.show("add-modal");
},
openDeleteModal() {
this.$bvModal.show("delete-modal");
},
closeModal() {
this.$bvModal.hide("add-modal");
this.$bvModal.hide("delete-modal");
},
async deleteOneJournal(id) {
await this.deleteJournal(id);
this.getAllJournals();
},
async getAllJournals() {
const { data } = await this.getJournals();
this.$store.commit("setJournals", data);
this.closeModal();
}
}
};
</script>
<style lang="scss" scoped>
.timeline {
margin: 0 auto;
}
</style>
This is the home page our app. We have a button for adding and deleting journal entries at the top of the page. When we click the buttons we open the modals to open a form for entering diary entries and display a list of diaries where you can click Delete button to delete them individually respectively. Below the buttons, there’s the timeline for displaying the journal entries. We pass in the journals
array into the timeline-items
.
Below that we have the modals for displaying the JournalForm
and a list of cards stacked vertically to display the existing items, where they can be read and deleted individually. we handle the saved
event from the JournalForm
to close the modal after items are saved.
In the delete-modal
, we list the items and have a button to delete each entry. The from
field is formatted to YYYY-MM-DD format by the VueFilterDateFormat package to display the dates properly.
In the scripts
section, we have the journals
property in the computed
object to get the latest journal entries. We map the fields to the ones required by the Timeline-Vuejs package so that we can display the timeline properly. We sort them by reverse chronological order to view the latest items at the top and older items below it. Also, we have the beforeMount
hook to get all the password entries during page load with the getJournals
function we wrote in our mixin. To delete a journal entry, we call deleteOneJournal
our mixin to make the request to the back end via the deleteJournal
function.
Next in App.vue
, we replace the existing code with:
<template>
<div id="app">
<b-navbar toggleable="lg" type="dark" variant="info">
<b-navbar-brand to="/">Journal 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-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 lang="scss">
.page {
padding: 20px;
}
button,
.btn.btn-primary {
margin-right: 10px !important;
}
.button-toolbar {
margin-bottom: 10px;
}
</style>
to add a Bootstrap navigation bar to the top of our pages, and a router-view
to display the routes we define. This style
section isn’t scoped so the styles will apply globally. In the .page
selector, we add some padding to our pages. We also add margins to our app’s buttons here.
Next in main.js
, we replace the existing code with:
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 "../node_modules/timeline-vuejs/dist/timeline-vuejs.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
import VueFilterDateFormat from "vue-filter-date-format";
extend("required", required);
extend("date", {
validate: value =>
/([12]d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]d|3[01]))/.test(value),
message: "Date must be in YYYY-MM-DD format"
});
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(VueFilterDateFormat);
Vue.use(BootstrapVue);
Vue.config.productionTip = false;
new Vue({
router,
store,
render: h => h(App)
}).$mount("#app");
We added all the libraries we need here, including BootstrapVue JavaScript and CSS, Vee-Validate components along with the validation rules. We created the date
rule which verifies that the inputs that used the rule will be in YYYY-MM-DD format. Also, we added the VueFilterDateFormat package to format the dates in the Date column of our table in Home.vue
.
In router.js
we replace the existing code with:
import Vue from "vue";
import Router from "vue-router";
import Home from "./views/Home.vue";
Vue.use(Router);
export default new Router({
mode: "history",
base: process.env.BASE_URL,
routes: [
{
path: "/",
name: "home",
component: Home
}
]
});
to include the home page in our routes so users can see the page.
And in store.js
, we replace the existing code with:
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
journals: []
},
mutations: {
setJournals(state, payload) {
state.journals = payload;
}
},
actions: {}
});
to add our journals
state to the store so we can observer it in the computed
block of JournalForm
, and HomePage
components. We have the setSavings
function to update the savings
state and we use it in the components by call this.$store.commit(“setJournals”, data);
to set the journal entries data for the JournalForm
and HomePage
.
Finally, in index.html
, we replace the existing code 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>Journal App</title>
</head>
<body>
<noscript>
<strong
>We're sorry but vue-timeline-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 title.
After all the hard work, we can start our app by running npm run serve
.
To start the back end, we first install the json-server
package by running npm i json-server
. Then, go to our project folder and run:
json-server --watch db.json
In db.json
, change the text to:
{
"journals": [],
}
So we have the journals
endpoints defined in the requests.js
available.