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

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

In this lesson, we will design the contact details page for viewing the details/properties of each contact. We will begin by adding a new route to our application and then adding an click event listener to the rows on our Contacts page so that when users click on any row/contact, the details of that contact will be opened.

Let's start by creating a new branch for our repo:

# Make sure you are within your project
git checkout -b 08-designing-the-contact-detail-page

The video below shows what we will achieve in this lesson:

Setup

Let's make some few changes to other files before we design the Contact Details page.

1. Improve ui/.eslintrc.js

Open ui/.eslintrc.js and add the line below. Refer to this snapshot.

    "@typescript-eslint/explicit-module-boundary-types": "off",
+    "@typescript-eslint/restrict-template-expressions": "off",

    // allow debugger during development only
    "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off",

2. Improve ui/src/router/routes.ts and Add New Route

Open ui/src/router/routes.ts and make the following changes. Refer to this snapshot.

    children: [
      {
        path: "",
        name: "home",
        component: () => import("pages/Index.vue"),
-        meta: { title: "Home" },
+        meta: { title: "Home", showDefaultTitle: false },
      },
      {
        path: "contacts/new",
        name: "new_contact",
        component: () => import("pages/contacts/CreateContact.vue"),
-        meta: { title: "New Contact" },
+        meta: { title: "New Contact", showDefaultTitle: true },
+      },
+      {
+        path: "contacts/:contactId/details",
+        name: "view_contact",
+        component: () => import("pages/contacts/ViewContact.vue"),
+        meta: { title: "View Contact", showDefaultTitle: false },
+        props: true,
      },
    ],

Here, we are perform two tasks:

  1. For the existing routes: home and new_contact, we add an extra properties to the meta property: showDefaultTitle. This property will be used to determine if the title toolbar above that page will be displayed or not. So, we set showDefaultTitle to false and true for the home and new_contact routes, respectively.

  2. We add a new route definition for our Contact Details page. The route is named view_contact and points at "pages/contacts/ViewContact.vue" as the route component. showDefaultTitle is set to false. Most importantly, we introduce a new route property called props. The props property is a standard Vue Router route property used to make all route parameters defined in the route path to be available in the route component as props. In this case, we have the path: "/contacts/:contactId/details". This path contains one route parameter: contactId. A route parameter is introduced into a route by appending it with a colon (:). Vue Router is match the path and extract the route paramaters (if more than one) from the path. When props is set to true, these route parameters will be made available within your route component (in this case, pages/contacts/ViewContact.vue) as part of the component props. You do not have to define these props set from Vue Router within the component. But defining them is a good practice for readability and type-checking.

    Let's dive into route paths

    So, for a URL like this: http://localhost:8008/#/contacts/309b20ac-bbcd-4268-b01f-43770527540d/details. Our actual route path is: /contacts/309b20ac-bbcd-4268-b01f-43770527540d/details. Within the route path for the view_contact route, we didn't add the leading (beginning) forward slash (/) because it was already defined as the path of the layout (layouts/MainLayout.vue) route. Since our view_contact route is a child of the layout route, Vue Router will append the leading /. Now, Vue Router will match the actual path and the route path. For example: contacts/309b20ac-bbcd-4268-b01f-43770527540d/details will be matched with contacts/:contactId/details. The route parameter contactId will be set to 309b20ac-bbcd-4268-b01f-43770527540d. Within pages/contacts/ViewContact.vue, the prop contactId will be programmatically assigned the value 309b20ac-bbcd-4268-b01f-43770527540d. We will make use of this prop and its value when we want to fetch the properties of the current contact being value within pages/contacts/ViewContact.vue. This will be discussed soon.

3. Improve ui/src/layouts/MainLayout.vue

Open ui/src/layouts/MainLayout.vue and change Line 224 as below. Here, we are making use of the meta.showDefaultTitle property of our route definition as the condition for displaying the default title toolbar above our pages. In this case, the title toolbar won't be displayed since the home route has the property: meta.showDefaultTitle equals false. Refer to this snapshot.

-        v-if="$route.name !== 'home'"
+        v-if="$route.meta.showDefaultTitle"

4. Add the click Event Listener to Contacts rows

Open ui/src/pages/Index.vue and add the following lines from Line 57. Refer to this snapshot. Here we are adding the click event listener to each table row (q-tr). We assign the Vue Router's push method as the event handler so that we can navigate to the view_contact route (i.e. the Contact Details page) for that specific row (props.row.id).

        <template #body="props">
          <q-tr
            :props="props"
            @mouseover="handleMouseEvents"
            @mouseleave="handleMouseEvents"
+            @click.stop.prevent="
+              $router.push({
+                name: 'view_contact',
+                params: { contactId: props.row.id },
+              })
+            "
          >

You will also notice that we assigned an object: { contactId: props.row.id } as the value of the params property of the $router.push's payload. The params object is used to specify all the parameters (and their values) of the route we want to navigate to. For our view_contact route, we have one parameter (contactId), so we assign props.row.id as the value of the contactId parameter. If the route we are navigating to does not have any route parameter, there is no need assigning the params property at all.

Contact Details Page Design

Refer to this snapshot as the content of our Contact details page.

Create and open the file: ui/src/pages/contacts/ViewContact.vue

# Ensure that you are in the root directory of your application
code ui/src/pages/contacts/ViewContact.vue 
# Opens the `ViewContact.vue` file with VS code
# CTRL+S to save for the first time

Copy the entire content of the snapshot into ui/src/pages/contacts/ViewContact.vue.

Now, let's discuss what's going on within the file. Beginning from the script section.

The script section

From Line 202, we define our props and assign contactId as our only prop for this component. This prop will be automatically injected by Vue router as discussed in this section. However, we still define it for the purpose of readability and type-checking by TypeScript. If we do not explicitly define it, TypeScript will be unable of the props.contactId object within our setup function. We use the imported PropType type from vue to properly cast the type of the prop to TypeScript's string type. Read more about type annotations for props.

  props: {
    contactId: {
      type: String as PropType<string>,
      required: true,
      default: "",
    },
  },

At Line 209, we declare and initialise our contact variable. The contact variable will hold the contact to be displayed on the page. We assign properties to the object because of TypeScript type-checking.

let contact: Contact | null = reactive({
      id: "",
      firstName: "",
      surname: "",
      email1: "",
      phoneNumber1: "",
    });

At Line 217, we call the watchEffect hook (imported from vue). The watchEffect hook is used to execute statements when the setup function is called during the rendering of the application. Read more about Vue's watchEffect here. The watchEffect hook takes a handler function as the first parameter. Within the handler, we filter the contacts array imported from /ui/src/data/Google_Contacts_Clone_Mock_Data to obtain the current contact being viewed. We make use of the prop (contactId) which is automatically set by Vue Router as discussed earlier in this section. We return the result of the filtering operation into fetchedContact. fetchedContact is an array. On Line 221, we destructure fetchedContactObject from the fetchedContact array. fetchedContactObject contains the object for our contact. We assign that object to our contact variable.

    const stopContactsEffect = watchEffect(() => {
      const fetchedContact = contacts.filter(
        (cont) => cont.id === props.contactId
      );
      const [fetchedContactObject] = fetchedContact;
      contact = fetchedContactObject;
    });

The watchEffect hook returns a function which is store in the stopContactsEffect constant. On Line 319, we call that function to stop the watchEffect hook before the component unmounts.

    onBeforeUnmount(() => {
      void stopContactsEffect();
    });

Though Vue will automatically stop the watchEffect for you, I demonstrated it here because it best practice to explicitly stop your watchEffects to prevent memory leaks in your application.

At Line 225, we compute our contact's full name from the firstName and lastName properties.

    const fullName = computed(
      () => `${contact?.firstName} ${contact?.surname}`
    );

At Line 229, we compute the job description of the contact from the jobTitle and company properties. We use of the String.trim() method ensures that any trailing or leading spaces are removed.

    const jobDescription = computed(() =>
      `${contact?.jobTitle ?? ""}${contact?.jobTitle ? " at " : ""}${
        contact?.company ?? ""
      }`.trim()
    );

At Line 235, we compute the contactData by returning an array containing new properties which will be used for rendering the contact on the page. The array is should contain the following properties:

{
        icon: string;
        text: string | undefined | null | Array<string | null | undefined>;
        label: string;
        key: string;
        side?: string | undefined;
        sideColor?: string | undefined;
        clampLines?: number | "none";
        linkAs?: "email" | "tel" | "website";
}

It is important to note that the text property can be a string or an array of strings. The array of strings is used when specifying the object for address. Within the template section, you will see how we check for this array and modify how the labels are rendered with an array is detected.

At Line 323, we return contact, fullName, contactData, jobDescription, and isNullArray to the template section.

return { contact, fullName, contactData, jobDescription, isNullArray };

The template section

The Contact view page is designed to be responsive. Save the ui/src/pages/contacts/ViewContact.vue file. Take some time to study the template section.

Ensure that your dev server is running:

# Ensure that you are in root directory
cd ui
yarn serve
# Allow frontend to be compiled and served

When the UI opens in your broswer, click any row on the table to open the Contact details page showing the details of the contact.

Press F12 on your keyboard the dev tools. Switch to the responsive mode and resize the window. You will see how the page adapts to different screen sizes.

Let's continue.

At Line 6, we introduce a q-toolbar at the top part of our page. The toolbar contains a back button on the left side and two other buttons on the right side. At Line 12, we add a click event listener to the back button so that we can go back to the previous view easily. The event listener called $router.go(-1) which simulates going back one step in our history. Read more that $router.go() here.

At Line 41, we interpolate the fullName of the contact. At Line 50, we interpolate the jobDescription of the contact. At Line 54, we use v-for to loop over the number 3 and display three buttons which mimics the groups of the contact. These groups will be implemented when we have a backend server for the application.

At Line 71, we have toolbar which contains three buttons for starring, showing more options, and editing a contact. These buttons will be implemented when we have the backend server for the app. The edit button will be implemented soon.

At Line 78, we introduce another row which wraps our Contact Details list. The list starts at Line 80 and makes use of Quasar's q-list component. We use a v-for directive to loop over our contactData array. At Line 87, we check that item.text exist or item.text is a string or item.text is an array. The v-ripple directive on q-item give each list item a click event capability and ripple effect when clicked.

At Line 98, we render the icon for the list item with :name="item.icon"

At Line 104, we render the block if item.text is an array:

                <q-item-section v-if="Array.isArray(item.text)">
                  <q-item-label class="text-caption" caption>{{
                    item.label
                  }}</q-item-label>
                  <q-item-label
                    v-for="(line, index) in item.text.filter((l) => l)"
                    :key="'item_line_' + index"
                    :lines="
                      line?.clampLines && line?.clampLines !== 'none'
                        ? line.clampLines
                        : line?.clampLines && line?.clampLines === 'none'
                        ? undefined
                        : 1
                    "
                    >{{ line }}</q-item-label
                  >
                </q-item-section>

The caption for the item is renders with the first q-item-label component by interpolating item.label. Then we loop through item.text to render the second q-item-label. Before looping, we filter out any falsy value from the item.text array within the v-for directive: item.text.filter((l) => l). The lines prop on the second q-item-label is used to display ellipsis if there is no sufficient space to display all the text for the label. Read more that it here. If line.clampLines is set and it is not equal to none, we make use of that value. If line.clampLines is set and the value is none, we return undefined which mimics the default behaviour when the lines prop is not set at all. Else, we use 1 as the value of the lines prop.

At Line 121, we check if the item.text is a string and render the block if true. This one is simpler but similar to the rendering when item.text is an array as described above.

At Line 137, we render the side q-item-section which shows extra information for our items. It is used to display a badge (q-badge) when item.side and item.sideColor is set. It is also used to display links when item.linkAs is set.

Lastly, there is a sticky button containing an edit button which displays in responsive mode when the window is not greater than the sm breakpoint. See Line 175.

This concludes our discussions for this lesson.

Save all your files, commit and merge with the master branch.

git add .
git commit -m "feat(ui): complete design of the contact details page"
git push origin 08-designing-the-contact-detail-page
git checkout master
git merge master 08-designing-the-contact-detail-page
git push origin master

In the next lesson, we will create the Contact edit page/form.