Week 10 - Uploading and Rendering Files - CI256

Week 10 - Uploading and Rendering Files

Refresher: REST APIs

REST APIs are the most common API type, and comprised of many url paths, or “endpoints”.

They are typically structured so that their URLs represent a hierarchy from left to right.

  • GET /contacts/{contact_id}/profile - Get a specific user’s profile image
  • PUT /contacts/{contact_id}/profile - Get a specific user’s profile image

The PUT HTTP method creates a new resource or replaces a representation of the target resource with the request content.

The difference between PUT and POST is that PUT is idempotent: calling it once is no different from calling it several times successively (there are no side effects).

Refresher: Fetch API Options

The fetch function provided by javascript accepts two arguments - the URL, and an object containing any options to customize the request.

const response = await fetch("https://example.org/post", options);

The options object can contain accept anything defined in the RequestInit docs. The options we are focused on are:

  • headers: Used to “describe” the request
  • method: fetch performs GET requests by default. To perform other types of requests -POST/DELETE/etc - we pass the method we want as a string.
  • body: The body of the request, usually containing data, for POST/PUT/PATCH requests

Uploading a File with fetch

To upload a file with fetch, we can use the special FormData object to let the browser handle the encoding for us. This will include the filename and other metadata automatically.

First, we use a file input to get access to a special File object.

export default function FileUpload() {
    function onChange(e) {
        console.log('uploaded files', e.target.files, e.target.value)
		// output: 
        //   text               e.target.files              e.target.value
        // vvvvvvvvvvvvvv vvvvvvvvvvvvvvvvvvvvvvvvvvvvv vvvvvvvvvvvvvvvvvvvvvv
		// uploaded files FileList {0: File, length: 1} C:\fakepath\MASCOT.png
    }

    return <Input onChange={onChange} type="file"> /</Input>
}

A few things to note here:

  • We must pass type="file" to an input field to make a file upload
  • e.target.files is always a list, although by default, only one file can be selected
  • e.target.value is a string, representing the real filename and a fake path on disk: C:\fakepath\MASCOT.png
    • The value here is relatively useless, but we can reset the file input by setting e.target.value = null. If we don’t reset the input, the file input will not trigger a change event if the user selects the same file again.

Extracting the file with const file = e.target.files[0], we have access to a single file upload. We need to add this to a FormData object to serialize it for a network request. We initialize a FormData with new FormData(), then use .append to add the file object to the FormData object:

export async function uploadProfilePicture(contactId, file) {
	let data = new FormData()
	data.append('file', file)

	const response = await fetch(`...`, {
        headers: { /* ... */ },
		method: 'PUT',
		// we can include FormData right in the call to fetch(), no need to serialize it ourselves
		body: data
	});

	// remember to check for any errors!
	if (!response.ok) {
		console.error(`Response status: ${response.status}`);
		throw new Error(`Response status: ${response.status}`)
	}

	return await response.json();
}

Lab - Final Project: Upload and Display Profile Images

For Lab, you’ll be working on the ability to upload profile images for contacts, and displaying them on the contact detail page.

Note: You cannot modify “public contacts” with IDs 1000 - 1100. To make this lab easier, you may want to remove public contacts from the list by including ?public=false in your call to GET /contacts

1. Display the Contact Profile Image

First, we’ll want to display the contact’s profile image when displaying the contact itself. Just like how we can display an image from Google, we can display an image from our REST API. We only need to set the src attribute of an img tag to the appropriate URL.

<img src={`https://api.ci256.cloud/contacts/${contactId}/profile`} alt="Contact Profile" />

We’ll want to include an alt tag incase the browser is unable to load the image.

You’ll know this is working if:

  • Loading a public contact displays a placeholder with some initials

2. Handling Contacts without an Image

We may wish to handle when a contact does not have a contact image. The contact object has the attribute profileImage, which will be true when the contact has an image, and false when they do not.

The Avatar component from @mui/joy can handle displaying a placeholder, as well as styling the image into a circle.

// only set the `src` attribute when `contact.profileImage` is true
<Avatar src={contact.profileImage && `${API_URL}/contacts/${contact.id}/profile`} />

You’ll know if this is working if:

  • Clicking on a contact without a profile image:
    • Results in no errors in the console or network tab
    • Displays a placeholder image when a contact image does not exist
    • Clicking on a public contact displays the appropriate image

3. Uploading a Profile Image

Now we’ll want to add the ability to upload a profile image for each of our contacts.

3A: File Upload Input

First we need to create a file upload input inside our EditContact and/or CreateContact component. As discussed above, create an Input component and set the prop type="file". Include an onChange listener to handle the file upload once selected by the user.

function handleFileUpload(e) {
  console.log('uploaded files', e.target.files)
  const file = e.target.files[0]
  
}

<Input onChange={handleFileUpload} type="file" />

You’ll know if this is working if:

  • An input displaying “choose file” appears on the screen
  • Clicking the button opens a file picker
  • Selecting a file results in a console message with file info, and no errors

3B: Handling Cancelled Uploads

If a user clicks the “upload” button, cancels the upload and does not select a file, you may still get an onChange event with e.target.files being an empty list.

Handle this edge case inside the onChange handler, ensuring no errors occur in the console.

function handleFileUpload(e) {
  // todo add code to handle when e.target.files is empty
  
  console.log('uploaded files', e.target.files)
  const file = e.target.files[0]
  
}

You’ll know if this is working if:

  • Clicking on “choose file”, then clicking X instead of selecting a file, does not result in a console error.

3C: Upload the File

Add a function to contacts.ts for performing the actual file upload, and call it from your onChange handler. Remember, you need to:

  • Make a PUT request to /contacts/{contactId}/profile
  • Add the file object to a FormData object
  • Include the FormData object in your request body
  • Include appropriate authorization headers!
    • Note: You should NOT include the content-type header

The function should follow this general pattern:

export async function uploadProfilePicture(contactId, file) {
	let data = new FormData()
	data.append('file', file)

	const response = await fetch(`...`, {
        headers: { /* ... */ },
		method: 'PUT',
		// we can include FormData right in the call to fetch(), no need to serialize it ourselves
		body: data
	});

	// remember to check for any errors!
	if (!response.ok) {
		console.error(`Response status: ${response.status}`);
		throw new Error(`Response status: ${response.status}`)
	}

	return await response.json();
}

You’ll know if this is working if:

  • Clicking “choose a file” and uploading the file results in a network request to PUT /contacts/{contactId}/profile
  • The request returns a 200 OK status
    • This is easiest verified by checking if the request in the network tab is red.
  • No console errors are present

3D: Update the UI

The UI on the edit page should be updating to display the new image after upload. This is done easiest with a useState for the image URL, which can easily be updated after file upload.

Note: Due to the url not actually changing, we need to first set the avatar to undefined, then set it back to the new value, so that React sees an update

  const [avatar, setAvatar] = useState(contact ? `${API_URL}/contacts/${contact.id}/profile`: undefined)

  async function handleFileUpload(e) {
    // ...
    // other stuff
    // ...
    
    // set Avatar to undefined while we make the request
    // setAvatar(undefined)
    
    // call function form step 3C
    await uploadProfilePicture(contact.id, file)
    
    // update avatar useState to render the new image from the remote server
    setAvatar(`${API_URL}/contacts/${contact.id}/profile`)
  }

You’ll know if this is working if:

  • Uploading a new profile image results in the new image being displayed on the edit page after upload