Lab3 4-Tutorial
Lab3 4-Tutorial
SERVICES
Building Contactbook App - Frontend
You will build a contact management app as a SPA app. The tech stack includes Nodejs/Express,
Knex.js, MySQL/MariaDB for backend (API server) and Vue.js for frontend (GUI). In the next two lab
sessions, you will build the app frontend.
The app is built as a SPA with Vue.js and has the following features:
A page showing a list of contacts, support pagination and the ability to filter any piece of
information of a contact (name, email, phone, address, favorite) on each page of contacts.
The app uses the HTTP API built in the first two lab sessions. The source code is managed by git
and uploaded to GitHub.
This step-by-step tutorial will help implement all the above requirements. However, students are
free to make their own implementation as long as the requirements are met.
The submitted report file is a PDF file containing images showing the results of your works.
Each functionality on a page may need several images to illustrate (e.g., images showing the
implemented functionalities, successful and failed scenarios, results of the operations, ...).
You should NOT screenshoot the source code.
You only need to create ONE report for the whole four lab sessions. At the end of each lab
session, students need to (1) submit the work-in-progress report and (2) push the code to the
GitHub repository given by the instructor.
The report should also filled with student information (student ID, student name, class ID)
and the links to the GitHub repositories.
For easier debugging, Vue.js devtools extension should be installed in the browser.
The HTTP API server built in the first two lab sessions should work correctly.
cd contactbook-frontend
npm install
npm run dev
Configure VSCode to automatically format the source code on save or paste: Go to File >
Settings, search for "format" and then check "Editor: Format on Paste" and "Editor: Format on
Save":
In the project directory, add jsconfig.json file. This file helps VSCode understand the project
structure:
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
}
}
cd contactbook-frontend
git add -A
git commit -m "Setup VSCode"
git push origin master
The above configuration means that when there is a HTTP request with an URI starting with
/api coming from the app (http://localhost:5173/), the target host of the request will be replaced
with the address of the API server (http://localhost:3000/). For example, a request to
http://localhost:5173/api/contacts will become a request to http://localhost:3000/api/contact s.
Create src/services/contacts.service.js as follows:
function makeContactsService() {
const baseUrl = '/api/contacts';
const headers = {
'Content-Type': 'application/json',
};
return {
getContacts,
deleteAllContacts,
getContact,
createContact,
updateContact,
deleteContact,
};
}
The contacts.service.js module defines functions interacting with the API server (built in lab
sessions 1 and 2) by issuing the corresponding HTTP requests.
const routes = [
{
path: '/',
name: 'contactbook',
component: ContactBook,
},
];
The import.meta.env object contains environment variables for the app managed by Vite.
env.BASE_URL returns the base URL of the app on the web server. This value comes from the "base"
option in the vite.config.js file ("/" by default - the app is deployed right in the document root on the
web server).
...
import router from './router';
createApp(App)
.use(router)
.mount('#app');
<template>
<h2>ContactBook page</h2>
</template>
<script setup>
import AppHeader from '@/components/AppHeader.vue';
</script>
<template>
<AppHeader />
<style>
.page {
max-width: 400px;
margin: auto;
}
</style>
<template>
<nav class="navbar navbar-expand navbar-dark bg-dark">
<a
href="/"
class="navbar-brand"
>Ứng dụng Quản lý danh bạ</a>
<div class="mr-auto navbar-nav">
<li class="nav-item">
<router-link
:to="{ name: 'contactbook' }"
class="nav-link"
>
Danh bạ
<i class="fas fa-address-book"></i>
</router-link>
</li>
</div>
</nav>
</template>
As shown in the figure, the ContactBook page use 4 components: InputSearch, ContactList,
ContactCard and Pagination. Let's create these components:
<script setup>
defineProps({
modelValue: { type: String, default: '' },
});
<template>
<div class="input-group">
<input
type="text"
class="form-control px-3"
placeholder="Nhập thông tin cần tìm"
:value="modelValue"
@input="(e) => $emit('update:modelValue', e.target.value)"
@keyup.enter="$emit('submit')"
/>
<div class="input-group-append">
<button
class="btn btn-outline-secondary"
type="button"
@click="$emit('submit')"
>
<i class="fas fa-search"></i>
</button>
</div>
</div>
</template>
The component has a property named modelValue. This property is bound to the input value.
An event named update:modelValue is emitted when the input value changes. These conditions
enable the use of v-model on InputSearch to create a two-way binding, i.e., <InputSearch v model="..."
/>.
<script setup>
defineProps({
contacts: { type: Array, default: () => [] },
selectedIndex: { type: Number, default: -1 },
});
<script setup>
defineProps({
contact: { type: Object, required: true },
});
</script>
<template>
<div>
<div class="p-1">
<strong>Tên:</strong>
{{ contact.name }}
</div>
<div class="p-1">
<strong>E-mail:</strong>
{{ contact.email }}
</div>
<div class="p-1">
<strong>Địa chỉ:</strong>
{{ contact.address }}
</div>
<div class="p-1">
<strong>Điện thoại:</strong>
{{ contact.phone }}
</div>
<div class="p-1">
<strong>Liên hệ yêu thích: </strong> <i
v-if="contact.favorite"
class="fas fa-check"
></i>
<i
v-else
class="fas fa-times"
></i>
</div>
</div>
</template>
if (start <= 0) {
start = 1;
end = props.length;
}
return pages;
});
</script>
<template>
<nav>
<ul class="pagination">
<li
class="page-item"
:class="{ disabled: currentPage == 1 }"
>
<a
role="button"
class="page-link"
@click.prevent="$emit('update:currentPage', currentPage - 1)" >
<span>«</span>
</a>
</li>
<li
v-for="page in pages"
:key="page"
class="page-item"
:class="{ active: currentPage == page }"
>
<a
role="button"
class="page-link"
@click.prevent="$emit('update:currentPage', page)"
>{{ page }}</a
>
</li>
<li
class="page-item"
:class="{ disabled: currentPage == totalPages }"
>
<a
role="button"
class="page-link"
@click.prevent="$emit('update:currentPage', currentPage + 1)" >
<span>»</span>
</a>
</li>
</ul>
</nav>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import ContactCard from '@/components/ContactCard.vue';
import InputSearch from '@/components/InputSearch.vue';
import ContactList from '@/components/ContactList.vue';
import Pagination from '@/components/Pagination.vue';
import contactsService from '@/services/contacts.service';
<template>
<div class="page row mb-5">
<div class="mt-3 col-md-6">
<h4>
Danh bạ
<i class="fas fa-address-book"></i>
</h4>
<div class="my-3">
<InputSearch v-model="searchText" />
</div>
<ContactList
v-if="filteredContacts.length > 0"
:contacts="filteredContacts"
v-model:selectedIndex="selectedIndex"
/>
<p v-else>
Không có liên hệ nào.
</p>
<button
class="btn btn-sm btn-success"
@click="goToAddContact"
>
<i class="fas fa-plus"></i> Thêm mới
</button>
<button
class="btn btn-sm btn-danger"
@click="onDeleteContacts"
>
<i class="fas fa-trash"></i> Xóa tất cả
</button>
</div>
</div>
<div class="mt-3 col-md-6">
<div v-if="selectedContact">
<h4>
Chi tiết Liên hệ
<i class="fas fa-address-card"></i>
</h4>
<ContactCard :contact="selectedContact" />
</div>
</div>
</div>
</template>
<style scoped>
.page {
text-align: left;
max-width: 750px;
}
</style>
totalPages: stores the total pages (in this case, paginating records happens on the server).
contacts: stores a list of contacts on a page. This list is loaded with the data from the server
when ContactBook is mounted.
selectedIndex: index of the selected contact in the list. selectedIndex identifies the contact
object passed to ContactCard for displaying detailed information.
The ContactBook page relies on contactsService for accessing data on the server:
// src/views/ContactBook.vue
<script setup>
...
// Get contacts for a specific pages and order them by name async function
retrieveContacts(page) {
try {
const chunk = await contactsService.getContacts(page); totalPages.value =
chunk.metadata.lastPage ?? 1;
contacts.value = chunk.contacts.sort((current, next) =>
current.name.localeCompare(next.name)
);
selectedIndex.value = -1;
} catch (error) {
console.log(error);
}
}
function goToAddContact() {
$router.push({ name: 'contact.add' });
}
// When this component is mounted, load the first page of contacts onMounted(() =>
retrieveContacts(1));
Start the API server at port 3000 and then start the Vue app: npm run dev (if it's not started yet).
Open a browser, go to http://localhost:5173/ and verify that: (1) a list of contacts is shown, (2) the
detailed information of a contact is shown when selecting a contact in the list, and (3) the search
functionality works correctly. If the database contains no data, add some example data (use a HTTP
client to send requests to the API server).
After making sure the code works, commit changes to git and upload to GitHub:
git add -u
git add .env src/components/ src/router/ src/services/ src/views/ git commit -m "Create a contact
listing page"
git push origin master
...
const routes = [
...
{
path: '/:pathMatch(.*)*',
name: 'notfound',
component: () => import('@/views/NotFound.vue'),
},
];
...
Open a browser, access to an unknown path and check that the error page is shown.
The edit page and the add page need a form namely ContactForm. ContactForm receives a
contact object as its property. If the contact object exists on the server (i.e., the id field has a valid
value) then ContacForm will be in edit mode. On the other hand, it will be in add mode. Only in edit
mode, the delete button on the form is shown.
When working with form in Vue, you can use vee-validate and yup to easily validate the form
data. vee-validate provides customized form and input components supporting data validation by
rules. yup helps create these validation rules.
Please not that the use of vee-validate and yup is not required, you can use normal form
and input tags and standard JavaScript code for form validation.
<script setup>
import { ref } from 'vue';
import * as yup from 'yup';
import { Form, Field, ErrorMessage } from 'vee-validate';
function submitContact() {
$emit('submit:contact', editedContact.value);
}
function deleteContact() {
$emit('delete:contact', editedContact.value.id);
}
</script>
<template>
<Form
@submit="submitContact"
:validation-schema="contactFormSchema"
>
<div class="form-group">
<label for="name">Tên</label>
<Field
name="name"
type="text"
class="form-control"
v-model="editedContact.name"
/>
<ErrorMessage name="name" class="error-feedback" /> </div>
<div class="form-group">
<label for="email">E-mail</label>
<Field
name="email"
type="email"
class="form-control"
v-model="editedContact.email"
/>
<ErrorMessage name="email" class="error-feedback" /> </div>
<div class="form-group">
<label for="address">Địa chỉ</label>
<Field
name="address"
type="text"
class="form-control"
v-model="editedContact.address"
/>
<ErrorMessage name="address" class="error-feedback" />
</div>
<div class="form-group">
<label for="phone">Điện thoại</label>
<Field
name="phone"
type="tel"
class="form-control"
v-model="editedContact.phone"
/>
<ErrorMessage name="phone" class="error-feedback" />
</div>
<div class="form-group">
<button class="btn btn-primary">Lưu</button>
<button
v-if="editedContact.id"
type="button"
class="ml-2 btn btn-danger"
@click="deleteContact"
>
Xóa
</button>
</div>
</Form>
</template>
<style scoped>
@import '@/assets/form.css';
</style>
In above code, we defined a schema containing validation rules for input data in the form (
:validation-schema="contactFormSchema" ). Also note that ContactForm can emit two events: submit:contact
and delete:contact.
<script setup>
import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import ContactForm from '@/components/ContactForm.vue';
import contactsService from '@/services/contacts.service';
getContact(props.contactId);
</script>
<template>
<div v-if="contact" class="page">
<h4>Hiệu chỉnh Liên hệ</h4>
<ContactForm
:initial-contact="contact"
@submit:contact="onUpdateContact"
@delete:contact="onDeleteContact"
/>
<p>{{ message }}</p>
</div>
</template>
The path for accessing to the edit page is /contacts/:id with id as the id of the contact. Before
the page is shown, the id property is used to fetch contact data from the server.
Add a link to the edit page in ContactBook (src/views/ContactBook.vue), just below ContactCard:
...
<ContactCard :contact="selectedContact" />
<router-link
:to="{
name: 'contact.edit',
params: { id: selectedContact.id },
}"
>
<span class="mt-2 badge badge-warning">
<i class="fas fa-edit"></i> Hiệu chỉnh</span>
</router-link>
...
Make sure the code works. Commit changes and upload to GitHub:
git add -u
git add src/assets/ src/components/ContactForm.vue src/views/ContactEdit.vue git commit -m "Create an edit page"
git push origin master
Make sure the add page works correctly and then commits changes to GitHub.
Instead of directly call server APIs inside components, update the project to use
@tanstack/vue-query to fetch and modify server state (e.g., contact resources).