Designing the Contacts Table | Full-Stack Google Contacts Clone with Adonis.js/Node.js and Quasar (Vue.js)

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

  1. Let's fix some inconsistencies between Prettier and Eslint by install the eslint-config-prettier extension for eslint

    # 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
    
  2. Copy-and-paste the entire content of this .eslintrc.js file into the ui/.eslintrc.js file.

  3. Create a .gitattributes in the root of your project. Copy-and-paste the following in the created .gitattributes file. This will fix any issues with git 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.

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 TableColumns. 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:

  1. 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">
  1. We introduced a div with a class of full-width so that the table can stretched across the entire width of the q-page. The class q-px-md keeps a medium padding on the both sides of the page.

  2. Then, we introduced the q-table component. The q-table component has a v-model with an argument. This is specifically used for two-way binding of the selected prop with the selected ref at Line 62 (i.e. v-model:selected="selected"). This means that the q-table component as an internal prop named selected. That internal prop can be modified by syncing the value of the selected array ref defined on Line 62. This is parent-to-child update. Our Index.vue component is the parent while the QTable component is the child. You can see this hierarchy when you spec the component with the Vue devtool. On the other hand, the QTable component can update its internal selected prop and emit an update:select event with the new value of the selected prop as the event payload. The parent component (our Index.vue) will pickup the event and update the selected property at Line 88 with the payload from the event. This two-way sync is what v-model:selected="selected" stands for. Read more aboutv-modelandv-model with arguments here`.

  3. Additionally, q-table takes the following props (for now, we could add more later). Read more about these props here.

    1. The column prop accepts an array of objects which defines the properties of each column to be rendered in the table. We assign the column array import from ui/src/data/table-definitions/contacts.ts. We discussed in the section, Create the Column Definition File.
    2. The row prop accepts an array of objects which defines all our contacts. We discussed this in the section, Create The Mock Data File.
    3. The loading prop accepts a boolean value which indicates whether the table is in loading state or not. The ref on Line 61 is assigned to it.
    4. 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 open ui/src/data/Google_Contacts_Clone_Mock_Data.ts, each row as a unique id property. The row-key must be assigned to a property which is unique across the entire dataset.
    5. The virtual-scroll prop is a boolean prop which activates the use of virtual (infinite) scrolling within our table.
    6. 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.
    7. The virtual-scroll-sticky-size-start prop accept a vaule which indicates the height of the sticky header, if present.
    8. 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.
    9. 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.
    10. The binary-state-sort prop is a boolean prop which indicates that we want to use only two states for sorting. That is, a column can either be sorted descending or ascending. Nothing in between.
    11. The selection prop takes three options: single, multiple, or none. It is used to enable single or multiple selections or disable selection completely.
    12. That flat prop is a boolean prop which indicates that the table should rendered with the flat design (no shadows).
  4. A single class: sticky-table-header is attached to the q-table component. The class enables the sticky header for the table. See the style section at the end of the file.

  5. The q-table component currently has one event listener: @virtual-scroll. The virtual-scroll event is fired when extra rows are needed as scroll down the table. This event listener calls the onScroll function which loads additional rows into our rows prop when extra rows are needed. The mechanism of the onScroll will be discussed later.

This lesson is already too long. In the next lesson, we will continue the discussion from the script section.