An income tracker will allow users to monitor and keep track of their expenses. The income tracker app makes it easy for anyone to add, edit, update, and delete specific data from the client-side, and it updates accordingly in the database.
This article will teach us how to build an income tracker app with Vue.js and using Appwrite for storing the data.
First, let's get an introduction to some of the technologies used to build the income tracker app.
Vue.js: It is an open-source progressive and versatile frontend framework for building web user interfaces and single page applications with the bedrock technologies of HTML, CSS, and JavaScript.
Appwrite: It is a secure self hosted open-source backend-as-a-service that provides developers all the core APIs to build applications ranging from web to mobile.
Getting Started with Vue
In our terminal run the following command. This will create a boilerplate app and scaffold the Vue.js code for developmemt.
vue create income-tracker
With the project set up, let's start a development server that is accessible on http://localhost:8080
cd income-tracker
yarn serve
In the terminal, let's install Appwrite client-side SDK with the command. The installation of this dependency will enable configure Appwrite and use it across our application.
yarn add appwrite
Appwrite Setup
To get the full functionalities of Appwrite backend features, we will manually set it up using Docker.
Now, let's get the Appwrite server running. Before, we can get this to work, install the Docker CLI. In our project folder, install the Docker installer tool in the terminal which will give us access to our Appwrite console dashboard. The installation supports different operating system (OS) with this getting started guide.
Note: Use http://localhost/console
to access the Appwrite console.
Creating a New Appwrite Project
Once we have created an account, click on the Create Project. We will name the project income-tracker
.
With the income tracker project created, let's create a collection and add a lists of attributes.
Navigate to the Database
tab and click the Add Collection
and create a new collection called tracker
Within the collection
, click the Attributes
tab and create the following attributes for our document.
The most exciting part of this configuration is that Appwrite will accept the data from the client-side and store them in the documents.
Initialising the Web SDK
In the project with our Vue code, create a new file utils.js
in the src
directory to define the new Appwrite instance and other helpful variables.
Copy and paste the following code.
import { Appwrite } from 'appwrite';
// Init your Web SDK
const appwrite = new Appwrite();
appwrite
.setEndpoint('http://EndpointURL.example') // Replace this with your endpoint
.setProject('ProjectID'); // Replace this with your ProjectID
appwrite.account.createAnonymousSession().then(
(response) => {
console.log(response);
},
(error) => {
console.log(error);
}
);
export const db = appwrite.database;
export const COLLECTION_ID = 'COLLECTION ID'; // Replace with your Collection ID
To bypass some security requirements, we created an anonymous session on Appwrite.
The PROJECT_ID
in the above code, the value is found in the Settings
under the Home
tab.
For the COLLECTION_ID
, access it in the Collection Settings
in the Database
tab.
At the Collection Level
within the settings tab, set the Read and Write access to have the values of role:all
.
Creating the Income Tracker
Let's create the navigation menu that will display the current expenses.
In the Header.vue
file in the components folder, paste in the following code.
<template>
<header>
<h1>Income Tracker</h1>
<div class="total-income">
$500
</div>
</header>
</template>
<style scoped>
header {
display: flex;
align-items: center;
justify-content: space-between;
}
h1, .total-income {
color: var(--grey);
font-weight: 700;
font-family: 'Inter', sans-serif;
}
h1 {
font-size: 2rem;
}
.total-income {
font-size: 1.75rem;
background: var(--bg-total-income);
padding: .3125rem 1.5625rem;
border-radius: 0.5rem;
}
</style>
Creating the Income Form
Here, we will create the income form with input that accept text and date attributes.
Create another file in the components folder called IncomeForm.vue
and paste the following code.
<template>
<section>
<form class="income-form">
<div class="form-inner">
<input
v-model="income"
placeholder="Income Description"
type="text"
required
/>
<input
v-model="price"
min="0"
placeholder="Price..."
type="number"
required
/>
<input
v-model="date"
placeholder="Income date..."
type="date"
required
/>
<input type="submit" value="Add Income" />
</div>
</form>
</section>
</template>
<script>
export default {
data() {
return {
income: '',
price: '',
date: null,
};
},
};
</script>
The code above has the data properties for the income, price, and date variables set to an empty string and null respectively. For the reference of this data properties, we bound them to the <input>
element using the v-model
directive.
Another important component that we need for this application is a list that will hold all the accepted data.
Create the IncomeList.vue
component and paste the following code.
<template>
<section>
<div class="income-item">
<div class="space desc">Web Design</div>
<div class="space price">$500</div>
<div class="space date">2022-05-24</div>
<div class="btn">
<div class="btn-edit">update</div>
<div class="btn-del">delete</div>
</div>
</div>
</section>
</template>
<style scoped>
section {
padding: unset;
}
.income-item {
background: #ffffff;
padding: 0.625em 0.94em;
border-radius: 5px;
box-shadow: 0px 4px 3px rgba(0, 0, 0, 0.1);
position: relative;
margin: 2em 0;
}
.space + .space {
margin-top: 1em;
}
.desc {
font-size: 1.5rem;
}
.btn {
position: absolute;
bottom: 0;
right: 0;
display: flex;
align-items: center;
padding: 0.75em;
text-transform: capitalize;
}
.btn-edit {
color: var(--grey);
}
.btn-del {
margin-left: 10px;
color: var(--alert);
}
.btn-edit,
.btn-del {
cursor: pointer;
}
@media screen and (min-width: 768px) {
.desc {
font-size: 2rem;
}
.price {
font-size: 1.5rem;
}
.date {
font-size: 1.5rem;
}
.btn-edit,
.btn-del {
font-size: 1.5rem;
}
}
@media screen and (min-width: 1200px) {
.income-item,
.modal__wrapper {
width: 80%;
margin-inline: auto;
}
}
</style>
With this in place, let's import the IncomeForm.vue
, IncomeList.vue
, and Header.vue
component into the application entry point App.vue
with the following code.
<template>
<section class="container">
<Header />
<IncomeForm />
<div>
<IncomeList />
</div>
</section>
</template>
<script>
import Header from "./components/Header"
import IncomeForm from './components/IncomeForm'
import IncomeList from "./components/IncomeList";
export default {
name: 'App',
components: {
Header,
IncomeForm,
IncomeList
},
}
</script>
<style>
:root {
--light: #F8F8F8;
--dark: #313131;
--grey: #888;
--primary: #FFCE00;
--secondary: #FE4880;
--alert: #FF1E2D;
--bg-total-income: #DFDFDF;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Reset margins */
body,
h1,
h2,
h3,
h4,
h5,
p,
figure,
picture {
margin: 0;
}
body {
font-family: 'Montserrat', sans-serif;
background: var(--light)
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
font-weight: 400;
}
img,
picutre {
max-width: 100%;
display: block;
}
/* make form elements easier to work with */
input,
button,
textarea,
select {
font: inherit;
}
button {
cursor: pointer;
}
section {
padding: 3em 0;
}
.container {
max-width: 75rem;
width: 85%;
margin-inline: auto;
}
/*income form and income list styling*/
input {
width: 100%;
border: 1px solid gray;
}
.income-form {
display: block;
}
.form-inner input {
font-size: 1.125rem;
padding: 0.625em 0.94em;
background: #fff;
border-radius: 5px;
}
input + input {
margin-top: 2em;
}
.form-inner input[type=submit] {
cursor: pointer;
background-image: linear-gradient(to right, var(--primary) 50%, var(--primary) 50%, var(--secondary));
background-size: 200%;
background-position: 0%;
color: var(--dark);
text-transform: uppercase;
transition: 0.4s;
border: unset;
}
.form-inner input[type="submit"]:hover {
background-position: 100%;
color: #FFF;
}
@media screen and (min-width: 1200px) {
.form-inner {
display: flex;
justify-content: center;
}
input + input {
margin: 0;
}
input {
border: unset;
}
}
</style>
Our app should look like this with the recent changes.
Fetch All Income List
We create a function to fetch all the listed income from the Appwrite database when the page loads. Update the <script>
section in the App.vue
file with the following code.
<script>
// imported component
import { COLLECTION_ID, db } from '@/utils';
export default {
name: 'App',
components: {
// all components
},
created() {
this.fetchLists();
},
data() {
return {
lists: [],
};
},
methods: {
fetchLists() {
let promise = db.listDocuments(COLLECTION_ID);
promise.then(
(res) => {
this.lists = res.documents;
},
(err) => {
console.log(err);
}
);
},
},
};
</script>
We created a lists array property in the data
function to store the lists and retrieve them using the listDocuments
API.
In the created()
lifecycle method, run the fetchLists()
function when the App component is created.
Finally update the <template>
section in the App.vue
component with the following code.
<template>
<section class="container">
<Header :totalIncome="lists" />
<IncomeForm :fetchLists="fetchLists" />
<div v-for="data in lists" :key="data.income">
<IncomeList :data="data" v-on:refreshData="fetchLists" />
</div>
</section>
</template>
To reuse the function to fetch all lists after creating a new income list, we bind the :fetchLists
prop to the fetchLists
method we defined earlier.
Creating a new Income List
In the IncomeForm.vue
file is where we handle the income addition to the database.
Paste the following code to update the file.
<template>
<section>
<form class="income-form" @submit.prevent="addIncome">
<div class="form-inner">
<input
v-model="income"
placeholder="Income Description"
type="text"
required
/>
<input
v-model="price"
min="0"
placeholder="Price..."
type="number"
required
/>
<input
v-model="date"
placeholder="Income date..."
type="date"
required
/>
<input type="submit" value="Add Income" />
</div>
</form>
</section>
</template>
<script>
import { COLLECTION_ID, db } from '@/utils';
export default {
props: ['fetchLists'],
// data
methods: {
addIncome() {
if (this.income === '' && this.price === '' && this.date === '') {
return;
}
let promise = db.createDocument(COLLECTION_ID, 'unique()', {
income: this.income.charAt(0).toUpperCase() + this.income.slice(1),
price: this.price,
date: this.date,
});
promise.then(
() => {
this.fetchLists();
this.income = '';
this.price = '';
this.date = '';
},
(err) => {
console.log(err);
}
);
},
},
};
</script>
In the addIncome
method, we use Appwriteโs createDocument
API to write a new list to the database. An error message is logged if the write operation fails. We fetch an updated list of all income after adding a new list.
The Appwrite web console will display one document representing a list in the image below:
Updating the Income list Component
In the App.vue
component, we update the income list componentโs props to include the looped data
and the fetchLists
method.
<template>
<section class="container">
<Header :totalIncome="lists" />
<IncomeForm :fetchLists="fetchLists" />
<div v-for="data in lists" :key="data.income">
<IncomeList :data="data" v-on:refreshData="fetchLists" />
</div>
</section>
</template>
<script>
// import component
import IncomeList from './components/IncomeList';
export default {
components: {
// other components
IncomeList,
},
};
</script>
fetchLists runs once the refreshData event is fired.
Let's update the IncomeList.vue
component to handle list updates and deletion. We will also include a component to edit an income list. First, we add the update list function in the script portion with:
<script>
import { db } from '@/utils';
export default {
props: ['data'],
data() {
return {
open: false,
income: '',
price: '',
date: '',
};
},
methods: {
updateIncome() {
this.open = !this.open;
},
updateIncomeMethod() {
if (this.income === '' && this.price === '' && this.date === '') {
return;
}
let promise = db.updateDocument(this.data.$collection, this.data.$id, {
income: this.income.charAt(0).toUpperCase() + this.income.slice(1),
price: this.price,
date: this.date,
});
this.open = false;
promise.then(
() => {
this.$emit('refreshData');
},
(err) => {
console.log(err);
}
);
},
deleteIncome() {
let promise = db.deleteDocument(this.data.$collection, this.data.$id);
promise.then(
() => {
this.$emit('refreshData');
},
(err) => {
console.log('error occured', err);
}
);
},
},
};
</script>
We added a state variable to manage the visibility of a listโs action buttons. Appwriteโs updateDocument API uses the collection ID and document ID passed as props to update the comment. Once the list is updated, we emit the refreshData event to fetch all income list.
We update the template portion to utilize the methods and variables created.
<template>
<section>
<div class="income-item">
<div class="space desc">{{ data.income }}</div>
<div class="space price">${{ data.price.toLocaleString('en-US') }}</div>
<div class="space date">{{ data.date }}</div>
<div class="btn">
<div class="btn-edit" @click.prevent="updateIncome">update</div>
<div class="btn-del" @click.prevent="deleteIncome">delete</div>
</div>
</div>
<div v-if="this.open" class="modal__wrapper">
<form class="income-form" @submit.prevent="updateIncomeMethod">
<div class="form-inner">
<input
v-model="income"
:placeholder="data.income"
type="text"
required
/>
<input
v-model="price"
:placeholder="data.price"
min="0"
type="number"
required
/>
<input v-model="date" :placeholder="data.date" type="date" required />
<input type="submit" value="Update" />
</div>
</form>
</div>
</section>
</template>
The image below represents the working app.
Top comments (3)
Your codes is not in the code tag
It took me some time to find the fault in the codeblock. It is fixed now.
I don't know why Dev formatted that way. I have tried to fix it but it shows in a different format.
I will continue to check on the code block and make sure it works.