Jobs List
The jobs application list page lists all the jobs that we’ve applied for. For this tutorial, it will also serve as the Dashboard.
The finished page will look like this:
Let’s start with the backend code and then make it look good.
But, first, we need a page and a route.
In the src > app > pages
directory, create a new folder called applications
. Inside, create a file called List.tsx
.
Directorysrc/
Directoryapp/
Directorypages/
Directoryapplications/
- List.tsx
Directoryauth/
- …
- Home.tsx
We can stub out a basic page, just to make sure it’s loading correctly.
const List = () => { return ( <div>List</div> )}
export { List }
Now, within our worker.tsx
file, we can add a route for our new page.
import { List } from "./app/pages/applications/List";...prefix("/applications", [ route("/", List),])
- We'll group all of our application routes (List, New, Detail, Update) under the
/applications
prefix. - When the user visits
/applications
, they'll see ourList
component.
Let’s test this out in the browser. Go to http://localhost:2332/applications
and you should see our stubbed out page.
Since we’ve already set up authentication, we can also protect this route.
prefix("/applications", [ route("/", [ ({ ctx }) => { if (!ctx.user) { return new Response(null, { status: 302, headers: { Location: "/user/login" }, }); } }, List]),])
This would get cumbersome (and annoying) if we have to do this for every.single.route we wanted to protect. Let’s abstract this code into a reusable function.
const isAuthenticated = ({ ctx }: { ctx: AppContext}) => { if (!ctx.user) { return new Response(null, { status: 302, headers: { Location: "/user/login" }, }); }}
We're passing in the ctx
that we get with each request. Then, we're checking to see if the user
exists on the context object. If it doesn't, we're returning a response that redirects the user to the login page.
Then, we can update our /applications
route to use isAuthenticated
:
prefix("/applications", [ route("/", [isAuthenticated, List]),])
Now, we can also update our protected page route to use the isAuthenticated
function, too:
route("/protected", [ isAuthenticated, Home ]),
Actually, let’s refactor this. The protected
route was really meant as an example. We don’t need /protected
, but we do want to protect our home page: route("/", Home),
. We could change /protected
to /
, and then delete route("/", Home),
.
route("/", [ isAuthenticated, Home ]),
I want to take this a step further, though, and show you another option. With the homepage route, you can also use the index
function. This works very similar to the route
function, but it already knows the path is /
, so it only takes one argument: the response.
index([ isAuthenticated, Home ]),
Be sure to import index
at the top of the file:
import { route, render, prefix, index } from "@redwoodjs/sdk/router";
Test it out. 👨🍳 Chef’s kiss! If you’re logged in, you should see the “List” text. If you’re not logged in, you’ll be redirected to the login page.
Now, let’s get some data into the database.
We can do this one of two ways:
Option 1: Create a Seed File
Earlier, I mentioned that sometimes I’ll create multiple seed files with various purposes.. This is a perfect opportunity to create a separate file just for adding job applications to our database.
Inside the src/scripts
directory, create a new file called applicationSeed.ts
.
Let’s stub it out:
import { defineScript } from "@redwoodjs/sdk/worker";import { db, setupDb } from "@/db";
export default defineScript(async ({ env }) => { setupDb(env);
console.log("🌱 Finished seeding");});
- On line 4, we're setting up the Cloudflare Worker environment to run our script. By default, we get the
env
object. - On line 5, we're setting up our database.
- On line 7, we're logging a message to the console to indicate that the script has finished running.
Inside our function, we can reach for a standard Prisma create
function:
const createApplication = async () => { await db.application.create({ data: { salaryMin: "100000", salaryMax: "120000", jobTitle: "Software Engineer", jobDescription: "Software Engineer", postingUrl: "https://redwoodjs.com", dateApplied: new Date(), } }};
await createApplication();
- On line 8, we're referencing the
application
table. Then, using the Prismacreate
function to add all the data in our database object. You'll notice that these values match the columns we defined inPrisma.schema
file.
You’ll probably see a few linting errors.
If you run the script now, you’ll hit a few errors because it’s also looking for related user
, status
, and company
entries. For the user
and status
connections, we already have entries within the database we can reference.
Let’s start with the user
connection. If you look at the user
table, you’ll see that their ID is 0f9a097c-d7bc-4ab5-8b11-6942163df348
. (Obviously, yours will be slightly different.) Copy that value.
Now, we can connect the entries, by setting user
to an object with a connect
key. Inside, we’ll specify the id
of the user we want to connect to.
export default defineScript(async ({ env }) => { setupDb(env);
const createApplication = async () => { await db.application.create({ data: { user: { connect: { id: "0f9a097c-d7bc-4ab5-8b11-6942163df348", }, }, ...
We want to do something similar for the status
. If we look at the ApplicationStatus
table, you’ll notice that an id
of 1
is associated with a New application.
We can connect the application record to the status record, by referencing an object with a connect
key that contains an object with an id
of 1
.
export default defineScript(async ({ env }) => { setupDb(env);
const createApplication = async () => { await db.application.create({ data: { ... applicationStatus: { connect: { id: 1, }, }, ...
The company field is a little different because we haven’t created any company records yet. However, we can create and connect a company record at the same time:
export default defineScript(async ({ env }) => { setupDb(env);
const createApplication = async () => { await db.application.create({ data: { ... company: { create: { name: "RedwoodSDK", contacts: { create: { firstName: "John", lastName: "Doe", role: "Hiring Manager", }, }, }, }, ...
This time instead of using an object with a connect
key, we’ll use a create
key inside. Then, we can list an object with all the company’s data.
Complete applicationSeed.ts file
import { defineScript } from "@redwoodjs/sdk/worker";import { db, setupDb } from "@/db";
export default defineScript(async ({ env }) => { setupDb(env);
const createApplication = async () => { await db.application.create({ data: { user: { connect: { id: "0f9a097c-d7bc-4ab5-8b11-6942163df348", // REPLACE THIS WITH YOUR USER ID }, }, applicationStatus: { connect: { id: 1, }, }, company: { create: { name: "RedwoodSDK", contacts: { create: { firstName: "John", lastName: "Doe", role: "Hiring Manager", }, }, }, }, salaryMin: "100000", salaryMax: "120000", jobTitle: "Software Engineer", jobDescription: "Software Engineer", postingUrl: "https://redwoodjs.com", dateApplied: new Date(), } }); };
await createApplication();
console.log("🌱 Finished seeding");});
To run the seed file, within the Terminal:
pnpm worker:run ./src/scripts/applicationSeed.ts
If this feels hard to remember, you can create a script in your package.json
file. Inside the scripts
block:
... "scripts": {12 collapsed lines
"build": "vite build", "dev": "NODE_ENV=${NODE_ENV:-development} vite dev", "dev:init": "rw-scripts dev-init", "worker:run": "rw-scripts worker-run", "clean": "pnpm build && pnpm clean:vendor", "clean:vite": "rm -rf ./node_modules/.vite", "clean:vendor": "rm -rf ./vendor/dist", "release": "pnpm build && wrangler deploy", "format": "prettier --write ./src", "migrate:dev": "wrangler d1 migrations apply DB --local", "migrate:prd": "wrangler d1 migrations apply DB --remote", "migrate:new": "rw-scripts migrate-new", "seed": "pnpm worker:run ./src/scripts/seed.ts", "seed:applications": "pnpm worker:run ./src/scripts/applicationSeed.ts", "icons": "lemon-lime-svgs" },...
Now, you can run the seed file by saying:
pnpm seed:applications
😅 Much more straight forward!
When you’re creating custom seed files, this does take more time on the frontend to set up. But, it makes it much easier in the long run. Now, anytime you need a fresh set of data, you can run the seed file.
Option 2: Prisma Studio
Another option is to use Prisma Studio. This option is easier, but requires manual entry and can take more time in the long run. (We already set up Prisma Studio, here.)
npx prisma studio
Prisma Studio should now be available at http://localhost:5555
. From here, you can create, read, update, and delete records.
Displaying the Job Application Data
Once you’ve added some data, let’s go back to our application and display the data on the page.
On the Applications list page, let’s import
the db
at the top of the file. Then, inside the List
function, let’s use the findMany
function to get all the applications:
import { db } from "src/db";
const List = async () => { const applications = await db.application.findMany();
return ( <div> <pre>{JSON.stringify(applications, null, 2)}</pre> </div> )}
export { List }
Notice, we also made our List
function an async
function. We need to await
the database call before rendering the page.
All the data from the database is returned as an array and saved in a variable called applications
. Then, we can display all the data by using the JSON.stringify
function. Wrapping our code in pre
tags, it makes it easier to read.
Easy, right?! Since we’re using React Server Components, this code runs on the server. We’re able to make database calls directly from the page and don’t need to worry about creating API routes. 🎉
Also, if you look right click on the page and select View Source, you’ll see that the data is being rendered directly on the page.
Now that we know the content from our database is getting on the page, let’s style it.
Styling the Job Applications List Page
Similar to the auth pages, let’s start by creating a layout that will wrap all of our interior pages.
Creating an Interior Page Layout
Inside our layouts
folder, let’s create a new file called InteriorLayout.tsx
.
Directorysrc/
Directoryapp/
Directorylayouts/
- AuthLayout.tsx
- InteriorLayout.tsx
Inside, we need should use some of the same the same styles that we used within our AuthLayout.tsx
file. As a quick reminder, let’s take a look at the AuthLayout.tsx
file:
const AuthLayout = ({ children }: { children: React.ReactNode }) => { return ( <div className="bg-bg min-h-screen min-w-screen p-12"> <div className="grid grid-cols-2 min-h-[calc(100vh-96px)] rounded-xl border-2 border-[#D6D5C5]"> ...
- The wrapping
div
sets the background color, the minimum height and width of the page, and adds some padding. - The child
div
sets up the grid, applies rounded corners, and adds a border.
We don’t need to set up a grid, but we can abstract the styles and reuse them within our interior layout.
From the AuthLayout.tsx
file, I’m going to copy the bg-bg min-h-screen min-w-screen p-12
styles and create a class inside our styles.css
file, inside the @layer components
block:
.page-wrapper { @apply bg-bg min-h-screen min-w-screen p-12;}
Then, let’s do something similar with the child div
from our AuthLayout.tsx
. We don’t need the grid
, but we can grab everything else: min-h-[calc(100vh-96px)] rounded-xl border-2 border-[#D6D5C5]
.page { @apply min-h-[calc(100vh-96px)] rounded-xl border-2 border-[#D6D5C5];}
@layer components
section of styles.css
@layer components { .page { @apply min-h-[calc(100vh-96px)] rounded-xl border-2 border-[#D6D5C5]; }
.page-wrapper { @apply bg-bg min-h-screen min-w-screen p-12; }
.page-title { @apply text-3xl }}
Let’s also add our route to our links.ts
file for type hinting:
import { defineLinks } from "@redwoodjs/sdk/router";
export const link = defineLinks([6 collapsed lines
"/", "/user/login", "/user/signup", "/user/logout", "/legal/privacy", "/legal/terms", "/applications",]);
Now, let’s update the class list within our AuthLayout.tsx
file:
const AuthLayout = ({ children }: { children: React.ReactNode }) => { return ( <div className="page-wrapper"> <div className="grid grid-cols-2 page"> ...
Now, let’s jump over to our InteriorLayout.tsx
file and use these classes there as well:
const InteriorLayout = ({ children }: { children: React.ReactNode}) => { return ( <div className="page-wrapper"> <main className="page bg-white"> {children} </main> </div> )}
export { InteriorLayout }
You'll also notice I added a background of white with bg-white
To see how this looks, we need to add this to our List.tsx
:
return ( <InteriorLayout> <pre>{JSON.stringify(applications, null, 2)}</pre> </InteriorLayout>)
It’s coming together! Across the top, let’s add the logo and navigation. We may need to reuse this component in the future, so let’s make it it’s own component. Inside the components
folder, let’s create a new file called Header.tsx
.
Directorysrc/
Directoryapp/
Directorycomponents/
- Header.tsx
const Header = () => { return ( <header> {/* left side */} <div></div>
{/* right side */} <div></div> </header> )}
Here are the basic building blocks we need.
- I used a semantic HTML element
header
to wrap everything. - Then, we'll have a left and right side. On the left, we'll display the logo and the navigation. On the right, we'll display a link the user's settings, the logout button, and the user's avatar.
Inside the left div
, let’s add the logo and “Apply Wize” text and wrap it in a link:
{/* left side */}<div> <a href={link("/")}> <img src="/images/logo.svg" alt="Apply Wize" /> <span>Apply Wize</span> </a></div>
- Pretty straightforward. We’re using the same
logo.svg
that we used on the auth pages. It should already be in yourpublic/images
folder. - Then, we’re using the
link
helper to link to home page. At the top of your file, you’ll need to import thelink
helper:
import { link } from '@/app/shared/links'
Now, let’s add the navigation. For now this is only one link that goes to the dashboard page.
{/* left side */}<div>4 collapsed lines
<a href={link("/")}> <img src="/images/logo.svg" alt="Apply Wize" /> <span>Apply Wize</span> </a> <nav> <ul> <li><a href={link("/applications")}>Dashboard</a></li> </ul> </nav></div>
For the right side, we want another unordered list and the Avatar component:
{/* right side */}<nav> <ul> <li><a href="#">Settings</a></li> <li><a href={link("/user/logout")}>Logout</a></li> <li> <Avatar> <AvatarFallback>R</AvatarFallback> </Avatar> </li> </ul></nav>
- Notice, I replaced the right side
div
with anav
element, keeping things nice and semantic. - Inside, I’ve included links for the settings and logout pages.
- For the
Avatar
component, you’ll need to import it at the top of your file. This is a shadcn/ui component, so it should already be part of your project.
import { Avatar, AvatarFallback } from '@/app/components/ui/avatar'
Normally, when you’re using the Avatar
component, you’ll also want to use the AvatarImage
component. This is where you define the Avatar image:
import { Avatar, AvatarFallback, AvatarImage } from '@/app/components/ui/avatar'
...
<li> <Avatar> <AvatarFallback>R</AvatarFallback> <AvatarImage src="./images/avatar.png" /> </Avatar></li>
If you want to go this route, you can download the avatar.png
placeholder image and hard code the image source. However, we’re not going to cover file uploads and storage in this tutorial. So, we’ll use the AvatarFallback
component to display the first character of the username.
The shadcn/ui Avatar component uses client side interactivity. So, you’ll need to add the use client
directive to the top of the Avatar.tsx
file:
"use client"
import * as React from "react"import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/app/lib/utils"
function Avatar({ ...
If you forget to add the use client
directive, you’ll see a “null reading useState
” error in the browser:
Now that we have all the elements, let’s add some styling:
<header className="py-5 px-10 h-20 flex justify-between items-center border-b-1 border-border mb-12">
py-5
adds20px
of padding to the top and bottom.px-10
adds40px
of padding to the left and right.h-20
sets the height to80px
.flex
andjustify-between
are used to align the items inside the header, putting as much space between each of the elements as possible.items-center
centers the items vertically.border-border
andborder-b-1
adds a border to the bottom of the header with a color ofborder
(defined as a custom color in the@theme
block of ourstyles.css
file).
We set the left and right padding to 40px
with px-10
. We’ll use this throughout our entire application. In order to maintain consistency, let’s define it as a custom utility. This will make it easy to reference (and change, if necessary).
--spacing-page-side: 40px;
Inside the @theme
block, below our color definitions, we'll add a new variable called --spacing-page-side
and set it to 40px
. Now, we can use this variable with margin or padding: mx-page-side
or px-page-side
respectively.
Now, we can update our header
element to use the new utility, replacing px-10
with px-page-side
:
<header className="py-5 px-page-side h-20 flex justify-between items-center border-b-1 border-border mb-12">
On the left side div
, we want the logo and the Dashboard link to align vertically:
{/* left side */}<div className="flex items-center gap-8">9 collapsed lines
<a href={link("/")}> <img src="/images/logo.svg" alt="Apply Wize" /> <span>Apply Wize</span> </a> <nav> <ul> <li><a href={link("/applications")}>Dashboard</a></li> </ul> </nav></div>
- We're using
flex
anditems-center
to align the items vertically. gap-8
adds32px
of space between the logo and the Dashboard link.
On the home page link, we want the logo and the “Apply Wize” text to align vertically too:
<a href={link("/")} className="flex items-center gap-3 font-display font-bold text-3xl">
flex
,items-center
, andgap-3
aligns the logo and text and puts12px
of space between them.font-display
andfont-bold
are used to style the text, applying the font Poppins and making the text bold.text-3xl
sets the font size to30px
If you look at the logo, it overlaps with the bottom border of the header.
In order to achieve this, we also need to add some styles to the img
tag:
<img src="/images/logo.svg" alt="Apply Wize" className="pt-5 -mb-3" />
pt-5
adds20px
of padding to the top.-mb-3
removes12px
of margin from the bottom and will make the bottom of the header shift up.
For the right side ul
, we need to add a few styles to position the links properly:
{/* right side */}<nav> <ul className="flex items-center gap-7"> ...
- Similar to techniques we've used before, we're using
flex
anditems-center
to align the items vertically. gap-7
adds28px
of space between each of the links.
To style our nav links, I want these styles to apply to both the left and the right side. So, let’s stick these inside the styles.css
file, inside the @layer base
block:
nav { @apply font-display font-medium text-sm;}
That should be it! (for the header at least)
Finished Header.tsx
component
import { link } from '@/app/shared/links'import { Avatar, AvatarFallback } from './ui/avatar'
const Header = () => { return ( <header className="py-5 px-page-side h-20 flex justify-between items-center border-b-1 border-border mb-12"> {/* left side */} <div className="flex items-center gap-8"> <a href={link("/")} className="flex items-center gap-3 font-display font-bold text-3xl"> <img src="/images/logo.svg" alt="Apply Wize" className="pt-5 -mb-3" /> <span>Apply Wize</span> </a> <nav> <ul> <li><a href={link("/applications")}>Dashboard</a></li> </ul> </nav> </div>
{/* right side */} <nav> <ul className="flex items-center gap-7"> <li><a href="#">Settings</a></li> <li><a href={link("/user/logout")}>Logout</a></li> <li> <Avatar> <AvatarFallback>R</AvatarFallback> </Avatar> </li> </ul> </nav> </header> )}
export { Header }
Now, let’s stick our Header
component into our InteriorLayout.tsx
file:
import { Header } from "@/app/components/Header";
const InteriorLayout = ({ children }: { children: React.ReactNode}) => { return ( <div className="page-wrapper"> <main className="page bg-white"> <Header /> <div>{children}</div> </main> </div> )}
export { InteriorLayout }
Check it out in the browser!
Moving on.
At the top of our file, let’s add a page heading and a button/link to add a new application:
import { Button } from "@/app/components/ui/button";...return ( <InteriorLayout> <> <div> <h1>All Applications</h1> <div> <Button asChild><a href="#">New Application</a></Button> </div> </div> <pre>{JSON.stringify(applications, null, 2)}</pre> </> </InteriorLayout>)
InteriorLayout
can only have one react node, so we need to wrap everything with a React fragment (<>
)- Above our application content, I added a wrapping
div
with ah1
heading for “All Applications”. - Then, I have another
div
that wraps a<Button>
component. Inside, I have a link that points to the new application page. TheButton
component is coming from shadcn/ui. It should already be part of your project, but you’ll need to import it at the top of your file. - Since we’re not triggering an event, we’re linking to another page, we have an
a
tag. Eventually, this will reference theapplications/new
route, but since we haven’t set that up yet, I used a placeholder#
instead.
Now, let’s add some styling:
<div className="px-page-side flex justify-between items-center"> <h1 className="page-title">All Applications</h1>
- On the wrapping
div
we can use our custompx-page-side
that adds20px
of padding to the left and right. - We can align the heading and the button with
flex
,justify-between
, anditems-center
. - We can use our
page-title
class to style the heading.
Styling the Applications Table
Now, let’s style the list of applications.
Inside our components
directory, let’s create a new file, called ApplicationsTable.tsx
.
Directorysrc/
Directoryapp/
Directorycomponents/
- ApplicationsTable.tsx
I’m going to stub out a basic component:
const ApplicationsTable = () => { return ( <div>ApplicationsTable</div> )}
export { ApplicationsTable }
Let’s go ahead and put this on our ApplicationsList.tsx
page, so we can see the updates we’re making in the browser:
import { ApplicationsTable } from "@/app/components/ApplicationsTable";...return ( <InteriorLayout> <> <div className="px-page-side flex justify-between items-center"> <h1 className="page-title">All Applications</h1> <div> <Button asChild><a href="#">New Application</a></Button> </div> </div> <ApplicationsTable /> <pre>{JSON.stringify(applications, null, 2)}</pre> </> </InteriorLayout>)
We’ve already added the shadcn/ui Table component to our project, so let’s go back to our ApplicationsTable.tsx
file and use it. Just to start, I’m going to copy the example code from the shadcn/ui documentation and then we can rework it for our needs:
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"
const ApplicationsTable = () => { return ( <Table> <TableCaption>A list of your recent invoices.</TableCaption> <TableHeader> <TableRow> <TableHead className="w-[100px]">Invoice</TableHead> <TableHead>Status</TableHead> <TableHead>Method</TableHead> <TableHead className="text-right">Amount</TableHead> </TableRow> </TableHeader> <TableBody> <TableRow> <TableCell className="font-medium">INV001</TableCell> <TableCell>Paid</TableCell> <TableCell>Credit Card</TableCell> <TableCell className="text-right">$250.00</TableCell> </TableRow> </TableBody> </Table> )}
export { ApplicationsTable }
It looks pretty good, but we need to make a few changes to make it work for our data.
First, we can get rid of the TableCaption
:
<TableCaption>A list of your recent invoices.</TableCaption>
Then, I’m going to change the TableHeader
to match our design within Figma:
<TableHeader> <TableRow> <TableHead className="w-[100px]">Status</TableHead> <TableHead>Date Applied</TableHead> <TableHead>Job Title</TableHead> <TableHead>Company</TableHead> <TableHead>Contact</TableHead> <TableHead>Salary Range</TableHead> <TableHead></TableHead> </TableRow></TableHeader>
- We now have columns for status, date applied, job title, company, contact, and salary range.
- You’ll notice that the last column is empty.
<TableHead></TableHead>
Within the table body, this will be our view icon, but this column doesn’t have a header.
For our TableBody
, we need our application data. First, let’s set up our table row, statically. Then, we’ll make it dynamic.
<TableBody> <TableRow> <TableCell>New</TableCell> <TableCell>April 15, 2025</TableCell> <TableCell>Software Engineer</TableCell> <TableCell>RedwoodJS</TableCell> <TableCell>John Doe</TableCell> <TableCell>$150,000-$250,000</TableCell> <TableCell><a href="#">View</a></TableCell> </TableRow></TableBody>
Cool. Let’s make a few stylistic changes before we plug in the data.
Adding Badges to the Status Column
The status column should be a badge. We can reach for the Badge
component from shadcn/ui.
import { Badge } from "./ui/badge"...<TableCell> <Badge>New</Badge></TableCell>
Let’s add styles based on the application status. We’ve already added some custom colors to our styles.css
file, so let’s use those.
--color-tag-applied: #b1c7c0; --color-tag-interview: #da9b7c; --color-tag-new: #db9a9f; --color-tag-rejected: #e4e3d4; --color-tag-offer: #aae198;
Inside of our badge.tsx
component, there’s a section at the top of variants
:
variants: { variant: {8 collapsed lines
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", applied: "bg-tag-applied text-black", interview: "bg-tag-interview text-black", new: "bg-tag-new text-black", rejected: "bg-tag-rejected text-black", offer: "bg-tag-offer text-black", },},
I added a custom variant for applied
, interview
, new
, rejected
, and offer
.
While, we’re here, I’m also going to add a few more classes to the default styling:
const badgeVariants = cva( "font-bold inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
- I added a font weight of
font-bold
- I changed the rounded corners to
rounded-full
Let’s go back to our ApplicationsTable.tsx
component and update our badge:
<TableCell><Badge variant="new">New</Badge></TableCell>
For the contact column, let’s include an avatar:
<TableCell> <Avatar> <AvatarFallback>J</AvatarFallback> </Avatar> John Doe</TableCell>
This is the same avatar component we used in the Header
component.
Be sure to import the Avatar
and AvatarFallback
components at the top of your file:
import { Avatar, AvatarFallback } from "./ui/avatar"
Let’s add a few styles to fix the positioning:
<TableCell className="flex items-center gap-2"> <Avatar> <AvatarFallback>J</AvatarFallback> </Avatar> John Doe</TableCell>
flex
anditems-center
aligns the avatar and name vertically.gap-2
adds8px
of padding between the avatar and the name.
Next, let’s replace the “View” text with an SVG icon.
Using an SVG Icon
We have several icons we want to use throughout our application. You can export all the SVGs directly from the Figma file, or you can download all of them within this project’s assets directory.
I’m going to create a new folder in the root of our our project called other
and then a sub directory inside that called svg-icons
and place all of the icons inside the svg-icons
directory.
Directoryother/
Directorysvg-icons/
- …
Directorysrc/
- …
My favorite way to implement SVG icons is through an SVG sprite. Basically, this combines all our SVG files into a single sprite.svg
file. We can control which icon is displayed by setting the id
attribute on the use
element.
You could set all of this up, manually, but I do this on every single project I build, so I’ve built an npm package to do all the heavy lifting. It’s called Lemon Lime SVGs.
Within the Terminal run:
pnpm install lemon-lime-svgs --save-dev
Once installed, run the setup command:
npx lemon-lime-svgs setup
This will ask a series of questions:
- First, it will ask you what framework you’re using. At it’s core RedwoodSDK is React and Vite = 6
- Next, it will ask you about file names and folder paths. It will make recommendations based on the framework you’re using. Most of the defaults, work:
- Input directory for SVG files:
./other/svg-icons
— we’ve already set this directory up! - Output directory for sprite:
./public/images/icons
This is the same location as the background andlogo.svg
- Directory for TypeScript types:
./src/types
- Sprite file name:
sprite.svg
- Type definition file name:
icons.d.ts
- Enable verbose logging: The default is set to “no”, but I usually like to turn this on and get a little extra feedback.
- Generate a README. The default is set to “yes”, but I usually like to turn this off. The README lives inside the same directory as your sprite and tells future developers that this file was created programmatically. It also provides a list of all the SVG icons available.
- Input directory for SVG files:
- These settings are saved inside your
package.json
file, in its own section called,lemonLimeSvgs
. - This script will also create a new
script
command inside yourpackage.json
file, calledicons
. Once we add the icons to oursvg-icons
folder, we can generate the sprite using this command:pnpm run icons
. - The last prompt asks us if we want to add an Icon component to our project. Say
y
. - Then, it will ask us where we want to save our component. We need to veer from the recommendation slightly:
src/app/components/Icon.tsx
Now, if you look inside your src/app/components
directory, you’ll see a new Icon.tsx
file.
interface Props { size?: number; id: string; className?: string;}
const Icon = ({ className, size = 24, id }: Props) => { return ( <svg width={size} height={size} className={className}> <use href={`/images/icons/sprite.svg#${id}`}></use> </svg> );};
export default Icon;
This component takes a className
, if you want to add additional styles to the component, a size
(the default is set to 24px
), and the id
of the icon you want to display. The id
matches the file name of the original icon SVG file.
Before we move on, I’m going to change this to a named export to be consistent with the other components we’ve created:
export { Icon }
Now, let’s take all our icon SVGs and dump them inside our other/svg-icons
directory.
Inside the terminal, let’s generate our sprite:
pnpm icons
Sweet! Now we can use our Icon
component.
Inside our ApplicationsTable.tsx
file:
import { Icon } from "./Icon"...<TableCell> <a href="#"> View <Icon id="view" /> </a></TableCell>
Making our Table Dynamic
Awesome! Now, let’s make it dynamic. Inside our List.tsx
page, let’s pass in all of our application data:
<ApplicationsTable applications={applications} />
We already have our applications saved inside a variable called applications
, which makes passing in all our data, easy.
When you first set this up, you’ll probably see an error
Our component isn’t expecting the applications
data we’re passing it. Inside our ApplicationsTable
component:
import { Application } from "@prisma/client"...const ApplicationsTable = ({ applications }: { applications: Application[] }) => {
- We’ll set it up to receive the
applications
data as a prop. - Then, we need to set the type. Fortunate for us, Prisma generates these for us. So, we can use the
Application
type and since there’s an array of them, we’ll use square brackets[]
. - At the top of our, file we can import the
Application
type from@prisma/client
.
Now, let’s go down to our TableBody
. First we want to loop over all the applications within our applications
array and display a row in the table for each:
<TableBody> {applications.map(application => ( <TableRow> <TableCell><Badge>New</Badge></TableCell> <TableCell>April 15, 2025</TableCell> <TableCell>Software Engineer</TableCell> <TableCell>RedwoodJS</TableCell> <TableCell className="flex items-center gap-2"> <Avatar> <AvatarFallback>J</AvatarFallback> </Avatar> John Doe </TableCell> <TableCell>$150,000-$250,000</TableCell> <TableCell> <a href="#"> <Icon id="view" /> </a> </TableCell> </TableRow> ))}</TableBody>
Now, we can start replacing the static content with dynamic content.
For our application status and our Badge
component, we can swap out the new
text with {application.applicationStatus.status}
.
<TableCell> <Badge variant={application.applicationStatus.status}> {application.applicationStatus.status} </Badge></TableCell>
I’m starting to get an error within my code editor.
Basically, it’s saying that it’s not aware of the applicationStatus
relationship.
Setting up Table Relationships
Let’s go back to our List.tsx
file. On line 6, we’re getting all the applications from our table:
const applications = await db.application.findMany();
But, we also need it to include the applicationStatus
, company
, and contact
relationships. Prisma has an easy want to do this using include
:
const applications = await db.application.findMany({ include: { applicationStatus: true, company: { include: { contacts: true } } }});
- On line 8 we're telling it to incldue the
applicationStatus
relationship. - On line 9 we're telling it to include the
company
relationship. - The
contact
relationship is a little different because it's through thecompany
relationship, but we can continue to drill down, including thecontacts
relationship on line 11.
You might need to temporarily comment out the Badge
component inside the ApplicationsTable.tsx
file, to avoid errors, but if you take a look at the result within the browser, you’ll see the JSON data change shape:
Now, if we go back to our ApplicationsTable.tsx
file, the TypeScript error is still there. We’re getting the applicationStatus
information now, but we need to make sure the type is updated to include it.
We’re already getting the Application
type from @prisma/client
. We can also import ApplicationStatus
, Company
, and Contact
types.
Then, let’s change the shape of our object. We have our Application
type, then, we can merge an object &
that has applicationStatus
and company
. Contacts is an array, nested inside the company
object.
const ApplicationsTable = ({ applications }: { applications: Application[] applications: (Application & { applicationStatus: ApplicationStatus, company: Company & { contacts: Contact[] }, })[]
If you look at your TableCell
again, you’re probably still seeing an error 🤯
<TableCell> <Badge variant={application.applicationStatus.status}> {application.applicationStatus.status} </Badge></TableCell>
When we pass in the variant
prop for our Badge
component, it’s not just looking for a string, it’s looking for a specific string: "default" | "secondary" | "destructive" | "outline" | "applied" | "interview" | "new" | "rejected" | "offer" | null | undefined
Inside our badge
component, when we defined our badgeVariants
for styles, it used our variant options to create a type definition. badgeVariants
is exported from the badge.tsx
component, making it easy to reuse and reference within our ApplicationsTable.tsx
:
<Badge variant={application.applicationStatus?.status.toLowerCase() as VariantProps<typeof badgeVariants>['variant']}> {application.applicationStatus?.status}</Badge>
- On the
application.applicationStatus?.status
, we can append.toLowerCase()
to make sure the status is formatted correctly and always lowercase. typeof badgeVariants
- Gets the type of ourbadgeVariants
configuration object, created withcva()
. This includes all our variant definitions.VariantProps<...>
- This is a utility type from class-variance-authority that extracts the prop types. It creates a type that includes all possible variants as properties.['variant']
- This accesses just thevariant
property type from those props. In our case, it resolves to the union type:"default" | "secondary" | "destructive" | "outline" | "applied" | "interview" | "new" | "rejected" | "offer" | null | undefined
Sweet! Now, all of our TypeScript errors should be taken care of.
Let’s keep making our data dynamic.
<TableCell> April 15, 2025 {application.dateApplied?.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</TableCell>
For the date, we can use the application.dateApplied
property. Then, we can format the date using the toLocaleDateString
JavaScript method. For more options, see MDN's Date.prototype.toLocaleDateString documentation.
For the job title:
<TableCell> Software Engineer {application.jobTitle}</TableCell>
For the company name:
<TableCell> RedwoodJS {application.company.name}</TableCell>
For the contact name, we’re just going to reference the first name within our contacts
array.
<TableCell className="flex items-center gap-2"> <Avatar> <AvatarFallback>J</AvatarFallback> <AvatarFallback>{application.company.contacts[0].firstName.charAt(0).toUpperCase()}</AvatarFallback> </Avatar> John Doe {application.company.contacts[0].firstName} {application.company.contacts[0].lastName}</TableCell>
For the salary range:
<TableCell> $150,000-$250,000 ${application.salaryMin}-${application.salaryMax}</TableCell>
For the view link:
<TableCell> <a href="#"> <a href={link("/applications/:id", { id: application.id })}> <Icon id="view" /> </a></TableCell>
Be sure to import link
at the top of the file:
import { link } from "../shared/links";
You’ll probably see another error. We need to do a little more setup to make this work.
In our worker.tsx
file, let’s add our route:
prefix("/applications", [ route("/", [isAuthenticated, List]), route("/:id", [isAuthenticated, () => <h1>Application</h1>]),]),
- We can nest our
route
definition within the applicationsprefix
. - The
:
designates that our route is dynamic and contains theid
in the URL. - We included
isAuthenticated
to ensure that the route is protected. - Temporarily, we'll display an
<h1>
Application heading.
Now, let’s jump over to shared/links.ts
file and add /applications/:id
to our array:
export const link = defineLinks([7 collapsed lines
"/", "/user/login", "/user/signup", "/user/logout", "/legal/privacy", "/legal/terms", "/applications", "/applications/:id",]);
Perfect. Everything should check out. Try clicking on the view icon in the browser. You should be redirected to a page with an “Application” heading.
Final ApplicationsTable.tsx
file
import { Application, ApplicationStatus, Company, Contact } from "@prisma/client"import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"import { Avatar, AvatarFallback } from "./ui/avatar"import { Badge } from "./ui/badge"import { Icon } from "./Icon"import type { badgeVariants } from "./ui/badge"import { VariantProps } from "class-variance-authority"import { link } from "../shared/links"
const ApplicationsTable = ({ applications }: { applications: (Application & { applicationStatus: ApplicationStatus, company: Company & { contacts: Contact[] }, })[]}) => { return ( <Table> <TableHeader> <TableHead className="w-[100px]">Status</TableHead> <TableHead>Date Applied</TableHead> <TableHead>Job Title</TableHead> <TableHead>Company</TableHead> <TableHead>Contact</TableHead> <TableHead>Salary Range</TableHead> <TableHead></TableHead> </TableHeader> <TableBody> {applications.map(application => ( <TableRow> <TableCell> <Badge variant={application.applicationStatus?.status.toLowerCase() as VariantProps<typeof badgeVariants>['variant']}> {application.applicationStatus?.status} </Badge> </TableCell> <TableCell> {application.dateApplied?.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} </TableCell> <TableCell> {application.jobTitle} </TableCell> <TableCell> {application.company.name} </TableCell> <TableCell className="flex items-center gap-2"> <Avatar> <AvatarFallback>{application.company.contacts[0].firstName.charAt(0).toUpperCase()}</AvatarFallback> </Avatar> {application.company.contacts[0].firstName} {application.company.contacts[0].lastName} </TableCell> <TableCell> ${application.salaryMin}-${application.salaryMax} </TableCell> <TableCell> <a href={link("/applications/:id", { id: application.id })}> <Icon id="view" /> </a> </TableCell> </TableRow> ))} </TableBody> </Table> )}
export { ApplicationsTable }
Finalizing the List Page
Now, let’s go back to our List.tsx
and tidy up a few things.
On line 27, we can remove our JSON.stringify
function:
<pre>{JSON.stringify(applications, null, 2)}</pre>
Below our table, let’s add a couple of buttons. An “Archive” button on the left and another “New Application” button on the right:
<div className="flex justify-between items-center"> <Button asChild variant="secondary"> <a href="#"> <Icon id="archive" /> Archive </a> </Button> <Button asChild> <a href="#"> <Icon id="plus" /> New Application </a> </Button></div>
- The classes
flex justify-between items-center
on the wrappingdiv
position our buttons. - On the first button, I've added a
variant="secondary"
which will make the button a light beige. - I also included the
archive
icon next to the archive text:<Icon id="archive" />
- On the "New Application" button, I added the
plus
icon:<Icon id="plus" />
If you look at this within the browser, we’re getting closer:
The Archive
button should link to a filtered view of all applications that have been archived. Let’s update our link:
<a href={`${link("/applications")}?status=archived`}> <Icon id="archive" /> Archive</a>
This is really a filtered view of our existing /applications
page, so we can use the existing /applications
link. Then, we’re attaching the query parameter: ?status=archived
. We don’t need to do anything extra to make this link work, but we do need to adjust our List page to account for the status
query parameter. We’ll come back to this!
For the New Application button, let’s update the href
:
<Button asChild> <a href={link("/applications/new")}> <Icon id="plus" /> New Application </a></Button>
/applications/new
doesn’t exist yet. We need to set up this page. In our worker.tsx
file, let’s add a new route:
prefix("/applications", [ route("/", [isAuthenticated, List]), route("/new", [isAuthenticated, () => <h1>New Application</h1>]), route("/:id", [isAuthenticated, () => <h1>Application</h1>]),]),
- We can nest our
route
definition within the applicationsprefix
. - We included
isAuthenticated
to ensure that the route is protected. - Temporarily, we'll display an
<h1>
New Application heading.
Now, let’s add our link to the links.ts
file:
export const link = defineLinks([7 collapsed lines
"/", "/user/login", "/user/signup", "/user/logout", "/legal/privacy", "/legal/terms", "/applications", "/applications/new", "/applications/:id",]);
If we test this out within the browser, clicking on the “New Application” button at the bottom, you should see our temporary “New Application” page:
Let’s make the “New Application” button at the top, match the button at the bottom.
return ( <InteriorLayout> <> <div className="px-page-side flex justify-between items-center"> <h1 className="page-title">All Applications</h1> <div> <Button asChild> <a href={link("/applications/new")}> <Icon id="plus" /> New Application </a> </Button> </div>9 collapsed lines
</div> <ApplicationsTable applications={applications} /> <div className="flex justify-between items-center"> <Button asChild variant="secondary"> <a href={`${link("/applications")}?status=archived`}> <Icon id="archive" /> Archive </a> </Button> <Button asChild> <a href={link("/applications/new")}> <Icon id="plus" /> New Application </a> </Button> </div> </> </InteriorLayout>)
Now, let’s adjust the spacing around our table. On line 22, we’re using a class of px-page-side
to add some padding to the left and right side.
<div className="px-page-side flex justify-between items-center">
Let’s remove this from the div
and add it to a wrapping div
.
<InteriorLayout> <div className="px-page-side"> <div className="flex justify-between items-center"> ... </div> </div></InteriorLayout>
- You'll notice, we converted the React Fragment
<>
to adiv
and added a class ofpx-page-side
to it. - Then, we removed
px-page-side
from nesteddiv
that contains our page title.
Before we preview this in the browser, let’s adjust the vertical spacing.
<InteriorLayout> <div className="px-page-side"> <div className="flex justify-between items-center mb-5">10 collapsed lines
<h1 className="page-title">All Applications</h1> <div> <Button asChild> <a href={link("/applications/new")}> <Icon id="plus" /> New Application </a> </Button> </div> </div> <div className="mb-8"><ApplicationsTable applications={applications} /></div> <div className="flex justify-between items-center mb-10">
- On line 22, I added
mb-5
for20px
of margin on the bottom. - On line 33, I added
mb-8
for32px
of margin on the bottom. - On line 44, I added
mb-10
for40px
of margin on the bottom.
Now, let’s preview this in the browser:
Before we call this done, make sure that our Archive filter is working.
Using Query Params to Filter Applications
On every page we get an object that contains ctx
, request
, and headers
. In our case, the request
contains the query parameters that we’re looking for.
On List.tsx
, when we define our List
function, let’s accept request
as a prop:
const List = async ({ request }: { request: Request }) => {
You'll notice when I type the request
, I'm using a standard Request
type.
Now, at the top of our function, we can get the status
query parameter using the URLSearchParams
method from the standard WebAPI:
const url = new URL(request.url);const status = url.searchParams.get("status");
console.log({ status });
If you run this in the browser, you should see { status: 'archived' }
displayed.
Now, that we’ve checked that our query parameter is working, we can remove our console.log
statement and adjust our Prisma findMany
query.
After the include
object, let’s add a where
object:
const applications = await db.application.findMany({ include: { applicationStatus: true, company: { include: { contacts: true } } }, where: { archived: status === "archived" ? true : false }});
The where
clause filters the results of our query based on whether archived
is true
or false
. We can se it to true
or false
depending on whether status
is set to archived
or not.
YOu can test this in the browser, by clicking on the Archive button. You should see the URL change to include /applications?status=archived
and the entry applications table should be empty.
Let’s make this better by adding an empty state:
<div className="mb-8"> {applications.length > 0 ? ( <ApplicationsTable applications={applications} /> ) : ( <div className="text-center text-sm text-muted-foreground"> No applications found </div> )}</div>
- On line 41, we're checking if the
applications
array has more than one item. If it does, display theApplicationsTable
. - If it doesn't, display a message that says "No applications were found."
- I also added a few styles to our message.
- Centered with
text-center
- Reduced the text size with
text-sm
- Changed the text color to
text-muted-foreground
- Centered with
Once we click on the “Archive” button, it’s easy to feel “stuck”.
Let’s change the “Archive” button so that it toggles based on the query parameter.
<Button asChild variant="secondary"> {status === "archived" ? ( <a href={`${link("/applications")}`}> <Icon id="archive" /> Active </a> ) : ( <a href={`${link("/applications")}?status=archived`}> <Icon id="archive" /> Archive </a> )}</Button>
- If the
status
is equal toarchived
then the link should go to/applications
and the button label should beActive
. - Otherwise, display the
Archive
button, with the query parameter?status=archived
Test it out in the browser:
It looks and works great!
Remember, you can use Prisma Studio to add more seed data, if you want to test various states.
Final code for List.tsx
import { InteriorLayout } from "@/app/layouts/InteriorLayout";import { db } from "@/db";import { Button } from "@/app/components/ui/button";import { ApplicationsTable } from "@/app/components/ApplicationsTable";import { Icon } from "@/app/components/Icon";import { link } from "@/app/shared/links";
const List = async ({ request }: { request: Request }) => { const url = new URL(request.url); const status = url.searchParams.get("status");
const applications = await db.application.findMany({ include: { applicationStatus: true, company: { include: { contacts: true } } }, where: { archived: status === "archived" ? true : false } });
return ( <InteriorLayout> <div className="px-page-side"> <div className="flex justify-between items-center mb-5"> <h1 className="page-title">All Applications</h1> <div> <Button asChild> <a href={link("/applications/new")}> <Icon id="plus" /> New Application </a> </Button> </div> </div> <div className="mb-8"> {applications.length > 0 ? ( <ApplicationsTable applications={applications} /> ) : ( <div className="text-center text-sm text-muted-foreground"> No applications found </div> )} </div> <div className="flex justify-between items-center mb-10"> <Button asChild variant="secondary"> {status === "archived" ? ( <a href={`${link("/applications")}`}> <Icon id="archive" /> Active </a> ) : ( <a href={`${link("/applications")}?status=archived`}> <Icon id="archive" /> Archive </a> )} </Button> <Button asChild> <a href={link("/applications/new")}> <Icon id="plus" /> New Application </a> </Button> </div> </div> </InteriorLayout> )}
export { List }