Uploading Files and Creating Avatars for Contacts With AdonisJS and Axios

Uploading Files and Creating Avatars for Contacts With AdonisJS and Axios

In this lesson, we will learn how to upload images for the purpose of creating avatars for contacts in our Google Contacts Clone App. We will make use of the QFile component from the Quasar Framework, the Axios HTTP library, and AdonisJS' Attachment Lite addon to upload images from the frontend and save them to the API server's local storage while also persisting metadata about the images to the database.

Let's create a new branch of our project:

# Make sure you are within your project
git checkout -b 20-add-avatars-to-contacts

Overview of the File Upload Process

Uploading a file is a very simple operation. Some frameworks make it look complicated but the AdonisJS Framework makes it very simple. You just need to understand the differences in the payload and HTTP request settings when a file is involved. The AdonisJS Framework goes further to make the file upload process even simpler and fun by provided file handling out-of-the-box. You do not need to install third-party addon and stitch them together with glue code. AdonisJS comes preinstalled with the BodyParser module which handles file uploads exceptionally. The validator module which we've used already in the series is also preinstalled and handles validation for free without installing third-party validation libraries. The AdonisJS attachment-lite addon (which we have to install) will handle the entire lifecycle of an uploaded file automatically when used.

With AdonisJS, you can still upload file very easily without the atttachment-lite addon. But we will use it to make things extremely simple and fun.

Earlier in the CreateContact.vue component, we composed the submitPayload and dispatched the payload via the contacts/CREATE_CONTACT action when we want to create or edit a contact. The axios HTTP library will transform the JavaScript object into JSON before making the API request. The JSON payload is just a bunch of keys with string/number/array/object/null values. It is impossible to add a File object to a JSON payload. Hence, our normal workflow won't work when we want to upload a file.

So how do we package a file or files for upload?

  1. We must use a FormData object created with the FormData class. The FormData class creates a multipart object with keys and values which might look like a JSON object on the surface. However, a FormData object allows us to add a File object as a value to any key unlike JSON object. The FormData will be dispatched as the payload of the POST or PUT request in our app.

  2. Set the header - Content-Type": "multipart/form-data - in the config object of the axios request.

Once we do the above, AdonisJS will automatically parse the files contained in the FormData object, validate them, and make them available in the Controller for consumption.

This lesson will take you through this process in very practical details.

Upgrades and Installations

If you are following along at the time this article was published, you might need to upgrade the dependencies for the API server. If you are following quite later, this upgrade step won't be necessary.

Stop the API server by pressing CTRL+C in the terminal instance where the API server is running. Then, run the following:

# Make sure you are in the `api` directory
yarn add @adonisjs/core@latest @adonisjs/ally@latest @adonisjs/view@latest @adonisjs/repl@latest @adonisjs/lucid@latest @adonisjs/auth@latest

Next, we will install the @adonisjs/attachment-lite addon which provides seamless image management for our API server. The @adonisjs/attachment-lite addon can save images for a particular model property, persist JSON metadata of the images corresponding columns in the database, handle updates of the images, automatically delete old images when a new one is uploaded or if the entire model is deleted. Read more about the addon here.

Install and configure the @adonisjs/attachment-lite addon.

# Make sure you are in the `api` directory
yarn add @adonisjs/attachment-lite

# Configure the addon
node ace configure @adonisjs/attachment-lite

You can now run the API server again.

Adding the profile_picture Column to the contacts Table

Here, we will create a new migration file, add the profile_picture column in the migration file, and run the migration. Run the command below to create the migration file.

# Make sure you are in the `api` directory
node ace make:migration add_profile_picture_column_to_contacts --table=contacts

The above command will create a file: api/database/migrations/xxxxxxxxxxxx_add_profile_picture_column_to_contacts .ts. Open the file. Refer to this snapshot of the file. Copy-and-paste the content of the snapshot into the api/database/migrations/xxxxxxxxxxxx_add_profile_picture_column_to_contacts .ts file.

import BaseSchema from '@ioc:Adonis/Lucid/Schema'

export default class Contacts extends BaseSchema {
  protected tableName = 'contacts'

  public async up() {
    this.schema.alterTable(this.tableName, (table) => {
      table.json('profile_picture').after('notes')
    })
  }

  public async down() {
    this.schema.alterTable(this.tableName, (table) => {
      table.dropColumn('profile_picture')
    })
  }
}

Basically, we are creating a new column with a JSON data type with the function call table.json('profile_picture').after('notes'). The column will be inserted after the notes column so that the timestamp columns are still at the end. If we rollback the migration, the column will be dropped as per the call - table.dropColumn('profile_picture') - in the down method.

We chose to use a JSON data type because the metadata of the images will be persisted to the database in JSON format as shown below:

{
    "url": "/uploads/avatars/ckvr0jw3j0001iwvo3c6x0kob.jpg",
    "name": "avatars/ckvr0jw3j0001iwvo3c6x0kob.jpg",
    "extname": "jpg",
    "size": 217546,
    "mimeType": "image/jpeg"
}

Adding profilePicture Property to the Contact Model

Open the api/app/Models/Contact.ts file. Refer to this snapshot of the file. Copy-and-paste the content of the snapshot into the api/app/Models/Contact.ts file.

At Line 4, we import the attachment decorator and AttachmentContract interface from '@ioc:Adonis/Addons/AttachmentLite' package.

From Line 63 to 69, we define the profilePicture property. However, instead of decorating it with the regular column decorator, we decorate the property with the attachment decorator. This notifies the attachment-lite addon that the column should be treated as an attachment column. The attachment-lite addon will then management the entire lifecycle (creation, update, retrieval, and deletion) of the profilePicture property of the Contact model.

+  @attachment({
+    disk: 'local',
+    folder: 'avatars',
+    preComputeUrl: true,
+    serializeAs: 'profilePicture',
+  })
+  public profilePicture?: AttachmentContract | null

We provide an option object containing disk, folder, preComputeUrl, and serializeAs properties.

  • The disk property specifies where the images will be stored. We choose local because we want the images to be saved to the local disk of the API server. If you have the AdonisJS Drive module installed, you could choose to use s3 or gcs as storage disk.

  • When using the local disk option, the folder property specifies where in the tmp/uploads directory the images will be stored. We specify avatars so the uploaded images will be saved to tmp/uploads/avatars directory.

  • The preComputeUrl option specifies if the attachment-lite module should autogenerate URLs of the profilePicture images when a contact is fetched individually or listed (like during a pagination). This option is very importance as it simplifies fetching of the image URLs from the database.

  • The serializeAs option is used to tell AdonisJS how to serialise the column. This has been discussed in great details in a previous lesson .

Improving the ContactValidator File

Here we will improve the ContactValidator file so that the uploaded file is adequately validated.

Open the api/app/Validators/ContactValidator.ts file. Refer to this snapshot of the file. Copy-and-paste the content of the snapshot into the api/app/Validators/ContactValidator.ts file.

At Lines 16 and 25, we remove the escape option from the email fields. Since we are enforcing email validation, there is no need to escape the email strings.

At Line 28, we increase the value of maxLength rule to 25.

From Line 48 to 51, we add the profilePicture property to validate the uploaded file. The size of the file is limted to a maximum of 500kb while the acceptable file types are: jpg, png, webp, and gif.

At Lines 69, 71, and 72, we add validation messages for the profilePicture and birthday properties.

Improving the ContactsController File

Here, we will improve the store and update methods of the ContactsController class so that the profilePicture can be saved and updated.

Open the api/app/Controllers/Http/ContactsController.ts file. Refer to this snapshot of the file. Copy-and-paste the content of the snapshot into the api/app/Controllers/Http/ContactsController.ts file.

At Line 5, we import the Attachment class from the @ioc:Adonis/Addons/AttachmentLitepackage.

At Line 13, we add profile_picture to the list of columns to be selected in the index method.

At Line 71, within the store method we destructure profilePicture from the validated payload. Same is done at Line 148 within the updated method.

At Line 92, we add profilePicture to the object parameter for the Contact.create static method. If profilePicture exists, the profilePicture property is assigned the result of the Attachment.fromFile() static method.

      const contact = await Contact.create({
         firstName,
         ...
         website,
         notes,
+        profilePicture: profilePicture ? Attachment.fromFile(profilePicture) : null,
      })

Same thing is done at Line 169 within the update method.

Let's return to the frontend.

Improve the Frontend Types

Open the ui/src/types/index.ts file. Refer to this snapshot of the file. Copy-and-paste the content of the snapshot into the ui/src/types/index.ts file.

From Lines 7 to 15, we improve the FormItem interface. We add File to the options for the value property so that File input can be accepted. We also add file to the options for the inputType property so that we can use the QFile component.

-  value: string | number | null | undefined;
-  inputType?: "text" | "number" | "date" | "email" | "url" | "textarea";
+  value: string | number | File | null | undefined;
+  inputType?:
+    | "text"
+    | "number"
+    | "date"
+    | "email"
+    | "url"
+    | "textarea"
+    | "file";

From Lines 62 to 87, we add the EditedContactInterface to hold the structure of the JSON returned from the ContactsController.show method.

export interface EditedContactInterface {
  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;
  profilePicture?: {
    extname: string;
    mimeType: string;
    name: string;
    url: string;
  };
}

Improve the Store Index File

Open the ui/src/store/contacts/index.ts file. Refer to this snapshot of the file. Copy-and-paste the content of the snapshot into the ui/src/store/contacts/index.ts file.

In this file, we will only rename all occurrences of exampleModule to contactsModule

Adding File Upload Functionality to the CreateContact Component

Here, we will improve the CreateContact.vue component so that it provides a field for selecting the file which will be uploaded when a contact is being created or edited. We will also update the functionality of the component so that the selected file is packed together with the rest of the fields and dispatched for upload to the API server.

Open the ui/src/pages/contacts/CreateContact.vue file. Refer to this snapshot of the file. Copy-and-paste the content of the snapshot into the ui/src/pages/contacts/CreateContact.vue file.

Let's discuss the new functionalities.

Before, we used the v-for directive on the QInput component to iterate over the form reactive object and render a QInput component for each entry in the form object. Now, we want to introduce the QFile component to handle file selection for the avatar. Hence, instead of using v-for directly on the QInput component, we will wrap the QInput component with a template wrapper (see Line 5) and also introduce the QFile component inside the template wrapper (see Line 41). The QInput component will only render when form[key].inputType is strictly not equal to file. Else, the QFile component will be rendered.

<template
          v-for="({ label, icon, inputType, autocomplete }, key) in form"
        >
          <q-input
            v-if="inputType !== 'file'"
            :key="key + '_input'"
            v-model="form[key].value"
            :for="`${key}_${inputType || 'text'}_input`"
            bottom-slots
            :label="label"
            :dense="dense"
            :class="!icon && 'q-pl-lg'"
            :type="inputType || 'text'"
            :autogrow="inputType === 'textarea'"
            :autofocus="key === 'firstName'"
            :aria-autocomplete="autocomplete"
            :autocomplete="autocomplete"
            :error="v$?.[key]?.$error"
            :error-message="
              v$?.[key]?.$errors?.map((error) => error.$message).join('\n')
            "
          >
            <template #before>
              <q-icon v-if="icon" :name="icon" />
            </template>
            <template #after>
              <q-icon
                v-if="form[key].value"
                name="close"
                class="cursor-pointer"
                @click="form[key].value = ''"
              />
            </template>
          </q-input>
          <q-file
            v-else
            :key="key + '_file'"
            v-model="form[key].value"
            :for="`${key}_${inputType || 'text'}_input`"
            bottom-slots
            :label="label"
            :dense="dense"
            :class="!icon && 'q-pl-lg'"
            accept=".jpg, .png, .webp, .gif"
            :max-file-size="maxFileSize"
            @rejected="onRejectProfilePicture"
          >
            <template #before>
              <q-icon v-if="icon" :name="icon" />
            </template>
            <template #after>
              <q-icon
                v-if="form[key].value"
                name="close"
                class="cursor-pointer"
                @click.stop.prevent="form[key].value = null"
              />
            </template>
          </q-file>
        </template>

The QFile component accepts two additional props: accept and maxFileSize; and emits a rejected event which we listen to and handle with the onRejectProfilePicture function. The accept prop takes a string of comma-separated file extensions. While the maxFileSize prop accepts the maximum allowable file size in bytes.

Within the QFile component we consume two slots: before and after. The before slot is used to render the QIcon component which shows the attachment icon on the left side of the QFile component. While the after slot is used to render the QIcon component with a click event listener for cancelling (nulling) the selected file. You may have noticed that the after slot will only be rendered if a file is selected. It makes sense this way.

In the setup hook, at Line 127, we define the maxFileSize constant and set the value to 500 * 1024 bytes which equals 500kb.

From Line 130 to 136, we add the profilePicture property to the form reactive object so that the QFile component will render on top of the form.

    const form: FormInterface = reactive({
+      profilePicture: {
+        label: "Profile Picture",
+        required: false,
+        value: null,
+        icon: "attach_file",
+        inputType: "file",
+      },
      firstName: {
        label: "First Name",
        required: true,
        ....
     }
})

At Line 327, we avoid initialising the profilePicture value in the form object when the form is being populated in edit mode.

-                if (["id", "createdAt", "updatedAt"].includes(key) === false) {
+                if (
+                  ["id", "createdAt", "updatedAt", "profilePicture"].includes(
+                    key
+                  ) === false
+                ) {
                  form[key].value = currentContact.value[key];
                }
              });

From Line 341 to 379, we modify the submitPayload computed ref to create and return a FormData object. As discussed earlier, we have to use a FormData to upload the file and other form fields. Line 342, creates an instance of the FormData class. In the rest of the lines, we append the values of the form fields to corresponding keys in the formData object. We also ensure that an empty string is returned if any of the value is falsy. At Line 374, we append the file to the key profilePicture. The FormData will be used during creation or edit of a contact.

Note that you do not need to do any extra work in the Validation so that the FormData can be parsed. The BodyParser module inbuilt in AdonisJS handles all that magically.

-    const submitPayload = computed(() => ({
-      birthday: form.birthday.value,
-      city: form.city.value,
-      company: form.company.value,
-      country: form.country.value,
-      email1: form.email1.value,
-      email2: form.email2.value,
-      firstName: form.firstName.value,
-      jobTitle: form.jobTitle.value,
-      notes: form.notes.value,
-      phoneNumber1: form.phoneNumber1.value,
-      phoneNumber2: form.phoneNumber2.value,
-      postCode: form.postCode.value,
-      state: form.state.value,
-      streetAddressLine1: form.streetAddressLine1.value,
-      streetAddressLine2: form.streetAddressLine2.value,
-      surname: form.surname.value,
-      website: form.website.value,
-    }));
+    const submitPayload = computed(() => {
+      const formData = new FormData();
+      formData.append("birthday", (form.birthday.value as string) ?? "");
+      formData.append("city", (form.city.value as string) ?? "");
+      formData.append("company", (form.company.value as string) ?? "");
+      formData.append("country", (form.country.value as string) ?? "");
+      formData.append("email1", (form.email1.value as string) ?? "");
+      formData.append("email2", (form.email2.value as string) ?? "");
+      formData.append("firstName", (form.firstName.value as string) ?? "");
+      formData.append("jobTitle", (form.jobTitle.value as string) ?? "");
+      formData.append("notes", (form.notes.value as string) ?? "");
+      formData.append(
+        "phoneNumber1",
+        (form.phoneNumber1.value as string) ?? ""
+      );
+      formData.append(
+        "phoneNumber2",
+        (form.phoneNumber2.value as string) ?? ""
+      );
+      formData.append("postCode", (form.postCode.value as string) ?? "");
+      formData.append("state", (form.state.value as string) ?? "");
+      formData.append(
+        "streetAddressLine1",
+        (form.streetAddressLine1.value as string) ?? ""
+      );
+      formData.append(
+        "streetAddressLine2",
+        (form.streetAddressLine2.value as string) ?? ""
+      );
+      formData.append("surname", (form.surname.value as string) ?? "");
+      formData.append("website", (form.website.value as string) ?? "");
+      formData.append(
+        "profilePicture",
+        (form.profilePicture.value as File) ?? ""
+      );
+      return formData;
+    });

At Line 414, we create the onRejectProfilePicture for handling the @rejected event on the QFile component. The function collects error messages and uses the Quasar Notify plugin to alert the user when the selected file is uploaded.

    const onRejectProfilePicture = function (
      validationError: Array<{
        failedPropValidation: "accept" | "max-file-size";
        file: File;
      }>
    ) {
      const messages: string[] = [];
      if (validationError && validationError.length) {
        validationError.forEach((error) => {
          if (error.failedPropValidation === "max-file-size")
            messages.push("Maximum file size is: 500 kb");
          if (error.failedPropValidation === "accept")
            messages.push("The provided file type is now allowed.");
        });
        if (messages && messages.length) {
          messages.forEach((message) => {
            $q.notify({
              message,
              type: "negative",
            });
          });
        }
      }
    };

Lastly for the CreateContact component, at Lines 449 and 450, we return the onRejectProfilePicture and maxFileSize to the template section.

Improving the CREATE_CONTACT Action of the contacts Module

We need to improve the CREATE_CONTACT action to allow us successfully upload a the FormData being sent from the CreateContact.vue component during edit or creation mode.

Open the ui/src/store/contacts/actions.ts file. Refer to this snapshot of the file. Copy-and-paste the content of the snapshot into the ui/src/store/contacts/actions.ts file.

At Line 58, we update the type of the payload to FormData

At Line 66 to 68, we add the header Content-Type to the headers property of the POST request config.

        await api
-          .post("/contacts", payload)
+          .post("/contacts", payload, {
+            headers: {
+              "Content-Type": "multipart/form-data",
+            },
+          })
          .then((response: HttpResponse) => {
            const newContactId = response.data.data as Contact["id"];
            return resolve(newContactId);
          })
          .catch((error) => reject(error));

We repeat same for the PUT request.

        await api
-          .put(`/contacts/${contactId}`, payload)
+          .put(`/contacts/${contactId}`, payload, {
+            headers: {
+              "Content-Type": "multipart/form-data",
+            },
+          })
          .then((response: HttpResponse) => {
            const editContactId = response.data.data as Contact["id"];
            return resolve(editContactId);
          })
          .catch((error) => reject(error));

By setting the Content-Type header to multipart/form-data, we are informing the API server to read the data uploaded as a multipart/form-data not a JSON object. This must be done because by default, the Content-Type is set or assumed to be application/json.

Now save all files and serve both the fronend and API server.

# Serve the frontend
cd ui
yarn serve

# Split the terminal and serve the backend
cd api
yarn serve

Visit the Contact Creation page, select a file, fill and submit the form, the new contact should be created, and you will be redirected to the View Contact page. You can inspect the Network tab of devtools to see the profilePicture property of the contact data sent from the API server. The profilePicture property will look like this:

{
    "url": "/uploads/avatars/ckvr0jw3j0001iwvo3c6x0kob.jpg",
    "name": "avatars/ckvr0jw3j0001iwvo3c6x0kob.jpg",
    "extname": "jpg",
    "size": 217546,
    "mimeType": "image/jpeg"
}

image.png

If everything went well, congratulations!! You have successfully uploaded an avatar and associated it with the profilePicture property of the Contact model.

Amidst this success, we still cannot see the profile picture in our View Contact page. Let's fix this.

Displaying the Avatar (Profile Picture) in the View Contact Page

In this section, we will edit the ViewContact component so that the uploaded avatar is displayed instead of the placeholder image.

Open the ui/src/pages/contacts/ViewContact.vue file. Refer to this snapshot of the file. Copy-and-paste the content of the snapshot into the ui/src/pages/contacts/ViewContact.vue file.

Let's start from the script section.

At Line 230, we import the EditContactInterface from the types/index.ts file.

+ import { EditedContactInterface } from "../../types";

At Line 246, we change the type casting within the currentContact computed ref from Contact to EditedContactInterface.

At Line 264, we introduce the profilePicture computed ref which computed the URL of the avatar using the value of store.getters.getRootURL.

    const profilePicture = computed(() => {
      const rootURL = computed(() => store.getters.getRootURL);
      return currentContact.value?.profilePicture
        ? `${rootURL.value}${currentContact.value.profilePicture.url}`
        : "";
    });

In the template section, we update the QAvatar component.

              <q-avatar size="200px"
-                ><img src="https://cdn.quasar.dev/img/avatar.png"
+                ><img
+                  :src="
+                    profilePicture
+                      ? profilePicture
+                      : 'https://cdn.quasar.dev/img/avatar.png'
+                  "
              /></q-avatar>

Now save the file and refresh the Contact View page, you will see the uploaded avatar instead of the placeholder image. As shown below:

image.png

Still, on the homepage, the placeholder avatars are still displayed on the contacts table. Let's fix this.

Display Uploaded Avatars on the Contacts Table

Open the ui/src/pages/Index.vue file. Refer to this snapshot of the file. Copy-and-paste the content of the snapshot into the ui/src/pages/Index.vue file.

Beginning from the script section.

At Line 143, we import the EditedContactInterface

- import { Contact, PaginatedData, VirtualScrollCtx } from "../types";
+ import {
+   Contact,
+   EditedContactInterface,
+   PaginatedData,
+   VirtualScrollCtx,
+ } from "../types";

At Line 162, we create the rootURL computed ref.

+ const rootURL = computed(() => store.getters.getRootURL);

At Line 265, we return the formatProfilePicture function. This function is called in the template section and used to dynamically generate the avatars.

    return {
      selected,
      ...
      isTouchEnabled,
      handleAvatarClick,
+      formatProfilePicture: (
+        profilePicture: EditedContactInterface["profilePicture"]
+      ): string =>
+        profilePicture ? `${rootURL.value}${profilePicture.url}` : "",
    };

In the template section, at Line 73, we update the QAvatar component to display the avatar.

                <q-avatar>
                  <img
-                    src="https://cdn.quasar.dev/img/avatar.png"
+                    :src="
+                      props.row.profilePicture
+                        ? formatProfilePicture(props.row.profilePicture)
+                        : 'https://cdn.quasar.dev/img/avatar.png'
+                    "
                    @click.stop.prevent="handleAvatarClick(props)"
                  />
                </q-avatar>

At Line 75, the formatProfilePicture function is called to obtain the avatar URL: formatProfilePicture(props.row.profilePicture). The Props object is exposed by the body slot at Line 52. It contains several objects including row which contains the data to be rendered for the current row per column. The profilePicture object contains the avatar metadata including the URL of the avatar.

Now, click on Contacts on the sidebar. Edit the first contact on the table. Upload an image and updated the contact. Go back to the Contacts table and refresh the window. The new avatar will be displayed now.

image.png

If you have gotten to this stage and everything is working well, congratulations!!

That is the end of the lesson. Save all files and commit the changes.

git add .
git commit -m "feat: implement contact avatars at the frontend and backend"
git push origin 20-add-avatars-to-contacts
git checkout master
git merge master 20-add-avatars-to-contacts
git push origin master