Skip to content
4th April 2025: This is a preview, whilst production-ready, it means some APIs might change

Contacts

Things are coming together nicely. But, we still hard coded contacts into the Job Application form. Let’s make this dynamic.

If we look at the Figma file, you’ll notice that we have a “Add a Contact” button on our sidebar:

When the user clicks on this button, we want to open a side panel that contains the contact form:

Let’s start with the UI and then we can connect it the database.

Adding a Contact

Styling the Contact Form

Again, shadcn/ui components are great for this.

This time, we’re going to reach for the Sheet component.

You can copy and paste the boilerplate code from the Shad docs:

Import statement for the top of our ApplicationForm component (but below the use client directive):

import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/app/components/ui/sheet"

Then, for our side bar:

<Sheet>
<SheetTrigger>Open</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Are you absolutely sure?</SheetTitle>
<SheetDescription>
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>

This code will go below our <div>Contact Card</div> placeholder:

/src/app/components/ApplicationForm.tsx
<div className="box">
<h3>Contacts</h3>
<p className="input-description">Invite your team members to collaborate.</p>
<div>
Contact Card
</div>
<Sheet>
<SheetTrigger>Open</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Are you absolutely sure?</SheetTitle>
<SheetDescription>
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
</div>

If you take a look at this within the browser, you should see the “Open” trigger:

If you click on the trigger, the sheet should slide open:

Now, let’s replace the placeholder content with the real stuff.

You may have noticed that the Sheet code contains, a trigger, SheetTrigger and SheetContent. Inside, the content, there’s a SheetHeader with a SheetTitle and SheetDescription.

First, let’s adjust the SheetTrigger:

/src/app/components/ApplicationForm.tsx
<SheetTrigger className="flex items-center gap-2 font-poppins text-sm font-bold bg-secondary py-3 px-6 rounded-md cursor-pointer">
<Icon id="plus" size={16} />Add a Contact
</SheetTrigger>
  • I added an Icon component and gave it an id of plus and a size of 16.
  • I changed the text to Add a Contact.
  • We can add some styling to the SheetTrigger to make it look like a button.
    • flex items-center gap-2 will align the icon and text next to each other, putting 8px of space between them.
    • font-display font-bold text-sm will use the font-family Poppins, make it bold, and set the font size to 14px.
    • bg-secondary will make the background a beige color (defined in our @theme)
    • py-3 px-6 will add 12px of padding to the top and bottom, and 24px of padding to the left and right.
    • rounded-md will round the corners of the button.
    • cursor-pointer will change the cursor to a pointer when hovering over the button.

Much better! Then, for our SheetContent:

/src/app/components/ApplicationForm.tsx
<SheetContent className="pt-[100px] px-12">
<SheetHeader>
<SheetTitle>Add a Contact</SheetTitle>
<SheetDescription>
Add a Contact to this application.
</SheetDescription>
<ContactForm />
</SheetHeader>
</SheetContent>
  • Change the SheetTitle to Add a Contact.
  • Change the SheetDescription to Add a Contact to this application.
  • For the form, I'm referencing the ContactForm component. (We'll need to create this.)
  • I added some styling to the SheetContent to our content some additional space. 100px of padding to the top and 48px of padding to the left and right.

Now, let’s create our ContactForm component. Inside our src/app/components directory, create a new file called ContactForm.tsx.

  • Directorysrc/
    • Directoryapp/
      • Directorycomponents/
        • ContactForm.tsx

Then, be sure to import the ContactForm component at the top of our ApplicationForm component:

/src/app/components/ApplicationForm.tsx
import { ContactForm } from "@/app/components/ContactForm"

Inside our ContactForm.tsx file, let’s start with a basic component (you should also see your linting error go away):

/src/app/components/ContactForm.tsx
const ContactForm = () => {
return (
<div>ContactForm</div>
)
}
export { ContactForm }

Now let’s replace our <div> with our form. It should contain fields for:

  • first name
  • last name
  • role
  • email
/src/app/components/ContactForm.tsx
return (
<form>
<div className="field">
<label htmlFor="firstName">First Name</label>
<input type="text" id="firstName" name="firstName" required />
</div>
<div className="field">
<label htmlFor="lastName">Last Name</label>
<input type="text" id="lastName" name="lastName" required />
</div>
<div className="field">
<label htmlFor="role">Role</label>
<input type="text" id="role" name="role" required />
</div>
<div className="field">
<label htmlFor="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div className="field">
<Button>
<Icon id="check" size={24} />
Create a Contact
</Button>
</div>
</form>
)
  • This form should be pretty straightforward. It's mostly vanilla HTML. I wrapped each label and input pairing with a div that has a class of field. Conveniently, we've already defined these styles within our styles.css file.
  • At the bottom of the form, I'm using a shadcn/ui Button component, similar to what we've done before. I have a check Icon inside with the text "Create a Contact".
  • I've also made every field required, using the HTML required attribute.

At the top of our file, we need to import our Button and Icon components:

/src/app/components/ContactForm.tsx
import { Icon } from "./Icon"
import { Button } from "./ui/button"

Now, let’s test this out, again, within the browser. When you click on the “Add a Contact” button, you should see the styled form slide in:

Great! Now, let’s hotwire this together. We’ll follow a similar approach to what we did with the Job Application form.

Adding a Contact to the Database

On the ContactForm component, let’s set an action on the form element:

/src/app/components/ContactForm.tsx
<form action={handleSubmit}>

Now, we need to write the handleSubmit function. We can add this code right above our return statement:

/src/app/components/ContactForm.tsx
const handleSubmit = async (formData: FormData) => {
const result = await createContact(formData);
if (result.success) {
console.log("Contact created successfully");
} else {
console.error("Error creating contact");
}
}
  • This function will take our form data and pass it to the createContact function.
  • The createContact function is a server action (we still need to write this). Since it's a server action, we can use async and await to wait for the result.
  • If the result is successful, we'll log a message to the console.
  • If the result is not successful, we'll log an error to the console.

Now, inside our src/app/pages/applications/actions.ts file, let’s create a new function at the very bottom of our file:

/src/app/pages/applications/actions.ts
export const createContact = async (formData: FormData) => {
try {
await db.contact.create({
data: {
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
email: formData.get("email") as string,
role: formData.get("role") as string,
},
})
return { success: true, error: null }
} catch (error) {
console.error(error)
return { success: false, error: error as Error }
}
}
  • We're exporting our createContact function so we can import it into our ContactForm component.
  • The function takes a single argument, formData.
  • Inside, we have a try / catch block. With the try block, we make a call to our database and await the result.
    • We're already importing our db at the top of our file.
    • Since we're working with the Contact table, we can reference it with db.contact. Then, we can invoke the create method and pass it an object with all of our form data.
  • Assuming, we were successful, we return an object with success set to true and error set to null.
  • If there's an error, we log it to the console and return an object with success set to false and the error.

Let’s go back to our ContactForm component and import the createContact function at the top of our file:

/src/app/components/ContactForm.tsx
import { createContact } from "../pages/applications/actions";

Let’s test it out! But, before you do, open up your developer tools and go to the console tab. If everything goes through successfully, you should see a success message in the console:

You can also check Prisma Studio (npx prisma studio) to see the new contact in the database:

All the John Doe entries are from when we hardcoded the data in.

This works, kind of. — It works, but it’s not the best user experience. It would be better if we could see a success (or error) message in browser.

Fortunate for us, shadcn/ui has a toast component that we can use. This component works a little differently than the other components we’ve used. It has two parts. It needs the Toaster and the Toast.

The Toaster handles where the message gets displayed. We can also cue up or show several messages at once. So, the Toaster “holds” the Toast.

The Toaster component typically lives in a layout component.

Then, components nested inside, can trigger Toast messages.

This might make more sense as we build it out.

Within our InteriorLayout component, let’s add the Toaster component:

/src/app/components/InteriorLayout.tsx
import { Header } from "../components/Header"
import { Toaster } from "@/app/components/ui/sonner"
const InteriorLayout = ({ children }: { children: React.ReactNode}) => {
return (
<div className="page-wrapper">
<main className="page bg-white">
<Header />
{children}
<Toaster position="top-right" richColors />
</main>
</div>
)
}
export { InteriorLayout }
  • We’ve imported our Toaster component at the top of our file (notice the import path is @/app/components/ui/sonner, this is slightly different than the official shadcn/ui documentation)
  • Then, we added the Toaster component below the {children}
  • I positioned the Toaster to the top right of the screen using the position prop and applied richColors

Even though we already added the sonner.tsx file through the shadcn/ui CLI, we need to make sure that our project knows it’s a client component. At the top of sonner.tsx, let’s add the use client directive:

/src/app/components/ui/sonner.tsx
"use client"

Now, on our ContactForm component, let’s import our toast at the top of our file:

/src/app/components/ContactForm.tsx
import { toast } from "sonner"

Then, we can update our conditional statement, inside our handleSubmit function:

/src/app/components/ContactForm.tsx
if (result.success) {
toast.success("Contact created successfully")
} else {
toast.error("Error creating contact")
}

If you test te form again, within the browser, you should see a successful toast message in top right of the screen.

Sweet! Now, we could leave our “Create Contact” form the way it is:

  1. Click “Add a Contact” button. It triggers the Sheet component, with our ContactForm nested inside.
  2. Fill out the form and click “Create a Contact”.
  3. A success message is shown in the browser.
  4. Our Sheet component stays open and you can keep adding as many contacts as you’d like.
  5. When you’ve finished adding contacts, you can close the Sheet component by clicking the “X” in the top right corner.

OR we could close the Sheet component after the form is submitted.

Let’s do that.

Enhancing the User Experience

At first glance, this might seem a little tricky because it doesn’t look like this functionality exists. But, if you click on the “Docs” or “API” button at the top of the Sheet documentation:

You’ll notice that this component is built on top of the Radix UI Dialog component. There’s a prop called open and onOpenChange that we can leverage.

First, at the top of our ApplicationsForm component, let’s add a piece of state that keeps track of whether the Sheet is open or not:

/src/app/components/ApplicationForm.tsx
const [isContactSheetOpen, setIsContactSheetOpen] = useState(false);

Then, be sure to import useState at the top of our file:

/src/app/components/ApplicationForm.tsx
import { useState } from "react"

Now, scroll down to the bottom of the ApplicationForm component, to our Sheet component:

/src/app/components/ApplicationForm.tsx
<Sheet open={isContactSheetOpen} onOpenChange={setIsContactSheetOpen}>
<SheetTrigger className="flex items-center gap-2 font-display text-sm font-bold bg-secondary py-3 px-6 rounded-md cursor-pointer">
<Icon id="plus" size={16} />Add a Contact
</SheetTrigger>
<SheetContent className="pt-[100px] px-12">
<SheetHeader>
<SheetTitle>Add a Contact</SheetTitle>
<SheetDescription>
Add a Contact to this application.
</SheetDescription>
<ContactForm callback={() => setIsContactSheetOpen(false)} />
</SheetHeader>
</SheetContent>
</Sheet>
  • We'll set open to isContactSheetOpen. -- This is the piece of state that keeps track of whether the Sheet is open or not.
  • We'll set onOpenChange to setIsContactSheetOpen. -- This updates our state when the Sheet is opened or closed.
  • Then, on our ContactForm, we'll pass in a callback function. This function will set our state to false, closing the Sheet.

Now, head over to our ContactForm component and update the handleSubmit function.

/src/app/components/ContactForm.tsx
.
const ContactForm = ({ callback }: { callback: () => void }) => {
const handleSubmit = async (formData: FormData) => {
const result = await createContact(formData);
if (result.success) {
toast.success("Contact created successfully");
callback();
} else {
toast.error("Error creating contact");
}
}

If you test everything out in the browser, you should still see the successful toaster message and the Sheet will close. 😎

Now, we need a way to see the contacts we’ve added.

Displaying Contacts

If we look at the design within Figma, we have a list of all the contacts, associated with the job application:

First things first, we need to get a list of all our contacts from the database. We don’t want ALL the contacts, we only want the ones associated with this job application. We haven’t submitted the form yet and any contacts we’ve created so far, aren’t associated with a company. Meaning, we can use the following flow:

  1. Grab any contacts that aren’t associated with a company.
  2. When we submit the form, we’ll associate the contact with the company and the job application.

This does create an interesting loop hole. If:

  1. The user creates some new contacts
  2. The user doesn’t submit the form and navigates away from the page
  3. The user comes back to the page, those contacts are still there.

That should be OK, as long as we have a way to remove any contacts in the list. But, we need make sure they’re only seeing contacts they’ve added.

Let’s start by displaying all contacts that aren’t associated with a company.

Getting the Contacts from the Database

Even though our contacts will be displayed in the ApplicationForm component, the ApplicationForm is a client component. It’d be better if we could grab the data inside a server component and then pass it in. (You’ll remember, we did the ame thing with our application statuses).

Inside our New page, let’s get all of our contacts, where the companyId is null:

/src/app/pages/applications/New.tsx
const New = async () => {
const statuses = await db.applicationStatus.findMany()
const contacts = await db.contact.findMany({
where: {
companyId: null
}
})
return (

Now, we can pass this data into our ApplicationForm component:

/src/app/pages/applications/New.tsx
<ApplicationsForm statuses={statuses} contacts={contacts} />

Inside our ApplicationForm component, let’s add contacts to our list of props:

/src/app/components/ApplicationForm.tsx
const ApplicationsForm = ({ statuses, contacts }:
{ statuses: ApplicationStatus[], contacts: Contact[] }) => {

We also need to import the Contact type at the top of our file from @prisma/client. We’re already importing ApplicationStatus from @prisma/client, so simply need to add it our list:

/src/app/components/ApplicationForm.tsx
import { ApplicationStatus, Contact } from "@prisma/client"

Scroll down to our <div>Contact Card</div> placeholder and replace it with a ul element:

/src/app/components/ApplicationForm.tsx
.
{contacts && (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>{contact.firstName} {contact.lastName} {contact.email} {contact.role}</li>
))}
</ul>
)}

If you take a look at this within the browser, you’ll see our contact list:

Let’s style our contact card.

Styling the Contact Card

Within the src/app/components directory, create a new file called ContactCard.tsx.

Inside our file, let’s start with a basic component structure:

/src/app/components/ContactCard.tsx
const ContactCard = () => {
return (
<div>ContactCard</div>
)
}
export { ContactCard }

We want to the contact object in as a prop:

/src/app/components/ContactCard.tsx
import { Contact } from "@prisma/client"
const ContactCard = ({ contact }: { contact: Contact }) => {

Then, inside the return statement:

/src/app/components/ContactCard.tsx
return (
<div>
<div>
<Avatar>
<AvatarFallback>
{contact.firstName.charAt(0)}
</AvatarFallback>
</Avatar>
</div>
<div>
<p>{contact.firstName} {contact.lastName}</p>
<p>{contact.role}</p>
</div>
<div>
<a href={`mailto:${contact.email}`}><Icon id="email" size={24} /></a>
</div>
</div>
)

Then, at the top the file, import the Avatar and Icon components:

/src/app/components/ContactCard.tsx
import { Avatar, AvatarFallback } from "./ui/avatar"
import { Icon } from "./Icon"

Let’s go back through and add some styles:

/src/app/components/ContactCard.tsx
return (
<div className="flex items-center gap-4 mb-6">
<div>
<Avatar className="size-10">
<AvatarFallback>
{contact.firstName.charAt(0)}
</AvatarFallback>
</Avatar>
</div>
<div className="flex-1">
<p className="text-sm font-medium">{contact.firstName} {contact.lastName}</p>
<p className="text-sm text-zinc-500">{contact.role}</p>
</div>
<div>
<a href={`mailto:${contact.email}`}><Icon id="email" size={24} /></a>
</div>
</div>
)
  • On line 7, we added a class of flex items-center gap-4 to align the avatar, name, and email icon vertically. We also put 16px of space between each element. Then, we also added 24px of margin to the bottom of the card.
  • On line 9, a class of size-10 makes the avatar 40px wide and 40px tall.
  • On line 15, the `flex-1 class ensure that the name and role take up as much horizontal space as possible.
  • On line 16, text-sm font-medium will make the name 14px and have a font weight of medium.
  • On line 17, the text role will be 14px with text-sm and gray with text-zinc-500.

Let’s swap out our placeholder content with our component. Inside the ApplicationsForm:

/src/app/components/ApplicationForm.tsx
{contacts.map((contact) => (
<li key={contact.id}><ContactCard contact={contact} /></li>
))}

At the top our file:

/src/app/components/ApplicationForm.tsx
import { ContactCard } from "./ContactCard"

If you take a look at this within the browser, you’ll see our styledcontact list:

😍 Lovely!

Now, we need to ensure that the user will only see the contacts that they’ve added.

Limiting the Contacts to the Current User

First, we’ll need to modify our prisma.schema file so that we can keep track of who authored the contact.

/prisma/schema.prisma
model Contact {
7 collapsed lines
id String @id @default(uuid()) // Contact ID (UUID-based)
firstName String
lastName String
email String?
role String?
companyId String?
company Company? @relation(fields: [companyId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
4 collapsed lines
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([id])
}
...
model User {
3 collapsed lines
id String @id @default(uuid()) // User ID (UUID-based)
username String @unique
createdAt DateTime @default(now())
contacts Contact[]
2 collapsed lines
applications Application[]
credentials Credential[] // Relationship: One user can have many credentials
}

On the Contact model, we need to add a userId column. Then, we need to establish the relationship between the Contact and User models, saying that the user column is a type of User. The relationship is connected through the userId column on the Contact model and references the id column on the User model.

Then, we need to set up the inverse relationship on the User model establishing there are contacts with a type Contact. Since this is an array, we’ll also include square brackets [].

Now, we need to create our migration. Within the terminal:

pnpm migrate:new "associate contacts with users"

When you run this, you’ll probably get an error:

The reason is because we already have contacts in our database. When we added the userId column to the Contact model, we said that it was a required field. This creates a problem for all existing contact entries that don’t have a user associated with them.

If you read the error message, the migration file was created, but not applied.

To fix this, you can try manually removing the existing contacts, or you can run the seed command:

pnpm seed

The seed command will also reset the database because we also clear all the tables at the top of the script:

/src/app/seed.ts
await db.$executeRawUnsafe(`\
DELETE FROM Application;
DELETE FROM ApplicationStatus;
DELETE FROM Contact;
DELETE FROM Company;
DELETE FROM Credential;
DELETE FROM User;
DELETE FROM sqlite_sequence;
`);

Now, try running pnpm migrate:dev.

The only downside is you’ll need to create a new user account, by visiting http://localhost:5173/user/signup to register.

Then, you can run the application seed command to add a new application, but first, you’ll need to update the user ID within the applicationSeed.ts file:

/src/app/seed/applicationSeed.ts
user: {
connect: {
id: "c4f35853-8909-4139-98bb-c08663e4230c",
},
},

If you scroll down within the applicationSeed.ts file, you’ll notice there’s an error when creating a contact:

It’s looking for the userId column. This is a quick fix. We can use userId we just updated the application entry with:

/src/app/seed/applicationSeed.ts
contacts: {
create: {
firstName: "John",
lastName: "Doe",
role: "Hiring Manager",
userId: "c4f35853-8909-4139-98bb-c08663e4230c"
},
},

Now, you can run:

pnpm seed:application

We also need to update our createContact function, inside our actions.ts file, to include the userId column:

/src/app/pages/applications/actions.ts
export const createContact = async (formData: FormData) => {
try {
const { ctx } = requestInfo;
if (!ctx.user) {
throw new Error("User not found");
}
await db.contact.create({
data: {
4 collapsed lines
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
email: formData.get("email") as string,
role: formData.get("role") as string,
user: {
connect: {
id: ctx.user?.id || "",
},
},
},
})
  • In order to get the current user ID, we can get the ctx from the requestInfo object (line 58). (We already imported requestInfo for the createApplication function.)
  • On lines 60-62, we can check to see if the ctx.user exists. If not, we'll throw an error.
  • If it does, we can connect the contact to the user using the connect method (lines 70-73)

Now, we need to update our database query on the New page.

/src/app/pages/applications/New.tsx
import { AppContext } from "@/worker"
const New = async ({ ctx }: { ctx: AppContext }) => {
const statuses = await db.applicationStatus.findMany()
const contacts = await db.contact.findMany({
where: {
companyId: null,
userId: ctx.user?.id || ""
}
})
  • On each page, we have access to the ctx object. This has a type of AppContext, defined in src/worker.
  • From ctx, we can access the user object and the user's ID.
  • On our database call, we can add another property to our where clause, saying that the userId should match the logged in user's ID.

If you try adding contacts using our form, the display will look the same as before. But, you can confirm the new functionality is working by using Prisma Studio to modify a contact’s userId property. If the userId doesn’t match the logged in user’s ID, the contact will not be displayed.

Associating the Contact with a Company

Now, when we add a job application, we need to associate the contact with the company. We’ll need to adjust our createApplication function within the src/app/pages/applications/actions.ts file slightly.

First, let’s ensure that we’re passing our contacts into our createApplication function properly.

Within the ApplicationForm component, go to the handleSubmit function:

/src/app/components/ApplicationForm.tsx
const ApplicationsForm = ({ statuses, contacts }:
{ statuses: ApplicationStatus[], contacts: Contact[] }) => {
const [isContactSheetOpen, setIsContactSheetOpen] = useState(false);
const handleSubmit = async (formData: FormData) => {
formData.append("contacts", JSON.stringify(contacts));
const result = await createApplication(formData);
  • On line 21, we're already passing all of our contacts into our ApplicationForm component.
  • Within our handleSubmit function, we can append the contacts to our formData. The only "catch" is that our contacts will need to be converted into a JSON string.

Now, within our src/app/pages/applications/actions.ts file, inside our createApplication function, we need to parse our JSON string:

/src/app/pages/applications/actions.ts
export const createApplication = async (formData: FormData) => {
7 collapsed lines
try {
const { ctx } = requestInfo;
if (!ctx.user) {
throw new Error("User not found");
}
const contacts = JSON.parse(formData.get("contacts") as string) as Contact[];
  • We get the contacts off the formData which is a string: formData.get("contacts") as string
  • Then, we use JSON.parse to convert the string into an array of Contact objects.

Be sure to import the Contact type at the top of the file from @prisma/client:

/src/app/pages/applications/actions.ts
import { Contact } from "@prisma/client"

Then, when we’re creating our company, inside the Prisma create method, we can connect our contacts to the company:

/src/app/pages/applications/actions.ts
company: {
create: {
name: formData.get("company") as string,
contacts: {
connect: {
firstName: "John",
lastName: "Doe",
role: "Hiring Manager",
userId: ctx.user.id,
}
connect: contacts,
},
},
},

Now, when you create a new job application, any contacts you’ve listed, will be associated with the company and job application you created.

And, if you try to add a New Job Application, the contacts list will be empty:

One step remains: removing a contact from the list.

Removing Contacts

Styling the Delete Button

First, we need to modify the ContactCard component to have a delete button:

/src/app/components/ContactCard.tsx
return (
<div className="flex items-center gap-4 mb-6">
<div>
<button role="button">
<Icon id="close" size={16} />
</button>
</div>
<div>
<Avatar className="size-10">
<AvatarFallback>
{contact.firstName.charAt(0)}
</AvatarFallback>
</Avatar>
</div>

I added a div right before the Avatar div that contains a button with an X icon inside. Pretty straightforward.

Now, we need to add some styles.

/src/app/components/ContactCard.tsx
return (
<div className="relative flex items-center gap-4 mb-6">
<div className="absolute top-2 -left-[37px]">
<button role="button"
className="rounded-full bg-destructive p-1">
<Icon id="close" size={16} />
</button>
</div>
  • On line 8, I'm using the wrapping div to position the button absolutely. It will appear 8px from the top and -37px over from the left. In order for it to be positioned, relative to the parent container, we need to set the div to relative on line 7.
  • On line 9, I'm using a regular button tag, instead of the shadcn/ui Button component because this button won't look like any of the other primary action buttons we've created so far.
    • I have a class of rounded-full to turn the button into a circle.
    • bg-destructive to make the background color red.
    • p-1 to add 4px of padding on all sides.

If you look at this in the browser, you should see our red X next to the contact card:

I think it’d look better if we made the X white. One of my favorite ways to do this is to make the svg fill match the current text color. We can do that by adding a class to our button of text-white and fill-current.

/src/app/components/ContactCard.tsx
<button role="button" className="text-white fill-current rounded-full bg-destructive p-1">
<Icon id="close" size={16} />
</button>

Let’s also change the hover color so that the user has some visual feedback when rolling over the button. We can change the background to black and ensure the cursor is a pointer:

/src/app/components/ContactCard.tsx
<button role="button" className="hover:bg-black cursor-pointer text-white fill-current rounded-full bg-destructive p-1">
<Icon id="close" size={16} />
</button>

I don’t want a bunch of red circles lined up. If you have a bunch of contact cards, these will be eye catching and distracting from the main form. Let’s make it so that the button only appears when hovering over each respective contact card.

To do this, we’ll need to add a group to the ContactCard component.

/src/app/components/ContactCard.tsx
<div className="relative group/card flex items-center gap-4 mb-6">

On the div wrapping the button:

/src/app/components/ContactCard.tsx
<div className="hidden group-hover/card:block pr-10 absolute top-1 -left-[37px]">
<button
role="button"
className="hover:bg-black cursor-pointer text-white fill-current rounded-full bg-destructive p-1">
<Icon id="close" size={16} />
</button>
</div>
  • We can set the div to hidden by default. But, when the user hovers over the group/card it applies a display of block with group-hover/card:block

Perfect! With the styling applied, let’s give this button the functionality it needs.

Adding the Delete Functionality

On the button, let’s add an onClick handler:

/src/app/components/ContactCard.tsx
<button
role="button"
className="hover:bg-black cursor-pointer text-white fill-current rounded-full bg-destructive p-1">
<Icon id="close" size={16} />
</button>

Now that we’ve introduced interactivity, this component needs to be a client component. At the top of the file, let’s add:

/src/app/components/ContactCard.tsx
"use client"

Now, let’s create the handleDelete function that we referenced in the onClick handler. Right above our return statement:

/src/app/components/ContactCard.tsx
const handleDelete = async () => {
const result = await deleteContact(contact.id);
if (result.error) {
toast.error("Yikes! Couldn't delete.");
} else {
toast.success("Contact deleted");
}
}
  • Since we're calling a server function, we can use async and await to wait for the deleteContact function to complete (we still need to write this function).
  • We'll pass over the contact's id, so that we know which contact to delete.
  • We can store the result in a variable called result.
  • If there's an error, we'll display a toast error.
  • If it works, we'll display a toast success message.

Now, we need to import toast and deleteContact at the top of our file:

/src/app/components/ContactCard.tsx
import { toast } from "sonner"
import { deleteContact } from "../pages/applications/actions"

Now, let’s head over to our actions.ts file and create the deleteContact function.

/src/app/pages/applications/actions.ts
export const deleteContact = async (contactId: string) => {
try {
await db.contact.delete({
where: {
id: contactId,
},
})
return { success: true, error: null }
} catch (error) {
console.error(error)
return { success: false, error: error as Error }
}
}

Hopefully, this workflow is starting to feel familiar.

  • On line 82, we're accepting the contactId, which is a string.
  • We have a try / catch block where we try to delete our contact. If it runs into any problems, it will catch our error (on line 90), console the error, and return success as false and the error itself.
  • On lines 84-88, we're using the Prisma API to delete the contact, where the id is equal to the contactId that we passed in.

Now, if you try running this in the browser, you’ll probably run into an error:

This is because when the use clicks on the button, the page tries to submit the form. To prevent the default browser behavior, we can update the onClick handler to capture the event:

/src/app/components/ContactCard.tsx
<button role="button"
className="hover:bg-black cursor-pointer text-white fill-current rounded-full bg-destructive p-1"
onClick={(e) => handleDelete(e)}
>
<Icon id="close" size={16} />
</button>

Now, let’s update the handleDelete function to accept the event and prevent the form from submitting:

/src/app/components/ContactCard.tsx
const handleDelete = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();

Now, try running this in the browser again. You should see the contact deleted and a successful toast message:

Lovely!