Week 9 - Modifying Remote Data - CI256

Week 9 - Modifying Remote Data

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.

  • POST /token - Retrieve a login token
  • GET /contacts - Get a list of contacts
  • GET /contacts/{contact_id} - Get a specific contact
    • Returns a single object instead of a list, and often returns more details than a list endpoint
  • GET /contacts/{contact_id}/profile - Get a specific user’s profile image
    • Note how /profile could be seen as “nested under” a specific contact’s path
  • POST /contacts - Create a new contact
  • PATCH /contacts/{contact_id} - Update an existing contact
  • DELETE /contacts/{contact_id} - Delete an existing contact

Some other common examples of nesting might be:

  • GET /groups/{group_id}/users: get all users from a given group
  • POST /groups/{group_id}/activities/{activity_id}/start to start a group activity

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

Request Body

POST, PUT, and PATCH requests can also accept a “body”, which allows us to send more complex data. The request body can accept a string or binary data, and there are several types of formatting when using a string.

Sending JSON

Since the body is a simple string, but the content type can vary, we must describe what the body of our request contains with a header. To send data as a JSON object, we must:

  1. Include the Content-Type type header, set to application/json.
  2. “Serialize” the data for the request using JSON.stringify()
    • If we don’t do this, we will end up with [Object object] in our post body
const contact = {
    firstName: "New",
	lastName: "Contact",
	email: "[email protected]",
	phone: '315-867-5309'
}
const response = await fetch("https://example.org/post", {
  method: "POST",
  // serialize request as JSON
  body: JSON.stringify(contact),
  headers: {
	  // tell the server our content is JSON
	  'Content-Type': 'application/json',
	  'Authorization': `ApiKey ${API_KEY}`, // don't forget about authentication!
  }
});

Contacts REST API

CI256 has a REST API set up at https://api.ci256.cloud for maintaining a list of Contacts. Don’t forget about the builtin docs!

POST /contacts - Create a Contact

To create a new contact, we use a POST request to /contacts. We can find the exact format required on the docs site: https://api.ci256.cloud/docs#/Contacts/create_contacts__post

It is the same as the contact object we’ve been working with so far, minus the id attribute. The server will assign us an ID attribute once the object is created in the database. That means our input data looks something like this:

{
  "firstName": "string",
  "lastName": "string",
  "email": "string",
  "phone": "string",
  "extra": {
	  "string": "string"
  }
}

A fetch request to create a new user would look like this:

async function addContact(contact) {
	const response = await fetch(`https://api.ci256.cloud/contacts`, {
      // post request - make new contact
	  method: "POST",
	  // include authentication information
	  headers: {'Authorization': `ApiKey ${apiKey}`},
	  // include the contact object in the body
	  body: JSON.stringify(contact)
	});

	// error handling
    if (!response.ok) {  
	    console.error(`Response status: ${response.status}`);  
	    throw new Error(`Response status: ${response.status}`)  
    }  

	// get response data if needed
    const data = response.json();
    console.log('created a new contact', data)
    return data
}

// get contact information from form fields in our UI
let newContact = {
  "firstName": "New",
  "lastName": "Person",
  "email": "[email protected]",
  "phone": "315-867-5309",
  "extra": {
	  "favorite_song": "Jenny by Tommy Tutone"
  }
}

// call our addContact() function to add our new contact to the API
let created = addContact(newContact)

The returned data will be the contact object with the fields we created, plus an ID field:

{
  // server adds this for us
  "id": 101, 
  // these fields are equal to our input values 
  "firstName": "New",
  "lastName": "User",
  "email": "[email protected]",
  "phone": "315-867-5309",
  "extra": {
	  "favorite_song": "Jenny by Tommy Tutone"
  }
}

PATCH /contacts/{contact_id}

Full Contact Object. A PATCH request will update an existing user. This request looks exactly like the POST request, except we change the method to PATCH, and we must include the contact ID in the URL.

Input: Fields to be updated. Any field may be omitted.

Note: Extra will be replaced entirely. Be sure to include any existing extra fields!

{
  "firstName": "string",
  "lastName": "string",
  "email": "string",
  "phone": "string",
  "extra": {}
}

Output: Full Contact Object, including the existing ID.

DELETE /contacts/{contact_id} - Delete a contact

To delete a contact with a given ID, we just need to make a DELETE request to the appropriate endpoint.

function deleteContact(id) {
		const response = await fetch(`https://api.ci256.cloud/contacts/${id}`, {
		  method: "DELETE",
		  headers: {'Authorization': `ApiKey ${apiKey}`}
		});
		// todo error handling, check response
}

Expected Response:

{
  "ok": true
}

Post-request actions - What now?

After creating, modifying a contact, we’ll want to perform 2 actions:

  1. Redirect the user to the contact detail page
  2. Update our list of users with the latest changes

Redirecting the User

To redirect the user to a new page within our application, the useNavigate hook from React Router is used.

useNavigate returns a function that can be called to navigate the user to a new page without reloading the entire page, like the <Link> component.

export default function EditContact() {
    const navigate = useNavigate();

	async function submit(e) {
		// save the contact
		const result = await saveContact(newContact);
		// redirect to contact detail page
		navigate(`/contacts/${result.id}`);
	}
}

Reloading Page Data

Since we may have modified a contact name or added a new entry to the list, we want to update our sidebar with the latest content.

React Router provides a revalidator hook for this purpose. By calling revalidate(), React Router will re-execute the loader() functions we’ve defined in our layouts and routes.

export default function EditContact() {
	const revalidator = useRevalidator();

	async function submit(e) {
		// save the contact
		const result = await saveContact(newContact);
		// update sidebar data
		revalidator.revalidate()
	}
}

Lab - Final Project: Creating, Editing, and Deleting Contacts

For Lab, you’ll be working on the ability to manage contacts on the remote server. This will include creating, editing, and deleting contacts.

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. Fix “Create Contact”

The first thing is to fix our “Create Contact” button by updating our button/link and adding a new route.

Update the “Create Contact” button to use a Link element, like we did in the last lab with the Contact link.

Example: <Link to="/contacts/create">(your button html here)</Link>

You know this step is working when you can click on the “create contact” button and the app navigates to the url /contacts/create. It’s okay if that page shows a 404/Route not found error.

1B. Adding a Create Contact route

Add a new route for /contacts/create in main.tsx.

If you already have a component like <CreateContact /> you can use it here - otherwise, you will need to create a new component.

{  
  path: '/contacts/create',  
  element: <CreateContact />,
},

2. Send the network request

Add new functions to contacts.ts for creating and updating a new contact. Remember to:

  • Use POST /contacts for creating new contacts
  • Use PATCH /contacts/{contact_id} for updating an existing contact
  • Serialize the data with JSON.stringify
  • Include the content-type and authorization headers

3. Save Contact

Call your new function from step when the user presses a Save button in the CreateContact component.

You’ll know this step is working if there are no errors in the browser console, and refreshing the page shows the contact you just saved.

4. Post-save actions

When the user presses the save button, we should update the UI with the new data, and redirect the user to the contact detail page.

You should have created a function to handle the onClick event of the save button within your CreateContact component. It might look something like this:

  async function submit(e) {
	let newContact = {
		firstName: first,
		lastName: last,
		phone,
		email,
		extra
	}
    
    const data = await saveContact(newContact);
}

We need to add two steps after the contact is successfully saved:

  1. Reload the page data with revalidate()
  2. Redirect the user to /contacts/${data.id}

You’ll know this step is working if, upon saving a new contact:

  • Your sidebar updates with the new contact name
  • Your URL changes to /contacts/${data.id}
    • Note: This should be a “seamless” transition using React Router’s useNavigate, NOT setting window.location directly
  • The new contact’s details are displayed on screen

NOTE: You may run into a situation where revalidate() is refreshing the data, but your UI is not refreshing. This can be caused by using the value of useLoaderData() within a useState hook.

You have two options to resolve this:

  1. Stop using useState, and utilize the value directly.
  2. Use the useEffect hook to update the state when the data changes.

Option 2 is more likely the “correct” answer for your situation. Example:

const contacts = useLoaderData();
const [displayedContacts, setDisplayedContacts] = useState(contacts)

// Add this useEffect hook
useEffect(
  // update the displayedContact list...
  () => setDisplayedContacts(contacts),
  // ... when the dependency variable "contacts" changes
  [contacts]
)

function onSearch(e) {
  setDisplayedContacts(contacts.filter(c => /* search logic here */))
}

5. Edit an existing contact

Now lets add the ability to modify an existing contact.

5A. Edit Contact Button

Add a button to edit an existing contact when a contact is being displayed. Be sure to use Link from React Router for this.

When clicked, the browser should navigate to the url /contacts/:id/edit (or similar).

You’ll know this is working if

  • An edit button/link displayed on existing Contacts
  • Clicking the button/link navigates the user to /contacts/:id/edit
    • It’s okay if this page shows a 404/Route not found error, that’s the next step!

5B. Edit Contact Route

Add a new route in main.tsx for /contacts/:id/edit. You may use a new component for this if you’d like, but the recommended approach is to _re-use your existing component used to create the contact.

This route will need to include a loader function - the loader function from /contacts/:id may be copied and re-used, as we are loading the same information. Don’t forget to use useLoaderData() in the component!

TIP: If no loader function is defined, userLoaderData() will return undefined. This is useful for determining within the component if we are editing an existing contact, or creating a new contact.

For example, given these two routes are using the same <EditContact /> component:

{
  path: '/contacts/:id/edit',
  element: <EditContact />,
  loader: () => { /* */ },
},
{
  path: "/contacts/create",
  element: <EditContact />,
},

The component can use logic like this:

export default function EditContact() {
  let existingContact = useLoaderData();
  if(existingContact) {
    console.log('modifying existing contact')
  } else {
    console.log('creating a new contact')
  }
}

Be sure to utilize the existing contact information to populate input fields on this page. Remember that the default value to useState is the first argument, so we can set the defaults like this:

let existingContact = useLoaderData();
const [firstName, setFirstName] = useState(existingContact?.firstName || '')
// ...

return (
    {/* ...other stuff... */}
    <Input value={firstName} onChange={e => setFirstName(e.target.value)} />
    {/* ...other stuff... */}
)

You’ll know this step is working if you can click the “Edit Contact” button and:

  • The browser directs you to /contacts/:id/edit
  • An edit screen is displayed
  • All input fields are pre-populated with existing contact data

5C. Save the updated information

Upon the user clicking save, be sure to call the appropriate function from step 2.

All of the post-save steps from step 4 should also apply.

You’ll know this step is working if:

  • You can click the save button and no errors occur in the browser console
  • After saving, you are redirected to the contact detail screen (/contacts/:id)
  • There is not a new entry added to the sidebar
    • Be sure to refresh the page to verify this! A common mistake here will be accidentally creating duplicate users.

6. Deleting Contacts

Finally, add a delete button for deleting existing contacts.

6A. Add a delete contact function

To delete a contact, make a network request with method DELETE to the endpoint /contacts/:id - no request body is required.

Add a function to contacts.ts to make this request. The function should accept a single parameter, contactId.

6B. Add a delete button

Delete buttons usually exist on the edit page for an object, but, it can go on the display page if desired.

Add a button somewhere in the UI for deleting contacts. Call the function from step 6A to perform the action.

6C. Post-Delete Actions

After deleting a user, we want to perform two actions, similar to step 4.

  1. Redirect the user, since the object being viewed/modified will no longer exist.
    • Sending the user to /, the “root” or “homepage”, probably makes the most sense
  2. Update the sidebar data

You’ll know this step is working if:

  • There is a delete button available for every user
  • Clicking the delete button results in no errors in the browser console
  • Deleting a user removes that user from the sidebar
  • After deleting a user, the user is redirected to /