Blitz.js: The Fullstack React Framework - Part 3

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.

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, image.png When a user is authenticated, image.png

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 hook useMutation 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.tsxwith 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:

image.png

Click on the Manage projects to see how the projects index page looks like. image.png

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. image.png

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. image.png

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. image.png

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. image.png

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: image.png

Single Page

Before editing, project single page looks like. image.png

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.

image.png

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.