Jobs Details
Can you believe it?! This is the last piece we need before deploying our application.
We’ve already done the bulk of our set up, so this part should easier-ly. 🙃
First, let’s take a look at what we’re building in Figma.
Pretty simple, right?
Styling the Jobs Details Page
Let’s start by creating a new file in our src/app/pages/applications
folder called Details.tsx
.
Directorysrc
Directoryapp
Directorypages
Directoryapplications
- actions.ts
- Details.tsx
- List.tsx
- New.tsx
We can start with the basics:
const Details = () => { return ( <div>Details</div> )}
export { Details }
Now, let’s jump over to our worker.tsx
file and update the route for our details page.
import { Details } from "./app/pages/applications/Details";
render(Document, [4 collapsed lines
index([ isAuthenticated, Home ]), prefix("/user", userRoutes), route("/legal/privacy", () => <h1>Privacy Policy</h1>), route("/legal/terms", () => <h1>Terms of Service</h1>), prefix("/applications", [ route("/", [isAuthenticated, List]), route("/new", [isAuthenticated, New]), route("/:id", [isAuthenticated, () => <h1>Application</h1>]), route("/:id", [isAuthenticated, () => Details]), ]),]),
We replaced our placeholder h1
with our Details
component and imported it at the top of our file.
If run go to our Applications list view and click on the eye icon, next to any application, it should take us to the detail page:
Right now, it’s pretty sparse:
Let’s go back to our Details.tsx
file and add our InteriorLayout
.
return ( <InteriorLayout> <p>Details</p> </InteriorLayout>)
Be sure to import InteriorLayout
at the top of our file:
import { InteriorLayout } from "@/app/layouts/InteriorLayout"
Already, that makes a huge difference:
Let’s start at the top of our page and work our way down. Up first, we’ll add our breadcrumbs. We already used the shadcn/ui Breadcrumb component on the New page. Let’s take a second and look at the implementation there:
<div className="mb-12 -mt-7 pl-[120px]"> <Breadcrumb> <BreadcrumbList> <BreadcrumbItem> <BreadcrumbLink href="/applications">Applications</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbPage>Add an Application</BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb></div>
It’s pretty standard, but we have a wrapping div
with styles applied for placement. Let’s abstract that out into a CSS class so that we can use it on both pages. In our styles.css
file, at the bottom of the @layer components
section:
.breadcrumbs { @apply mb-12 -mt-7 pl-[120px];}
Now, let’s update the styles on the New page:
<div className="breadcrumbs"> <Breadcrumb> <BreadcrumbList> <BreadcrumbItem> <BreadcrumbLink href="/applications">Applications</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbPage>Add an Application</BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb></div>
Then, let’s copy this code and past it into our Details.tsx
file:
return ( <InteriorLayout> <div className="breadcrumbs"> <Breadcrumb> <BreadcrumbList> <BreadcrumbItem> <BreadcrumbLink href="/applications">Applications</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbPage>Add an Application</BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> </div> <p>Details</p> </InteriorLayout>)
We need to update the breadcrumb links. The first link is for our Dashboard:
<BreadcrumbItem> <BreadcrumbLink href="/applications">Dashboard</BreadcrumbLink></BreadcrumbItem>
The second page should contain the job title and company name.
We need to get that data dynamically, based on the URL, where the application id is being passed in as a parameter.
Getting the Content Based on the URL
The URL for your page should look something like this: http://localhost:5173/applications/cc70d03f-6bf0-4a07-a46c-7f940be774b6
Everything after applications/
is the application id.
We can get all the details we need based on the RequestInfo
object that gets passed into all pages by default.
import { RequestInfo } from "@redwoodjs/sdk/worker"
const Details = async ({ params }: RequestInfo) => {
Let’s console.log
the params
, right before our return
statement:
console.log({ params })
Since this code is running on the server, this will get logged to the terminal, not the browser.
Perfect! We can get the application ID by referencing params.id
.
Now, we can remove our console.log
and make our database call:
import { db } from "@/db"
const Details = async ({ params }: RequestInfo) => { const application = await db.application.findUnique({ where: { id: params.id, }, })
console.log({ application })
We're using the Prisma API to find the unique application by based on its ID. Then, we're logging the result to the terminal.
If you check the terminal again, you should see the application data:
Now, that we know we’re getting the right application back, let’s update our <BreadcrumbPage>
to display the job title and company name.
<BreadcrumbPage>Add an Application</BreadcrumbPage><BreadcrumbPage>{application?.jobTitle} at {application?.company?.name}</BreadcrumbPage>
We’re using the Elvis operator (?.
) or optional chaining to ensure that we don’t throw an error if the application object is null
.
When you preview this in the browser, you might be surprised to see that the company information is not showing up.
We need to include the company
relationship in our database call:.
const application = await db.application.findUnique({ where: { id: params.id, }, include: { company: true, },})
and actually, while we’re here, let’s go ahead and include our applicationStatus
and contact
relationships as well. Since the contact
relationship is through our company
object, we need to go another level deeper:
const application = await db.application.findUnique({ where: { id: params.id, }, include: { applicationStatus: true, company: { include: { contacts: true, }, }, },})
We can get rid of our console.log
statement and move down the page.
Building the Page Header
Next, we have a page header:
Below our bread crumbs, let’s start with our JSX and then we can come back and style it.
<header> <div> <h1>{application?.jobTitle}</h1> <Badge variant={application?.applicationStatus?.status.toLowerCase() as VariantProps<typeof badgeVariants>['variant']}>{application?.applicationStatus?.status}</Badge> <p>at {application?.company?.name}</p> </div> <div> <Button><a href={application?.postingUrl as string} target="_blank">View Application <Icon id="external-link" size={16} /></a></Button> </div></header>
- We're using a semantic
header
HTML tag. - Inside, we have 2
div
s to separate the content that will appear on the left and right side. - On the left side
- We have an
h1
for our job title. - A
Badge
that contains our application status. Similar to what we did on theApplicationTable
, we're using the shadcn/uiBadge
component. The "tricky" part is the button variant types. We can use the application status to get the name of the type:application?.applicationStatus?.status.toLowerCase()
but we have to cast it to the correct type:VariantProps<typeof badgeVariants>['variant']
. If you'll rememberVariantProps
is a utility that we're getting from thecva
library that shadcn/ui uses under the hood. - A
p
tag that contains the company name.
- We have an
- On the right side, we have a
Button
that contains a link to the application posting URL. Inside, we have a label View Application and anIcon
component with anid
ofexternal-link
and a size of16px
. The icon will help communicate that this is an external link.
At the top of our file, we need to add a few imports:
import { Badge, badgeVariants } from "@/app/components/ui/badge"import { Button } from "@/app/components/ui/button"import { VariantProps } from "class-variance-authority"
Now, let’s do another pass, this time adding our styles.
<header className="flex justify-between border-b-1 border-border pb-6 mb-12">
- We can position the items on the left and right side with
flex justify-between
. - Then, we can add a
1px
border to the bottom withborder-b-1
and give it a light gray color withborder-border
. - We also add some padding (
24px
on teh bottom withpb-6
and48px
of margin on the bottom withmb-12
).
I want the heading and the badge to appear next to each other, so I’ll wrap those elements with an additional div
.
<div> <div className="flex items-center gap-5 mb-1"> <h1 className="page-title">{application?.jobTitle}</h1> <Badge variant={application?.applicationStatus?.status.toLowerCase() as VariantProps<typeof badgeVariants>['variant']}>{application?.applicationStatus?.status}</Badge> </div> <p><span className="text-zinc-500">at</span> {application?.company?.name}</p></div>
- On line 42, I'll use
flex items-center gap-5
to center the heading and badge vertically and add20px
of space between them. Then, I'll add some8px
of space to the bottom withmb-1
. - On line 43 we can apply the
page-title
class to theh1
. - Then, let's wrap "at" with a
span
and give it a lighter gray color withtext-zinc-500
.
For the button on the right, we need to add some styles to get the icon and label to align vertically:
<Button><a href={application?.postingUrl as string} target="_blank" className="flex items-center gap-2">View Application <Icon id="external-link" size={16} /></a></Button>
I want to add some space to the left and right side. But, this will also need to be applied to the content below our page heading. So, let’s wrap, both, our page header and the placeholder <p>Details</p>
with a div
and add our px-page-side
class.
<div className="px-page-side"> <header className="flex justify-between border-b-1 border-border pb-6 mb-12">10 collapsed lines
<div> <div className="flex items-center gap-5 mb-1"> <h1 className="page-title">{application?.jobTitle}</h1> <Badge variant={application?.applicationStatus?.status.toLowerCase() as VariantProps<typeof badgeVariants>['variant']}>{application?.applicationStatus?.status}</Badge> </div> <p><span className="text-zinc-500">at</span> {application?.company?.name}</p> </div> <div> <Button><a href={application?.postingUrl as string} target="_blank">View Application</a></Button> </div> </header>
<p>Details</p></div>
Build the Main Content Area
Our main content area is a two column grid. This spacing should match the two column grid we set up on our new Application page. We can pull up our ApplicationForm
component and abstract those styles into a reusable CSS class:
return ( <form action={handleSubmit}> <div className="grid grid-cols-2 gap-[200px] px-page-side mb-[75px]">
I’m going to replace all of our classes, except for px-page-side
, with two-column-grid
:
return ( <form action={handleSubmit}> <div className="px-page-side two-column-grid">
Now, within our CSS file, inside the @layer components
section, let’s add our new two-column-grid
class:
.two-column-grid { @apply grid grid-cols-2 gap-[200px] mb-[75px]}
Back in our Details.tsx
file, we can use our new class:
<div className="two-column-grid"> <p>Details</p> <aside> <p>Sidebar</p> </aside></div>
Notice, I also added an aside
placeholder so that when you preview this within the browser, the two column layout is obvious.
For the content on the left, we can replace the <p>Details</p>
with the content from the database:
<div className="two-column-grid"> <p>Details</p> <div> {application?.jobDescription} </div> <aside> <p>Sidebar</p> </aside></div>
Now, we want to put a couple of buttons below our content. I’m going to use a couple more divs
, one to wrap our job description and another to wrap our buttons.
<div className="two-column-grid"> <div> <div>{application?.jobDescription}</div> <div> <Button variant="secondary"><Icon id="edit" size={16} /> Edit</Button> <Button variant="link"><Icon id="trash" size={16} /> Delete</Button> </div> </div>3 collapsed lines
<aside> <p>Sidebar</p> </aside></div>
- On line 59 we have our edit button. I'm using the shadcn/ui
Button
component and applied thesecondary
variant to give it a beige color. Inside, I'm using ourIcon
component. I set theid
toedit
and the size to16px
. - On line 60 we have our delete button. I'm using the shadcn/ui
Button
component again, bu this time I applied thelink
variant to remove the background color. There is adestructive
variant, but it turns the background color red and you can only apply one variant at a time. We'll account for the difference through styling. Inside, I'm using ourIcon
component. I set theid
totrash
and the size to16px
.
Let’s add some polish through CSS.
<div className="two-column-grid"> <div> <div className="mb-12">{application?.jobDescription}</div> <div className="flex items-center gap-5"> <Button variant="secondary"><Icon id="edit" size={16} /> Edit</Button> <Button variant="link" className="text-destructive fill-current"><Icon id="trash" size={16} /> Delete</Button> </div> </div>3 collapsed lines
<aside> <p>Sidebar</p> </aside></div>
- On line 57 I added
48px
of margin below our content withmb-12
- On line 58 I adjusted the alignment for the buttons by adding a class of
flex items-center gap-5
. This forces this buttons to align vertically and puts20px
of spacing between them. - On line 60 we can make our delete button red by adding a class of
text-destructive
. Then, to change the color of our svg to match the text color, we can add a class offill-current
.
Excellent! Now, for our sidebar.
We can reuse a lot of the styles that we created for our new application form. Let’s start with the basic JSX:
<aside> <div className="box"> <h3>Compensation</h3> <div> <Icon id="salary" size={32} /> <div> <p>Salary</p> <p>{application?.salaryMin} - {application?.salaryMax}</p> </div> </div> </div></aside>
- On line 64, I used the
box
class we've already created. - We already have some existing styles for
h3
s inside of thebox
class. - On line 67 I added an
Icon
component with anid
ofsalary
and a size of32px
. - On line 69-70, we have a "Salary" label and a
p
tag that contains the salary range. I placed this content in a wrappingdiv
so that we can eventually align it vertically with the icon.
Let’s go back through and add some more styles:
<aside> <div className="box"> <h3 className="mb-4">Compensation</h3> <div className="flex items-center gap-6"> <Icon id="salary" size={32} /> <div className="text-sm"> <p className="text-zinc-500">Salary</p> <p className="font-bold">{application?.salaryMin} - {application?.salaryMax}</p> </div> </div> </div></aside>
- On line 65 I added
16px
of margin below the heading withmb-4
- On line 66, I aligned the icon and salary information with
flex items-center
. I also added24px
of padding between withgap-6
. - On line 68 I added a class of
text-sm
to make the salary information14px
. - On line 69 I made the text gray with
text-zinc-500
. - On line 70 I added a class of
font-bold
to the salary range to make it bold.
Now, let’s add the contact box. The only difference between this contact box and the one on the new application form is we don’t need the “Add a Contact” button.
In fact, let’s go over to our ApplicationForm
component and copy paste.
<div className="box"> <h3>Contacts</h3> <p className="input-description">Invite your team members to collaborate.</p> {contacts && ( <ul> {contacts.map((contact) => ( <li key={contact.id}><ContactCard contact={contact} /></li> ))} </ul> )}
Notice I stopped right before our Sheet
component. We won't need it, but when we paste our code, we need to be sure to close our <div>
tag.
Now, let’s paste our code into our Details.tsx
file.
<div className="box"> <h3>Contacts</h3> <p className="input-description">Invite your team members to collaborate.</p> {contacts && ( <ul> {contacts.map((contact) => ( <li key={contact.id}><ContactCard contact={contact} /></li> ))} </ul> )}</div>
At the top of our file, import the ContactCard
component:
import { ContactCard } from "@/app/components/ContactCard"
On our Contact box, we have some linting errors because it cannot find our contacts
.
We have this data, but it’s nested inside our application.company
object. We can adjust our conditional statement and the object we’re mapping over to get rid of the error:
{application?.company?.contacts && ( <ul> {application?.company?.contacts.map((contact) => ( <li key={contact.id}><ContactCard contact={contact} /></li> ))} </ul>)}
Amazing!
The only problem is when you hover over a contact card, the delete button appears. I don’t want to be able to modify the contact information here.
Let’s change the ContactCard
component to take a new prop: isEditable
.
const ContactCard = ({ contact, isEditable = true }: { contact: Contact, isEditable?: boolean }) => {
I made the isEditable
prop optional and set it to true
by default. This way, it won't effect our existing code.
Now, I’m going to wrap our Delete button with a conditional statement that checks if isEditable
is true
:
{isEditable && ( <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" onClick={(e) => handleDelete(e)} > <Icon id="close" size={16} /> </button> </div>)}
Back on our Details
page, where we’re looping over the ContactCard
component, we can pass in false
for the isEditable
prop:
{application?.company?.contacts && ( <ul> {application?.company?.contacts.map((contact) => ( <li key={contact.id}><ContactCard contact={contact} isEditable={false} /></li> ))} </ul>)}
If you test this out in the browser, you’ll see that the delete button doesn’t appear when you hover over a contact card on the Details
page, but it still appears on the New Application
page.
Next up, let’s hot wire the delete button.
Deleting a Job Application
The functionality for the delete button will be similar to how we set up the delete contacts button, but this time let’s use a confirmation dialog or modal window to confirm the user really wants to delete the application.
For this, let’s reach for another shadcn/ui component: the dialog box.
Styling the Dialog Component
Looking at the documentation, the code for this component resembles the Sheet
component code:
<Dialog> <DialogTrigger>Open</DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Are you absolutely sure?</DialogTitle> <DialogDescription> This action cannot be undone. This will permanently delete your account and remove your data from our servers. </DialogDescription> </DialogHeader> </DialogContent></Dialog>
There’s a trigger and a content section, with a nested title and description. If you look at some of the additional examples within the shadcn/ui documentation, you’ll notice we also have access to a <DialogFooter>
component, perfect for buttons.
The Dialog
component requires button clicks and maintaining state, so we’ll need to be sure we’re using a client component. Since the Details.tsx
page is a server component, let’s create a new component specifically for this feature. As an added benefit, we can reuse this component throughout our application.
Inside our src/app/components
, let’s create a new file and name it DeleteApplicationButton.tsx
Directorysrc/
Directoryapp/
Directorycomponents/
- DeleteApplicationButton.tsx
Let’s start with a bare bones component:
const DeleteApplicationButton = () => { return ( <div>DeleteApplicationButton</div> )}
export { DeleteApplicationButton }
Then, we can replace the placeholder <div>DeleteApplicationButton</div>
with the Dialog code from shadcn/ui:
"use client";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from "@/app/components/ui/dialog"
const DeleteApplicationButton = () => { return ( <Dialog> <DialogTrigger>Open</DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Are you absolutely sure?</DialogTitle> <DialogDescription> This action cannot be undone. This will permanently delete your account and remove your data from our servers. </DialogDescription> </DialogHeader> </DialogContent> </Dialog> )}
export { DeleteApplicationButton }
- You'll notice I included the
use client
directive at the top of our file, as well as our imports. If you copied theimport
statement directly from shadcn/ui, the path will vary slightly.
Before, I forget, we also need to go into our app/components/ui/dialog.tsx
component and add a use client
directive to the top of our shadcn/ui component as well:
"use client";
Back inside our DeleteApplicationButton
component, let’s start customizing it, to meet our needs.
Let’s replace Open
inside the DialogTrigger
component with the Delete
button we’ve already styled:
<DialogTrigger> Open <Button variant="link" className="text-destructive fill-current"><Icon id="trash" size={16} /> Delete</Button></DialogTrigger>
If you look at the DOM that’s rendered in the browser, DialogTrigger
renders as a button
. Then, we have a Button
nested inside. To prevent this from happening, we can use the asChild
prop on the DialogTrigger
.
<DialogTrigger asChild> <Button variant="link" className="text-destructive fill-current"><Icon id="trash" size={16} /> Delete</Button></DialogTrigger>
Inside the DialogTitle
we can use the text: “Are you absolutely sure?”
<DialogTitle>Are you absolutely sure?</DialogTitle>
For the DialogDescription
, we can use the text: “This will permanently delete the application and any related companies and contacts. This action cannot be undone.”
<DialogDescription> This will permanently delete the application and any related companies and contacts. This action cannot be undone.</DialogDescription>
Below the DialogHeader
, let’s add a DialogFooter
with two buttons: Nevermind
and Yes, Delete It
.
<DialogFooter> <Button variant="secondary">Nevermind</Button> <Button variant="destructive"> <Icon id="check" /> Yes, Delete It </Button></DialogFooter>
- For the "Nevermind" button, I'm using the
secondary
variant, applying a beige background color. - For the "Yes, Delete It" button, I set the
variant
todestructive
. Inside, I added anIcon
component with anid
ofcheck
.
Let’s go back through and add a little more styling:
<DialogContent className="py-12 px-14"> <DialogHeader className="mb-4"> <DialogTitle className="text-destructive text-3xl font-bold mb-2">Are you absolutely sure?</DialogTitle> <DialogDescription> This will permanently delete the application and any related companies and contacts. This action cannot be undone. </DialogDescription> </DialogHeader> <DialogFooter> <Button variant="secondary">Nevermind</Button> <Button variant="destructive" className="fill-current"> <Icon id="check" /> Yes, Delete It </Button> </DialogFooter></DialogContent>
- On line 23, I added
48px
of padding to the top and bottom, withpy-12
and56px
to the left and right withpx-14
. - On line 24, I added
16px
of margin below the header withmb-4
. - On line 25, I made the heading red with
text-destructive
,30px
big withtext-3xl
, and added8px
of margin below withmb-2
. - On line 32, I added a class of
fill-current
so that the check icon would match the text color of the button.
Now, let’s connect the delete application functionality.
Delete Application Functionality
On our “Yes, Delete It” button, we can add an onClick
handler that will call our deleteApplication
function.
<Button variant="destructive" className="fill-current" onClick={(e) => handleDelete(e)}> <Icon id="check" /> Yes, Delete It</Button>
- We're passing in the
e
event object to thehandleDelete
function. This will allow us to prevent the default action of the button.
Now, let’s create the handleDelete
function, right above the return
statement:
const handleDelete = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault(); const result = await deleteApplication(applicationId); if (result.success) { toast.success("Application deleted successfully"); window.location.replace("/applications"); } else { console.error(result.error) toast.error("Failed to delete application"); }}
- On line 20, we're using the event (
e
) object to prevent the default action of the button. - On line 21, we're calling the
deleteApplication
function (we still need to write this function). This is a server function, we can useasync
/await
. We'll save the result in a variable calledresult
. - On line 22, we're checking to see if the result was successful. If it was, we'll display a successful toast message and redirect the user to the applications list page.
- On line 25, otherwise, if we ran into an error, we'll
console.error
the result and display a toast error message.
At the top of our file, we need to import toast
and deleteApplication
function:
import { toast } from "sonner";import { deleteApplication } from "@/app/actions";
We also need a way of knowing what application we need to delete. We can pass the applicationId
into the DeleteApplicationButton
component and then on to the deleteApplication
function.
const DeleteApplicationButton = ({ applicationId }: { applicationId: string }) => { const [isOpen, setIsOpen] = useState(false);
const handleDelete = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault(); const result = await deleteApplication(applicationId);
Within our action.ts
file, we need to create the deleteApplication
function.
export const deleteApplication = async (applicationId: string) => { try { await db.application.delete({ where: { id: applicationId, }, }); return { success: true, error: null } } catch (error) { console.error(error) return { success: false, error: error as Error } }}
- On line 96, we're
export
ing thedeleteApplication
function so that we can use it in ourDeleteApplicationButton
component. This is anasync
function, so we can useawait
to wait for the database call to complete. - We using a
try
/catch
block to run the Prisma API call. - On line 98, we're running the calling the
delete
method,where
theid
matches theapplicationId
that we passed in. - If we're successful, we'll return
success
astrue
anderror
asnull
. - If there are any errors, they'll be caught on line 104,
console.error
out. Then, we'll returnsuccess
asfalse
and include theerror
in the response.
Now, let’s go back to our Details.tsx
file and add our DeleteApplicationButton
component. We can replace the “Delete” Button
with our DeleteApplicationButton
component and pass in the application?.id
as the applicationId
prop. As always, be sure to import DeleteApplicationButton
at the top of your file:
import { DeleteApplicationButton } from "@/app/components/DeleteApplicationButton"... <Button variant="link" className="text-destructive fill-current"><Icon id="trash" size={16} /> Delete</Button><DeleteApplicationButton applicationId={application?.id as string} />
Let’s test this out in the browser.
🙌 Looks like everything is working.
Before we can move on, we need to set up the “Nevermind” button to close the dialog box.
Closing the Dialog Box
This is a similar problem to the one we ran into with our Sheet
component. If you’ll remember, the Sheet
component is built on top of the Radix UI Dialog
component and this is the Dialog
component.
Within our DeleteApplicationButton
component, we need to create a piece of state to keep track of whether the dialog box is open or not.
const [isOpen, setIsOpen] = useState(false);
Then, we can use the Dialog
’s props, open
and onOpenChange
, to control the dialog box, referencing our state variable. (For more information, check out the official Radix UI documentation for the Dialog component.)
<Dialog open={isOpen} onOpenChange={setIsOpen}>
Now, we can update the “Nevermind” button to close the dialog box, by updating the isOpen
state:
<Button variant="secondary" onClick={ () => setIsOpen(false) }>Nevermind</Button>
If you test this in the browser, the “Nevermind” button should close the dialog box, without deleting the application. 😎
Implementing Edit Functionality
Our application is looking great, but we’re missing one thing: the ability to edit the application.
Setting up the Edit Page and Components
Let’s take the smarter, not harder approach and start with our New
application form page and modify it. Let’s duplicate the New.tsx
file and name the new file Edit.tsx
.
Directorysrc/
Directoryapp/
Directorypages/
Directoryapplications/
- actions.ts
- Details.tsx
- Edit.tsx
- List.tsx
- New.tsx
Inside, we need to rename our component to Edit
const New = async ({ ctx }: { ctx: AppContext }) => { const Edit = async ({ ctx }: { ctx: AppContext }) => {... export { New } export { Edit }
Within our worker.tsx
, let’s add the new route:
prefix("/applications", [ route("/", [isAuthenticated, List]), route("/new", [isAuthenticated, New]), route("/:id", [isAuthenticated, Details]), route("/:id/edit", [isAuthenticated, Edit]),]),
Be sure to import the Edit
component at the top of our worker.tsx
file:
import { Edit } from "@/app/pages/applications/Edit";
Then, let’s add our new edit page to the list of routes inside our src/app/shared/links.ts
file:
export const link = defineLinks([9 collapsed lines
"/", "/user/login", "/user/signup", "/user/logout", "/legal/privacy", "/legal/terms", "/applications", "/applications/new", "/applications/:id", "/applications/:id/edit",]);
Now, let’s update the Edit button on the Details page so that we can easily navigate to our new page:
<Button variant="secondary" asChild> <a href={link('/applications/:id/edit', { id: application?.id })}> <Icon id="edit" size={16} /> Edit </a></Button>
- I changed the
Button
component to include theasChild
prop. This allows us to maintain the button styles, but render a link instead. - Inside the
Button
component, I added a link, that uses thelink
function to reference theEdit
page. The first parameter is the route and the second parameter contains an object with the parameters we're passing in. Here, theapplication?.id
You’ll also need to import the links
function at the top of our file:
import { link } from "@/app/shared/links"
If you look at this in the browser and click on the Edit button, it should take you to a page that looks like the new application form. But, the URL is different.
Loading the Application from the Database
Let’s get our job application content from the database and pass it into the form:
const Edit = async ({ params, ctx }: RequestInfo) => { const application = await db.application.findUnique({ where: { id: params.id, }, include: { applicationStatus: true, company: { include: { contacts: true, }, }, }, })
- This code is similar to what we're already doing on the
Details.tsx
page. By default, our page gets aRequestInfo
object, which contains theparams
andctx
. - We can access the
id
in the URL through theparams
object withparams.id
. - On lines 10-21 we're using the Prisma API to find the unique application,
where
theid
is equal toparam.id
. - We're also including the
applicationStatus
,company
, andcontacts
in the response, withinclude
.
Be sure to import RequestInfo
at the top of our file:
import { RequestInfo } from "@redwoodjs/sdk/worker"
If we work our way down the file, we still need to grab all the application statuses to populate the dropdown menu:
const statuses = await db.applicationStatus.findMany()
But, we can delete the block of code where we get all the contacts that aren’t related to a company:
const contacts = await db.contact.findMany({ where: { companyId: null, userId: ctx.user?.id || "" }})
We already have all the contact information we need through the application
database call.
Let’s keep moving down the file. We can change our BreadcrumbPage
to display the Job Title:
<BreadcrumbList>4 collapsed lines
<BreadcrumbItem> <BreadcrumbLink href="/applications">Applications</BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbLink href={link('/applications/:id', { id: application?.id ?? '' })}> {application?.jobTitle} at {application?.company?.name} </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbPage>Edit Application</BreadcrumbPage> </BreadcrumbItem></BreadcrumbList>
- I injected a
BreadcrumbItem
and aBreadcrumbSeparator
after the Applications link to include the job details page. - On the
BreadcrumbLink
, I used ourlink
function to reference the job details page. - I used the content from our database to dynamically display the job title and company name.
- Then, I changed the
BreadcrumbPage
toEdit Application
.
Looking at the next section:
<div className="mx-page-side pb-6 mb-8 border-b-1 border-border"> <h1 className="page-title">New Application</h1> <p className="page-description">Create a new application</p></div>
We can change the contents of our page title and description:
<h1 className="page-title">Edit {application?.jobTitle}</h1><p className="page-description">Edit the details of this job application.</p>
For the form, I’m going to duplicate the src/app/components/ApplicationsForm.tsx
file and name the new file EditApplicationForm.tsx
.
Inside, we need to change the name of the component to match the name of the file:
const ApplicationsForm = ({ statuses, contacts }: const EditApplicationForm = ({ statuses, contacts }:... export {ApplicationsForm} export {EditApplicationForm}
Then, on the Edit
page, let’s swap out the ApplicationsForm
component with our EditApplicationForm
component:
import { ApplicationsForm } from "@/app/components/ApplicationsForm" import { EditApplicationForm } from "@/app/components/EditApplicationForm"... <ApplicationsForm statuses={statuses} contacts={contacts} /> <EditApplicationForm statuses={statuses} contacts={contacts} />
While we’re here, let’s modify the props that we’re passing into our component. We no longer need the contacts
prop, but we do need to pass in the application:
<EditApplicationForm statuses={statuses} contacts={contacts} /><EditApplicationForm statuses={statuses} application={application} />
Now, let’s move over to our EditApplicationForm.tsx
file and update the props, there, to match:
import { ApplicationStatus, Contact } from "@prisma/client" import { ApplicationStatus, Application, Company, Contact } from "@prisma/client"... const EditApplicationForm = ({ statuses, contacts }: { statuses: ApplicationStatus[], contacts: Contact[] }) => { const EditApplicationForm = ({ statuses, application }: { statuses: ApplicationStatus[], application: Application & { applicationStatus: ApplicationStatus } & { company: Company & { contacts: Contact[] } } }) => {
We updated the types that we’re importing from Prisma to remove Contact
and include Application
. Then, we made similar changes to the props that we’re passing into our component.
You’ll notice that our Application
type also includes the company
and contacts
details.
Let’s work our way through the form, for each input field, we want to set a defaultValue
, referencing the application details.
.<input type="text" id="company" name="company" defaultValue={application?.company?.name ?? ''} />
<input type="text" id="jobTitle" name="jobTitle" defaultValue={application?.jobTitle ?? ''} />
<textarea id="jobDescription" name="jobDescription" defaultValue={application?.jobDescription ?? ''} />
<input type="text" id="salaryMin" name="salaryMin" defaultValue={application?.salaryMin ?? ''} />
<input type="text" id="salaryMax" name="salaryMax" defaultValue={application?.salaryMax ?? ''} />
<input type="text" id="postingUrl" name="postingUrl" defaultValue={application?.postingUrl ?? ''} />
Then, for the submit button, change the label from “Create” to “Update”:
<Button type="submit"> Update</Button>
Let’s add a second button, to cancel:
<div className="field flex items-center gap-4"> <Button type="submit"> Update </Button> <Button variant="secondary" asChild> <a href={link('/applications/:id', { id: application?.id ?? '' })}>Cancel</a> </Button></div>
- On the wrapping
field
div
, I added a few classes to align the buttons vertically and add16px
of space between them withflex items-center gap-4
. - On line 79, I gave the Cancel button a
variant
ofsecondary
to give it abeige
color. TheasChild
prop allows us to use thea
tag inside. - On line 80, I used our
link
function to reference the job details page and pass in theapplication?.id
as theid
parameter.
For the Date Picker, we need to adjust the component slightly. If you try to pass in a defaultValue
:
<DatePicker name="dateApplied" defaultValue={application?.dateApplied?.toISOString() ?? ''} />
It will show a linting error because it’s not prepared to accept that prop:
Within the DatePicker
component, let’s update the list of props we’re passing in, to include the defaultValue
prop:
export function DatePicker({ name, defaultValue }: { name: string, defaultValue: string }) {
We’re already saving the selected date in a piece of state, let’s set the default value of our state to defaultValue
we passed in:
- const [date, setDate] = React.useState<Date>()+ const [date, setDate] = React.useState<Date>(defaultValue ? new Date(defaultValue) : new Date())
Excellent! Now, let’s go back to our EditApplicationForm.tsx
file and update the Application Status. Just like our standard inputs, we can pass in a defaultValue
prop:
<Select name="statusId" defaultValue={application?.applicationStatus?.status ?? ''}>
For the Contacts section, we just need to adjust the contact array its referencing:
{contacts && ( {application?.company?.contacts && ( <ul> {contacts.map((contact) => ( {application?.company?.contacts.map((contact) => ( <li key={contact.id}><ContactCard contact={contact} /></li> ))} </ul>)}
If you check out the page within the browser, you should see all our application details loaded in:
Updating the Application
Now, we just need to handle the form updates.
Let’s start with the contact form. If you’ll remember, we set the contact form up so that when a new contact is created, it’s not assigned to a company. Here, though, we have a company.
Instead of duplicating the ContactForm
component, let’s make it “smart” enough to handle both use cases.
Inside our EditApplicationForm
component, where we’re using our ContactForm
component, let’s pass in the companyId
:
<ContactForm callback={() => setIsContactSheetOpen(false)} companyId={application?.company?.id ?? ''} />
Right away, you will see an error because our ContactForm
isn’t expecting a companyId
prop. Let’s fix that:
const ContactForm = ({ callback, companyId = '' }: { callback: () => void, companyId?: string }) => {
- I set the default value to an empty string:
companyId = ''
- I also made the
companyId
prop optional with a question mark?
- The beauty in this approach is that it won't break or change our existing implementation on the
New
job application page.
Inside the handleSubmit
function, let’s append
the companyId
to our formData
:
const handleSubmit = async (formData: FormData) => { formData.append("companyId", companyId); const result = await createContact(formData);}
Now, let’s jump over to our createContact
function, inside our src/app/pages/applications/actions.tsx
file and get the companyId
from the form data:
export const createContact = async (formData: FormData) => {7 collapsed lines
try { const { ctx } = requestInfo;
if (!ctx.user) { throw new Error("User not found"); }
const companyId = formData.get("companyId") as string;
On our database call, we can conditionally connect
the company, if the companyId
exists:
await db.contact.create({10 collapsed lines
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, user: { connect: { id: ctx.user?.id || "", }, }, ...(companyId ? { company: { connect: { id: companyId, }, } } : {}) },})
- On line 74, we're using a spread operator to say if the
companyId
exists, then connect the company to the contact. Otherwise, we'll use an empty object.
You can test this out in the browser, ensuring it works on both the New and Edit pages.
The only remaining task is to update the handleSubmit
function on the EditApplicationForm
. This is what we currently have.
const handleSubmit = async (formData: FormData) => { formData.append("contacts", JSON.stringify(contacts)); const result = await createApplication(formData); if (result.success) { window.location.href = `/applications`; } else { console.error(result.error); }}
We can remove line 26
, where we’re appending the contacts
to the formData
.
Instead of calling createApplication
, let’s call updateApplication
instead (we still need to write this function):
const handleSubmit = async (formData: FormData) => { const result = await updateApplication(formData);5 collapsed lines
if (result.success) { window.location.href = `/applications`; } else { console.error(result.error); }}
Be sure to update the import statement at the top of your file:
import { createApplication } from "@/app/pages/applications/actions"import { updateApplication } from "@/app/pages/applications/actions"
Inside our src/app/pages/applications/actions.ts
file, let’s write the updateApplication
function:
export const updateApplication = async (formData: FormData) => { try { await db.application.update({ where: { id: formData.get("id") as string, }, data: { salaryMin: formData.get("salaryMin") as string, salaryMax: formData.get("salaryMax") as string, jobTitle: formData.get("jobTitle") as string, jobDescription: formData.get("jobDescription") as string, postingUrl: formData.get("url") as string, dateApplied: formData.get("dateApplied") as string, company: { update: { name: formData.get("company") as string, }, }, applicationStatus: { connect: { id: parseInt(formData.get("statusId") as string), }, }, } })
return { success: true, error: null } } catch (error) { console.error(error) return { success: false, error: error as Error } }}
- This is basic
try
/catch
block. Wetry
and run our database method an if it encounters an error, then we'llcatch
it and return an error object. - On line 92 - Our database call is similar to the
createApplication
function, but instead ofcreate
, we're usingupdate
. - On lines 93-95 - We're using a
where
clause to specify which application we want to update. - On lines 103-107 - We're using an
update
method to update the company name (instead ofconnect
) - On lines 108-112 - We're using the
update
method to update the application status (instead ofconnect
). - On line 110 - the Application Status ID is a number, but the
statusId
on theformData
is anumber
, so we're casting it from a string into a number withparseInt
.
This is great, the only problem is that the application id
does not exist in our form data.
Let’s go back to our EditApplicationForm
and add a hidden
input field to our form:
<input type="hidden" id="id" name="id" defaultValue={application?.id ?? ''} />
Perfect! Now test the update form within the browser. You should be able to edit and save changes to our application.
💥 We did it! Now, we just need to get our application online where people can use it!