Uploading Files and Creating Avatars for Contacts With AdonisJS and Axios
Table of contents
- Overview of the File Upload Process
- Upgrades and Installations
- Adding the profile_picture Column to the contacts Table
- Adding profilePicture Property to the Contact Model
- Improving the ContactValidator File
- Improving the ContactsController File
- Improve the Frontend Types
- Improve the Store Index File
- Adding File Upload Functionality to the CreateContact Component
- Improving the CREATE_CONTACT Action of the contacts Module
- Displaying the Avatar (Profile Picture) in the View Contact Page
- Display Uploaded Avatars on the Contacts Table
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?
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 aFile
object as a value to any key unlike JSON object. TheFormData
will be dispatched as the payload of thePOST
orPUT
request in our app.Set the
header
-Content-Type": "multipart/form-data
- in the config object of theaxios
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 chooselocal
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 uses3
orgcs
as storage disk.When using the
local
disk option, thefolder
property specifies where in thetmp/uploads
directory the images will be stored. We specifyavatars
so the uploaded images will be saved totmp/uploads/avatars
directory.The
preComputeUrl
option specifies if theattachment-lite
module should autogenerate URLs of theprofilePicture
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/AttachmentLite
package.
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"
}
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:
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.
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