April 28, 2022
Build A Fullstack App with Remix, Prisma & MongoDB: Referential Integrity & Image Uploads
Welcome to the fourth article of this series where you are learning how to build a full-stack application from the ground up using MongoDB, Prisma, and Remix! In this part, you will build the profile settings section of your application, including an image upload component, and configure your schema to provide referential integrity in your data.
Table Of Contents
- Introduction
- Build the profile settings modal
- Add an image upload component
- Display the profile pictures
- Add a delete account function
- Add form validation
- Summary & What's next
Introduction
In the previous part of this series you built the main areas of this application, including the kudos feed, the user list, the recent kudos list, and the kudos-sending form.
In this part you will be wrapping up this application's development by building a way for users to update their profile information and upload a profile picture. You will also make a few changes to your schema that will give your database referential integrity.
Note: The starting point for this project is available in the part-3 branch of the GitHub repository. If you'd like to see the final result of this part, head over to the part-4 branch.
Development environment
In order to follow along with the examples provided, you will be expected to ...
- ... have Node.js installed.
- ... have Git installed.
- ... have the TailwindCSS VSCode Extension installed. (optional)
- ... have the Prisma VSCode Extension installed. (optional)
Note: The optional extensions add some really nice intellisense and syntax highlighting for Tailwind and Prisma.
Build the profile settings modal
The profile settings page of your application will be displayed in a modal that is accessed by clicking a profile settings button at the top right of the page.
In app/components/search-bar.tsx
:
- Add a new prop to the exported component named
profile
that is of theProfile
type generated by Prisma - Import the
UserCircle
component. - Render the
UserCircle
component at the very end of theform
's contents, passing it the newprofile
prop data. This will act as your profile settings button.
// app/components/search-bar.tsx// ...import { UserCircle } from "./user-circle"import type { Profile } from "@prisma/client"interface props {profile: Profile}export function SearchBar() {export function SearchBar({ profile }: props) {// ...return (<form className="w-full px-6 flex items-center gap-x-4 border-b-4 border-b-blue-900 border-opacity-30 h-20">{/* ... */}<UserCircleclassName="h-14 w-14 transition duration-300 ease-in-out hover:scale-110 hover:border-2 hover:border-yellow-300"profile={profile}/></form>)}Copy
If your development server was already running, this will cause your home page to throw an error the SearchBar
component is now expecting the profile data.
In the app/routes/home.tsx
file, use the getUser
function written in the second part of this series from app/utils/auth.server.ts
. Use this function to load the logged in user's data inside of the loader
function. Then provide that data to the SearchBar
component.
// app/routes/home.tsx// ...import {requireUserId,getUser} from '~/utils/auth.server'export const loader: LoaderFunction = async ({ request }) => {// ...const user = await getUser(request)return json({ users, recentKudos, kudos })return json({ users, recentKudos, kudos, user })}export default function Home() {const { users, kudos, recentKudos } = useLoaderData()const { users, kudos, recentKudos, user } = useLoaderData()// ...return <Layout><Outlet /><div className="h-full flex"><UserPanel users={users} /><div className="flex-1 flex flex-col"><SearchBar/><SearchBar profile={user.profile} />{/* ... */}</div></div></Layout>}Copy
Your SearchBar
will now have access to the profile
data it needs. If you had previously recieved an error because of the absence of this data, a refresh of the page in your browser should reveal the profile button rendering successfully in the top right corner of the page.
Create the modal
The goal is to open a profile settings modal when the profile settings button is clicked. Similar to the kudos modal built in the previous section of this series, you will need to set up a nested route where you will render the new modal.
In app/routes/home
add a new file named profile.tsx
with the following contents to start it off:
// app/routes/home/profile.tsximport { json, LoaderFunction } from "@remix-run/node"import { useLoaderData } from "@remix-run/react"import { Modal } from "~/components/modal";import { getUser } from "~/utils/auth.server";export const loader: LoaderFunction = async ({ request }) => {const user = await getUser(request)return json({ user })}export default function ProfileSettings() {const { user } = useLoaderData()return (<Modal isOpen={true} className="w-1/3"><div className="p-3"><h2 className="text-4xl font-semibold text-blue-600 text-center mb-4">Your Profile</h2></div></Modal>)}Copy
The snippet above ...
- ... renders a modal into a new
ProfileSettings
component. - ... retrieves and returns the logged in user's data within a
loader
function. - ... uses the
useLoaderData
hook to access theuser
data returned from theloader
function.
To open this new modal, in app/components/search-bar.tsx
add an onClick
handler to the UserCircle
component that navigates the user to the /home/profile
sub-route using Remix's useNavigate
hook.
// app/components/search-bar.tsx// ...<UserCircleclassName="h-14 w-14 transition duration-300 ease-in-out hover:scale-110 hover:border-2 hover:border-yellow-300"profile={profile}onClick={() => navigate('profile')}/>// ...Copy
If you now click on the profile settings button, you should see the new modal displayed on the screen.
Build the form
The form you will build will have three fields that will allow the user to modify their profile details: first name, last name, and department.
Start building the form by adding the first and last name inputs:
// app/routes/home/profile.tsx// ...// 1import { useState } from "react";import { FormField } from '~/components/form-field'// loader ...export default function ProfileSettings() {const { user } = useLoaderData()// 2const [formData, setFormData] = useState({firstName: user?.profile?.firstName,lastName: user?.profile?.lastName,})// 3const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>, field: string) => {setFormData(form => ({ ...form, [field]: event.target.value }))}// 4return (<Modal isOpen={true} className="w-1/3"><div className="p-3"><h2 className="text-4xl font-semibold text-blue-600 text-center mb-4">Your Profile</h2><div className="flex"><div className="flex-1"><form method="post"><FormField htmlFor="firstName" label="First Name" value={formData.firstName} onChange={e => handleInputChange(e, 'firstName')} /><FormField htmlFor="lastName" label="Last Name" value={formData.lastName} onChange={e => handleInputChange(e, 'lastName')} /><div className="w-full text-right mt-4"><button className="rounded-xl bg-yellow-300 font-semibold text-blue-600 px-16 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1">Save</button></div></form></div></div></div></Modal>)}Copy
Here's an overview of what was added above:
- Added the imports needed in the changes made.
- Created a
formData
object in state that holds the form's values. This defaults those values to the logged in user's existing profile data. - Created a function that takes in an HTML
change
event and a field name as parameters. Those are used to update theformData
state as input fields' values change in the component. - Renders the basic layout of the form as well as the two input fields.
At this point, there is no error handling put in place and the form does not do anything. Before you add those pieces you will need to add the department dropdown.
In app/utils/constants.ts
add a new departments
constant to hold the possible options defined in your Prisma schema. Add the following export to that file:
// app/utils/constants.ts// ...export const departments = [{ name: "HR", value: "HR" },{ name: "Engineering", value: "ENGINEERING" },{ name: "Sales", value: "SALES" },{ name: "Marketing", value: "MARKETING" },];Copy
Import departments
into your app/routes/home/profile.tsx
file along with the SelectBox
component and use them to add a new input to your form:
// app/routes/home/profile.tsx// ...import { departments } from "~/utils/constants";import { SelectBox } from "~/components/select-box";// ...export default function ProfileSettings() {// ...const [formData, setFormData] = useState({firstName: user?.profile?.firstName,lastName: user?.profile?.lastName,department: (user?.profile?.department || 'MARKETING'),})// ...return ({/* ... */}<form method="post">{/* ... */}<SelectBoxclassName="w-full rounded-xl px-3 py-2 text-gray-400"id="department"label="Department"name="department"options={departments}value={formData.department}onChange={e => handleInputChange(e, 'department')}/>{/* Save button div */}</form>{/* ... */})}Copy
At this point, your form should render the correct inputs and their options. It will default their values to the current values associated with the logged in user's profile.
Allow users to submit the form
The next piece you will build is the action
function which will make this form functional.
In your app/routes/home/profile.tsx
, add an action
function that retrieves the form data from the request
object and validates the firstName
, lastName
and department
fields:
// app/routes/home/profile.tsx// ...import { validateName } from "~/utils/validators.server";// Added the ActionFunction and redirect imports 👇import { LoaderFunction, ActionFunction, redirect, json } from "@remix-run/node";export const action: ActionFunction = async ({ request }) => {const form = await request.formData();// 1let firstName = form.get('firstName')let lastName = form.get('lastName')let department = form.get('department')// 2if (typeof firstName !== 'string'|| typeof lastName !== 'string'|| typeof department !== 'string') {return json({ error: `Invalid Form Data` }, { status: 400 });}// 3const errors = {firstName: validateName(firstName),lastName: validateName(lastName),department: validateName(department)}if (Object.values(errors).some(Boolean))return json({ errors, fields: { department, firstName, lastName } }, { status: 400 });// Update the user here...// 4return redirect('/home')}// ...Copy
The action
function above does the following:
- Pulls out the form data points you need from the
request
object. - Ensures each piece of data you care about is of the
string
data type. - Validates the data using the
validateName
function written previously. - Redirects to the
/home
route, closing the settings modal.
The snippet above also throws relevent errors when the various validations fail. In order to put the validated data to use, write a function that allows you to update a user.
In app/utils/user.server.ts
, export the following function:
// app/utils/user.server.tsimport { Profile } from "@prisma/client";// ...export const updateUser = async (userId: string, profile: Partial<Profile>) => {await prisma.user.update({where: {id: userId,},data: {profile: {update: profile,},},});};Copy
This function will allow you to pass in any profile
data and update a user whose id
matches the provided userId
.
Back in the app/routes/home/profile.tsx
file, import that new function and use it to update the logged in user within the action
function:
// app/routes/home/profile.tsximport {getUser,requireUserId} from "~/utils/auth.server";import { updateUser } from "~/utils/user.server";import type { Department } from "@prisma/client";// ...export const action: ActionFunction = async ({ request }) => {const userId = await requireUserId(request);// ...await updateUser(userId, {firstName,lastName,department: department as Department})return redirect('/home')}// ...Copy
Now when a user hits the Save button, their updated profile data will be stored and the modal will be closed.
Add an image upload component
Set up an AWS account
Your users now have the ability to update some key information in their profile, however one thing that would be nice to add is the ability to allow a user to set up a profile picture so other users might more easily identify them.
To do this, you will set up an AWS S3 file storage bucket to hold the uploaded images. If you don't already have an AWS account, you can sign up here.
Note: Amazon offers a free tier that gives you access to S3 for free.
Create an IAM user
Once you have an account, you will need an Identity Access Management (IAM) user set up in AWS so you can generate an access key ID and secret key, which are both needed to interact with S3.
Note: If you already have an IAM user and its keys, feel free to skip ahead.
Head over to to the AWS console home page. On the top right corner of the page, click on the dropdown labeled with your user name and select Security Credentials.
Once inside that section, hit the Users option in the left-hand menu under Access Management.
On this page, click the Add users button on the top right of the page.
This will bring you through a short wizard that allows you to configure your user. Follow through the steps below:
This first section asks for:
- Username: Provide any user name.
- Select AWS access type: Select the Access key - Programmatic access option, which enables the generation of an access key ID and secret key.
On the second step of the wizard, make the following selections:
- Select the "Attach existing policies directly" option.
- Search for the term "S3".
- Hit the checkmark next to an option labeled AmazonS3FullAccess.
- Hit next at the bottom of the form.
If you would like to add tags to your user help make it easier to manage and organize the users in your account, add those here on the third step of the wizard. Hit Next when you are finished on this page.
If the summary on this page looks good, hit the Create user button at the bottom of the page.
After hitting that button, you will land on a page with your access key ID and secret key. Copy those and store them somewhere you can easily access as you will be using them shortly.
Set up an S3 bucket
Now that you have a user and access keys, head over to the AWS S3 dashboard where you will set up the file storage bucket.
On the top right of this page, hit the Create bucket button.
You will be asked for a name and region for your bucket. Fill those details out and save the values you choose with the access key ID and secret key you previously saved. You will need these later as well.
After filling those out, hit Create bucket at the very bottom of the form.
When the bucket is done being created, you will be sent to the bucket's dashboard page on the Objects tab. Navigate to the Permissions tab.
In this tab, hit the Edit button under the Block public access section. In this form, uncheck the Block all public access box and hit Save changes. This sets your bucket as public, which will allow your application to access the images.
Below that section you will see a Bucket policy section. Paste in the following policy, and be sure to replace <bucket-name>
with your bucket's name. This policy will allow your images to be publicly read:
{"Version": "2008-10-17","Statement": [{"Sid": "AllowPublicRead","Effect": "Allow","Principal": {"AWS": "*"},"Action": "s3:GetObject","Resource": "arn:aws:s3:::<bucket-name>/*"}]}Copy
You now have your AWS user and S3 bucket set up. Next you need to save the keys and bucket configurations into your .env
file so they can be used later on.
// .env# ...KUDOS_ACCESS_KEY_ID="<access key ID>"KUDOS_SECRET_ACCESS_KEY="<secret key>"KUDOS_BUCKET_NAME="<s3 bucket name>"KUDOS_BUCKET_REGION="<s3 bucket region>"Copy
Update your Prisma schema
You will now create a field in your database where you will store the links to the uploaded images. These should be stored with the Profile
embedded document, so add a new field to your Profile
type block.
// prisma/schema.prisma// ...type Profile {firstName StringlastName Stringdepartment Department? @default(MARKETING)profilePicture String?}// ...Copy
To update Prisma Client with these changes, run npx prisma generate
.
Build the image upload component
Create a new file in app/components
named image-uploader.tsx
with the following contents:
// app/components/image-uploader.tsximport React, { useRef, useState } from "react";interface props {onChange: (file: File) => any,imageUrl?: string}export const ImageUploader = ({ onChange, imageUrl }: props) => {const [draggingOver, setDraggingOver] = useState(false)const fileInputRef = useRef<HTMLInputElement | null>(null)const dropRef = useRef(null)// 1const preventDefaults = (e: React.DragEvent<HTMLDivElement>) => {e.preventDefault()e.stopPropagation()}// 2const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {preventDefaults(e)if (e.dataTransfer.files && e.dataTransfer.files[0]) {onChange(e.dataTransfer.files[0])e.dataTransfer.clearData()}}// 3const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {if (event.currentTarget.files && event.currentTarget.files[0]) {onChange(event.currentTarget.files[0])}}// 4return (<div ref={dropRef}className={`${draggingOver ? 'border-4 border-dashed border-yellow-300 border-rounded' : ''} group rounded-full relative w-24 h-24 flex justify-center items-center bg-gray-400 transition duration-300 ease-in-out hover:bg-gray-500 cursor-pointer`}style={{backgroundSize: "cover",...(imageUrl ? { backgroundImage: `url(${imageUrl})` } : {}),}}onDragEnter={() => setDraggingOver(true)}onDragLeave={() => setDraggingOver(false)}onDrag={preventDefaults}onDragStart={preventDefaults}onDragEnd={preventDefaults}onDragOver={preventDefaults}onDrop={handleDrop}onClick={() => fileInputRef.current?.click()}>{imageUrl &&<div className="absolute w-full h-full bg-blue-400 opacity-50 rounded-full transition duration-300 ease-in-out group-hover:opacity-0" />}{<p className="font-extrabold text-4xl text-gray-200 cursor-pointer select-none transition duration-300 ease-in-out group-hover:opacity-0 pointer-events-none z-10">+</p>}<input type="file" ref={fileInputRef} onChange={handleChange} className="hidden" /></div>)}Copy
The snippet above is the full image upload component. Here is an overview of what is going on:
- A
preventDefault
function is defined to handle changes to the file input in the component. - A
handleDrop
function is defined to handledrop
events on the file input in the component. - A
handleChange
function is defined to handle anychange
events on the file input in the component. - A
div
is rendered with various event handlers defined, allowing it to react to file drops, drag events and clicks. These are used to trigger image uploads and style changes that appear only when the element is receiving a drag event.
Whenever the value of the input
in this component changes, the onChange
function from the props
is called, passing along the file data. That data is what will be uploaded to S3.
Next create the service that will handle the image uploads.
Build the image upload service
To build your image upload service you will need two new npm packages:
aws-sdk
: Exposes a JavaScript API that allows you to interact with AWS services.cuid
: A tool used to generate unique ids. You will use this to generate random file names.
npm i aws-sdk cuidCopy
Your image upload service will live in a new utility file. Create a file in app/utils
named s3.server.ts
.
In order to handle the upload, you will make use of Remix's unstable_parseMultipartFormData
function which handles a request
object's multipart/form-data
values.
Note:
multipart/form-data
is the form data type when posting an entire file within the form.
unstable_parseMultipartFormData
will take in two parameters:
- A
request
object retrieved from a form submission. - An
uploadHandler
function, which streams the file data and handles the upload.
Note: The
unstable_parseMultipartFormData
function is used in a way similar to Remix'srequest.formData
function we've used in the past.
Add the following function and imports to the new file you created:
// app/utils/s3.server.tsimport {unstable_parseMultipartFormData,UploadHandler,} from "@remix-run/node";import S3 from "aws-sdk/clients/s3";import cuid from "cuid";// 1const s3 = new S3({region: process.env.KUDOS_BUCKET_REGION,accessKeyId: process.env.KUDOS_ACCESS_KEY_ID,secretAccessKey: process.env.KUDOS_SECRET_ACCESS_KEY,});const uploadHandler: UploadHandler = async ({ name, filename, stream }) => {// 2if (name !== "profile-pic") {stream.resume();return;}// 3const { Location } = await s3.upload({Bucket: process.env.KUDOS_BUCKET_NAME || "",Key: `${cuid()}.${filename.split(".").slice(-1)}`,Body: stream,}).promise();// 4return Location;};Copy
This code sets up your S3 API so you can iteract with your bucket. It also adds the uploadHandler
function. This function:
- Uses the environment variables you stored while setting up your AWS user and S3 bucket to set up the S3 SDK.
- Streams the file data from the
request
as long as the data key's name is'profile-pic'
. - Uploads the file to S3.
- Returns the
Location
data S3 returns, which includes the new file's URL location in S3.
Now that the uploadHandler
is complete, add another function that actually takes in the request
object and passes it along with the uploadHandler
into the unstable_parseMultipartFormData
function.
// app/utils/s3.server.ts// ...export async function uploadAvatar(request: Request) {const formData = await unstable_parseMultipartFormData(request,uploadHandler);const file = formData.get("profile-pic")?.toString() || "";return file;}Copy
This function is passed a request
object, which will be sent over from an action
function later on.
The file data is passed through the uploadHandler
function, which handles the upload to S3 and the formData
gives you back the new file's location inside of a form data object. The 'profile-pic'
URL is then pulled from that object and returned by the function.
Put the component and service to use
Now that the two pieces needed to implement a working profile picture upload are complete, put them together.
Add a resource route that handles your upload form data by creating a new file in app/routes
named avatar.ts
with the following action
function:
// app/routes/avatar.tsximport { ActionFunction, json } from "@remix-run/node";import { requireUserId } from "~/utils/auth.server";import { uploadAvatar } from "~/utils/s3.server";import { prisma } from "~/utils/prisma.server";export const action: ActionFunction = async ({ request }) => {// 1const userId = await requireUserId(request);// 2const imageUrl = await uploadAvatar(request);// 3await prisma.user.update({data: {profile: {update: {profilePicture: imageUrl,},},},where: {id: userId,},});// 4return json({ imageUrl });};Copy
The function above performs these steps to handle the upload form:
- Grabs the requesting user's
id
. - Uploads the file past along in the request data.
- Updates the requesting user's profile data with the new
profilePicture
URL. - Responds to the
POST
request with theimageUrl
variable.
Now you can use the ImageUploader
component to handle a file upload and send the file data to this new /avatar
route.
In app/routes/home/profile.tsx
, import the ImageUploader
component and add it to your form to the left of the input fields.
Also add a new function to handle the onChange
event emitted by the ImageUploader
component and a new field in formData
variable to store the profile picture data.
// app/routes/home/profile.tsx// ...import { ImageUploader } from '~/components/image-uploader'// ...export default function ProfileSettings() {// ...const [formData, setFormData] = useState({firstName: user?.profile?.firstName,lastName: user?.profile?.lastName,department: (user?.profile?.department || 'MARKETING'),profilePicture: user?.profile?.profilePicture || ''})const handleFileUpload = async (file: File) => {let inputFormData = new FormData()inputFormData.append('profile-pic', file)const response = await fetch('/avatar', {method: 'POST',body: inputFormData})const { imageUrl } = await response.json()setFormData({...formData,profilePicture: imageUrl})}// ...return (<Modal><div className="p-3"><h2 className="text-4xl font-semibold text-blue-600 text-center mb-4">Your Profile</h2><div className="flex"><div className="w-1/3"><ImageUploader onChange={handleFileUpload} imageUrl={formData.profilePicture || ''}/></div>{/* ... */}</div></div></Modal>)}Copy
Now if you go to that form and attempt to upload a file the data should save correctly in S3, the database, and in your form's state.
Display the profile pictures
This is great! The image upload is working smoothly, now you just need to display those images across the site wherever a user's circle shows up.
Open the UserCircle
component in app/components/user-circle.tsx
and make these changes to set the circle's background image to be the profile picture if available:
// app/components/user-circle.tsximport { Profile } from '@prisma/client';interface props {profile: Profile,className?: string,onClick?: (...args: any) => any}export function UserCircle({ profile, onClick, className }: props) {return (<divclassName={`${className} cursor-pointer bg-gray-400 rounded-full flex justify-center items-center`}onClick={onClick}style={{backgroundSize: "cover",...(profile.profilePicture ? { backgroundImage: `url(${profile.profilePicture})` } : {}),}}><h2>{profile.firstName.charAt(0).toUpperCase()}{profile.lastName.charAt(0).toUpperCase()}</h2>{!profile.profilePicture && (<h2>{profile.firstName.charAt(0).toUpperCase()}{profile.lastName.charAt(0).toUpperCase()}</h2>)}</div>)}Copy
If you now give a couple of your users a profile picture, you should see those displayed throughout the site!
Add a delete account function
The last piece of functionalty your profile settings modal needs is the ability to delete an account.
Deleting data, especially in a schemaless database, has the possibility of creating "orphan documents", or documents that were once related to a parent document, but whose parent was at some point deleted.
You will put in safeguards against that scenario in this section.
Add the delete button
You will handle this form in a way similar to how the sign in and sign up forms were handled. This one form will send along an _action
key that lets the action
function know what kind of request it receives.
In app/routes/home/profile.tsx
make the following changes to the form
returned in the ProfileSettings
function:
// app/routes/home/profile.tsx{/* ... */}<form method="post"><form method="post" onSubmit={e => !confirm('Are you sure?') ? e.preventDefault() : true}>{/* ... form fields*/}<button name="_action" value="delete" className="rounded-xl w-full bg-red-300 font-semibold text-white mt-4 px-16 py-2 transition duration-300 ease-in-out hover:bg-red-400 hover:-translate-y-1">Delete Account</button><div className="w-full text-right mt-4"><button className="rounded-xl bg-yellow-300 font-semibold text-blue-600 px-16 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"name="_action"value="save">Save</button></div></form>{/* ... */}Copy
Now depending on the button clicked, you can handle a different _action
in the action
function.
Update the action
function to use a switch
statement to perform the different actions:
// app/routes/home/profile.tsx// ...export const action: ActionFunction = async ({ request }) => {const userId = await requireUserId(request);const form = await request.formData();let firstName = form.get('firstName')let lastName = form.get('lastName')let department = form.get('department')const action = form.get('_action')switch (action) {case 'save':if (typeof firstName !== 'string'|| typeof lastName !== 'string'|| typeof department !== 'string') {return json({ error: `Invalid Form Data` }, { status: 400 });}const errors = {firstName: validateName(firstName),lastName: validateName(lastName),department: validateName(department)}if (Object.values(errors).some(Boolean))return json({ errors, fields: { department, firstName, lastName } }, { status: 400 });await updateUser(userId, {firstName,lastName,department: department as Department})return redirect('/home')case 'delete':// Perform delete functionbreak;default:return json({ error: `Invalid Form Data` }, { status: 400 });}}// ...Copy
Now if the user saves the form, the 'save'
case will be hit and the existing functionality will occur. The 'delete'
case currently does nothing, however.
Add a new function in app/utils/user.server.ts
that takes in a id
and deletes the user associated with it:
// app/utils/user.server.ts// ...export const deleteUser = async (id: string) => {await prisma.user.delete({ where: { id } });};Copy
You may now fill out the rest of the "delete"
case on the profile page.
// app/routes/home/profile.tsx// ...// 👇 Added the deleteUser functionimport { updateUser, deleteUser } from "~/utils/user.server";// 👇 Added the logout functionimport { getUser, requireUserId, logout } from "~/utils/auth.server";// ...export const action: ActionFunction = async ({ request }) => {// ...switch (action) {case 'save':// ...case 'delete':await deleteUser(userId)return logout(request)default:// ...}}Copy
Your users can now delete their account!
Update the data model to add referential integrity
The only problem with this delete user functionality is that when a user is deleted, all of their authored kudos become orphans.
You can use referrential actions to trigger the deletion of any kudos when their author is deleted.
// prisma/schema.prismamodel Kudo {id String @id @default(auto()) @map("_id") @db.ObjectIdmessage StringcreatedAt DateTime @default(now())style KudoStyle?author User @relation(references: [id], fields: [authorId], "AuthoredKudos")author User @relation(references: [id], fields: [authorId], onDelete: Cascade, "AuthoredKudos")authorId String @db.ObjectIdrecipient User @relation(references: [id], fields: [recipientId], "RecievedKudos")recipientId String @db.ObjectId}Copy
Run npx prisma db push
to propagate those changes and generate Prisma Client.
Now if you delete an account, any Kudos
authored by that account will be deleted along with it!
Add form validation
You're getting close to the end! The final piece is to hook up the error message handling in the profile settings form.
Your action
function is already returning all of the correct error messages; they simply need to be handled.
Make the following changes in app/routes/home/profile.tsx
to handle these errors:
// app/routes/home/profile.tsximport {useState,useRef,useEffect} from "react";// 👇 Added the useActionData hookimport {useLoaderData,useActionData} from "@remix-run/react"// ...export default function ProfileSettings() {const { user } = useLoaderData()// 1const actionData = useActionData()const [formError, setFormError] = useState(actionData?.error || '')const firstLoad = useRef(true)const [formData, setFormData] = useState({firstName: user?.profile?.firstName,lastName: user?.profile?.lastName,department: (user?.profile?.department || 'MARKETING'),profilePicture: user?.profile?.profilePicture || ''firstName: actionData?.fields?.firstName || user?.profile?.firstName,lastName: actionData?.fields?.lastName || user?.profile?.lastName,department: actionData?.fields?.department || (user?.profile?.department || 'MARKETING'),profilePicture: user?.profile?.profilePicture || ''})useEffect(() => {if (!firstLoad.current) {setFormError('')}}, [formData])useEffect(() => {firstLoad.current = false}, [])// ...return (<Modal isOpen={true} className="w-1/3"><div className="p-3"><h2 className="text-4xl font-semibold text-blue-600 text-center mb-4">Your Profile</h2>{/* 2 */}<div className="text-xs font-semibold text-center tracking-wide text-red-500 w-full mb-2">{formError}</div><div className="flex"><div className="w-1/3"><ImageUploader onChange={handleFileUpload} imageUrl={formData.profilePicture || ''} /></div><div className="flex-1">{/* 3 */}<form method="post" onSubmit={e => !confirm('Are you sure?') ? e.preventDefault() : true}><FormFieldhtmlFor="firstName"label="First Name"value={formData.firstName}onChange={e => handleInputChange(e, 'firstName')}error={actionData?.errors?.firstName}/><FormFieldhtmlFor="lastName"label="Last Name"value={formData.lastName}onChange={e => handleInputChange(e, 'lastName')}error={actionData?.errors?.lastName}/>{/* ... */}</form></div></div></div></Modal >)}Copy
The following changes were made in the snippet above:
- The
useActionData
hook was used to retrieve the error messages. Those were stored in state variables and used to populate the form in the case that a user is returned to the modal after submitting a bad form. - An error output was added to display any form-level errors.
- Error data was passed along to the
FormField
components to allow them to display their field-level errors if needed.
After making the changes above, you will see any form and validation errors are displayed on the form.
Summary & What's next
With the changes made in this article, you successfully finished off your Kudos application! All pieces of the site are functional and ready to be shipped to your users.
In this section you learned about:
- Nested routes in Remix
- AWS S3
- Referrential actions and integrity with Prisma and MongoDB
In the next section of this series you will wrap things up by taking the application you've built and deploy it to Vercel!