Blitz.js: The Fullstack React Framework - Part 3
Learn by building a project management application
👋 Welcome Back,
Hey Hashnoders, welcome back to part 3 of the series "Learn by Building - Blitz.js". Today we'll create the UI for projects and tasks models. And also add the functionalities in the UI to CRUD from the database.
Index
Recap of the previous part
In the previous part of this series, we updated the database schema and updated and understand the logic for the CRUD operation of projects and tasks. And also build the UI for the authentication pages.
By looking at the above line, it looks like that the previous article doesn't include much information, but it crosses more than 2900 words. 🤯
Today's objectives 🎯
In today's articles, we'll create the UI for the CRUD operations of projects and tasks model and connect the UI with the logic. And we'll also learn to add the search functionalities for both projects and tasks.
Today, we'll start by editing the Layout Component
. We used AuthLayout
for the authentication pages, and now we'll use Layout Component
for other pages.
Layout
Open app/core/layouts/layout.tsx
and add the <Header/>
tag after <Head/>
tag like below and also wrap {children}
with div
of class container mx-auto px-4
:
// app/core/layouts/layout.tsx
import { Header } from "../components/Header"
...
</Head>
<Header />
<div className="container mx-auto px-4">{children}</div>
...
In the <Layout />
component, we have used the <Header />
component, so let's build it.
Header
Create a new file at app/core/components/Header.tsx
and add the following code.
// app/core/components/Header.tsx
import logout from "app/auth/mutations/logout"
import { Link, Routes, useMutation } from "blitz"
import { Suspense } from "react"
import { useCurrentUser } from "../hooks/useCurrentUser"
import { Button } from "./Button"
const NavLink = ({ href, children }) => {
return (
<Link href={href}>
<a className="bg-purple-600 text-white py-2 px-3 rounded hover:bg-purple-800 block">
{children}
</a>
</Link>
)
}
const Nav = () => {
const currentUser = useCurrentUser()
const [logoutMutation] = useMutation(logout)
return (
<nav>
{!currentUser ? (
<ul className="flex gap-8">
<li>
<NavLink href={Routes.LoginPage()}>Login</NavLink>
</li>
<li>
<NavLink href={Routes.SignupPage()}>Register</NavLink>
</li>
</ul>
) : (
<ul className="">
<li>
<Button
onClick={async () => {
await logoutMutation()
}}
>
Logout
</Button>
</li>
</ul>
)}
</nav>
)
}
export const Header = () => {
return (
<header className="flex sticky top-0 z-30 bg-white justify-end h-20 items-center px-6 border-b">
<Suspense fallback="Loading...">
<Nav />
</Suspense>
</header>
)
}
With this, you'll get the header like as shown below:
When a user is not logged in, When a user is authenticated,
In the header component, there are some lines that you might not understand. So, let's know what they really do.
<Suspense>...</Suspense>
: component that lets you “wait” for some code to load and declaratively specify a loading state (like a spinner) while we’re waiting. ( React Docs)useCurrentUser()
: It is a react hook that returns a authenticated session of a user. (Blitz.js Dccs)useMutation(logout)
: Logout task is a mutation and to run the mutation we use the powerful hookuseMutation
provided by Blitz.js.( Blitz.js Docs )
If you look over the onClick
event listener in the Logout
button. There we are using async/await, because mutations
return promises.
Now, let's display the User Email
on the index page and add a link to go to the projects index page.
Index page
If you guys, remembered we have removed the content from the index.tsx
page. Now, we'll display the email of the authenticated user and make that page accessible only by the logged-in user.
To work with the index page, first, go to the signup page and create an account. And then you will get redirected to the index
page.
Now, replace everything in app/pages/index.tsx
with the given content.
// app/pages/index.tsx
import { Suspense } from "react"
import { Image, Link, BlitzPage, useMutation, Routes } from "blitz"
import Layout from "app/core/layouts/Layout"
import { useCurrentUser } from "app/core/hooks/useCurrentUser"
import logout from "app/auth/mutations/logout"
import logo from "public/logo.png"
import { CustomLink } from "app/core/components/CustomLink"
/*
* This file is just for a pleasant getting started page for your new app.
* You can delete everything in here and start from scratch if you like.
*/
const UserInfo = () => {
const user = useCurrentUser()
return (
<div className="flex justify-center my-4">{user && <div>Logged in as {user.email}.</div>}</div>
)
}
const Home: BlitzPage = () => {
return (
<>
<Suspense fallback="Loading User Info...">
<UserInfo />
</Suspense>
<div className="flex justify-center">
<CustomLink href="/projects">Manage Projects</CustomLink>
</div>
</>
)
}
Home.suppressFirstRenderFlicker = true
Home.getLayout = (page) => <Layout title="Home">{page}</Layout>
Home.authenticate = true
export default Home
If you see the third last line in the code, Home.authenticate = true
, this means, this page requires a user to be authenticated to access this page.
Now, the index page should look like this:
Click on the Manage projects
to see how the projects
index page looks like.
Now, let's customize the project creation page. We are not editing the index page, first because we need to show the data on the index
page.
Project
Create Page
If you go to /projects/new
, currently it should look like this.
Now, let's customize this page.
In our schema, we have a description
field for the projects
model. So, let's add the text area for the description field.
We also need a text area for the task model too. So, we'll create a new component for the text area. For this I have created a new file /app/core/components/LabeledTextAreaField.tsx
and copied the content of LabeledTextField
and customized it for textarea
.
// app/core/components/LabeledTextAreaField
import { forwardRef, PropsWithoutRef } from "react"
import { Field, useField } from "react-final-form"
export interface LabeledTextFieldProps extends PropsWithoutRef<JSX.IntrinsicElements["input"]> {
/** Field name. */
name: string
/** Field label. */
label: string
/** Field type. Doesn't include radio buttons and checkboxes */
type?: "text" | "password" | "email" | "number"
outerProps?: PropsWithoutRef<JSX.IntrinsicElements["div"]>
}
export const LabeledTextAreaField = forwardRef<HTMLInputElement, LabeledTextFieldProps>(
({ name, label, outerProps, ...props }, ref) => {
const {
input,
meta: { touched, error, submitError, submitting },
} = useField(name, {
parse: props.type === "number" ? Number : undefined,
})
const normalizedError = Array.isArray(error) ? error.join(", ") : error || submitError
return (
<div {...outerProps}>
<label className="flex flex-col items-start">
{label}
<Field
component={"textarea"}
className="px-1 py-2 border rounded focus:ring focus:outline-none ring-purple-200 block w-full my-2"
{...props}
{...input}
disabled={submitting}
></Field>
</label>
{touched && normalizedError && (
<div role="alert" className="text-sm" style={{ color: "red" }}>
{normalizedError}
</div>
)}
</div>
)
}
)
export default LabeledTextAreaField
After doing this, now you can use it in the /app/projects/components/ProjectForm.tsx
.
// app/projects/components/ProjectForm.tsx
import { Form, FormProps } from "app/core/components/Form"
import { LabeledTextField } from "app/core/components/LabeledTextField"
import { LabeledTextAreaField } from "app/core/components/LabeledTextAreaField"
import { z } from "zod"
export { FORM_ERROR } from "app/core/components/Form"
export function ProjectForm<S extends z.ZodType<any, any>>(props: FormProps<S>) {
return (
<Form<S> {...props}>
<LabeledTextField name="name" label="Name" placeholder="Name" />
<LabeledTextAreaField name="description" label="Description" placeholder="Description" />
</Form>
)
}
Now /projects/new
page should look like.
Now, you can use that form to create a project.
But, there is still many thing to customize in this page.
// app/pages/projects/new
import { Link, useRouter, useMutation, BlitzPage, Routes } from "blitz"
import Layout from "app/core/layouts/Layout"
import createProject from "app/projects/mutations/createProject"
import { ProjectForm, FORM_ERROR } from "app/projects/components/ProjectForm"
import { CustomLink } from "app/core/components/CustomLink"
const NewProjectPage: BlitzPage = () => {
const router = useRouter()
const [createProjectMutation] = useMutation(createProject)
return (
<div className="mt-4">
<h1 className="text-xl mb-4">Create New Project</h1>
<ProjectForm
....
/>
<p className="mt-4">
<CustomLink href={Routes.ProjectsPage()}>
<a>Projects</a>
</CustomLink>
</p>
</div>
)
}
NewProjectPage.authenticate = true
NewProjectPage.getLayout = (page) => <Layout title={"Create New Project"}>{page}</Layout>
export default NewProjectPage
Here, I have added some class in h1
and divs
and replace Link
tag with our CustomLink
component.
After this, the page will look like this.
Now, let's style the index page ' /projects '.
Index Page
Before styling the index page, add some of the projects to play with.
After adding them. You'll get redirected to single
project page. Go to /projects
.
This is what your page will look like.
Now, paste the following content in app/pages/projects/index.tsx
.
// app/pages/projects/index.tsx
import { Suspense } from "react"
import { Head, Link, usePaginatedQuery, useRouter, BlitzPage, Routes } from "blitz"
import Layout from "app/core/layouts/Layout"
import getProjects from "app/projects/queries/getProjects"
import { CustomLink } from "app/core/components/CustomLink"
import { Button } from "app/core/components/Button"
const ITEMS_PER_PAGE = 100
export const ProjectsList = () => {
const router = useRouter()
const page = Number(router.query.page) || 0
const [{ projects, hasMore }] = usePaginatedQuery(getProjects, {
orderBy: { id: "asc" },
skip: ITEMS_PER_PAGE * page,
take: ITEMS_PER_PAGE,
})
const goToPreviousPage = () => router.push({ query: { page: page - 1 } })
const goToNextPage = () => router.push({ query: { page: page + 1 } })
return (
<div className="mt-4">
<h2>Your projects</h2>
<ul className="mb-4 mt-3 flex flex-col gap-4">
{projects.map((project) => (
<li key={project.id}>
<CustomLink href={Routes.ShowProjectPage({ projectId: project.id })}>
<a>{project.name}</a>
</CustomLink>
</li>
))}
</ul>
<div className="flex gap-2">
<Button disabled={page === 0} onClick={goToPreviousPage}>
Previous
</Button>
<Button disabled={!hasMore} onClick={goToNextPage}>
Next
</Button>
</div>
</div>
)
}
const ProjectsPage: BlitzPage = () => {
return (
<>
<Head>
<title>Projects</title>
</Head>
<div>
<p>
<CustomLink href={Routes.NewProjectPage()}>Create Project</CustomLink>
</p>
<Suspense fallback={<div>Loading...</div>}>
<ProjectsList />
</Suspense>
</div>
</>
)
}
ProjectsPage.authenticate = true
ProjectsPage.getLayout = (page) => <Layout>{page}</Layout>
export default ProjectsPage
And let's make the Button
component looks unclickable when it is disabled.
For that, you can add disabled:bg-purple-400 disabled:cursor-not-allowed
class in Button
component.
// app/core/components/Button.tsx
export const Button = ({ children, ...props }) => {
return (
<button
className="... disabled:bg-purple-400 disabled:cursor-not-allowed"
>
{children}
</button>
)
}
Now, projects index page should look like:
Single Page
Before editing, project single page looks like.
Now, replace the code of app/pages/projects/[projectId].tsx
with following.
// app/pages/projects/[projectId].tsx
import { Suspense } from "react"
import { Head, Link, useRouter, useQuery, useParam, BlitzPage, useMutation, Routes } from "blitz"
import Layout from "app/core/layouts/Layout"
import getProject from "app/projects/queries/getProject"
import deleteProject from "app/projects/mutations/deleteProject"
import { CustomLink } from "app/core/components/CustomLink"
import { Button } from "app/core/components/Button"
export const Project = () => {
const router = useRouter()
const projectId = useParam("projectId", "number")
const [deleteProjectMutation] = useMutation(deleteProject)
const [project] = useQuery(getProject, { id: projectId })
return (
<>
<Head>
<title>Project {project.id}</title>
</Head>
<div>
<h1>Project {project.id}</h1>
<pre>{JSON.stringify(project, null, 2)}</pre>
<CustomLink href={Routes.EditProjectPage({ projectId: project.id })}>Edit</CustomLink>
<Button
type="button"
onClick={async () => {
if (window.confirm("This will be deleted")) {
await deleteProjectMutation({ id: project.id })
router.push(Routes.ProjectsPage())
}
}}
style={{ marginLeft: "0.5rem", marginRight: "0.5rem" }}
>
Delete
</Button>
<CustomLink href={Routes.TasksPage({ projectId: project.id })}>Tasks</CustomLink>
</div>
</>
)
}
const ShowProjectPage: BlitzPage = () => {
return (
<div className="mt-2">
<p className="mb-2">
<CustomLink href={Routes.ProjectsPage()}>Projects</CustomLink>
</p>
<Suspense fallback={<div>Loading...</div>}>
<Project />
</Suspense>
</div>
)
}
ShowProjectPage.authenticate = true
ShowProjectPage.getLayout = (page) => <Layout>{page}</Layout>
export default ShowProjectPage
Now the page should look like.
Edit
We'll do a decent style on the edit page. We'll just replace <Link>
tag with <CustomLink>
component and add text-lg
class to h1
.
// From
<Link href={Routes.ProjectsPage()}>
<a>Projects</a>
</Link>
// To
<CustomLink href={Routes.ProjectsPage()}>
Projects
</CustomLink>
// From
<h1>Edit Project {project.id}</h1>
// To
<h1 className="text-lg">Edit Project {project.id}</h1>
Now, it's time to edit the Tasks pages.
Tasks
Create and Update
We have added description field in the schema, so let's add textarea for description in the form. Both create and update use the same form, we don't have to customize them seperately.
// app/tasks/components/TaskForm.tsx
import { Form, FormProps } from "app/core/components/Form"
import LabeledTextAreaField from "app/core/components/LabeledTextAreaField"
import { LabeledTextField } from "app/core/components/LabeledTextField"
import { z } from "zod"
export { FORM_ERROR } from "app/core/components/Form"
export function TaskForm<S extends z.ZodType<any, any>>(props: FormProps<S>) {
return (
<Form<S> {...props}>
<LabeledTextField name="name" label="Name" placeholder="Name" />
<LabeledTextAreaField name="description" label="Description" placeholder="Description" />
</Form>
)
}
I have already written on how to customize the pages for projects so, you can follow the same to customize tasks pages. So, now I'll not style any pages.
Index
In the index page, you need to add projectId
in query param.
...
<ul>
{tasks.map((task) => (
<li key={task.id}>
<Link href={Routes.ShowTaskPage({ projectId, taskId: task.id })}>
<a>{task.name}</a>
</Link>
</li>
))}
</ul>
...
Conclusion
Now, all the functionalities works fine. So this much for today guys, In next article. We'll see how to deploy this app. In that I will show you the complete guide to deploy in multiple platform.