Designing the Contacts Table | Full-Stack Google Contacts Clone with Adonis.js/Node.js and Quasar (Vue.js)
In this lesson, we will learn how to implement the Quasar Table component for displaying all our contacts. We will also implement virtual scrolling on the table for mimicking infinite scrolling for the contacts. This way, we won't have an obvious pagination (though the virtual scrolling implements pagination under the hood) with a manual pagination component. This is how the contacts table on the real Google Contacts app is implemented.
I have made use of mock data free from Mockaroo to test the Contacts table. The mock data (of course) has the same fields (shape) as the form on the New Contact page. When we implement the backend and replace the mock data, we will fetch data from the backend with the same shape.
At the end of this lesson, your contacts table should work as demonstrated in the video below and you should understand how to implement the QTable
component by reading the entire lesson.
Start by creating a new branch of your project:
# Make sure you are within your project
git checkout -b 05-the-contacts-table
Extra Setup
Let's fix some inconsistencies between
Prettier
andEslint
by install theeslint-config-prettier
extension foreslint
# Make sure you are in the root directory of your project, then: cd ui # change into the `ui` directory yarn add eslint-config-prettier -D # install the packages for `eslint-config-prettier` as a dev dependency
Copy-and-paste the entire content of this
.eslintrc.js
file into theui/.eslintrc.js
file.Create a
.gitattributes
in the root of your project. Copy-and-paste the following in the created.gitattributes
file. This will fix any issues withgit
and EOL (End of Line) markers.*.js text eol=lf *.ts text eol=lf
Save all your files.
Pre-emptive Warning: I noticed a several degradation of performance when the Vue DevTools is opened after virtual scrolling was implemented on the
QTable
component. If you notice that scrolling of the Contacts table is slow or seizing, close the Chrome DevTools completely.
Recommended reads
It is highly recommended that you study the QTable
component on the Quasar Docs. Study the QTable
API and the dozens of use cases for the component on the page.
Designing the Contacts form
Before we beginning discussion how the Contacts table will be designed, please created some important files. I will explain their purposes and you will understand how they are used as you read along.
Create The Mock Data File
Create and open ui/src/data/Google_Contacts_Clone_Mock_Data.ts
file which will contain our mock data. The mock data will be used to test our Contacts table
in the absence of real data from the database. After creating the file, copy-and-paste all the content of this snapshot into the Google_Contacts_Clone_Mock_Data.ts
file.
Modify the Type Index File
Open ui/src/types/index.ts
. Add the following lines to the end of the file. Also make reference to this snapshot .
export interface Contact {
id: string;
firstName: string;
surname: string;
company?: string | null | undefined;
jobTitle?: string | null | undefined;
email1: string;
email2?: string | null | undefined;
phoneNumber1: string;
phoneNumber2?: string | null | undefined;
country?: string | null | undefined;
streetAddressLine1?: string | null | undefined;
streetAddressLine2?: string | null | undefined;
city?: string | null | undefined;
state?: string | null | undefined;
birthday?: string | null | undefined;
website?: string | null | undefined;
notes?: string | null | undefined;
}
type SortStringToBooleanFn = (arg1: string, arg2: string) => boolean;
type SortStringToNumberFn = (arg1: string, arg2: string) => number;
type SortNumberFn = (arg1: number, arg2: number) => number;
export interface TableColumn {
name: string;
label: string;
align?: string;
sortable?: boolean;
sort?: SortStringToBooleanFn | SortNumberFn | SortStringToNumberFn;
field: string | ((row: TableRow) => unknown) | unknown;
required?: boolean; // Use of `required` is important to avoid breaking QTable
format?: unknown;
}
export interface VirtualScrollCtx {
to: number;
ref: {
refresh: () => void;
};
}
We just added the interfaces required to sufficient type our column definition
file and some function calls for QTable
.
The Contact
interface defines the properties which each contact should have. This properties are used to define each object representing a contact in our mock data. Open the ui/src/data/Google_Contacts_Clone_Mock_Data.ts
and compare the properties with the Contact
interface. Some properties of the Contact
interface are optional e.g. company?: string | null | undefined;
. Since the property is optional, it is important to define its type as either a string
or null
or undefined
. Meaning that we could supply a null
value or omit that property completed when defining a Contact
or assign an actual string value and you won't get TypeScript compilation errors.
It is important to adequately type all objects, functions, classes, etc. which you introduce into your codebase. It might appear as though you are being slowed down initially. However, in the long run, it boosts your confidence while coding and drastically reduces mistakes. If you try typing the contents of the
ui/src/data/table-definitions/contacts.ts
file discussed below, you will see how TypeScript correctly suggests the properties of the objects.
The TableColumn
interface is used to define the types of the properties of each column for our Contacts table. Two properties of the TableColumn
are worthy of note and you will see how they are applied in the item 3 below. There are field
and format
properties. They have the following typings:
field: string | ((row: TableRow) => unknown) | unknown;
...
format?: unknown;
The field
property is a required field which could be assigned a string
directly or assigned a function. When you assign a string
to field
, QTable
will map the value of that column for each row directly without any modification. However, if you assign a function, you have the flexibility to determine is returned, and when combined with the format
field, you can manipulate your data before displaying.
The format
property is optional and it used to manipulate/format your data before displaying it. For example, if you have a Timestamp
column like createdAt
, you could use the format
property to manipulate the timestamp and make it display the time in the timezone of the user.
The sortable
property is used to determine which column can be sorted or not. We will look at sorting when we implemented server-side data.
Create the Column Definition File
Create and open ui/src/data/table-definitions/contacts.ts
file which will hold the column definitions for our Contacts table
. The column definition declares the properties of each column on our Contacts table
. It is absolutely required by the QTable
component. After create the contacts.ts
file, copy-and-paste all the content of this snapshot into it.
The file exports an array of TableColumn
s. TableColumn
is an interface imported from ui/src/types/index.ts
which was created above and defines the shape of each column in our column definition file, ui/src/data/table-definitions/contacts.ts
. The array looks like this:
const columns: Array<TableColumn> = [
{
name: "profilePicture",
label: "Profile Picture",
align: "center",
field: "profilePicture",
sortable: false,
},
{
name: "firstName",
align: "center",
label: "First Name",
field: "firstName",
sortable: true,
},
{
name: "surname",
align: "center",
label: "Surname",
field: "surname",
sortable: true,
},
{
name: "email1",
align: "center",
label: "Email 1",
field: "email1",
sortable: true,
},
{
name: "phoneNumber1",
align: "center",
label: "Phone Number 1",
field: "phoneNumber1",
sortable: true,
},
{
name: "jobTitleAndCompany",
align: "center",
label: "Job Title & Company",
field: (row: Contact) => row,
format: (row: Contact): string | null => {
if (!row) return null;
return row?.jobTitle && row?.company
? `${row.jobTitle} at ${row.company}`
: row?.jobTitle && !row?.company
? `${row.jobTitle}`
: !row?.jobTitle && row?.company
? `${row.company}`
: "";
},
sortable: false,
},
];
As you may have noticed, all columns but jobTitleAndCompany
have their field
property set to the name of the column. This means that for each object (row) in the mock data (ui/src/data/Google_Contacts_Clone_Mock_Data.ts
), the value of each column will be mapped directly to the corresponding property in the mock data without further manipulations. Since, there is no further manipulations, the format
property is not necessary for such columns.
However, for the jobTitleAndCompany
column, there is need for further manipulation of the data because we want to merge the company
and jobTitle
properties of the Contact
interface and get a composite column. jobTitleAndCompany
is not a natural property of the Contact
interface. To this effect, we introduce a function as the value of the field
property. The function will take the current Contact
row as its argument and return the same row. We are doing this to override the default behaviour of the field
property which returns the string
value defined in the data. The value returned by field
(the entire Contact
row) becomes the argument of the format
property. So, we define a function which takes the Contact
row and after some manipulations of the company
and jobTitle
properties of Contact
will return either a string
or null
. The value returned by the format
property is the final string which will be rendered for that column and row.
Next, we will make a brief detour to the ui/src/layouts/MainLayout.vue
file and remove the class
attribute attached to the q-layout
component as shown:
<template>
- <q-layout view="hHh Lpr lff" class="bg-grey-1">
+ <q-layout view="hHh Lpr lff">
...
</q-layout>
</template>
This removes the light grey background from the entire layout and gives the app a plain white background.
Creating the Contacts
table
Now, we will create the actual Contacts table
using the QTable
component from the Quasar framework.
Open ui/src/pages/Index.vue
file. If you recall, this is the component file responsible for rendering the home
page. Check the ui/src/router/routes.ts
file to re-confirm this. I will strong suggest that you type out all the entire changes in the ui/src/pages/Index.vue
file. Make reference to this snaphost for the Index.vue file.
Beginning from the template
section. Completely replace all the contents wrapped with <template>...</template>
. Your template section should look like this afterwards:
<template>
<q-page class="row justify-center justify-evenly">
<div class="q-px-md full-width">
<q-table
v-model:selected="selected"
:rows="rows"
:columns="columns"
:loading="loading"
row-key="id"
virtual-scroll
:virtual-scroll-item-size="48"
:virtual-scroll-sticky-size-start="48"
:pagination="pagination"
:rows-per-page-options="[0]"
binary-state-sort
@virtual-scroll="onScroll"
selection="multiple"
flat
class="sticky-table-header"
>
<template v-slot:top-row>
<q-tr>
<q-td colspan="100%"> Starred Contacts (xx) </q-td>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-checkbox v-model="props.selected" />
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-avatar v-if="col.name === 'profilePicture'">
<img src="https://cdn.quasar.dev/img/avatar.png" />
</q-avatar>
<span v-else>
{{ col.value }}
</span>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-page>
</template>
These are changes introduced:
- For the
q-page
component, the class "items-center" was changed to "justify-center". The former centers both vertically and horizontally (i.e. places everything at the dead-center of the page) while the latter only centers horizontally which is how a page is normally centered.
- <q-page class="row items-center justify-evenly">
+ <q-page class="row justify-center justify-evenly">
We introduced a
div
with a class offull-width
so that the table can stretched across the entire width of theq-page
. The classq-px-md
keeps a medium padding on the both sides of the page.Then, we introduced the
q-table
component. Theq-table
component has av-model
with an argument. This is specifically used for two-way binding of theselected
prop with theselected
ref at Line 62 (i.e.v-model:selected="selected"
). This means that theq-table
component as an internal prop namedselected
. That internal prop can be modified by syncing the value of theselected
array ref defined on Line 62. This is parent-to-child update. OurIndex.vue
component is the parent while theQTable
component is the child. You can see this hierarchy when you spec the component with the Vue devtool. On the other hand, theQTable
component can update its internalselected
prop and emit anupdate:select
event with the new value of theselected
prop as the event payload. The parent component (ourIndex.vue
) will pickup the event and update theselected
property at Line 88 with the payload from the event. This two-way sync is whatv-model:selected="selected" stands for. Read more about
v-modeland
v-model with arguments here`.Additionally,
q-table
takes the following props (for now, we could add more later). Read more about these props here.- The
column
prop accepts an array of objects which defines the properties of each column to be rendered in the table. We assign thecolumn
array import fromui/src/data/table-definitions/contacts.ts
. We discussed in the section,Create the Column Definition File
. - The
row
prop accepts an array of objects which defines all our contacts. We discussed this in the section,Create The Mock Data File
. - The
loading
prop accepts aboolean
value which indicates whether the table is in loading state or not. Theref
on Line 61 is assigned to it. - The
row-key
prop defines which property in our data object will be used as the unique key for each row of data. If you openui/src/data/Google_Contacts_Clone_Mock_Data.ts
, each row as a uniqueid
property. Therow-key
must be assigned to a property which is unique across the entire dataset. - The
virtual-scroll
prop is aboolean
prop which activates the use of virtual (infinite) scrolling within our table. - The
virtual-scroll-item-size
prop accept a value which indicates the minimum pixel height of a row to be rendered. If you inspect the height of each row of the table, you should set the value of this prop close to that pixel height. - The
virtual-scroll-sticky-size-start
prop accept a vaule which indicates the height of the sticky header, if present. - The
pagination
prop accepts an object which defines the settings for the table pagination. Note that, even though we are using virtual scrolling, there is pagination behind the screen as will be explain later. - The
rows-per-page-options
prop defines the value of the dropdown which will be used for selecting the number of rows per page. A single value of "0" in the array indicates the option for displaying all rows at once. This is important for virtual scrolling. - The
binary-state-sort
prop is aboolean
prop which indicates that we want to use only two states for sorting. That is, a column can either be sorteddescending
orascending
. Nothing in between. - The
selection
prop takes three options:single
,multiple
, ornone
. It is used to enable single or multiple selections or disable selection completely. - That
flat
prop is a boolean prop which indicates that the table should rendered with theflat
design (no shadows).
- The
A single class:
sticky-table-header
is attached to theq-table
component. The class enables the sticky header for the table. See thestyle
section at the end of the file.The
q-table
component currently has one event listener:@virtual-scroll
. Thevirtual-scroll
event is fired when extra rows are needed as scroll down the table. This event listener calls theonScroll
function which loads additional rows into ourrows
prop when extra rows are needed. The mechanism of theonScroll
will be discussed later.
This lesson is already too long. In the next lesson, we will continue the discussion from the script
section.