Skip to content
DragonSenses edited this page Aug 3, 2025 · 1 revision

Welcome to the visionize wiki!

Architectural Decision Log for Visionize

Visionize is a Next.js 14 Kanban productivity app designed for task management. Built with a focus on server actions, utilizes the FormData Web API, and implements loading and error states using React hooks such as useFormStatus and useFormState.

This is simply the documentation for the process of building the project. This is like my journey.md files that come with my previous projects, but this time I will also explain the rationale behind certain choices and design decisions made while making this project.

This file is an informal document, named "Architectural Decision Record" because I'm fond of the name and it gives it that extra level of sophistication.

Let's demystify some jargon:

  • An architecture decision log (ADL) is the collection of all ADRs created and maintained for a particular project (or organization).

  • An architectural decision record (ADR) is a document that captures an important architectural decision made along with its context and consequences.

  • An architecture decision (AD) is a software design choice that addresses a significant requirement.

I've really start to appreciate and recognize the full worth of documentation, especially ones like ADR that facilitate the onboarding process.

Here are some links if you are interested in learning more about ADR:

With that out of the way, let's move on to development.

Visionize

Visualize and realize your vision with Visionize, a task management app that will help you reach your goals.

Inspired by the Kanban development process, allows users to view their progress and process, from start to finish.

Currently, this project is just a way to learn and implement and practice skills and is not meant for commercial use. Certain design decisions made here favor ease of development over costs in service (e.g., Clerk as authentication as a service as opposed to a self-hosting solution) so may not be lucrative in the long-term. See section on commercial feasibility for more discussion on service pricing, commercial feasaibility, and alternatives to improve it.

Specifications

  • Auth
  • Organizations / Workspaces
  • Board creation
  • Unsplash API for random beautiful cover images
  • Activity log for entire organization
  • Board rename and delete
  • List creation
  • List rename, delete, drag & drop reorder and copy
  • Card creation
  • Card description, rename, delete, drag & drop reorder and copy
  • Card activity log
  • Board limit for every organization
  • Stripe subscription for each organization to unlock unlimited boards
  • Landing page
  • PostgreSQL DB
  • Prisma ORM
  • shadcnUI & TailwindCSS

Technologies

The technologies I plan to use:

Programming Language

  • TypeScript

Browsers

  • Google Chrome
  • Microsoft Edge

Code Editor

  • VSCode
  • Vim

Front-End

  • Next.js 14's App Router
  • Tailwind CSS
  • shadcn/ui
  • zustand (global state management)

Database

  • PostgresSQL
  • Prisma (ORM)

Payment and Billing

  • Stripe

Authentication

  • Clerk
    • For now Clerk is fine to develop quickly, but may switch to Passportjs

HTTP Client

  • Axios

Packages

Discussion on tech design choices

  • Next.js 14 App Router is my new favorite way to create front-end applications.

    • Next.js 14 released around Oct. 2023, yet my previous projects only used Next.js 13.
    • I wanted to incorporate its latest features such as: partial prerendering and server actions.
  • Tailwind CSS is another favorite choice, I prefer the ease of styling and responsive design.

  • shadcn/ui has become ny favorite collection of re-usable components

    • We can build our own component system and not be dependent on any 3rd party npm library which needs to be updated and maintained.

Commercial Feasibility

Add note on service pricing to commercial feasibility

Explain why services such as Clerk is not a viable option for visionize in the long-term due to its high per-user cost. Compare Clerk's pricing with other authentication providers and suggest alternatives.

A collaboration app that uses organizations even on a decent free-to-paid plan conversion rate, we can take a look at the costs. $0.02 per Monthly Active User (MAU) after 10,000 with $25 on the pro plan. Then we include the cost for every additional MAO (Monthly Active Organization) for $1 each. Not to mention all the overhead costs for additional features such as "authentication add-on" and more, then for a small business it is no longer feasible to use Clerk. Because this assumes that every user is a paid user which is not the case for this app.

Some alternatives to authentication are:

  1. Authentication Providers
  • Auth0, Okta, Firebase, Duo
  1. Self-hosted authentication servers

To self-host your own authentication server, you need to:

  • Choose a software or library that suits your needs and supports the protocols and features you want, such as OAuth, OpenID Connect, SAML, JWT, etc.

  • Install and configure the software or library on your server or cloud platform, following the documentation and best practices.

  • Integrate the software or library with your web application, using the SDKs, APIs, or plugins provided.

  • Test and monitor your authentication server, ensuring its security, performance, and reliability.

Also in the Next.js tutorial it covers the authentication using nextauth (Nextjs adding authentication).

Coding style & naming conventions

Here I'd like to list out some general rules I'd like to set for myself to ensure consistent coding style and naming conventions. I'll be updating this section intermittently.

Enforcing these rules keeps the codebase consistent and reduces overhead when thinking about how to name files and variables.

Coding Style

  • Favor vertical code hierarchy over horizontal

Naming Conventions

In this React, Typescript project here is some rules to adhere to:

Case Subject Example
camelCase (start with lowercase letter and capitalizes the first letter of each subsequent word) Code: variables, functions, objects, etc. someVariable, doSomething, myId
PascalCase (Uppercase with no separators, first letter must be uppercase) Components App, Layout, Navbar, CardList
kebab-case (lowercase words separated by hypens) File names that are NOT components pages, API routes, utils, etc.
Square brackets ([ ]) Dynamic route segments [id], [slug], [boardId], etc.
Underscore ( _ ) Special files, or private properties _app, _document, _error, etc.

Project configuration

Let's get started with Next.js 14 - App Router.

npx create-next-app@latest visionize

Now we answer the prompts that defines the set up of our project

√ Would you like to use TypeScript? ... No / [Yes]
√ Would you like to use ESLint? ... No / [Yes]
√ Would you like to use Tailwind CSS? ... No / [Yes]
√ Would you like to use `src/` directory? ... [No] / Yes
√ Would you like to use App Router? (recommended) ... No / [Yes]
√ Would you like to customize the default import alias (@/*)? ... [No] / Yes

Initialize Next.js 14 project

  • This commit sets up the basic structure and configuration for a nextjs 14 project with the following setup:
  • Typescript, ESLint, TailwindCSS
  • App Router

The next step is to initialize shadcn/ui.

npx shadcn-ui@latest init

This should give us the following prompts

Need to install the following packages:
  shadcn-ui@0.6.0
Ok to proceed? (y) y
√ Would you like to use TypeScript (recommended)? ... no / *yes*
√ Which style would you like to use? » *Default*
√ Which color would you like to use as base color? » *Neutral*
√ Where is your global CSS file? ... *app/globals.css*
√ Would you like to use CSS variables for colors? ... no / *yes*
√ Are you using a custom tailwind prefix eg. tw-? (Leave blank if not) ...
√ Where is your tailwind.config.js located? ... *tailwind.config.ts*
√ Configure the import alias for components: ... *@/components*
√ Configure the import alias for utils: ... *@/lib/utils*
√ Are you using React Server Components? ... no / *yes*
√ Write configuration to components.json. Proceed? ... *yes*

Initialize shadcn-ui@0.6.0

Let's take a closer look at the /lib/utils.ts file that shadcn/ui installed. Going to add comments to describe utils.ts and the cn function that it contains.

The cn() function allows us to safely combine and merge tailwind classes. Useful for applying both conditional and merged class names to a React component or element. e.g., we may have a specific class for a success state, and another class for the error state.

/* A utility function that combines clsx and tailwind-merge to create a single
string of class names */
import { type ClassValue, clsx } from "clsx";
/* twMerge is a function that takes one or more strings of Tailwind CSS 
classes and returns a single string of merged classes, following the 
Tailwind CSS rules. twMerge is useful for combining and overriding class 
names from different sources, such as props, state, or theme */
import { twMerge } from "tailwind-merge";

/**
 * Export the cn helper function, which takes one or more ClassValue arguments
 * and returns a single string of merged class names. Useful for applying both
 * conditional and merged class names to a React component or element.
 * @param inputs ClassValue is a type that represents a valid CSS class name
 * @returns a single string of merged classnames
 */
export function cn(...inputs: ClassValue[]) {
  // Use clsx to convert the inputs to a single string of class names
  // Use twMerge to merge the class names according to the Tailwind CSS rules
  // Return the merged string of class names
  return twMerge(clsx(inputs));
}

Developing the app

Start with the globals.css, and set all html, body and :root elements to 100% of the viewport height.

app\globals.css

@tailwind base;
@tailwind components;
@tailwind utilities;

html,
body,
:root {
  height: 100%;
}

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 0 0% 3.9%;

/* ... */

Why Next.js?

Some Next.js app router features:

Creating Routes

Creating routes

Next.js uses a file-system based router where folders are used to define routes.

  • Each folder represents a route segment that maps to a URL segment. To create a nested route, you can nest folders inside each other.

  • A special page.js file is used to make route segments publicly accessible.

Pages in Next.js

  • A page is UI that is unique to a route. You can define pages by exporting a component from a page.js file. Use nested folders to define a route and a page.js file to make the route publicly accessible.

Every page component should be exported as default, becauses of the file-system based routing, it needs to know which component is the default one for each page.

Project Structure

Using App router, we can see all the use cases for how to name our folders and files for our project.

Another reference is:

As we can see in the documentation, Next.js uses a file-system based router where:

  • Folders are used to define routes. A route is a single path of nested folders, following the file-system hierarchy from the root folder down to a final leaf folder that includes a page.js file.

  • Files are used to create UI that is shown for a route segment.

So in order to organize our project without affecting routing, we need to use Route Groups and Private Folders which is denoted by (folder) and _folder respectively.

On to project development

Let's look at the entry point to our application, the home page. We'll remove the boilerplate that it shipped with and keep it simple, a Home component that returns a div.

app\page.tsx

export default function Home() {
  return (
    <div className="text-sky-500">
      Visionize
    </div>
  )
}

Metadata, HTML head and SEO

One thing to make sure to update is the metadata. In most web pages, we have a document meta data thats set in the <head> HTML element which adds titles, scripts, and stylesheets. For example, we can set the title (the text on top of a browser tab).

Next.js has a Metadata API that can be used to define your application metadata (e.g. meta and link tags inside your HTML head element) for improved SEO and web shareability.

The meta description should consist of the following elements:

  • The name of your app and its main feature (kanban-style productivity app)
  • The benefit of using your app (turn your vision into reality)
  • The main functionalities of your app (boards, lists, and cards)
  • A call to action (try Visionize for free today)
Visionize is a kanban-style productivity app that helps you turn your vision into reality. Plan, prioritize, and execute your goals with boards, lists, and cards. Visionize your tasks with visionary kanban boards. Try Visionize for free today.

May even mention a catchy slogan such as "Visionize your tasks with visionary kanban boards". As we want to draw the connection between visonary (i.e., (especially of a person) thinking about or planning the future with imagination or wisdom) and visual representation of the tasks.

Let's add the static metadata to our global layout. Inside app\layout.tsx, we modify the properties of the metadata object.

import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Visionize',
  description: `Visionize is a kanban-style productivity app that helps you
  turn your vision into reality. Plan, prioritize, and execute your goals
  with boards, lists, and cards. Visionize your tasks with visionary kanban
  boards. Try Visionize for free today.`,
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  )
}

With that we set our static metadata object which now properly sets the <head> element.

Let's make a config so we can have dynamic metadata.

Separation of concerns - Metadata

The CS concept of separation is also known as separation of concerns. It is a design principle for separating a computer program into distinct sections, each addressing a separate concern, a set of information that affects the code of a computer program. Separation of concerns can improve the modularity, readability, reusability, and maintainability of the code, as well as reduce the complexity and potential errors.

One way to achieve separation of concerns is to use modules, which are units of code that encapsulate some functionality and have a well-defined interface. Modules can be imported and used by other modules, without exposing their implementation details. For example, the siteConfig module that will define an object that contains the name and description of our app.

This object can be used by other modules, such as the RootLayout component, to customize the metadata of our pages. By separating the configuration data from the presentation logic, you can easily change the app name or description without affecting the rest of the code. You can also reuse the siteConfig module in other projects that need similar functionality.

Create a folder named config at the root of the project, then create a file named site.ts inside. We create and export the object named siteConfig, which will have the same contents as our static metadata object we specified in our root layout.

/config

export const siteConfig = {
  name: "Visionize",
  description: `Visionize is a kanban-style productivity app that helps you
  turn your vision into reality. Plan, prioritize, and execute your goals
  with boards, lists, and cards. Visionize your tasks with visionary kanban
  boards. Try Visionize for free today.`,
};

Why did we do this? Well later on when we have authentication and user is signed-in, then we want them to skip the landing page and go straight to the application. In that application they will have a different title. By separating the configuration data from presentation logic, we can now do this a lot easier and present the head and metadata differently depending on the user.

Let's parameterize the metadata in our root layout now using the siteConfig.

import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

import { siteConfig } from '@/config/site'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: {
    default: siteConfig.name,
    template: `%s | ${siteConfig.name}`,
  },
  description: `Visionize is a kanban-style productivity app that helps you
  turn your vision into reality. Plan, prioritize, and execute your goals
  with boards, lists, and cards. Visionize your tasks with visionary kanban
  boards. Try Visionize for free today.`,
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  )
}

This code is using the config-based metadata feature of Next.js, which allows you to define your application metadata (such as title and description) for improved SEO and web shareability.

The metadata object t exported from the page.tsx file contains the following fields:

  • title: an object that specifies the title of your page. It has two properties:
    • default: the default title of your page, which is the name of your app (Visionize) from the siteConfig object that you imported.
    • template: a template string that can be used to generate dynamic titles based on the current route. It uses %s as a placeholder for the route-specific title, and appends the app name after a | character. For example, if the route-specific title is Home, the template will produce Home | Visionize.
  • description: a string that describes your app and its features. This will be used as the content of the <meta name="description"> tag in the <head> element of your page.

The RootLayout component that you exported as the default export from the page.tsx file is a Server Component that renders the <html> and <body> elements of your page. It also imports the Inter font from Google Fonts and applies it to the <body> element using the inter.className property. The RootLayout component takes a children prop, which is the content of your page, and renders it inside the <body> element.

Add favicon to customized metadata

To change the favicon for the site, we can also specify it inside our metadata.

export const metadata: Metadata = {
  title: {
    default: siteConfig.name,
    template: `%s | ${siteConfig.name}`,
  },
  description: siteConfig.description,
  icons: [
    {
      url: "/logo.svg",
      href: "/logo.svg",
    }
  ],
};

This will set the favicon to the /logo.svg from the public folder.

Landing Page

Better yet, we should create an organizational folder named (landing) and move the page.tsx in there. Next rename the function to LandingPage.

app\(landing)\page.tsx

import React from 'react';

export default function LandingPage() {
  return (
    <div>page</div>
  )
}

A landing page is a standalone web page that is created for a specific marketing or advertising campaign. It has a single goal or call to action, such as capturing leads or making sales.

A marketing page is a more general term that can refer to any web page that is used to promote a product, service, or brand. A marketing page can have multiple goals or links, such as providing information, building trust, or directing visitors to other page.

A landing page is a type of marketing page, but not all marketing pages are landing pages. For example, a homepage is a marketing page that showcases a brand and its offerings, but it is not a landing page because it does not have a specific campaign goal or a clear call to action. A landing page, on the other hand, is designed to persuade visitors to take one action, such as signing up for a free trial, downloading an ebook, or buying a product.

Next, create a specific layout for the LandingPage. Create layout.tsx inside (landing), a LandingLayout component which accepts a children prop that will be populated with a child page.

app\(landing)\layout.tsx

const LandingLayout = ({
  children
}: {
  children: React.ReactNode;
}) => {
  return (
    <div>
      {children}
    </div>
  );
};

export default LandingLayout;

Next let's give the LandingLayout some styles, h-full and bg-slate-100. Wrap the children in a <main> with the same background color, and padding for both the top and bottom side. The padding is there for the components Navbar and Footer that we will add later, for now we'll add comments to mark as indicators for where it should be.

app\(landing)\layout.tsx

const LandingLayout = ({
  children
}: {
  children: React.ReactNode;
}) => {
  return (
    <div className="h-full bg-slate-100">
      {/* Navbar */}
      <main className="pt-40 pb-20 bg-slate-100">
        {children}
      </main>
      {/* Footer */}
    </div>
  );
};

export default LandingLayout;

Let's develop the LandingPage.

  • Center the contents inside the element using flex-col
  • Add a Medal icon from lucide-react
  • Add some text right under that

app\(landing)\page.tsx

import React from 'react';
import { Medal } from 'lucide-react';

export default function LandingPage() {
  return (
    <div className='flex items-center justify-center flex-col'>
      <div className='flex items-center justify-center flex-col'>
        <div>
          <Medal className='h-6 w-6 mr-2'/>
          Achieve more with <strong><em>Visionize</em></strong>, the ultimate task management app
        </div>
      </div>
    </div>
  )
}

Instead of a catch-phrase, let's change it to a badge. Reduce the text to something shorter. Let's also style the innermost div with some amber coloring, box shadow and slightly rounded corners. Switch the icons up to symbolically represent what the app is clsoer too.

lucide-react has icons for Kanban, this better conveys the purpose of the app.

import React from 'react';
import { ClipboardCheck, KanbanSquare } from 'lucide-react';

export default function LandingPage() {
  return (
    <div className='flex items-center justify-center flex-col'>
      <div className='flex items-center justify-center flex-col'>
        <div className='mb-4 flex items-center border shadow-sm p-4 bg-amber-100 text-amber-700 rounded-full uppercase'>
          <KanbanSquare className='h-6 w-6 mr-2'/>
          <span className='mr-1'><strong>Visionize</strong></span> your tasks
          <ClipboardCheck className='h-6 w-6 mx-2'/>
        </div>
      </div>
    </div>
  )
}

Add an h1 as a sibling to the innermost div, and another div sibling. We will add some more text as marketing and descriptive phrases to describe the project.

export default function LandingPage() {
  return (
    <div className='flex items-center justify-center flex-col'>
      <div className='flex items-center justify-center flex-col'>
        <div className='mb-4 flex items-center border shadow-sm p-4 bg-amber-100 text-amber-700 rounded-full uppercase'>
          <KanbanSquare className='h-6 w-6 mr-2'/>
          Kanban your way
          <ClipboardCheck className='h-6 w-6 mx-2'/>
        </div>
        <h1 className='text-3xl md:text-6xl text-center text-neutral-800 mb-6'>
          <span className='mr-1'><strong>Visionize</strong></span> your tasks
        </h1>
        <div className='text-3xl md:text-6xl bg-gradient-to-r from-fuchsia-600 to-pink-600 text-white px-4 p-2 rounded-md pb-4 w-fit'>
          Turn your vision into reality.
        </div>
      </div>
    </div>
  )
}

Going to brainstorm some ideas of some promotional text I could use here. Visionize allows for: project management, task management, brainstorming, resource hub, meetings and onboarding.

Let's add another div with promotional text.

import React from 'react';
import { ClipboardCheck, KanbanSquare } from 'lucide-react';

export default function LandingPage() {
  return (
    <div className='flex items-center justify-center flex-col'>
      <div className='flex items-center justify-center flex-col'>
        <div className='mb-4 flex items-center border shadow-sm p-4 bg-amber-100 text-amber-700 rounded-full uppercase'>
          <KanbanSquare className='h-6 w-6 mr-2'/>
          Kanban your way
          <ClipboardCheck className='h-6 w-6 mx-2'/>
        </div>
        <h1 className='text-3xl md:text-6xl text-center text-neutral-800 mb-6'>
          <span className='mr-1'><strong>Visionize</strong></span> your tasks
        </h1>
        <div className='text-3xl md:text-6xl bg-gradient-to-r from-fuchsia-600 to-pink-600 text-white px-4 p-2 rounded-md pb-4 w-fit'>
          Turn your vision into reality.
        </div>
      </div>
      <div className='text-sm md:text-xl text-neutral-400 mt-4 max-w-xs md:max-w-2xl text-center mx-auto'>
        Fight mediocrity with Visionize. With just boards, lists and cards you can 
        get a clear overview of your tasks. Then you can plan, prioritize, and
        execute your goals with confidence.      
        You can drag and drop tasks, add labels and due dates, attach files 
        and comments, and more.
      </div>
    </div>
  )
}

Landing Page - Button component

Install shadcn/ui - Button component.

npx shadcn-ui@latest add button

Notice that this installed two things: components/ui/button.tsx and a package named @radix-ui-react/slot as a dependency. Let's break it down, starting with the latter.

  • Radix-ui/react-slot is a React component that merges its props onto its immediate child. It can be used to create your own asChild API, which allows you to render a component as a child of another component.
    • For example, you can use radix-ui/react-slot to create a custom button component that can accept an anchor tag as a child and inherit its props.
    • Radix-ui/react-slot also handles event handlers correctly, so that the child handler takes precedence over the slot handler.
    • Radix-ui/react-slot is part of the Radix Primitives library, which provides low-level UI components for building design systems and web applications.

So in the package.json, Add @radix-ui/react-slot dependency.

Next the button.tsx component, we can see the actual code (which usually isn't the case for other component libraries.). We can customize it to our liking Some changes we could make:

  • Modifying the const buttonVariants:
    • focus-visible
    • opacity when its disabled
  • variants
  • sizes

shadcn has created multiple variants for the button here - including default, destructive, outline, subtle, ghost, and link. Each of these variants is set up with specific tailwind classes that will be applied to the button.

All of these variants are built on top of the core button set of tailwind classes

The VariantProps type from CVA is used to ensure that the variant and size props are typed correctly.

Now you can easily import the Button component and use it in your app.

Alright so what is our broader goal here? We want want to redirect the user and have them navigate to the sign-up or log-in page. For that we will be using both Button component and Link component from next/link.

Wait a minute. Link inside a button? That's bad!

Issue: Link inside a Button

A link inside a button is bad because it violates the HTML5 specification and the accessibility guidelines. According to the HTML5 spec, interactive elements such as links and buttons are not allowed to be nested inside each other.

Content model: Transparent, but there must be no interactive content descendant.

The a element may be wrapped around entire paragraphs, lists, tables, and so forth, even entire sections, so long as there is no interactive content within (e.g. buttons or other links).

In other words, you can nest any elements inside an <a> except the following:

  • <a>
  • <audio> (if the controls attribute is present)
  • <button>
  • <details>
  • <embed>
  • <iframe>
  • <img> (if the usemap attribute is present)
  • <input> (if the type attribute is not in the hidden state)
  • <keygen>
  • <label>
  • <menu> (if the type attribute is in the toolbar state)
  • <object> (if the usemap attribute is present)
  • <select>
  • <textarea>
  • <video> (if the controls attribute is present)

Source: stackoverflow | nest a button element inside an a

According to the HTML5 spec, interactive elements such as links and buttons are not allowed to be nested inside each other. This is because it creates confusion and ambiguity for the user's intention. For example, if a user clicks on a link inside a button, should the link or the button action be triggered? Different browsers may handle this situation differently, resulting in inconsistent and unpredictable behavior.

A link inside a button is bad for accessibility, as it makes it harder for keyboard and screen reader users to navigate and interact with the web page. A link and a button have different roles and expectations for how they should behave when activated. A link should navigate the user to another page or location, while a button should perform a specific action or submit a form. A link inside a button breaks these conventions and confuses the assistive technology and the user.

Therefore, it is best to avoid nesting a link inside a button, and instead use either a link or a button depending on the purpose and context. If you want to create a link that looks like a button, you can style it with CSS. If you want to create a button that navigates to another page, you can use a form element or a JavaScript function.

Solution: shadcn/ui Button with a link inside

We have two options:

  1. You can use the asChild prop of the shadcn/ui Button component, which lets you render the button as a child of another component. This way, you can wrap the button with a Next.js Link component and pass the link props to the button. For example:
import { Button } from "@shadcn/ui/button";
import Link from "next/link";

export default function MyComponent() {
  return (
    <Link href="/about" passHref>
      <Button asChild>Go to About Page</Button>
    </Link>
  );
}
  1. You can use the as prop of the shadcn/ui Button component, which lets you change the underlying element of the button. This way, you can render the button as an anchor tag and pass the link props to the button. For example:
import { Button } from "@shadcn/ui/button";

export default function MyComponent() {
  return (
    <Button as="a" href="/about">
      Go to About Page
    </Button>
  );
}

Both options are good for accessibility, as they will produce semantic HTML elements that are keyboard and screen reader friendly. However, the first option may be more convenient if you want to use Next.js features such as prefetching or dynamic routes. The second option may be simpler if you want to use external links or custom styles.

  1. Another option is to use buttonVariants helper directly to create a link that looks like a button. For example:
import Link from "next/link";

import { buttonVariants } from "@/components/ui/button";

export default function page() {
  return (
    <div>
      <Link className={buttonVariants({ variant: "outline" })} href="/login">
        login
      </Link>
    </div>
  );
}

There is an interesting GitHub discussion on hyperlink button component for shadcn/ui, and according to elie222 we can use the Slot from @radix-ui/react-slot like this:

<Button asChild>
   <Link href="#" target="_blank">My Link</Link>
</Button>

And the output of the code will have an <a> tag instead of a <button> tag.

We will go with that option, using the asChild prop of the shadcn/ui Button component to render the button as a child of the Next.js Link component. This is a valid option if you want to create a button that navigates to another page in your Next.js app.

app\(landing)\page.tsx

import React from 'react';
import { ClipboardCheck, KanbanSquare } from 'lucide-react';

import { Button } from '@/components/ui/button';
import Link from 'next/link';

export default function LandingPage() {
  return (
    <div className='flex items-center justify-center flex-col'>
      <div className='flex items-center justify-center flex-col'>
        <div className='mb-4 flex items-center border shadow-sm p-4 bg-amber-100 text-amber-700 rounded-full uppercase'>
          <KanbanSquare className='h-6 w-6 mr-2'/>
          Kanban your way
          <ClipboardCheck className='h-6 w-6 mx-2'/>
        </div>
        <h1 className='text-3xl md:text-6xl text-center text-neutral-800 mb-6'>
          <span className='mr-1'><strong>Visionize</strong></span> your tasks
        </h1>
        <div className='text-3xl md:text-6xl bg-gradient-to-r from-fuchsia-600 to-pink-600 text-white px-4 p-2 rounded-md pb-4 w-fit'>
          Turn your vision into reality.
        </div>
      </div>
      <div className='text-sm md:text-xl text-neutral-400 mt-4 max-w-xs md:max-w-2xl text-center mx-auto'>
        Fight mediocrity with Visionize. With just boards, lists and cards you can 
        get a clear overview of your tasks. Then you can plan, prioritize, and
        execute your goals with confidence.      
        You can drag and drop tasks, add labels and due dates, attach files 
        and comments, and more.
      </div>

      <Button className='mt-6' size='lg' asChild>
        <Link href="/sign-up">
          Try Visionize for free
        </Link>
      </Button>

    </div>
  )
}

Now let's check the localhost:3000 developer console pressing F12, under Elements tab.

Find the element for our Link to confirm that the output is an <a> tag instead of a <button>.

  <a class="inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 rounded-md px-8 mt-6" href="/sign-up">Try Visionize for free</a>

Awesome, using the asChild prop to Button component renders the button as a child of the Link component. This properly navigates to another page in our Next.js app when we click on it.

Landing Page - pad text in the icon container

After looking through, there needs to be some top padding for the textt inside the icon container. So wrapped the text that's between the two icons inside a span and gave it top padding.

export default function LandingPage() {
  return (
    <div className='flex items-center justify-center flex-col'>
      <div className={cn(
        'flex items-center justify-center flex-col',
        headingFont.className,
        )}>
        <div className='mb-4 flex items-center border shadow-sm p-4 bg-amber-100 text-amber-700 rounded-full uppercase'>

          <KanbanSquare className='h-6 w-6 mr-2'/>
          <span className='pt-1'>Kanban your way</span>
          <ClipboardCheck className='h-6 w-6 mx-2'/>

        </div>

Fonts

Using local fonts in Next.js

Let's import a local font in Next.js. Going to use Cal Sans 1.0 font.

Now to install a font we can check the Nextjs Font | Reference.

Create a folder named font inside public. Then drag and drop or copy over your font of choice. Then save the relative path name as we'll use it for later.

Next in our landing page:

  • import localFont from 'next/font/local';
  • Create a headingFont that has an object with a src property set to the relative path of the font, like this: ../../public/fonts/YOUR_FONT_HERE
  • import cn util
import localFont from 'next/font/local';

import { cn } from '@/lib/utils';

const headingFont = localFont({
  src: "../../public/fonts/CalSans-SemiBold.woff2",
});

Now we can use the cn function to append the font to the already existing tailwind css clasnames.

  • To do so we wrap our default className styles in curly brackets ({}) so that we can step into TypeScript and interpolate code here. We call the cn() function with the default classNames as the first argument, and the headingFont.className as the second argument.
// ...
import localFont from 'next/font/local';

import { cn } from '@/lib/utils';

const headingFont = localFont({
  src: "../../public/fonts/CalSans-SemiBold.woff2",
});

export default function LandingPage() {
  return (
    <div className='flex items-center justify-center flex-col'>

      <div className={cn(
        'flex items-center justify-center flex-col',
        headingFont.className,
        )}>

      {/* ... */}

Importing and using fonts in Next.js

Next we can try importing the Poppins font from next/font/google.

Then we initialize the font in a similar way but calling the variable textFont. Also gove it font function arguments using the API reference for next/font/google. We will set the weight to "latin" and subsets from "100-900".

import { Poppins } from 'next/font/google';

const textFont = Poppins({
  subsets: ["latin"],
  weight: [
    "100",
    "200",
    "300",
    "400",
    "500",
    "600",
    "700",
    "800",
    "900",
  ],
});

Now append the font to the rest of the text, this time let's add the textFont to the promotional text.

import { Poppins } from 'next/font/google';

const textFont = Poppins({
  subsets: ["latin"],
  weight: [
    100,
    // ...
  ],
});

export default function LandingPage() {
  return (
    <div className='flex items-center justify-center flex-col'>
      <div className={cn(
        'flex items-center justify-center flex-col',
        headingFont.className,
        )}>
        <div className='mb-4 flex items-center border shadow-sm p-4 bg-amber-100 text-amber-700 rounded-full uppercase'>
          <KanbanSquare className='h-6 w-6 mr-2'/>
          <span className='pt-1'>Kanban your way</span>
          <ClipboardCheck className='h-6 w-6 mx-2'/>
        </div>
        {/* Headings here... */}
      </div>

      {/* Promotional Text */}
      <div className={cn(
        'text-sm md:text-xl text-neutral-400 mt-4 max-w-xs md:max-w-2xl text-center mx-auto',
        textFont.className,
      )}>
        Fight mediocrity with Visionize. With just boards, lists and cards you can 
        get a clear overview of your tasks. Then you can plan, prioritize, and
        execute your goals with confidence.      
        You can drag and drop tasks, add labels and due dates, attach files 
        and comments, and more.
      </div>

Add heading and text fonts to landing page

  • Use localFont and Poppins from next/font
  • Add KanbanSquare and ClipboardCheck icons from lucide-react
  • Add padding to text between icons
  • Add gradient background and padding to title and subtitle

Components

Components for the LandingLayout

Now we develop the Navbar and Footer components for the LandingLayout. Also make a re-usable component the Logo.

Logo

To make a logo then convert to SVG I used Adobe Express.

Then move the file with the name logo.svg to the /public folder. Then in /components we can create Logo.tsx.

Add reusable Logo component

  • Import Image and Link from next/image and next/link
  • Create Logo component that renders a logo image inside a link
  • Use hover, transition, and gap utilities from Tailwind CSS
  • Export Logo component as default
import Image from 'next/image';
import Link from 'next/link';
import React from 'react';

const Logo = () => {
  return (
    <Link href="/">
      <div className='hover:opacity-75 transition items-center gap-x-2 hidden md:flex'>
        <Image 
          src='/logo.svg'
          alt='Logo for Visionize'
          height={30}
          width={30}
        />
      </div>
    </Link>
  )
}

export default Logo

Notice the hidden class which sets the display: none on devices smaller than 768px.

Add text to logo component & use custom font

  • Add the text next to the logo image and style it with Tailwind CSS classes.
  • Import the CalSans-SemiBold font from the local folder and use it for the text.
  • Use the cn function from the utils library to combine the classes.
import Image from 'next/image';
import Link from 'next/link';
import localFont from 'next/font/local';
import React from 'react';

import { cn } from '@/lib/utils';

const headingFont = localFont({
  src: "../public/fonts/CalSans-SemiBold.woff2",
});

const Logo = () => {
  return (
    <Link href="/">
      <div className='hover:opacity-75 transition items-center gap-x-2 hidden md:flex'>
        <Image 
          src='/logo.svg'
          alt='Logo for Visionize'
          height={30}
          width={30}
        />
        <p className={cn(
          'text-xl text-neutral-700 px-1 pt-1',
          headingFont.className,
        )}>
          Visionize
        </p>
      </div>
    </Link>
  )
}

export default Logo

Navbar

Let's use a private folder in next.js so that we can opt folder and all child segments out of routing.

We will create a _components folder under the route group (landing) and create the Navbar.tsx component. Make it a named export, not a default export so that we do not have inconsistency and naming conflicts.

app\(landing)\_components\Navbar.tsx

import React from 'react';

export const Navbar = () => {
  return (
    <div>Navbar</div>
  );
};

Now import the Navbar inside the LandingLayout

app\(landing)\layout.tsx

import { Navbar } from "./_components/Navbar";

const LandingLayout = ({
  children
}: {
  children: React.ReactNode;
}) => {
  return (
    <div className="h-full bg-slate-100">
      <Navbar />
      <main className="pt-40 pb-20 bg-slate-100">
        {children}
      </main>
      {/* Footer */}
    </div>
  );
};

export default LandingLayout;

Let's style the Navbar. Keep it fixed, centered. Also add another div which sets the max screen width to 2xl.

Then render the Logo inside the inner div.

Update Navbar component with logo

Adds a new navbar component that renders a logo and a placeholder for a button. The navbar is fixed at the top of the page and has a border and a shadow. It uses the Logo component from '@/components/Logo' and tailwindcss for styling.

import React from 'react';

import Logo from '@/components/Logo';

export const Navbar = () => {
  return (
    <div className='fixed top-0 w-full h-14 px-4 border-b shadow-sm bg-white flex items-center'>
      <div className='md:max-w-screen-2-xl mx-auto flex items-center w-full justify-between'>
        <Logo />
        {/* Button */}
      </div>
    </div>
  );
};

After the Logo let's add the sign-in and sign-up buttons

import React from 'react';
import Link from 'next/link';

import Logo from '@/components/Logo';
import { Button } from '@/components/ui/button';

export const Navbar = () => {
  return (
    <div className='fixed top-0 w-full h-14 px-4 border-b shadow-sm bg-white flex items-center'>
      <div className='md:max-w-screen-2-xl mx-auto flex items-center w-full justify-between'>
        <Logo />
        <div className='space-x-4 md:block md:w-auto flex items-center justify-between w-full'>
          <Button size='sm' asChild>
            <Link href="/sign-up">
              Sign Up
            </Link>
          </Button>
          <Button size='sm' variant='outline' asChild>
            <Link href="/sign-in">
              Login
            </Link>
          </Button>
        </div>
      </div>
    </div>
  );
};

Add sign-in and sign-up buttons for Navbar

This commit adds two buttons for sign-in and sign-up to the Navbar component. The buttons use the Link component from 'next/link' to navigate to the corresponding pages.

Footer

Create Footer.tsx inside app\(landing)\_components.

The Footer component will be fixed at the bottom, it will also have a container that has the Logo and two Link components that uses the Button component's buttonVariants. The Button components will have "Privacy Policy" and "Terms of Service" as children text.

app\(landing)\_components\Footer.tsx

import React from 'react';
import Link from 'next/link';

import { buttonVariants } from "@/components/ui/button";


export const Footer = () => {
  return (
    <div className='fixed bottom-0 w-full p-4 border-t flex items-center bg-slate-100'>
      <div className='md:max-w-screen-2-xl mx-auto flex items-center w-full justify-center'>
        <div className='space-x-4 md:block md:w-auto flex items-center justify-center w-full'>
          <Link className={buttonVariants({ size: "lg", variant: "ghost" })} href="/privacy-policy">
            Privacy Policy
          </Link>
          <Link className={buttonVariants({ size: "lg", variant: "ghost" })} href="/terms-of-service">
            Terms of Service
          </Link>
        </div>
      </div>
    </div>
  );
};

Add footer component with links

Create a responsive footer component that displays two links for privacy policy and terms of service.

Refactor Footer with buttonVariants & Link

Replace the Button component with buttonVariants and use the Link component from next.js to render the links. Resize links to improve user experience. Also center the content horizontally and vertically with flexbox.

Now import the Footer and render it inside the LandingLayout:

app\(landing)\layout.tsx

import { Footer } from "./_components/Footer";
import { Navbar } from "./_components/Navbar";

const LandingLayout = ({
  children
}: {
  children: React.ReactNode;
}) => {
  return (
    <div className="h-full bg-slate-100">
      <Navbar />
      <main className="pt-40 pb-20 bg-slate-100">
        {children}
      </main>
      <Footer />
    </div>
  );
};

Authentication

We will use Clerk to handler user authentication.

Go ahead and sign up and click "Add an Application", this will bring us to a modal with the "Let's build your <SignIn />" which allows you to customize the component users will see when they log-in. You can specify the application name and the ways a user can sign in.

I'm going to go with email, username, google and microsoft as ways to login.

Go ahead and create the component. Next we will see another page that contains our API keys. We can also click the framework the app will be using, Next.js for our case.

Environment Variables

Before we add sensitive data (e.g., API keys, passwords, etc.) to our application you want to make sure these are not published or found publicly in any way.

Add .env to .gitignore to protect sensitive data

Navigate to .gitignore file and add .env.

Although we may see .env*local already there, we want to specifically add our own .env file because later on we will use prisma which uses the .env file.

.gitignore

# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local
.env

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

This way the .env file will not be committed to git or GitHub, so .env won't exist anywhere except our local computer. We do not want our API keys to be stolen, especially if we made the mistake of adding it to our public repository.

Exposing our API keys, especially on paid services, would be disastrous. It would allow malicious actors to abuse our resources and incur huge expenses for us.

Add our first environment variables

Now add the two API keys we got from Clerk to your .env file. Use the clipboard at the top right to copy the full data. The variables should be NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY.

Next we can click the "Continue in docs", which should send us to clerk quickstart nextjs | reference.

  1. Install Clerk and add auth to protected routes.
npm install @clerk/nextjs

Add Clerk dependency and secure routes with auth.

  1. Set environment keys

  2. Wrap your app in <ClerkProvider>

The <ClerkProvider> component provides active session and user context to Clerk's hooks and other components. Import it into your app by adding import { ClerkProvider } from '@clerk/nextjs' at the top of your file.

Note that we won't wrap the provider around our main layout, (i.e., app\layout.tsx), like it does in the documentation. We only want to wrap the provider around the layouts that are protected, the routes where we would want the user to be authenticated.

Wrap AppLayout with ClerkProvider for auth

Create a route group, folder named (app) inside the /app folder. Then create a layout.tsx within. Inside we create a react arrow functional component export named AppLayout.

app\(app)\layout.tsx

import React from 'react';

const AppLayout = ({
  children
}: {
  children: React.ReactNode;
}) => {
  return (
    <div>AppLayout</div>
  )
}

export default AppLayout

The user needs to be logged-in to be in the app. As opposed to (landing), where we don't the user to be logged-in. So to secure the (app) routes we need to wrap it in the ClerkProvider.

import { ClerkProvider } from '@clerk/nextjs';
import React from 'react';

const AppLayout = ({
  children
}: {
  children: React.ReactNode;
}) => {
  return (
    <ClerkProvider>
      {children}
    </ClerkProvider>
  )
}

export default AppLayout
  1. Require authentication to access your app

Now that Clerk is installed and mounted in your application, you can decide which pages are public and which should require authentication to access.

Create a middleware.ts file in your root directory alongside .env.

middleware.ts

import { authMiddleware } from "@clerk/nextjs";
 
// This example protects all routes including api/trpc routes
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
export default authMiddleware({});
 
export const config = {
  matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};

This adds the auth middleware to all routes, so to make certain routes public we have to use publicRoutes. See the docs for more details.

So we have to modify the authMiddleware so that it will make the following routes /, /sign-in and /sign-up public, no routes ignored, and all remaining routes protected.

import { authMiddleware } from "@clerk/nextjs";
 
export default authMiddleware({
  publicRoutes: ["/"],
});
 
export const config = {
  matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};

Assuming that the .env based settings for sign-in and sign-up are set to /sign-in and /sign-up respectively, the following authMiddleware would make the routes /, /contact, /sign-in and /sign-up public, no routes ignored, and all remaining routes protected.

import { authMiddleware } from "@clerk/nextjs";
 
export default authMiddleware({
  publicRoutes: ["/", "/contact"],
});
 
export const config = {
  matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};
  1. Embed the <UserButton />

Clerk offers a set of prebuilt components to add functionality to your app with minimal effort. The <UserButton /> allows users to manage their account information and log out, completing the full authentication circle.

Before we embed this component we need to create the sign-in and sign-up pages to allow users to login or create an account.

Sign-Up and Sign-In pages

We need to create the Sign-Up and Sign-In pages.

The first step in the documentation for the sign-up page is to create the file app/sign-up/[[...sign-up]]/page.tsx and add the code

import { SignUp } from "@clerk/nextjs";
 
export default function Page() {
  return <SignUp />;
}

Notice how it uses a catch-all segment which is a dynamic segment that is extended to catch-all subsequent segments by adding an ellipsis inside the brackets [...folderName].

For example, app/shop/[...slug]/page.js will match /shop/clothes, but also /shop/clothes/tops, /shop/clothes/tops/t-shirts, and so on.

Route group to hold our authentication

Let's first create the route group (auth) inside (app) to group these related files.

Create sign-up page using Clerk

  1. Create the folder sign-up inside (auth)
  2. Create the folder [[...sign-up]] inside sign-up
  3. Create page.tsx file inside [[...sign-up]]

app\(app)\(auth)\sign-up\[[...sign-up]]\page.tsx

import { SignUp } from "@clerk/nextjs";
 
export default function Page() {
  return <SignUp />;
}

Create sign-in page using Clerk

  1. Create the folder sign-in inside (auth)
  2. Create the folder [[...sign-in]] inside sign-in
  3. Create page.tsx file inside [[...sign-in]]

app\(app)\(auth)\sign-in\[[...sign-in]]\page.tsx

import { SignIn } from "@clerk/nextjs";
 
export default function Page() {
  return <SignIn />;
}

Next, add environment variables for the signIn, signUp, afterSignUp, and afterSignIn paths:

.env

NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/

These values control the behavior of the components when you sign in or sign up and when you click on the respective links at the bottom of each component.

This will let the middleware know where to redirect. See how our project structure aligns with this in app\(app)\(auth)\sign-in\[[...sign-in]]\page.tsx.

Remember that route groups are not part of the URL. So the route would become localhost:3000/sign-in.

Style the Sign-In and Sign-Up Page

Let's create a nested layout so that we can center both the sign-in page and sign-up page.

Create a layout.tsx inside the (auth) folder, which centers the children prop.

Add layout component for clerk pages

Create a nested layout called AuthLayout that renders a centered flex container for clerk pages. This component can be used to wrap other components that need to be aligned in the center of the screen.

app\(app)\(auth)\layout.tsx

import React from 'react';

const AuthLayout = ({ children}:{ 
  children: React.ReactNode;
}) => {
  return (
    <div className='h-full flex items-center justify-center'>
      {children}
    </div>
  );
};

export default AuthLayout;

Organizations

We can check our organizations through https://dashboard.clerk.com, click the Organizations tab.

Then enable organizations.

With that we can now create a protected route where users can create or select an organization. We now need to create a route that has org-selection route with a catch all segment.

The <OrganizationList /> component is used to display organization related memberships, invitations, and suggestions for the user.

Inside (auth) create a route named org-selection/[[...org-selection]]/page.tsx

Add page to create new organization

This commit adds a new React component called OrganizationSelectionPage that renders an OrganizationList component from @clerk/nextjs. This component allows the user to create a new organization and see the existing ones.

app\(app)\(auth)\org-selection\[[...org-selection]]\page.tsx

import React from 'react';
import { OrganizationList } from '@clerk/nextjs';

export default function OrganizationSelectionPage() {
  return (
    <OrganizationList />
  );
};

This page is already centered as it shares the layout with the (auth) route group.

Use both personal and organization accounts

We have the decision to either have individual users and organizations or just organizations. Visionize will be for both individuals and organizations so that more will benefit.

Allow for both personal & organizational use

This commit changes the app logic and UI to enable visionize to be used for both personal and organizational purposes. The user can now switch between different modes and access different features depending on their needs.

We can add the prop hidePersonal to OrganizationalList to remove selecting a personal account if we intend this app to only be between groups, teams and organizations. But we want this app to be tailored towards individual users too, so we omit that.

Let's add the props according to the docs.

import React from 'react';
import { OrganizationList } from '@clerk/nextjs';

export default function OrganizationSelectionPage() {
  return (
    <OrganizationList 
      afterCreateOrganizationUrl='/org/:id'
      afterSelectPersonalUrl='/user/:id'
      afterSelectOrganizationUrl='/org/:id'
    />
  );
};

This commit adds three props to the OrganizationList component from @clerk/nextjs: afterCreateOrganizationUrl, afterSelectPersonalUrl, and afterSelectOrganizationUrl. These props allow the user to to navigate to after creating or selecting an organization or a personal account. This design decision enables both teams and individuals.

Update: Disallow personal pages, only allow organization accounts

In the OrganizationList component, we will set hidePersonal prop to false to hide the personal account entry, so users will only be able to switch between organizations.

feat: Exclude personal accounts from org list

import React from 'react';
import { OrganizationList } from '@clerk/nextjs';

export default function OrganizationSelectionPage() {
  return (
    <OrganizationList
      hidePersonal={true}
      afterCreateOrganizationUrl='/org/:id'
      afterSelectOrganizationUrl='/org/:id'
    />
  );
};

Dashboard

We want the user to be routed to the app's dashboard so they can start using the app.

The urls to navigate to will be in a route group named (dashboard). Inside will be both the user and organization routes, containing the dynamic routes/ dynamic segments for each using id. Each will have a page.tsx.

Add organization page to dashboard

app\(app)\(dashboard)\org\[orgId]\page.tsx

import React from 'react';

const OrganizationIdPage = () => {
  return (
    <div>Organization Page</div>
  );
};

export default OrganizationIdPage

Add personal user page to dashboard

app\(app)\(dashboard)\user\[userId]\page.tsx

import React from 'react';

const UserIdPage = () => {
  return (
    <div>User Page</div>
  );
};

export default UserIdPage

Let's retrieve the userId in the UserIdPage and interpolate the data.

import { auth } from '@clerk/nextjs';
import React from 'react';

const UserIdPage = () => {
  const { userId } : { userId: string | null } = auth();

  return (
    <div>
      `User: ${userId}`
    </div>
  );
};

export default UserIdPage

Let's also do the same for OrganizationIdPage.

import { auth } from '@clerk/nextjs';
import React from 'react';

const OrganizationIdPage = () => {
  const { userId, orgId } = auth();

  return (
    <div>
      {`Organization: ${orgId}`}
    </div>
  );
};

export default OrganizationIdPage

Dashboard layout

Let's create the layout.tsx file for the (dashboard), which accepts a children prop that will be populated with a child layout or a child page during rendering.

app\(app)\(dashboard)\layout.tsx

import React from 'react';

const DashboardLayout = ({children}: {
  children: React.ReactNode;
}) => {
  return (
    <div className='h-full'>
      {/* Navbar */}
      {children}
    </div>
  )
}

export default DashboardLayout

We are also going to create local components for the dashboard, which includes a <Navbar />.

Create the folder under (dashboard) named _components with a file named Navbar.tsx

app\(app)\(dashboard)\_components\Navbar.tsx

export const Navbar = () => {
  return (
    <div>
      Navbar
    </div>
  );
};

Then import and add it inside the DashboardLayout.

app\(app)\(dashboard)\layout.tsx

import { Navbar } from './_components/navbar';

const DashboardLayout = ({children}: {
  children: React.ReactNode;
}) => {
  return (
    <div className='h-full'>
      <Navbar />
      {children}
    </div>
  )
}

export default DashboardLayout

Now add responsive styles to the Navbar and include a create Button. Also change the div to the semantic tag nav.

import React from 'react';
import { Plus } from 'lucide-react';

import Logo from '@/components/Logo';
import { Button } from '@/components/ui/button';

export const Navbar = () => {
  return (
    <nav className='flex items-center fixed px-4 z-10 top-0 w-full h-14 border-b shadow-sm bg-white'>
      {/* Responsive Container */}
      <div className='flex items-center gap-x-4'>
        {/* For screens 768px and larger  */}
        <div className='hidden md:flex'>
          <Logo />
        </div>
        <Button 
          size='sm' 
          className='rounded-sm py-1.5 px-2 h-auto'
        >
          <span className='hidden md:block'>Create</span>
          <Plus className='block md:pl-1 h-4 w-4'/>
        </Button>
      </div>
    </nav>
  );
};

Add create button & responsive styles to navbar

This commit adds a new feature to the navbar that allows users to create new organizations. It also improves the appearance and functionality of the navbar on different screen sizes and devices.

Customize Button component

One thing to add is to modify the Button component to have a lighter color that matches the background.

Navigate to button.tsx and add a primary variant that gives the Button a sky blue color.

Add primary variant to buttonVariants

Add a new variant called primary to the buttonVariants object in the Button.tsx component. The primary variant has a sky blue background color and a white text color. It also has a hover effect that changes the background color to a darker shade of sky blue. The primary variant can be used for buttons that need to stand out or indicate a primary action.

Gives the Button component in Navbar a primary variant, which has a sky blue background color and a white text color. It also has a hover effect that changes the background color to a darker shade of sky blue. This indicates to the user the action to create.

components\ui\button.tsx

const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline:
          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
        primary: "bg-sky-500 hover:bg-sky-600/90 text-primary-foreground",
      },

Then add add the variant prop and set it to primary in Navbar.tsx.

app\(app)\(dashboard)\_components\Navbar.tsx

export const Navbar = () => {
  return (
    <nav className='flex items-center fixed px-4 z-10 top-0 w-full h-14 border-b shadow-sm bg-white'>
      {/* Responsive Container */}
      <div className='flex items-center gap-x-4'>
        {/* For screens 768px and larger  */}
        <div className='hidden md:flex'>
          <Logo />
        </div>
        <Button
          variant='primary'
          size='sm' 
          className='rounded-sm py-1.5 px-2 h-auto'
        >

OrganizationSwitcher component

Let's add OrganizationSwitcher in the Navbar and set the prop urls.

import { OrganizationSwitcher } from '@clerk/nextjs';

export const Navbar = () => {
  return (
    <nav className='flex items-center fixed px-4 z-10 top-0 w-full h-14 border-b shadow-sm bg-white'>
      {/* Responsive Container */}
      <div className='flex items-center gap-x-4'>
        {/* For screens 768px and larger  */}
        <div className='hidden md:flex'>
          <Logo />
        </div>
        <Button 
          size='sm' 
          className='rounded-sm py-1.5 px-2 h-auto'
        >
          <span className='hidden md:block'>Create</span>
          <Plus className='block md:pl-1 h-4 w-4'/>
        </Button>
      </div>
      <div className='ml-auto flex items-center gap-x-2'>
        <OrganizationSwitcher 
          hidePersonal={true}
          afterCreateOrganizationUrl='/org/:id'
          afterLeaveOrganizationUrl='/org-selection'
          afterSelectOrganizationUrl="/org/:id"
        />
      </div>
    </nav>
  );
};

Let's use the appearance prop to style the OrganizationSwitcher component.

<OrganizationSwitcher 
  hidePersonal={true}
  afterCreateOrganizationUrl='/org/:id'
  afterLeaveOrganizationUrl='/org-selection'
  afterSelectOrganizationUrl="/org/:id"
  appearance={{
    elements: {
      rootBox: {
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center'
      },
    },
  }}
/>

Now add the UserButton right next to the switcher. Give it the props afterSignOutUrl and appearance.

import React from 'react';
import { OrganizationSwitcher, UserButton } from '@clerk/nextjs';
import { Plus } from 'lucide-react';

import Logo from '@/components/Logo';
import { Button } from '@/components/ui/button';

export const Navbar = () => {
  return (
    <nav className='flex items-center fixed px-4 z-10 top-0 w-full h-14 border-b shadow-sm bg-white'>
      {/* Responsive Container */}
      <div className='flex items-center gap-x-4'>
        {/* For screens 768px and larger  */}
        <div className='hidden md:flex'>
          <Logo />
        </div>
        <Button 
          size='sm' 
          className='rounded-sm py-1.5 px-2 h-auto'
        >
          <span className='hidden md:block'>Create</span>
          <Plus className='block md:pl-1 h-4 w-4'/>
        </Button>
      </div>
      <div className='ml-auto flex items-center gap-x-2'>
        <OrganizationSwitcher 
          hidePersonal={true}
          afterCreateOrganizationUrl='/org/:id'
          afterLeaveOrganizationUrl='/org-selection'
          afterSelectOrganizationUrl="/org/:id"
          appearance={{
            elements: {
              rootBox: {
                display: 'flex',
                justifyContent: 'center',
                alignItems: 'center'
              },
            },
          }}
        />
        <UserButton 
          afterSignOutUrl='/'
          appearance={{
              elements: {
                avatarBox: {
                  height: 30,
                  width: 30,
                },
              },
          }}
        />
      </div>
    </nav>
  );
};

Add account and logout options in navbar

In Navbar, add UserButton component which allows users to manage their account information and log out.

Update middleware to control navigation after authentication

Let's change the behavior for when user is already logged-in and they attempt to visit the landing page. We want the user to always be on a specific organization or their individual page. We want to be redirected to SelectOrganizationPage.

One way to do this is to use the the afterAuth() in our middleware.

Some developers will need to handle specific cases such as handling redirects differently or detecting if a user is inside an organization. These cases can be handled with afterAuth().

Configure redirects for public & private routes

middleware.ts

import { authMiddleware, redirectToSignIn } from "@clerk/nextjs";
import { NextResponse } from "next/server";

export default authMiddleware({
  publicRoutes: ["/"],

  afterAuth(auth, req, evt) {
    // Handle users who aren't authenticated
    if (!auth.userId && !auth.isPublicRoute) {
      return redirectToSignIn({ returnBackUrl: req.url });
    }

    // If user is logged-in and on the landing page, redirect them
    if (
      auth.userId && 
      req.nextUrl.pathname === "/") 
    {
      const orgSelection = new URL("/org-selection", req.url);
      return NextResponse.redirect(orgSelection);
    }

    // If the user is logged in and trying to access a protected route, allow them to access route
    if (auth.userId && !auth.isPublicRoute) {
      return NextResponse.next();
    }
    // Allow users visiting public routes to access them
    return NextResponse.next();
  },
});

export const config = {
  matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};

The first case when we handle users who aren't authenticated we want to redirect them to the sign-in page. Then after signing in, we return them back to the URL they came from. The URL they come from was either a private route that they couldn't access earlier, e.g., they bookmarked the dashboard page.

  afterAuth(auth, req, evt) {
    // Handle users who aren't authenticated
    if (!auth.userId && !auth.isPublicRoute) {
      return redirectToSignIn({ returnBackUrl: req.url });
    }
    // If user is logged-in and on the landing page, redirect them
    if (
      auth.userId && 
      req.nextUrl.pathname === "/") 
    {
      const orgSelection = new URL("/org-selection", req.url);
      return NextResponse.redirect(orgSelection);
    }

In this next condition we redirect users already logged-in and on the landing page to the selection page.

Organization and User layouts

Now we work on the content of the pages within the dashboard.

So far we have the Navbar at the top. We want a layout that wraps the content below the navbar, along with a sidebar to the left that allows users to switch between profiles.

Create a layout.tsx inside app\(app)\(dashboard)\org, which accepts a children prop and returns a <main>.

app\(app)\(dashboard)\org\layout.tsx

import React from 'react';

export default function OrganizationLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <main>
      {children}
    </main>
  )
}

Similarly, create the UserLayout inside app\(app)\(dashboard)\user/

app\(app)\(dashboard)\user\layout.tsx

import React from 'react';

export default function UserLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <main>
      {children}
    </main>
  )
}

Start with the OrganizationLayout. Style it so that we have padding and space for our content and a sidebar. We also want a div that appears only on medium screens and larger with a fixed w-64.

export default function OrganizationLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <main className='px-4 pt-20 md:pt-24 mx-auto max-w-6xl 2xl:max-w-screen-xl'>
      <div className='flex '>
        <div className='w-64 shrink-0 hidden md:block'>
        </div>
        {children}
      </div>
    </main>
  )
}

We come across the issue where if you go to a organization page with a specific ID it does not switch to that component. We are going to have to check whether a user ID or organization ID matches the URL and switch to that. Create OrganizationIdLayout.tsx.

import React from 'react';

export default function OrganizationIdLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div>
      {children}
    </div>
  )
}

Issue: ID in the URL does not match the switcher and the content

When using the OrganizationSwitcher the URL and the orgId will match the chosen group. But if a user were to save the URL of one organization, use the switcher to switch to another, then load the saved URL back to the browser without using the switcher then we come across an issue where the URL and contents are not synchronized.

An issue we come across is that the user may access another organization directly, such as a bookmark. The ID inside the URL may not reflect the content of the layout. We need a way to match and sychronize the ID of the URL to that of the content of the page.

Create a private components folder with the component URLMatcher.tsx inside [orgId].

Create URLMatcher.tsx

Create a component that actively checks the URL and matches the ID with the content currently displayed on the page.

app\(app)\(dashboard)\org\[orgId]\_components\URLMatcher.tsx

"use client";

import { useOrganizationList } from '@clerk/nextjs';
import { useParams } from 'next/navigation';
import React, { useEffect } from 'react';

// Checks the organization ID of the URL and synchronize it with the page
export default function URLMatcher() {
  const params = useParams();
  const { setActive } = useOrganizationList();

  useEffect(() => {
    if (!setActive) return;

    setActive({
      organization: params.orgId as string,
    });
  }, [setActive, params.orgId]);

  return null;
}

Now render that inside OrganizationIdLayout right above the children.

app\(app)\(dashboard)\org\[orgId]\layout.tsx

import React from 'react';
import URLMatcher from './_components/URLMatcher';

export default function OrganizationIdLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <>
      <URLMatcher />
      {children}
    </>
  )
}

Testing to see if URLMatcher fixes the issue

  1. Create two organizations.
  2. Use switcher to choose 1st organization
  3. Copy the URL
  4. Switch to 2nd org.
  5. Paste copied URL of 1st organization and hit Enter

What we should see is that the content and display will sychronize with the ID in the URL. The switcher will also reflect this change and it should be the name of the 1st organization.

Installing packages

Before we start developing the Sidebar, we are going to install some packages.

npm i usehooks-ts
  • shadcn/ui - Accordion is a vertically stacked set of interactive headings that each reveal a section of content.
npx shadcn-ui@latest add accordion
npx shadcn-ui@latest add skeleton

-shadcn/ui - Separator, visually or semantically separates content.

npx shadcn-ui@latest add separator

Sidebar

Create a client component Sidebar.tsx under (dashboard)/_components folder.

app\(app)\(dashboard)\_components\Sidebar.tsx

"use client";

import React from 'react'

export default function Sidebar() {
  return (
    <div>Sidebar</div>
  );
};

Now render the Sidebar inside OrganizationLayout.

app\(app)\(dashboard)\org\layout.tsx

import React from 'react';
import Sidebar from '../_components/Sidebar';

export default function OrganizationLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <main className='px-4 pt-20 md:pt-24 mx-auto max-w-6xl 2xl:max-w-screen-xl'>
      <div className='flex '>
        <div className='w-64 shrink-0 hidden md:block'>
          <Sidebar />
        </div>
        {children}
      </div>
    </main>
  )
}

Preserve user interaction with Accordion component in Sidebar

The Accordion is a component that expands to reveals a section of content, or collapses to hide the content.

We need to preserve the user's interaction with the accordion across UI reloads. The Accordion should maintain its previous state after reloading.

To do that we will use localStorage and the hook useLocalStorage to store this information.

Let's start with the imports we may need to use for the Sidebar.

app\(app)\(dashboard)\_components\Sidebar.tsx

"use client";

import Link from 'next/link';
import React from 'react'
import { Plus } from 'lucide-react';
import { useLocalStorage } from 'usehooks-ts';
import { useOrganization, useOrganizationList } from '@clerk/nextjs';

import { Accordion } from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';

Then we create the prop interface that has the storageKey property. We call it storageKey because in the useLocalStorage docs, the hook is used like useState except that you must pass the storage key in the 1st parameter.

We want to create this storageKey property in the prop interface in order to make the Sidebar component more re-usable.

Update Sidebar with local storage to persist state

Save the open or collapse state of Accordion component with local storage inside the sidebar.

  • Create prop interface SideBarProps which contain the storageKey
  • Accept a storageKey prop with a default value
  • Create a state variable open and persist it with local storage so that it remains after a page refresh
"use client";

import React from 'react'
import { useLocalStorage } from 'usehooks-ts';

interface SidebarProps {
  storageKey?: string;
};

export default function Sidebar({
  storageKey = "sidebarState",
}: SidebarProps ) {
  const [open, setOpen] = useLocalStorage(storageKey, {});

  return (
    <div>
      Sidebar
    </div>  
  );
};

Let's also give useLocalStorage hook a defined type of what to expect. In this case we expect a Record, with string as keys and any as values.

  const [open, setOpen] = useLocalStorage<Record<string, any>>(
    storageKey, 
    {}
  );

So far:

"use client";

import React from 'react'
import { useLocalStorage } from 'usehooks-ts';

import { Accordion } from '@/components/ui/accordion';

// Define an interface for the Sidebar component props
interface SidebarProps {
  // Optional prop to specify the storage key for the sidebar state
  storageKey?: string;
};

// Define the Sidebar component as a default export
export default function Sidebar({
  // Destructure the storageKey prop and assign a default value
  storageKey = "sidebarState",
}: SidebarProps ) {
  
  // Use the useLocalStorage hook to store and retrieve the open state of the sidebar
  // The open state is an object that maps each accordion item key to a boolean value
  const [open, setOpen] = useLocalStorage<Record<string, any>>(
    storageKey, 
    {} // Initial value is an empty object
  );

  return (
    <div>
      Sidebar
    </div>  
  );
};

With that we can now start taking the active organization and the infinite list of joined organizations.

We want to use the useOrganization hook to get the active organization and its loading status. The active organization is the one that the user is currently viewing or managing.

Use the useOrganizationList hook to get the user memberships and their loading status. The user memberships are the organizations that the user belongs to or has access to. The infinite option enables pagination and infinite scrolling for the organization list.

export default function Sidebar({
  storageKey = "sidebarState",
}: SidebarProps) {

  const [open, setOpen] = useLocalStorage<Record<string, any>>(
    storageKey,
    {} 
  );

  const {
    organization: activeOrg,
    isLoaded: isLoadedOrg,
  } = useOrganization();


  const {
    userMemberships,
    isLoaded: isLoadedOrgList,
  } = useOrganizationList({
    userMemberships: {
      infinite: true,
    },
  });

  return (
    <div>
      Sidebar
    </div>
  );
};

feat: add hooks for active org & org list in Sidebar

  • Use useOrganization hook to get the active organization and its loading status
  • Use useOrganizationList hook to get the user memberships and their loading status
  • Display the organization name in the sidebar header
  • Display the organization list in the sidebar accordion

Finally define the function onOpen that uses the setOpen method to toggle the open state of an accordion item by its id. Copy the current open state and update the id key with the opposite value.

Then we handle the loading states for active organization, organization list and userMemberships. In place of it, we use the Skeleton component as a placeholder for the loading progress.

  /**
   * Toggles the open state of an accordion item by its id
   * Use the setOpen function to update the open state object
   * Use the spread operator to copy the current open state and 
   * update the id key with the opposite value
   * @param id the id of the accordion item
   */
  function onOpen(id: string) {
    setOpen((current) => ({
      ...current,
      [id]: !open[id],
    }));
  };

  // Check if the organization data and the user memberships data are loaded
  // If any of them is not loaded or is loading, return a div element with a Skeleton component
  // The Skeleton component is a placeholder that shows the loading progress
  if (!isLoadedOrg || !isLoadedOrgList || userMemberships.isLoading) {
    return (
      <div>
        <Skeleton />
      </div>
    )
  }

  // Return the JSX for the sidebar component
  return (
    <div>
      Sidebar
    </div>
  );
};

Handle the open & loading states in sidebar

feat: add onOpen function and loading logic

  • Add onOpen function to toggle the open state of an accordion item by its id
  • Use the spread operator to copy and update the open state object
  • Check the loading status of the organization data and the user memberships data
  • Return a Skeleton component if any of the data is not loaded or is loading

Sidebar output

Add a link styled as a button that adds workspaces, an organization or user board, to the sidebar.

"use client";

import Link from 'next/link';

import { Button } from '@/components/ui/button';

export default function Sidebar({
  storageKey = "sidebarState",
}: SidebarProps) {

  // ...

  // Return the JSX for the sidebar component
  return (
    <div className='flex items-center mb-1 font-medium text-xs'>
      <span className='pl-4'>
        Workspaces
      </span>
      <Button 
        asChild
        className='ml-auto'
        size='icon'
        type='button'
        variant='ghost'
      >
        <Link href='/org-selection'>
          <Plus 
            className='h-4 w-4'
          />
        </Link>
      </Button>
    </div>
  );
};

Now we want to actually render the actual workspaces with the Accordion component.

For the props: a type of multiple, so that multiple items can be opened at the same time. And a defaultValue prop set to prevAccordionValue.

import { Accordion } from '@/components/ui/accordion';

export default function Sidebar({
  storageKey = "sidebarState",
}: SidebarProps) {

  // ...
  // Return the JSX for the sidebar component
  return (
    <>
      <div className='flex items-center mb-1 font-medium text-xs'>
        <span className='pl-4'>
          Workspaces
        </span>
        <Button
          asChild
          className='ml-auto'
          size='icon'
          type='button'
          variant='ghost'
        >
          <Link href='/org-selection'>
            <Plus
              className='h-4 w-4'
            />
          </Link>
        </Button>
      </div>
      <Accordion
        type='multiple'
        defaultValue={prevAccordionValue}
      >

      </Accordion>
    </>
  );
};

Hold on what's prevAccordionValue? Well the way we store values inside local storage through the open state is not compatible to how the defaultValue is expected inside the Accordion. So we need to create a constant that transforms and reduces the data we have inside local storage and the open state into something that matches with the prop defaultValue and what default values expect.

prevAccordionValue converts the accordion data in local storage to conform to defaultValue prop of the Accordion component.

  • Define a variable to store the previous accordion value as an array of keys
  • Use the Object.keys method to get the keys of the open state object
  • Use the reduce method to filter out the keys that have a false value
  • If the value of the key is true (i.e., it is open & expanded), add it to the accumulator array
export default function Sidebar({
  storageKey = "sidebarState",
}: SidebarProps) {

  // Use the useLocalStorage hook to store and retrieve the open state of the sidebar
  // The open state is an object that maps each accordion item key to a boolean value
  const [open, setOpen] = useLocalStorage<Record<string, any>>(
    storageKey,
    {} // Initial value is an empty object
  );

  // Define a variable to store the previous accordion value as an array of keys
  // Use the Object.keys method to get the keys of the open state object
  // Use the reduce method to filter out the keys that have a false value
  const prevAccordionValue: string[] = Object.keys(open)
    .reduce((accumulator: string[], key: string) => {
      // If the value of the key is true, add it to the accumulator array
      if (open[key]) {
        accumulator.push(key);
      }

      // Return the accumulator array
      return accumulator;
    }, [])

  return (
    <>
      <div className='flex items-center mb-1 font-medium text-xs'>
        <span className='pl-4'>
          Workspaces
        </span>
        <Button
          asChild
          className='ml-auto'
          size='icon'
          type='button'
          variant='ghost'
        >
          <Link href='/org-selection'>
            <Plus
              className='h-4 w-4'
            />
          </Link>
        </Button>
      </div>
      <Accordion
        type='multiple'
        defaultValue={prevAccordionValue}
      >

      </Accordion>
    </>
  );
};

In short, it reduces over the open state object, which looks like this as an example:

{
  "my-organization-id" : true
}

It reduces this entire open object to create an array that only holds active IDs. e.g.,

// open state object
{ "123" : true } 
// prevAccordionValue
["123"]

Now let's style and render the organizations inside Accordion. We map the userMemberships.data to a <p> with key prop as the organization.id

      <Accordion
        type='multiple'
        defaultValue={prevAccordionValue}
        className='space-y-2'
      >
        {userMemberships.data.map(({ organization }) => (
          <p key={organization.id}>
            {organization.id}
          </p>
        ))}
      </Accordion>

Now we can see the output of the Sidebar on the web page, which renders each org ID.

SidebarItem component

Instead of a <p>, we should return a component that has more functionality.

Create a SidebarItem client component inside the (dashboard)/_components folder.

import React from 'react';

export default function SidebarItem() {
  return (
    <div>SidebarItem</div>
  )
}

Then import and use SidebarItem

Add SidebarItem to display user workspaces

import SidebarItem from './SidebarItem';

interface SidebarProps {
  storageKey?: string;
};

export default function Sidebar({
  storageKey = "sidebarState",
}: SidebarProps) {

  const [open, setOpen] = useLocalStorage<Record<string, any>>(
    storageKey,
    {}
  );

  // Use the useOrganization hook to get the active organization and its loading status
  // The active organization is the one that the user is currently viewing or managing
  const {
    organization: activeOrg,
    isLoaded: isLoadedOrg,
  } = useOrganization();

  // Use the useOrganizationList hook to get the user memberships and their loading status
  // The user memberships are the organizations that the user belongs to or has access to
  // The infinite option enables pagination and infinite scrolling for the organization list
  const {
    userMemberships,
    isLoaded: isLoadedOrgList,
  } = useOrganizationList({
    userMemberships: {
      infinite: true,
    },
  });

  // Define a variable to store the previous accordion value as an array of keys
  // Use the Object.keys method to get the keys of the open state object
  // Use the reduce method to filter out the keys that have a false value
  const prevAccordionValue: string[] = Object.keys(open)
    .reduce((accumulator: string[], key: string) => {
      // If the value of the key is true, add it to the accumulator array
      if (open[key]) {
        accumulator.push(key);
      }

      // Return the accumulator array
      return accumulator;
    }, [])

  /**
   * Toggles the open state of an accordion item by its id
   * Use the setOpen function to update the open state object
   * Use the spread operator to copy the current open state and 
   * update the id key with the opposite value
   * @param id the id of the accordion item
   */
  function onOpen(id: string) {
    setOpen((current) => ({
      ...current,
      [id]: !open[id],
    }));
  };

  // Check if the organization data and the user memberships data are loaded
  // If any of them is not loaded or is loading, return a div element with a Skeleton component
  // The Skeleton component is a placeholder that shows the loading progress
  if (!isLoadedOrg || !isLoadedOrgList || userMemberships.isLoading) {
    return (
      <div>
        <Skeleton />
      </div>
    )
  }

  // Return the JSX for the sidebar component
  return (
    <>
      <div className='flex items-center mb-1 font-medium text-xs'>
        <span className='pl-4'>
          Workspaces
        </span>
        <Button
          asChild
          className='ml-auto'
          size='icon'
          type='button'
          variant='ghost'
        >
          <Link href='/org-selection'>
            <Plus
              className='h-4 w-4'
            />
          </Link>
        </Button>
      </div>
      <Accordion
        type='multiple'
        defaultValue={prevAccordionValue}
        className='space-y-2'
      >
        {userMemberships.data.map(({ organization }) => (
          <SidebarItem
            key={organization.id}
            isActive={activeOrg?.id === organization.id}
            isOpen={open[organization.id]}
            onOpen={onOpen}
            organization={organization}
          />
        ))}
      </Accordion>
    </>
  );
};
SidebarItem props

Before making the prop interface, create a types/Organization.ts file. Inside an Organization type with { id, imageUrl, name, slug }, and export it.

types\Organization.ts

type Organization = {
  id: string;
  imageUrl: string;
  name: string;
  slug: string;
};

export default Organization;

Next we want to create the SidebarItemProps, containing { isActive, isOpen, onOpen, organization }. Have SidebarItem component accept these props.

import React from 'react';

import Organization from '@/types/organization';

interface SidebarItemProps {
  isActive: boolean;
  isOpen: boolean;
  onOpen: (id: string) => void;
  organization: Organization;
}

export default function SidebarItem({
  isActive,
  isOpen,
  onOpen,
  organization,
}: SidebarItemProps ) {
  return (
    <div>SidebarItem</div>
  )
}

Back in Sidebar.tsx, we have an error:

Type 'OrganizationResource' is not assignable to type 'Organization'.
  Types of property 'slug' are incompatible.
    Type 'string | null' is not assignable to type 'string'.
      Type 'null' is not assignable to type 'string'.ts(2322)
SidebarItem.tsx(9, 3): The expected type comes from property 'organization' which is declared here on type 'IntrinsicAttributes & SidebarItemProps'

in this code:

<SidebarItem
  key={organization.id}
  isActive={activeOrg?.id === organization.id}
  isOpen={open[organization.id]}
  onOpen={onOpen}
  organization={organization}
/>

We can fix that with

import Organization from '@/types/Organization';
// ...
  <SidebarItem
    key={organization.id}
    isActive={activeOrg?.id === organization.id}
    isOpen={open[organization.id]}
    onOpen={onOpen}
    organization={organization as Organization}
  />
Output of SidebarItem

Now render an AccordionItem as output of SidebarItem. After that an AccordionTrigger, which contains the onClick property set to onOpen function. Within that is a nested div containing an Image from next and a span that contains the organization name. Import cn to combine tailwind utility classes for the trigger.

import React from 'react';
import Image from 'next/image';

import { cn } from '@/lib/utils';
import Organization from '@/types/Organization';
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"

interface SidebarItemProps {
  isActive: boolean;
  isOpen: boolean;
  onOpen: (id: string) => void;
  organization: Organization;
}

export default function SidebarItem({
  isActive,
  isOpen,
  onOpen,
  organization,
}: SidebarItemProps ) {
  return (
    <AccordionItem
      value={organization.id}
      className='border-none'
    >
      <AccordionTrigger
        onClick={() => onOpen(organization.id)}
        className={cn()}
      >
        <div className=''>
          <div className=''>
            <Image 
            />
          </div>
          <span className=''>
            {organization.name}
          </span>
        </div>
      </AccordionTrigger>
    </AccordionItem>
  )
}

Add styles SidebarItem and fill out Image properties.

export default function SidebarItem({
  isActive,
  isOpen,
  onOpen,
  organization,
}: SidebarItemProps ) {
  return (
    <AccordionItem
      value={organization.id}
      className='border-none'
    >
      <AccordionTrigger
        onClick={() => onOpen(organization.id)}
        className={cn()}
      >
        <div className='flex items-center gap-x-2'>
          <div className='relative w-7 h-7'>
            <Image
              fill
              src={organization.imageUrl}
              alt="organization image"
              className='rounded-sm object-cover'
            />
          </div>
          <span className='font-medium text-sm'>
            {organization.name}
          </span>
        </div>
      </AccordionTrigger>
    </AccordionItem>
  )
}

We also need to improve Image performance by addding sizes prop which gives info on how wide the image will be at different breakpoints.

feat: add sizes prop to Image in SidebarItem

This commit adds the sizes prop to the Image component from nextjs in the SidebarItem component. This prop allows the image to adjust its size according to the viewport width, improving the performance and responsiveness of the app.

<Image
  fill
  src={organization.imageUrl}
  alt="organization image"
  className='rounded-sm object-cover'
  sizes="(max-width: 768px) 33vw, (max-width: 1200px) 30vw, 25vw"
/>
Unhandled runtime error - invalid src prop for next/image

We get an error because the src prop for Image is not configured in next.config.js, to allow images to be used from that source.

Go to next.config.js and add the images object

feat(images): add remotePatterns to nextConfig

Configure Next.js to allow images from img.clerk.com domain. This enables the use of Clerk's image service for user avatars and other images in the app.

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'img.clerk.com',
      },
    ],
  },
}

module.exports = nextConfig
AccordionContent

Add routes array and navigateTo function

Create an array of routes with display name, href, and icon for each route. Create a navigateTo function that takes an href as a parameter and uses the router hook to perform client-side navigation. Use the useRouter and usePathname hooks to get the router instance and the current URL pathname.

To finish our SidebarItem we need AccordionContent, but we need to create routes, an array of objects that contain { displayName, href, icon }. These properties will be used to render a Button when we map the routes inside the AccordionContent.

Here is the routes we create before we return SidebarItem

import { Activity, CreditCard, Layout, Settings } from 'lucide-react';
// ...
export default function SidebarItem({
  // ...
}: SidebarItemProps ) {

  const routes = [
    {
      displayName: "Boards",
      href: `/org/${organization.id}`,
      icon: <Layout className='h-4 w-4 mr-2' />,
    },
    {
      displayName: "Activity",
      href: `/org/${organization.id}/activity`,
      icon: <Activity className='h-4 w-4 mr-2' />,
    },
    {
      displayName: "Settings",
      href: `/org/${organization.id}/settings`,
      icon: <Settings className='h-4 w-4 mr-2' />,
    },
    {
      displayName: "Billing",
      href: `/org/${organization.id}/billing`,
      icon: <CreditCard className='h-4 w-4 mr-2' />,
    },
  ];

Next import hooks useRouter and usePathname from next/navigation. Create the click handler function navigateTo that takes in href string as parameter, and pushes to the specified href URL.

import { usePathname, useRouter } from 'next/navigation';

export default function SidebarItem({
  // ...
}: SidebarItemProps ) {
  // Get router instance from useRouter hook to perform client-side navigation
  const router = useRouter();

  // Get current URL pathname from the usePathname hook
  const pathname = usePathname();

  /**
   * Click handler that performs client-side navigation to the specified href.
   * @param href the URL link to navigate to
   */
  function navigateTo(href: string): void {
    router.push(href);
  }

Finally after AccordionTrigger, add AccordionContent which maps each route to a Button that has an onClick function of navigateTo along with the icon and displayName interpolated inside.

Add AccordionContent component to SidebarItem

This component maps each route to a Button component and renders them inside an AccordionItem. It allows the user to navigate to different pages within the organization. The routes are defined as an array of objects with the displayName, href, and icon properties.

import { Button } from '@/components/ui/button';

export default function SidebarItem({
  // ...
  }: SidebarItemProps) {
    // ...
  return (
    <AccordionItem
      value={organization.id}
      className='border-none'
    >
      <AccordionTrigger
        onClick={() => onOpen(organization.id)}
        className={cn()}
      >
        <div className='flex items-center gap-x-2'>
          <div className='relative w-7 h-7'>
            <Image
              fill
              src={organization.imageUrl}
              alt="organization image"
              className='rounded-sm object-cover'
            />
          </div>
          <span className='font-medium text-sm'>
            {organization.name}
          </span>
        </div>
      </AccordionTrigger>
      <AccordionContent className=''>
        {routes.map((route) => (
          <Button
            key={route.href}
            size='sm'
            onClick={() => navigateTo(route.href)}
            className={cn()}
            variant='ghost'
          >
            {route.icon}
            {route.displayName}
          </Button>
        ))}
      </AccordionContent>
    </AccordionItem>
  )
}
Style SidebarItem

Add conditional styling to AccordionTrigger

  • Add flex, items-center, padding, gap, and rounded-md
  • Align text to the start with neutral gray color. On hover, set background to neutral gray. Remove the underline of text in both non-hover and hover states
  • Conditional styling that will give a background and text color change to the AccordionItem that is currently active using the isActive prop, but lose the styling when the AccordionItem is open and expanded.
  • When AccordionItem is expanded it should highlight the active element within it.
      <AccordionTrigger
        onClick={() => onOpen(organization.id)}
        className={cn(
          'flex items-center p-1.5 gap-x-2 rounded-md',
          'transition text-start text-neutral-700 hover:bg-neutral-500/10 no-underline hover:no-underline',
          isActive && !isOpen && 'bg-sky-500/10 text-sky-700'
        )}
      >

Similarly, add conditional styling for the Button inside AccordionContent.

      <AccordionContent className='pt-1 text-neutral-700'>
        {routes.map((route) => (
          <Button
            key={route.href}
            size='sm'
            onClick={() => navigateTo(route.href)}
            className={cn(
              'justify-start w-full font-normal pl-10 mb-1',
              pathname === route.href && 'bg-sky-500/10 text-sky-700'
            )}
            variant='ghost'
          >
            {route.icon}
            {route.displayName}
          </Button>
        ))}
      </AccordionContent>

Sidebar testing

With that we can now test the functionality of the Sidebar. We can see the conditional styling makes it visually easy for the user to determine which route or organization is active at any given moment.

Another key feature is that clicking the Boards of another organization while a different one is active, will switch to that organization while also reflecting that change in both the URL and switcher component.

The next issue that comes up is that we need a way to render the Sidebar menu on mobile screens.

Mobile Sidebar

There are a few ways to implement a mobile sidebar. One way is to put the sidebar in a column layout where it would be at the top, pushing the content downwards. Another way is to have a sidebar pop out with a button.

The architectural decision we'll go with for the mobile sidebar is to use state to control the mobile sidebar.

Let's install what we need:

npx shadcn-ui@latest add sheet

feat: add shadcn/ui Sheet component

  • Install shadcn/ui Sheet component from shadcn-ui/ui repository
  • Copy and paste the code from Sheet - shadcn/ui documentation
  • Modify the code to fit the project requirements
  • Import and use the Sheet component in components\ui\sheet.tsx

zustand state management

Next we want to use zustand, a state management solution.

First let's walktrhough the introduction of the zustand docs.

  1. Installation
npm install zustand
  1. First create a store

Your store is a hook! You can put anything in it: primitives, objects, functions. The set function merges state.

import { create } from 'zustand'

const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))
  1. Then bind your components, and that's it!

You can use the hook anywhere, without the need of providers. Select your state and the consuming component will re-render when that state changes.

function BearCounter() {
  const bears = useStore((state) => state.bears)
  return <h1>{bears} around here...</h1>
}

function Controls() {
  const increasePopulation = useStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

Ok let's explain step 2.

zustand library is a state management solution for React. Zustand allows you to create a custom hook that can store and update your application's state.

import { create } from 'zustand'

const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))
import { create } from 'zustand'

The first line of the code imports the create function from zustand, which is used to create a store hook. The create function takes a callback function as an argument, which receives a set function as a parameter. The set function is used to update the state immutably.

const useStore = create((set) => ({
  // ...
}))

The second line of the code declares a constant called useStore, which is the name of the custom hook. The value of useStore is the result of calling the create function with a callback function.

const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))
  • The callback function returns an object that represents the initial state and some actions that can modify the state.
  • The state has one property called bears, which is a number that indicates how many bears are in the store.
  • The actions are two functions: increasePopulation, removeAllBears.
  • The increasePopulation function increments the bears property by one using the set function.
  • The removeAllBears function sets the bears property to zero using the set function.

Lastly, with step 3 we "bind our components", which means to use the custom hook that you create with the create function to access and update state in your React components.

  • React Hooks | Reference, hooks let you use different React features from your components. TThey often start with the word use.

You can use the zustand hook anywhere, without the need of providers. You can also pass a selector function to the hook to get a slice of the state that you are interested in.

To use the store hook in a React component, you can call it with a selector function as an argument. The selector function takes the state object as a parameter and returns a slice of the state that you want to access. For example, if you want to get the number of bears, you can write:

const bears = useStore((state) => state.bears)

This will make the component re-render whenever the bears property changes.

You can also access the actions from the state object and use them to update the state. For example, if you want to increase the population of bears, you can write:

const increasePopulation = useStore((state) => state.increasePopulation)
increasePopulation()

This will update the state and cause the components that depend on the bears property to re-render.

Note: arrow function expression and function body

Let's take a closer look at the callback function inside create().

(set) => ({
  object: 1
})

is the same as

(set) => {
  return {
    object: 1
  }
}

Arrow functions can have either an expression body or the usual block body.

In an expression body, only a single expression is specified, which becomes the implicit return value. In a block body, you must use an explicit return statement.

Returning object literals using the expression body syntax (params) => { object: literal } does not work as expected.

// Calling func() returns undefined!
const func = () => { object: 1 }

This is because JavaScript only sees the arrow function as having an expression body if the token following the arrow is not a left brace, so the code inside braces ({}) is parsed as a sequence of statements, where object is a label, not a key in an object literal.

To fix this, wrap the object literal in parentheses:

const func = () => ({ object: 1 });
zustand store

What's a "store" exactly? Are all hooks made in zustand a store?

In zustand, a store is a container for a specific piece of state and any functions that modify that state.

You can create a store with the create function, which returns a custom hook that you can use to access and update the state in your React components. So, yes, all hooks made in zustand are stores, and you can have multiple stores for different parts of your state.

There isn't a standard naming convention for zustand hooks, but the general rule is to use the "hook" prefix such as useStore, useTodo or useCounter. This is because zustand stores are custom hooks that can be used in React components.

In our case, we can name our hook either useMobileSidebarStore or just useMobileSidebar. I will go with the latter since it is shorter and more consistent with the hook prefix convention.

Use zustand to handle the state for our mobile sidebar

Now we can create a hook to help manage our state for the mobile sidebar.

Create a hooks folder at the base of the project, with a file named useMobileSidebar.ts.

  • import { create } from 'zustand'
  • Create type MobileSidebar which has the following properties: { isOpen, onOpen, onClose }
  • Use the create function to create the custom hook useMobileSidebar with one property isOpen and two actions onOpen and onClose

hooks\useMobileSidebar.ts

import { create } from 'zustand';

type MobileSidebarStore = {
  isOpen: boolean;
  onOpen: () => void;
  onClose: () => void;
};

const useMobileSidebar = create<MobileSidebarStore>((set) => ({
  isOpen: false,
  onOpen: () => {},
  onClose: () => {},
}));

Let's assign the functions to the actions. We want to change the property of isOpen in the useMobileSidebar.

To update and change the state we must use the set function.

  • onOpen will use set to change the state of isOpen to true
  • onClose will use set to change the state of isOpen to false
import { create } from 'zustand';

type MobileSidebarStore = {
  isOpen: boolean;
  onOpen: () => void;
  onClose: () => void;
};

const useMobileSidebar = create<MobileSidebarStore>((set) => ({
  isOpen: false,
  onOpen: () => set({ isOpen: true }),
  onClose: () => set({ isOpen: false }),
}));

feat: implement onOpen and onClose actions for mobile sidebar

  • Use the set function from zustand to update the isOpen state
  • Set isOpen to true when onOpen is called
  • Set isOpen to false when onClose is called

Then export the custom hook useMobileSidebar and also add currying parenthesis to create by rewriting create(...) to create<T>()(...).

import { create } from 'zustand';

type MobileSidebarStore = {
  isOpen: boolean;
  onOpen: () => void;
  onClose: () => void;
};

export const useMobileSidebar = create<MobileSidebarStore>()((set) => ({
  isOpen: false,
  onOpen: () => set({ isOpen: true }),
  onClose: () => set({ isOpen: false }),
}));

feat: export useMobileSidebar hook

Export the useMobileSidebar hook from the module, so that it can be imported and used by other components. This hook provides the state and actions for the mobile sidebar component, using Zustand and TypeScript.

zustand with TypeScript

There is one final change we have to make in our code, we have to add the currying ()(...) as a workaround for microsoft/TypeScript#10571.

Going to provide two sources that states we need the currying parenthesis.

  1. zustand TypeScript Guide

The difference when using TypeScript is that instead of writing create(...), you have to write create<T>()(...) (notice the extra parentheses () too along with the type parameter) where T is the type of the state to annotate it.

  1. zustand Github TypeScript Usage

Basic typescript usage doesn't require anything special except for writing create<State>()(...) instead of create(...)...

This is because the TypeScript version of create is a curried function that takes a type parameter and a state creator function as separate arguments. Without the parentheses, TypeScript will not be able to infer the type of the state correctly.

Why use a state management library like zustand?

I'll address some common questions that even I had when learning React and the intricacies of state.

  1. What problem do state management libraries try to solve? Doesn't React have built-in state management solutions like React useContext?
  • useContext is a React Hook that lets you read and subscribe to context from your component.

Answer: it's about preventing React from unnecessarily re-rendering our components.

We commonly build views where a certain data is accessed in multiple components. Data accessed in multiple components needs to be handled in a higher level common component. Handling the data accessed in multiple components in a common is called lifting the state up. A side-effect of lifting the state up is that it causes unnecessary rendering of some components.

A state management library allows us to have a global state while re-rendering only those components that use the changing parts of the global state. With a state management library, our lifted-up sttate doesn't cause re-render of those children components that don't use the changing parts of the state.

  1. How about React context API?

React Context API only enables us to avoid prop-drilling. It doesn't prevent the re-renders. On any changed to the lifted-up state, every component and its children that use the Context containing the state re-render. Context API does not provide a way to re-render only for a subset of the state changes.

  1. So shouldn't we always use a state management library?

No. There may be situations where we may not need a state management library even when we may have a global state.

We don't need to ooptimize state management if our global state changes in-frequently or if it causes only a few components to re-render.

Cons to adding a third-party state management library is:

  • Additional learning
  • Additional depedendency to be maintained
  • More boilerplate code to repo

We should use it only if rendering components with React's built-in state management causes our UI to be heavy, slow or sluggish.

  1. What state management library should we use?

It depends on the kind of state we seek to manage. There's two kinds of these.

  • I. Manage state based on the data on the sever?

e.g., display products fetched from the server & allow update, delete for these product records stored on the server

Use server state management libraries like react-query, redux-query, swx

  • II. Manage state based on the client-side activity and no server-side syncing?

    e.g., browser-side filtering, slicing, zoom-in / zoom-out of the data on an analytics dashboard

    Use state management libraries like Zustand, Redux toolkit

Here is a an npm package download trends for React state managment in the last 3 years. As of Jan. 2024, @reduxjs/toolkit has had weekly downloads of 3,126,930 over zustand's 2,913,681 downloads. But @reduxjs/toolkit has 10,219 Github stars and zustand has 39,641 Github stars.

Develop the MobileSidebar

Create a file app\(app)\(dashboard)\_components\MobileSidebar.tsx

import React from 'react';

export default function MobileSidebar() {
  return (
    <div>MobileSidebar</div>
  )
}

Inside Navbar, import and add the MobileSidebar right under the <nav>.

app\(app)\(dashboard)\_components\Navbar.tsx

import MobileSidebar from './MobileSidebar';

export const Navbar = () => {
  return (
    <nav className='flex items-center fixed px-4 z-10 top-0 w-full h-14 border-b shadow-sm bg-white'>
      <MobileSidebar />

Back to MobileSidebar.tsx, mark it as "use client" at the top. Then add the imports we need:

  • import { usePathname } from 'next/navigation';
  • import React, { useEffect, useState } from 'react';
  • import { useMobileSidebar } from '@/hooks/useMobileSidebar';

Then get the pathname from the usePathname hook. Afterwards, get the the state values from the useMobileSidebar hook.

"use client";

import { usePathname } from 'next/navigation';
import React, { useEffect, useState } from 'react';

import { useMobileSidebar } from '@/hooks/useMobileSidebar';

export default function MobileSidebar() {
  // Get the current path of the page
  const pathname = usePathname();

  /* These values are used to control the visibility and behavior 
  of the mobile sidebar component */
  const isOpen = useMobileSidebar((state) => state.isOpen);
  const onOpen = useMobileSidebar((state) => state.onOpen);
  const onClose = useMobileSidebar((state) => state.onClose);

  return (
    <div>MobileSidebar</div>
  )
}

After adding the imports, state and actions to MobileSidebar we need to make sure that it runs only after component has mounted. In other words, we want to guarantee that the component runs on the client-side.

Before I explain it myself, there is a wonderful blog post named The Perils of Hydration by Josh Comeau that discusses this in a user-friendly, accessible way that goes into deeper details of React, server-side rendering of Next.js, React app hydration and the DOM.

Mounting trick to fix Hydration errors in Next.js

What are hydration errors?

Hydration errors are errors that occur when React tries to attach event handlers and manage the state of a component that doesn't match the initial rendering. This mismatch can lead to unexpected behavior, such as missing or doubled event listeners, unhandled state changes, or even crashing the application. Hydration errors can happen when the server-rendered HTML and the client-rendered HTML are different, or when the client-side code relies on browser-only APIs or checks.

The mounting trick creates a isMounted state variable. It then runs setIsMounted to true inside a useEffect. Finally, it checks if the component has been mounted. If it hasn't, it returns null. Otherwise, it continues to render the component. This can help prevent hydration errors in Next.js.

  // Declare isMounted state variable and initialize it to false
  const [isMounted, setIsMounted] = useState(false);

  // useEffect hook to set isMounted variable to true
  // Delays the execution of client-side-only code until after hydration
  useEffect(() => {
    setIsMounted(true);
  }, []); // Only run once after the initial render

  // Prevent rendering of the component before the effect has run
  // To protect from hydration errors or unwanted flashes of content
  if (!isMounted) {
    return null;
  }

This code does the following:

  • It uses the useState hook to create a state variable called isMounted and a function to update it called setIsMounted. The initial value of isMounted is false, which means the component is not mounted yet.
  • It uses the useEffect hook to run a function that sets isMounted to true after the component is mounted. The empty dependency array [] ensures that this function only runs once, after the initial render.
  • It uses a conditional statement to return null if isMounted is false. This prevents the component from rendering anything before the useEffect function has run. This is done to avoid hydration errors or unwanted flashes of content.

To prevent hydration errors, the code above delays the execution of any client-side-only code until after the hydration process is completed. By returning null until isMounted is true, the code ensures that the server-rendered HTML and the client-rendered HTML are identical, and that no browser-only APIs or checks are used before the component is mounted. This way, the hydration process can succeed without any errors or flashes of content.

fix: add mounting trick to MobileSidebar component

Add a mounting trick to the MobileSidebar component to prevent hydration errors or flashes of content. The trick uses a state variable called isMounted and a useEffect hook to delay the rendering of the component until after the hydration process is completed. This ensures that the server-rendered HTML and the client-rendered HTML are identical, and that no browser-only APIs or checks are used before the component is mounted.

Mobile Sidebar functionality and output

feat: add UI elements and logic to MobileSidebar

Add UI elements such as Button, Menu, Sheet, and SheetContent to the MobileSidebar component. Use the useMobileSidebar hook to access and update the state of the sidebar. Add logic to close the sidebar when the pathname changes or before the component is mounted. This improves the user experience and performance of the mobile sidebar component.

Next we import some components we need for the output:

  • Menu icon from lucide-react
  • Button, Sheet & SheetContent from @/components/ui
  • Sidebar

Next create a useEffect that calls onClose with a dependency array of pathname and onClose. This will close the MobileSidebar every time the pathname changes.

Then the output should contain a Button with the Menu icon which should be shown on screen sizes less than 768px and hidden when screen sizes are equal or larger than 768px. This Button will contain the onOpen function and encapsulates the interaction and functionality of the MobileSidebar.

After the Button is the Sheet with SheetContent. The SheetContent will reuse the Sidebar component but pass in a different storageKey so that the state for the MobileSidebar is saved but separate from the main desktop Sidebar. The Sheet will props open={isOpen} onOpenChange={onClose}.

"use client";

import React, { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { Menu } from 'lucide-react';

import { useMobileSidebar } from '@/hooks/useMobileSidebar';
import { Button } from '@/components/ui/button';
import {
  Sheet,
  SheetContent,
} from '@/components/ui/sheet';
import Sidebar from './Sidebar';

export default function MobileSidebar() {
  // Get 0the current path of the page
  const pathname = usePathname();

  /* These values are used to control the visibility and behavior 
  of the mobile sidebar component */
  const isOpen = useMobileSidebar((state) => state.isOpen);
  const onOpen = useMobileSidebar((state) => state.onOpen);
  const onClose = useMobileSidebar((state) => state.onClose);

  // Declare isMounted state variable and initialize it to false
  const [isMounted, setIsMounted] = useState(false);

  // useEffect hook to set isMounted variable to true
  // Delays the execution of client-side-only code until after hydration
  useEffect(() => {
    setIsMounted(true);
  }, []); // Only run once after the initial render
  
  // Every time the pathname changes, close the MobileSidebar
  useEffect(() => {
    onClose();
  }, [pathname, onClose]);

  // Prevent rendering of the component before the effect has run
  // To protect from hydration errors or unwanted flashes of content
  if (!isMounted) {
    return null;
  }

  return (
    <>
      <Button
        onClick={onOpen}
        className='block md:hidden'
        variant='ghost'
        size='sm'
      >
        <Menu className='h-4 w-4'/>
      </Button>
      <Sheet open={isOpen} onOpenChange={onClose}>
        <SheetContent
          side='left'
          className='p-2 pt-10'
        >
          <Sidebar
            storageKey="mobileSidebarState"
          />
        </SheetContent>
      </Sheet>
    </>
  )
}

Skeleton for Sidebar

A skeleton component is a UI element that shows a placeholder for the expected shape of a component while it is loading. It is often used to improve the user experience by reducing the perceived loading time and avoiding layout shifts.

Goal: create a Skeleton for the Sidebar. Let's look at the place where we render the placeholder.

app\(app)\(dashboard)\_components\Sidebar.tsx

  // Check if the organization data and the user memberships data are loaded
  // If any of them is not loaded or is loading, return a div element with a Skeleton component
  // The Skeleton component is a placeholder that shows the loading progress
  if (!isLoadedOrg || !isLoadedOrgList || userMemberships.isLoading) {
    return (
      <div>
        <Skeleton />
      </div>
    )
  }

Instead we should return a component that follows the same structure as the output of the Sidebar.

Create a component SkeletonSidebar inside the global components/ui folder. We want it to be server-side rendered and in global components/ui so it can be loaded in first as a way to improve user experience by reducing the perceived loading time.

components\ui\SkeletonSidebar.tsx

import React from 'react';

export default function SkeletonSidebar() {
  return (
    <div>SkeletonSidebar</div>
  )
}

Now import SkeletonSidebar and return that when conditionally rendering the placeholder.

feat: add SkeletonSidebar component to Sidebar

This commit adds the SkeletonSidebar component, which shows a placeholder for the sidebar while the data is loading, improving the user experience and avoiding layout shifts.

import SkeletonSidebar from '@/components/SkeletonSidebar';

export default function Sidebar({
  storageKey = "sidebarState",
}: SidebarProps) {

  if (!isLoadedOrg || !isLoadedOrgList || userMemberships.isLoading) {
    return (
      <SkeletonSidebar />
    )
  }

}

We want to emulate the output of the Sidebar component:

app\(app)\(dashboard)\_components\Sidebar.tsx

  // Return the JSX for the sidebar component
  return (
    <>
      <div className='flex items-center mb-1 font-medium text-xs'>
        <span className='text-base pl-4'>
          Workspaces
        </span>
        <Button
          asChild
          className='ml-1'
          size='icon'
          type='button'
          variant='ghost'
        >
          <Link href='/org-selection'>
            <Plus
              className='h-4 w-4'
            />
          </Link>
        </Button>
      </div>
      <Accordion
        type='multiple'
        defaultValue={prevAccordionValue}
        className='space-y-2'
      >
        {userMemberships.data.map(({ organization }) => (
          <SidebarItem
            key={organization.id}
            isActive={activeOrg?.id === organization.id}
            isOpen={open[organization.id]}
            onOpen={onOpen}
            organization={organization as Organization}
          />
        ))}
      </Accordion>
    </>
  );
};

Let's first create a Skeleton for the span, Button and Link.

import React from 'react';
import { Skeleton } from '@/components/ui/skeleton';

export default function SkeletonSidebar() {
  return (
    <div className='flex items-center justify-between mb-2'>
      {/* Skeleton for the Workspace text and Button */}
      <Skeleton className='h-10 w-[60%]' />
    </div>
  )
}

Next we want a way to represent the SidebarItem, to do that we need to create SkeletonSidebarItem component.

  • Create the two styled divsthat contains the SidebarItem
  • Create the Skeleton for the Image component
  • Create the Skeleton for the Accordion component

Enhance SkeletonSidebarItem component

This commit improves the SkeletonSidebarItem component, which renders a placeholder for the SidebarItem component while it is loading. It uses the Skeleton component from shadcn/ui to create the image and accordion shapes.

components\ui\SkeletonSidebarItem.tsx

import React from 'react';
import { Skeleton } from '@/components/ui/skeleton';

export default function SkeletonSidebarItem() {
  return (
    <div className="flex items-center gap-x-2">
      {/* Skeleton for the Image component of SidebarItem */}
      <div className="w-10 h-10 relative shrink-0">
        <Skeleton className='h-full w-full absolute'/>
      </div>
      {/* Skeleton for the Accordion component of SidebarItem */}
      <Skeleton className='h-10 w-full'/>
    </div>
  );
};

Now we can go back to the SkeletonSidebar and add a div withh SkeletonSidebarItem to represent the organizations.

import React from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import SkeletonSidebarItem from '@/components/ui/SkeletonSidebarItem';

/**
 * A skeleton component is a UI element that shows a placeholder for 
 * the expected shape of a Sidebar component while it is loading.
 * @returns A placeholder for the expected shape of a Sidebar
 */
export default function SkeletonSidebar() {
  return (
    <div className='flex items-center justify-between mb-2'>
      {/* Skeleton for the Workspace text and Button */}
      <Skeleton className='h-10 w-[60%]' />
      {/* Skeleton for the Sidebar item list */}
      <div className='space-y-2'>
        <SkeletonSidebarItem />
        <SkeletonSidebarItem />
        <SkeletonSidebarItem />
      </div>
    </div>
  )
}

Settings Page

Next we need to create the SettingsPage for the organization.

app\(app)\(dashboard)\org\[orgId]\settings\page.tsx

import React from 'react';

export default function SettingsPage() {
  return (
    <div>page</div>
  )
}

Then use OrganizationProfile component inside the SettingsPage

import { OrganizationProfile } from '@clerk/nextjs';

export default function SettingsPage() {
  return (
    <div className='w-full'>
     <OrganizationProfile />
    </div>
  )
}

Now add the appearance and card prop to OrganizationProfile to enhance the appearance of the component.

style: update OrganizationProfile appearance

Use the appearance prop of the OrganizationProfile component from @clerk/nextjs to customize the rootBox and card elements. Remove the boxShadow and add a border to the card element. Set the width of both elements to 100% to fit the container.

import React from 'react';
import { OrganizationProfile } from '@clerk/nextjs';

export default function SettingsPage() {
  return (
    <div className='w-full'>
     <OrganizationProfile
      appearance={{
        elements: {
          rootBox: {
            boxShadow: "none",
            width: "100%"
          },
          card: {
            border: "1px solid #e5e5e5",
            boxShadow: "none",
            width: "100%"
          }
        }
      }}
      />
    </div>
  )
}

Why remove the box shadow? Well we don't want it to look like a Modal. The OrganizationProfile should be on the page.

Fix error Clerk: The <OrganizationProfile/> component is not configured correctly.

Here is the error:

Error: 
Clerk: The <OrganizationProfile/> component is not configured correctly. The most likely reasons for this error are:

1. The "/org/org_234/settings" route is not a catch-all route.
It is recommended to convert this route to a catch-all route, eg: "/org/org_234/settings/[[...rest]]/page.tsx". Alternatively, you can update the <OrganizationProfile/> component to use hash-based routing by setting the "routing" prop to "hash".

2. The <OrganizationProfile/> component is mounted in a catch-all route, but all routes under "/org/org_234/settings" are protected by the middleware.
To resolve this, ensure that the middleware does not protect the catch-all route or any of its children. If you are using the "createRouteMatcher" helper, consider adding "(.*)" to the end of the route pattern, eg: "/org/org_234/settings(.*)". For more information, see: https://clerk.com/docs/references/nextjs/clerk-middleware#create-route-matcher

So let's create the folder [[...rest]] inside /settings and place the page.tsx inside.

refactor: Make SettingsPage a catch-all route

fix: Configure OrganizationProfile correctly

  • Converted organization settings page to a catch-all route.

OrganizationMembers page

The OrganizationProfile component within the Settings Page has two tabs: General and Members. By default the General tab displays a pane on the right with Organization Profile, Leave organization and Delete organization buttons.

The Members tab displays the members within the organization, clicking it navigates us to a route named /organization-members. So we need to create the OrganizationMembersPage under the settings folder.

app\(app)\(dashboard)\org\[orgId]\settings\organization-members\page.tsx

import React from 'react';

import { ManageRoles } from '@/components/ManageRoles';

export default function OrganizationMembersPage() {

  return (
    <div className='w-full'>
      <ManageRoles />
    </div>
  )
}

We will then create the ManageRoles component. See Managing Roles in Organizations | Clerk docs.

feat: Add member view & role management component

  • Implemented a new component to display a list of organization members.
  • Added functionality to update member roles.
  • Included actions for removing members from the organization.

style: Enhance ManageRoles visual appearance

  • Container: Centered content with flex, padding.
  • Table: Full width, white background, bordered.
  • Table Head: Light gray background.
  • Table Rows: Hover effect.
  • Table Cells: Padding, bottom border.
  • Buttons: Blue background, white text, rounded, disabled state.

components\ManageRoles.tsx

'use client';

import { useState, useEffect, ChangeEventHandler, useRef } from 'react';
import { useOrganization, useUser } from '@clerk/nextjs';
import type { OrganizationCustomRoleKey } from '@clerk/types';

export const OrgMembersParams = {
  memberships: {
    pageSize: 5,
    keepPreviousData: true,
  },
};

// List of organization memberships. Administrators can
// change member roles or remove members from the organization.
export const ManageRoles = () => {
  const { user } = useUser();
  const { isLoaded, memberships } = useOrganization(OrgMembersParams);

  if (!isLoaded) {
    return <>Loading</>;
  }

  return (
    <div className="flex flex-col items-center p-4">
      <h1 className="text-2xl font-bold mb-4">Memberships List</h1>
      <div className="overflow-x-auto w-full">
        <table className="min-w-full bg-white border border-gray-200">
          <thead className="bg-gray-100">
            <tr>
              <th className="py-2 px-4 border-b">User</th>
              <th className="py-2 px-4 border-b">Joined</th>
              <th className="py-2 px-4 border-b">Role</th>
              <th className="py-2 px-4 border-b">Actions</th>
            </tr>
          </thead>
          <tbody>
            {memberships?.data?.map((mem) => (
              <tr key={mem.id} className="hover:bg-gray-50">
                <td className="py-2 px-4 border-b">
                  {mem.publicUserData.identifier}{' '}
                  {mem.publicUserData.userId === user?.id && '(You)'}
                </td>
                <td className="py-2 px-4 border-b">{mem.createdAt.toLocaleDateString()}</td>
                <td className="py-2 px-4 border-b">
                  <SelectRole
                    defaultRole={mem.role}
                    onChange={async (e) => {
                      await mem.update({
                        role: e.target.value as OrganizationCustomRoleKey,
                      });
                      await memberships?.revalidate();
                    }}
                  />
                </td>
                <td className="py-2 px-4 border-b">
                  <button
                    className="text-red-500 hover:text-red-700"
                    onClick={async () => {
                      await mem.destroy();
                      await memberships?.revalidate();
                    }}
                  >
                    Remove
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="flex justify-between mt-4 w-full">
        <button
          className="bg-blue-500 text-white py-2 px-4 rounded disabled:opacity-50"
          disabled={!memberships?.hasPreviousPage || memberships?.isFetching}
          onClick={() => memberships?.fetchPrevious?.()}
        >
          Previous
        </button>

        <button
          className="bg-blue-500 text-white py-2 px-4 rounded disabled:opacity-50"
          disabled={!memberships?.hasNextPage || memberships?.isFetching}
          onClick={() => memberships?.fetchNext?.()}
        >
          Next
        </button>
      </div>
    </div>
  );
};

type SelectRoleProps = {
  fieldName?: string;
  isDisabled?: boolean;
  onChange?: ChangeEventHandler<HTMLSelectElement>;
  defaultRole?: string;
};

const SelectRole = (props: SelectRoleProps) => {
  const { fieldName, isDisabled = false, onChange, defaultRole } = props;
  const { organization } = useOrganization();
  const [fetchedRoles, setRoles] = useState<OrganizationCustomRoleKey[]>([]);
  const isPopulated = useRef(false);

  useEffect(() => {
    if (isPopulated.current) return;
    organization
      ?.getRoles({
        pageSize: 20,
        initialPage: 1,
      })
      .then((res) => {
        isPopulated.current = true;
        setRoles(
          res.data.map((roles) => roles.key as OrganizationCustomRoleKey)
        );
      });
  }, [organization, organization?.id]);

  if (fetchedRoles.length === 0) return null;

  return (
    <select
      name={fieldName}
      disabled={isDisabled}
      aria-disabled={isDisabled}
      onChange={onChange}
      defaultValue={defaultRole}
    >
      {fetchedRoles?.map((roleKey) => (
        <option key={roleKey} value={roleKey}>
          {roleKey}
        </option>
      ))}
    </select>
  );
};

Database

Let's setup the database. We are going to use Prisma.

Let's start by installing:

npm install prisma --save-dev

You can now invoke the Prisma CLI by prefixing it with npx:

npx prisma

Next, set up your Prisma project by creating your Prisma schema file template with the following command:

npx prisma init

This command does two things:

  1. creates a new directory called prisma that contains a file called schema.prisma, which contains the Prisma schema with your database connection variable and schema models

  2. creates the .env file in the root directory of the project, which is used for defining environment variables (such as your database connection)

The output of npx prisma init

npx prisma init                                                                                      

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver, mongodb or cockroachdb.
3. Run prisma db pull to turn your database schema into a Prisma schema.
4. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

Next we either host our own database locally on our computer or use a database service. Whatever we go with we need two things:

  1. database connection string, save it under DATABASE_URL inside .env file
  2. Configure schema.prisma

Whenever we create changes to schema.prisma, we need to run the command:

prisma generate

This command will generate the Prisma Client. Then we can start querying our database.

Next, we want to run the following command in the terminal

npx prisma db push

This will push the initial schema to the database. It will also synchronize it with a database provider service like planetscale, so that they will be in sync with any changes we made to the schema.

The command npx prisma db push is a way to prototype your database schema using Prisma. It does the following:

  • It introspects your database to infer the current schema and compares it with your Prisma schema file.
  • It executes the changes required to make your database schema reflect the state of your Prisma schema file. This may involve creating, altering, or dropping tables, columns, indexes, etc.
  • It triggers the generators defined in your Prisma schema file, such as Prisma Client, to update your application code with the latest database schema.

Prisma Client

npm install @prisma/client

The install command invokes prisma generate for you which reads your Prisma schema and generates a version of Prisma Client that is tailored to your models.

Whenever you update your Prisma schema, you will have to update your database schema using either prisma migrate dev or prisma db push. This will keep your database schema in sync with your Prisma schema. The commands will also regenerate Prisma Client.

Querying the database

Create a library called database.ts, and set up Prisma Client.

lib\database.ts

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function main() {
  // ... you will write your Prisma Client queries here
}

main()
  .then(async () => {
    await prisma.$disconnect()
  })
  .catch(async (e) => {
    console.error(e)
    await prisma.$disconnect()
    process.exit(1)
  })

Here's a quick overview of the different parts of the code snippet:

  1. Import the PrismaClient constructor from the @prisma/client node module
  2. Instantiate PrismaClient
  3. Define an async function named main to send queries to the database
  4. Call the main function
  5. Close the database connections when the script terminates

Initialize Prisma Client and handle errors

  • Import Prisma Client from @prisma/client
  • Create a prisma instance and use it in an async main function
  • Use .then() and .catch() to disconnect from the database and exit the process with an appropriate status code

Use global variable to avoid multiple Prisma Client instances

Since we are using NextJS 14, there is a feature in development called Fast Refresh which gives you instantaneous feedback on edits made to your React components. Fast Refresh is enabled by default in all Next.js applications on 9.4 or newer. With Next.js Fast Refresh enabled, most edits should be visible within a second, without losing component state.

Fast Refresh however, can become an issue with Prisma Client in that it may create more than one instance. To solve this we need to create a utility in the lib that creates a global variable that saves the existing global prisma instance. We then compare the global instance of Prisma Client or create a new one. We also check if the environment is in production, then set it to the global instance.

feat: use global variable to avoid multiple Prisma Client instances

lib\db.ts

import { PrismaClient } from '@prisma/client'

/** 
 * Instantiate Prisma Client by defining a global prisma instance.
 *  
 * This code is a way to prevent creating multiple instances of 
 * Prisma Client in your application, which can lead to performance
 * issues or errors.
*/

// Declare a global variable prisma of type PrismaClient or undefined
declare global {
  var prisma: PrismaClient | undefined;
};

// Export a database variable that is either the existing global prisma instance or a new one
export const database = globalThis.prisma || new PrismaClient();

// If the environment is not production, assign the database variable to the global prisma variable
if(process.env.NODE_ENV !== "production") {
  globalThis.prisma = database;
}

Prisma schema overview

Prisma Schema Overview

The Prisma schema file (short: schema file, Prisma schema or schema) is the main configuration file for your Prisma ORM setup. It is typically called schema.prisma and consists of the following parts:

  • Data sources: Specify the details of the data sources Prisma ORM should connect to (e.g. a PostgreSQL database)
  • Generators: Specifies what clients should be generated based on the data model (e.g. Prisma Client)
  • Data model definition: Specifies your application models (the shape of the data per data source) and their relations

See the Prisma schema API reference for detailed information about each section of the schema.

Whenever a prisma command is invoked, the CLI typically reads some information from the schema file, e.g.:

  • prisma generate: Reads all above mentioned information from the Prisma schema to generate the correct data source client code (e.g. Prisma Client).
  • prisma migrate dev: Reads the data sources and data model definition to create a new migration.

You can also use environment variables inside the schema file to provide configuration options when a CLI command is invoked.

Prisma schema example

The following is an example of a Prisma schema file that specifies:

  • A data source (PostgreSQL or MongoDB)
  • A generator (Prisma Client)
  • A data model definition with two models (with one relation) and one enum
  • Several native data type attributes (@db.VarChar(255), @db.ObjectId)

For a relational database such as PostgreSQL:

schema.prisma

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  posts     Post[]
}

model Post {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  published Boolean  @default(false)
  title     String   @db.VarChar(255)
  author    User?    @relation(fields: [authorId], references: [id])
  authorId  Int?
}

enum Role {
  USER
  ADMIN
}

For a non-relational database such as MongoDB:

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  createdAt DateTime @default(now())
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  posts     Post[]
}

model Post {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  published Boolean  @default(false)
  title     String
  author    User?    @relation(fields: [authorId], references: [id])
  authorId  String   @db.ObjectId
}

enum Role {
  USER
  ADMIN
}

Install local PostgreSQL database

To install on Windows

  1. Get Windows postgresql installer, get the Windows x86-64
  2. Go through the installer steps
  3. Confirm a password for the PostgreSQL superuser called postgres
    • Write this password down physically somewhere to be used later
  4. Setup port (default at 5432)
  5. Default locale
  6. Review the pre installation summary log (can be found in the directory "C:\Program Files\PostgreSQL\16\installation_summary.log" )
  7. Finish installation
  8. Skip or cancel Stack Builder

Set up postgreSQL and prisma

docs: Add Prisma setup instructions for PostgreSQL

Set up prisma schema with local database

Create an .env file. Add an environment variable for the postgresql connection URI.

Inside the .env file create a DATABASE_URL variable. This will store the connection URI string to our local database.

An example connection URI string should be something like this:

.env

DATABASE_URL="postgresql://johndoe:mypassword@localhost:5432/mydb?schema=public"
  1. Provider: The provider specifies the type of database you're connecting to. In this case, it's PostgreSQL.

  2. URL Components:

    • User: "johndoe" is the username for the database.
    • Password: "mypassword" is the password for the user.
    • Host: "localhost" refers to the machine where the PostgreSQL server is running.
    • Port: 5432 is the default port for PostgreSQL.
    • Database Name: "mydb" is the name of the database.
    • Schema: "public" specifies the schema within the database.
      • If you omit the schema, Prisma will use the "public" schema by default

So, the complete URL connects to a PostgreSQL database with the given credentials and schema. If you're using Prisma, this URL allows Prisma ORM to connect to your database when executing queries with Prisma Client or making schema changes with Prisma Migrate. If you need to make the URL dynamic, you can pass it programmatically when creating the Prisma Client.

To connect to a PostgreSQL database server, you need to configure a datasource block in your Prisma schema file:

schema.prisma

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

The fields passed to the datasource block are:

Or without environment variables (not recommended):

datasource db {
  provider = "postgresql"
  url      = "postgresql://johndoe:mypassword@localhost:5432/mydb?schema=public"
}

Connection URI strings

Let's look at the spec for a PostgreSQL connection URI:

postgres[ql]://[username[:password]@][host[:port],]/database[?parameter_list]

\_____________/\____________________/\____________/\_______/\_______________/
     |                   |                  |          |            |
     |- schema           |- userspec        |          |            |- parameter list
                                            |          |
                                            |          |- database name
                                            |
                                            |- hostspec

We can test a PostgreSQL connection string in the terminal by running the command pg_isready

pg_isready -d DATABASE_NAME -h HOST_NAME -p PORT_NUMBER -U DATABASE_USER
Important Connection URL for PostgreSQL must percentage-encode special characters!

For MySQL, PostgreSQL and CockroachDB you must percentage-encode special characters in any part of your connection URL - including passwords. For example, p@$$w0rd becomes p%40%24%24w0rd.

For Microsoft SQL Server, you must escape special characters in any part of your connection string.

Find connection URI string with database tool like pgAdmin

To find the URI (connection URL) for your PostgreSQL database in pgAdmin 4, follow these steps:

  1. Open your desktop pgAdmin 4 application.
  2. Click on File > Runtime > View Log.
  3. Scroll to the bottom of the log, where you'll find the Application Server URL. It will look something like this:
    • http://127.0.0.1:{PORT_NUMBER}/?key={YOUR_KEY}
  4. Copy this URL and open it in your web browser.

This URL allows you to connect to your PostgreSQL server using pgAdmin 4. Make sure to replace {PORT_NUMBER} and {YOUR_KEY} with the actual values specific to your setup.

(Optional) Database providers

We can host our database online with database providers. Here are some free or affordable options:

  1. Supabase: Offers a fantastic free tier with PostgreSQL, authentication, real-time subscriptions, and storage. It's an open-source alternative to Firebase
  2. Neon: Fully managed serverless platform with a free tier, providing autoscaling, branching, and unlimited storage
  3. Turso: Offers a generous free tier with SQLite, especially known for ultra-low latency edge deployments
  4. CockroachDB: Provides a free tier with distributed SQL, suitable for most hobby projects
  5. AWS RDS: AWS offers free usage (750 hours and 20GB storage) for Amazon RDS with MySQL, MariaDB, and PostgreSQL

Neon tech

We can use postgresql with neon tech.

Planetscale

To streamline the process we can use prisma with SQL on planetscale.

Go through the planetscale documentation to set it up and get your DB connection string and paste into .env file.

Now in our .env file we have a variable DATABASE_URL, where it will store our connection string.

Next configure schema.prisma, for planetscale mySQL

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
  relationMode = "prisma
}

Update: On 6 March 2024, the Planetscale team announced their decision to remove their Hobby plan — a free tier developers used to manage and deploy their serverless databases. According to Sam Lambert, the CEO of Planetscale, they made this decision to "prioritize profitability and build a company that can last forever."

  • Note: I'm a tad bit crestfallen that planetscale discontinued their free tier for developers, but I appreciate their transparency in not providing any other reason or hiding behind a pretense.

Database Models

The Prisma schema is a declarative way to define your application models and map them to your database. The Prisma schema is independent of the database provider you choose, so you can use the same syntax and logic to define your models for MySQL or PostgreSQL. However, there may be some differences in how Prisma handles certain features or data types depending on the database provider. For example, PostgreSQL supports enums and arrays, while MySQL does not. Prisma will automatically generate the appropriate SQL code for each database provider based on your Prisma schema.

Board Model

Let's create the first Model, Board which will have a id and title.

prisma\schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Board {
  id String @id @default(uuid())
  title String
}

Now to prototype the schema we can run the command

npx prisma db push

Server Actions

Server Actions & Mutations | Nextjs reference

  • Server Actions are asynchronous functions that are executed on the server. They can be used in Server and Client Components to handle form submissions and data mutations in Next.js applications.

Mutate data in a server component

Instead of creating a POST route and API to create a Board in our database, we can use server actions.

Navigate to [orgId] page and render a form with an input.

app\(app)\(dashboard)\org\[orgId]\page.tsx

import React from 'react';
import { auth } from '@clerk/nextjs';

const OrganizationIdPage = () => {
  const { userId, orgId } = auth();

  return (
    <div>
      <form>
        <input 
          id='title'
          name='title'
          placeholder='Enter a board title'
          required
          className='border-black border p-1'
        />
      </form>
    </div>
  );
};

export default OrganizationIdPage

Next we create an async function that takes the title from the form input and creates it in our database. Then we assign the function to the action property of the form.

import { database } from "@/lib/database";

const OrganizationIdPage = () => {
  const { userId, orgId } = auth();

  async function createBoard(formData: FormData) {
    "use server";

    const title = formData.get("title") as string;

    await database.board.create({
      data: {
        title,
      },
    });
  }

  return (
    <div>
      <form action={createBoard}>
        <input 
          id='title'
          name='title'
          placeholder='Enter a board title'
          required
          className='border-black border p-1'
        />
      </form>
    </div>
  );
};

Let's refactor the server action and move it to a folder named actions at the base of the project. Name the file createBoard.ts and cut and paste the server action into it. Also move the "use server" directive to the top.

refactor: move createBoard logic to actions folder

actions\createBoard.ts

"use server";

import { database } from "@/lib/database";

export default async function createBoard(formData: FormData) {
  const title = formData.get("title") as string;

  await database.board.create({
    data: {
      title,
    },
  });
}

Then we can clean up the org id page and simply import the server action and use it like this:

app\(app)\(dashboard)\org\[orgId]\page.tsx

import createBoard from '@/actions/createBoard';

const OrganizationIdPage = () => {
  return (
    <div>
      <form action={createBoard}>
        <input 
          id='title'
          name='title'
          placeholder='Enter a board title'
          required
          className='border-black border p-1'
        />
      </form>
    </div>
  );
};

Let's also add a submit button for the org ID page.

import { Button } from '@/components/ui/button';

const OrganizationIdPage = () => {
  return (
    <div>
      <form action={createBoard}>
        <input
          id='title'
          name='title'
          placeholder='Enter a board title'
          required
          className='border-black border p-1'
        />
        {/* Submit Button */}
        <Button type='submit'>
          Submit
        </Button>
      </form>
    </div>
  );
};

Form Validation

As of now, the only form of validation and error checking we have is the native HTML that adds a required property to the form input. Let's improve it with Zod.

What is Zod?

Zod is a TypeScript-first schema declaration and validation library. It allows you to define the structure and constraints of your data types, and then validate them against any input. Zod can also infer the static TypeScript type from your schema, eliminating the need for duplicating type declarations. Zod is designed to be developer-friendly, flexible, and performant. You can use Zod to validate user input, API requests, configuration files, and more.

feat: use zod validation library for user data

Use Zod to define and validate the structure and constraints of user data types. Zod can also infer the TypeScript types from the schemas, eliminating the need for duplicating type declarations. Zod improves the readability, maintainability, and performance of the validation logic.

Install zod

npm install zod

Create a CreateBoard object schema in createBoard server action.

actions\createBoard.ts

import { z } from "zod";

const CreateBoard = z.object({
  title: z.string(),
});

// ...

Then we can add form validation to the title.

Before zod validation:

actions\createBoard.ts

export default async function createBoard(formData: FormData) {
  const title = formData.get("title") as string;

After zod validation:

import { z } from "zod";

const CreateBoard = z.object({
  title: z.string(),
});

export default async function createBoard(formData: FormData) {
  const { title } = CreateBoard.parse({
    title: formData.get("title"),
  });

refactor(board): validate user input with zod

Use Zod to define and validate the structure and constraints of the title field. Zod can also infer the TypeScript types from the schemas, eliminating the need for duplicating type declarations. Zod improves the readability, maintainability, and performance of the validation logic.

Query and display Boards

In the org ID page, query the boards. Then create a div below the output to map out each board to a div.

import { database } from '@/lib/database';

const OrganizationIdPage = async () => {
  const boards = await database.board.findMany();

  return (
    <div className='flex flex-col space-y-4'>
      <form action={createBoard}>
        <input
          id='title'
          name='title'
          placeholder='Enter a board title'
          required
          className='border-black border p-1'
        />
        {/* Submit Button */}
        <Button type='submit'>
          Submit
        </Button>
      </form>
      <div className='space-y-2'>
        {boards.map((board) => (
          <div key={board.id}>
            {board.title}
          </div>
        ))} 
      </div>
    </div>
  );
};

Display new boards

A current issue at the moment is that when a user submits a new board in the org ID page, the new board does not display in the mapping. However the new board is in the database.

We need to revalidate the path so that the newly added board will be display immediately to the user.

Introducing a new feature for revalidation in Next.js_14

revalidatePath allows you to purge cached data on-demand for a specific path.

We want to do this in our server action createBoard. We should pass in the path to the /org with the dynamic organization id. For now we can hardcode it, but later we will dynamically get the org ID.

actions\createBoard.ts

"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";

import { database } from "@/lib/database";

const CreateBoard = z.object({
  title: z.string(),
});

export default async function createBoard(formData: FormData) {
  const { title } = CreateBoard.parse({
    title: formData.get("title"),
  });

  await database.board.create({
    data: {
      title,
    },
  });

  revalidatePath('/org/org_yourOrgIdHere');
}

With this in place, we can now add a new board in the org ID page and when we hit submit we will see the list of boards update to incorporate the new board.

Passing data into Server action

Now that we can create a board, how do we update and delete?

We need to have access to an ID inside the server action createBoard.

Let's first refactor the code where we map out the Board. Create a Board.tsx component inside /components/ui which accepts two props: { id, title } and renders them as output.

import React from 'react';

interface BoardProps {
  id: string;
  title: string;
};

export default function Board({
  id,
  title,
}: BoardProps) {
  return (
    <div>
      {title}
      {id}
    </div>
  )
}

Then inside org ID page, import the Board component and map out the fetched boards into a Board component.

import Board from '@/components/Board';

const OrganizationIdPage = async () => {
  const boards = await database.board.findMany();

  return (
    <div className='flex flex-col space-y-4'>
      {/* ... */}
      <div className='space-y-2'>

        {boards.map((board) => (
          <Board  
            key={board.id} 
            id={board.id}
            title={board.title}
          />
        ))}

      </div>
    </div>
  );
};

Develop the Board component

feat: add update and delete buttons to Board

  • Convert the div to a form in the output
  • Add update and delete buttons
import React from 'react';
import { Button } from '@/components/ui/button';

interface BoardProps {
  id: string;
  title: string;
};

export default function Board({
  id,
  title,
}: BoardProps) {
  return (
    <form className='flex items-center gap-x-2'>
      <p>{title}</p>
      <p>{id}</p>
      <Button 
        type="submit"
        variant="default"
        size="sm"
      >
        Update
      </Button>
      <Button 
        type="submit"
        variant="destructive"
        size="sm"
      >
        Delete
      </Button>
    </form>
  )
}

Next we create the server actions to add the functionality to the buttons.

In /actions create the files: updateBoard.ts and deleteBoard.ts.

updateBoard server action will

  • Have { id, boardData } as parameters
  • Update the board un the database with boardData using the the id
  • Revalidate path to reflect the newly updated board

actions\updateBoard.ts

"use server";

import { revalidatePath } from 'next/cache';

import { database } from '@/lib/database';

interface BoardData {
  title: string;
};

export default async function updateBoard(id: string, boardData: BoardData) {

  await database.board.update({
    where: {
      id: id,
    },
    data: {
      title: boardData.title
    }
  });

  revalidatePath('/org/org_yourOrgIdHere');
}

deleteBoard server action wwill

  • Have id as parameters
  • Delete the board in the database given the id
  • Revalidate path to reflect the deleted board
"use server";

import { revalidatePath } from 'next/cache';

import { database } from '@/lib/database';

export default async function deleteBoard(id: string) {
  // 
  await database.board.delete({
    where: {
      id: id
    }
  });

  revalidatePath('/org/org_yourOrgIdHere');
}

Now we can use the server actions and assign it to the corresponding buttons in the Board component

components\Board.tsx

import React from 'react';
import { Button } from '@/components/ui/button';
import deleteBoard from '@/actions/deleteBoard';
import updateBoard from '@/actions/updateBoard';

interface BoardProps {
  id: string;
  title: string;
};

interface BoardData {
  title: string;
};

export default function Board({
  id,
  title,
}: BoardProps) {
  
  const handleDelete = async () => {
    console.log('Deleting board with id:', id);
    await deleteBoard(id);
  };
  
  const handleUpdate = async () => {
    console.log('Updating board with id:', id);
    const data = { title: "updated_title"};
    await updateBoard(id, data);
  };

  return (
    <form className='flex items-center gap-x-2'>
      <p>{title}</p>
      <p>{id}</p>
      <Button 
        type="submit"
        variant="default"
        size="sm"
        onClick={handleUpdate}
      >
        Update
      </Button>
      <Button 
        type="submit"
        variant="destructive"
        size="sm"
        onClick={handleDelete}
      >
        Delete
      </Button>
    </form>
  )
}

BoardForm component

Looking at the org ID page, we should also isolate the form that creates a board. Refactor the create form including the submit button into a component called BoardForm.

Let's also make it a client component

components\BoardForm.tsx

"use client";

import React from 'react';

import createBoard from '@/actions/createBoard';
import { Button } from '@/components/ui/button';

/* Create a form for creating a new board */
export default function BoardForm() {
  return (
    <form action={createBoard}>
      <input
        id='title'
        name='title'
        placeholder='Enter a board title'
        required
        className='border-black border p-1'
      />
      <Button type='submit'>
        Submit
      </Button>
    </form>
  )
}

Now refactor OrganizationIdPage with BoardForm

refactor: use BoardForm component

  • Replace the inline form with the BoardForm component
  • Import BoardForm from '@/components/BoardForm'
  • Remove unused imports and comments
import React from 'react';
import { database } from '@/lib/database';
import Board from '@/components/Board';
import BoardForm from '@/components/BoardForm';

const OrganizationIdPage = async () => {
  // Fetch the boards from the database
  const boards = await database.board.findMany();

  return (
    <div className='flex flex-col space-y-4'>
      <BoardForm />
      {/* Create a div for displaying the boards */}
      <div className='space-y-2'>
        {/* Map over the boards and render a Board component for each one */}
        {boards.map((board) => (
          <Board
            key={board.id}
            id={board.id}
            title={board.title}
          />
        ))}
      </div>
    </div>
  );
};

export default OrganizationIdPage

Loading states and errors in form fields

An advantage of refactoring the BoardForm client component is that we can have specific loading states and display errors inside the fields. We will be using useFormState hook.

  • useFormState | React Reference
  • useFormState is a Hook that allows you to update state based on the result of a form action.
  • Usage: const [state, formAction] = useFormState(fn, initialState, permalink?);

Call useFormState at the top level of your component to create component state that is updated when a form action is invoked. You pass useFormState an existing form action function as well as an initial state, and it returns a new action that you use in your form, along with the latest form state. The latest form state is also passed to the function that you provided.

The form state is the value returned by the action when the form was last submitted. If the form has not yet been submitted, it is the initial state that you pass.

If used with a Server Action, useFormState allows the server's response from submitting the form to be shown even before hydration has completed.

Parameters of useFormState:

  • fn: The function to be called when the form is submitted or button pressed. When the function is called, it will receive the previous state of the form (initially the initialState that you pass, subsequently its previous return value) as its initial argument, followed by the arguments that a form action normally receives.
  • initialState: The value you want the state to be initially. It can be any serializable value. This argument is ignored after the action is first invoked.

Use useFormState hook for form control

feat: use useFormState hook for form control

  • Import useFormState hook from react-dom
  • Create a state and an action for the form using useFormState
  • Pass the formAction as the action prop to the form element

Let's create the initialState and call the hook useFormState inside the BoardForm. Instead of using thecreateBoard server action directly inside the form's action, we use the formAction that we get from the useFormState hook.

components\BoardForm.tsx

"use client";

import React from 'react';
import { useFormState } from 'react-dom';

import createBoard from '@/actions/createBoard';
import { Button } from '@/components/ui/button';

/* Create a form for creating a new board */
export default function BoardForm() {

  const initialState = {
    errors: {},
    message: "",
  };

  const [state, formAction] = useFormState(createBoard, initialState);

  return (
    <form action={formAction}>
      <input
        id='title'
        name='title'
        placeholder='Enter a board title'
        required
        className='border-black border p-1'
      />
      <Button type='submit'>
        Submit
      </Button>
    </form>
  )
}

Modify createBoard server action to fix "No overload matches this call"

By adding useFormState we also need to modify the createBoard server action as we get this error in the terminal:

No overload matches this call.
  Overload 1 of 2, '(action: (state: void) => void | Promise<void>, initialState: void, permalink?: string | undefined): [state: void, dispatch: () => void]', gave the following error.
    Argument of type '(formData: FormData) => Promise<void>' is not assignable to parameter of type '(state: void) => void | Promise<void>'.
      Types of parameters 'formData' and 'state' are incompatible.
        Type 'void' is not assignable to type 'FormData'.
  Overload 2 of 2, '(action: (state: void, payload: unknown) => void | Promise<void>, initialState: void, permalink?: string | undefined): [state: void, dispatch: (payload: unknown) => void]', gave the following error.
    Argument of type '(formData: FormData) => Promise<void>' is not assignable to parameter of type '(state: void, payload: unknown) => void | Promise<void>'.

Navigate to createBoard.ts.

feat: add field validation & state to createBoard

  • Add zod's string-specific validations to the object schema, a minimum of 3 characters and a message on error
  • Create a type State which is similar in shape as the initialState, meaning it contains a message of type string and errors
  • Assign the type of State to a new parameter called prevState to the createBoard function
  • During validation we do not want Zod to throw an error, but an error instance so replace parse() with safeParse method and assign it to variable called validatedFields
  • Check if validateFields has success property, if not then return with error message

actions\createBoard.ts

"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";

import { database } from "@/lib/database";

export type State = {
  errors?: {
    title?: string[];
  };
  message?: string | null;
};

const CreateBoard = z.object({
  title: z.string().min(3, {
    message: "Must be 3 or more characters long",
  }),
});

export default async function createBoard(
  prevState: State,
  formData: FormData
) {
  const validatedFields = CreateBoard.safeParse({
    title: formData.get("title"),
  });

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: "Missing fields.",
    };
  }

  const { title } = validatedFields.data;

  try {
    await database.board.create({
      data: {
        title,
      },
    });
  } catch (error) {
    console.log(`Database Error: ${error}`);
  }

  revalidatePath("/org/org_yourOrgIdHere");
}

Let's also add a redirect to the path at the end:

import { redirect } from "next/navigation";

export default async function createBoard(
  prevState: State,
  formData: FormData
) {
  // ...

  const pathname = `/org/org_yourOrgIdHere`;
  revalidatePath(pathname);
  redirect(pathname);
}

Now with that setup this should fix the "No overload matches this call" error.

Render the error message for the form field

feat(board): display validation errors in BoardForm

Back to BoardForm, let's render the error message if it exists within the state

"use client";

import React from 'react';
import { useFormState } from 'react-dom';

import createBoard from '@/actions/createBoard';
import { Button } from '@/components/ui/button';

export default function BoardForm() {

  const initialState = {
    errors: {},
    message: "",
  };

  const [state, formAction] = useFormState(createBoard, initialState);

  return (
    <form action={formAction}>
      <div className="flex flex-col space-y-2">
        <input
          id='title'
          name='title'
          placeholder='Enter a board title'
          required
          className='border-black border p-1'
        />
        {state?.errors?.title ? (
           <div>
              {state.errors.title.map((error: string) => (
                <p key={error} className='text-rose-500'>
                  {error}
                </p>
              ))}
           </div>
        ) : null}
      </div>
      <Button type='submit'>
        Submit
      </Button>
    </form>
  )
}

Why is this really interesting? We do not need to have javascript enabled to have form validation or error states. We are doing server-side form validation and error state management through server actions.

Loading states

When user submits an item in the BoardForm and it was successful, during that time we would want to disable the submit button.

  • useFormStatus | React Reference
  • useFormStatus is a Hook that gives you status information of the last form submission.
  • const { pending, data, method, action } = useFormStatus();
  • To get status information, the Submit component must be rendered within a <form>. The Hook returns information like the pending property which tells you if the form is actively submitting.

Let's first refactor the output of the BoardForm into BoardFormInput component (create it in /components folder) that accepts the prop errors where we will pass in the state.errors directly.

Refactor both board form input and board form button inside BoardForm.

import BoardFormInput from '@/components/BoardFormInput';
import BoardFormButton from '@/components/BoardFormButton';

export default function BoardForm() {

  const initialState = {
    errors: {},
    message: "",
  };

  const [state, formAction] = useFormState(createBoard, initialState);

  return (
    <form action={formAction}>
      <BoardFormInput errors={state?.errors}/>
      <BoardFormButton />
    </form>
  )
}

Install shadcn/ui Input

npx shadcn-ui@latest add input

Then inside the BoardFormInput

  • Create BoardFormInputProps interface to accept errors
  • Return a div that contains the input and the conditional render for error messages
  • Replace the input element with Input component, and we can remove the className prop

components\BoardFormInput.tsx

"use client";

import React from 'react';

import { Input } from '@/components/ui/input';

interface BoardFormInputProps {
  errors?: {
    title?: string[];
  }
}

export default function BoardFormInput({
  errors
} : BoardFormInputProps) {

  return (
    <div className="flex flex-col space-y-2">
      <Input
        id='title'
        name='title'
        placeholder='Enter a board title'
        required
      />
      {errors?.title ? (
        <div>
          {errors.title.map((error: string) => (
            <p key={error} className='text-rose-500'>
              {error}
            </p>
          ))}
        </div>
      ) : null}
    </div>
  )
}

Now we can add the loading state by using the pending property from useFormStatus hook. We can now add the disabled prop to the Input component using the pending status:

useFormStatus to disable input on form submission

import { useFormStatus } from 'react-dom';
// ...
export default function BoardFormInput({
  errors
} : BoardFormInputProps) {

  const { pending } = useFormStatus();

  return (
    <div className="flex flex-col space-y-2">
      <Input
        id='title'
        name='title'
        placeholder='Enter a board title'
        required
        disabled={pending}
      />

Similary, extract out BoardFormButton and add the disabled prop

useFormStatus to disable button when submitting

components\BoardFormButton.tsx

"use client";

import React from 'react';
import { Button } from '@/components/ui/button';
import { useFormStatus } from 'react-dom';

export default function BoardFormButton() {

  const { pending } = useFormStatus();

  return (
    <Button disabled={pending} type='submit'>
      Submit
    </Button>
  )
}

So far BoardFormButton hardcodes "Submit" as the children prop, but we want to re-use this component to also handle other types of buttons such as the delete button. Let's parameterize BoardFormButton.

Parameterize BoardFormButton component

The BoardFormButton component was previously hard-coded to render a submit button with the text "Submit". This commit adds props to the component to allow customization of the button content, size, type, variant, and onClick handler. This makes the component more re-usable and flexible for different use cases.

"use client";

import React from 'react';
import { Button } from '@/components/ui/button';
import { useFormStatus } from 'react-dom';

interface BoardFormButtonProps {
  children: React.ReactNode;
  size?: "default" | "sm" | "lg" | "icon" | null | undefined;
  type: "submit" | "reset" | "button";
  variant?: "default" | "destructive" | "outline" | "secondary" | 
            "ghost" | "link" | "primary" | null | undefined;
  onClick?: React.MouseEventHandler<HTMLButtonElement> | undefined;
}

export default function BoardFormButton({
  children,
  size,
  type,
  variant,
  onClick,
}: BoardFormButtonProps ) {

  const { pending } = useFormStatus();

  return (
    <Button 
      disabled={pending} 
      type={type} 
      variant={variant} 
      size={size} 
      onClick={onClick}
    >
      {children}
    </Button>
  )
}

Now we can pass the right props to "Submit" button in the BoardForm

components\BoardForm.tsx

export default function BoardForm() {
  // ...
  return (
    <form action={formAction}>
      <BoardFormInput errors={state?.errors}/>
      <BoardFormButton type="submit" variant="default" size="default">
        Submit
      </BoardFormButton>
    </form>
  )
}

And also re-use the BoardFormButton component to create a delete button with the onClick assigned to a delete handler.

Refactor delete button to BoardFormButton in Board

The BoardFormButton component is a parameterized version of the Button component that can be customized with props. This commit replaces the Button component with the BoardFormButton component in deleting a board. The variant prop is set to destructive to characterize the desired functionality and appearance of the button.

export default function Board({
  id,
  title,
}: BoardProps) {
  
  const handleDelete = async () => {
    console.log('Deleting board with id:', id);
    await deleteBoard(id);
  };
  
  // ...

  return (
    <form className='flex items-center gap-x-2'>
      <p>{title}</p>
      <p>{id}</p>

      <BoardFormButton
        type="submit"
        variant="destructive"
        size="sm"
        onClick={handleDelete}
      >
        Delete
      </BoardFormButton>

    </form>
  )
}

Server Action Abstraction


Add section on server action abstraction

This commit adds a new section to the markdown file that explains how to create a server action abstraction that is type safe. The server action abstraction is a asynchronous function that is executed on the server to handle form submissions and data mutations. The abstraction handles the input, output, validation, and error handling. The section describes the motivation, design, implementation, and usage of the creating the function.


I plan to use as much server actions and as little API routes as possible in this project.

This section will be a way to aggregate my notes and design phase of the app.

Here is a diagram of the use case for server actions.

  • Server Action
    1. Input & Output (serverActionTypes.ts)
    2. Zod Validation (serverActionSchema.ts)
    3. Server Action (serverAction.ts) or (index.ts)

We can break this up into 3 sections:

  1. Input & Output
    • types.ts
    • Defines the shape of the data that the user will input
    • Defines the shape of the data that we expect to output from the server action
      • e.g., an Error or specific type from Prisma Database
  2. Zod Validation
  • schema.ts
  • Contains our zod validation, schema, etc.
  • The schema will be our input type
  1. Server Action
  • index.ts
  • The server action itself
  • Executes the asynchrounouse function that are executed on the server
  • Handle form submissions and data mutations in Next.js applications

Server Action Abstraction

Let's say we want to break down the createBoard server action. When abstracting the createBoard.ts file into three distinct parts, you can organize them as follows:

  1. Type Definitions (Inputs and Outputs):

    • In this section, define the TypeScript types or interfaces that represent the inputs and outputs for creating a board. These types should capture the relevant data structures needed for the createBoard functionality. For example:
    // createBoardTypes.ts
    
    // Input type for creating a board
    export interface CreateBoardInput {
      title: string;
      // Other relevant properties...
    }
    
    // Output type for the result of creating a board
    export interface CreateBoardOutput {
      boardId: string;
      // Other relevant properties...
    }
  2. Validation Rules:

    • Here, encapsulate the validation rules specific to creating a board. You can use a library like Zod (as mentioned earlier) to define validation schemas. For instance:
    // createBoardValidation.ts
    
    import { z } from 'zod';
    
    export const CreateBoardSchema = z.object({
      title: z.string().min(3, 'Must be 3 or more characters long.'),
      // Other validation rules...
    });
  3. Server Action (createBoard):

    • Implement the actual server action responsible for creating a board. This section should handle business logic, database interactions, and any other necessary steps. For example:
    // createBoardServerAction.ts
    
    import { CreateBoardInput, CreateBoardOutput } from './createBoardTypes';
    import { CreateBoardSchema } from './createBoardValidation';
    
    export async function createBoard(input: CreateBoardInput): Promise<CreateBoardOutput> {
      // Validate input using CreateBoardSchema
      const validatedInput = CreateBoardSchema.parse(input);
    
      // Perform database operations, create the board, and return the result
      const boardId = await createBoardInDatabase(validatedInput);
    
      return { boardId };
    }
    
    // Other helper functions or database interactions...

In summary, we can break it down to these 3 distinct parts:

  • Server Action Abstraction (Type-safe)
    1. Type Definitions: Inputs & Outputs
      • serverActionTypes.ts
    2. Validation Rules
      • serverActionSchema.ts
    3. Server Action
      • serverAction.ts

Benefits of Abstraction in programming

The form of abstraction we described, where we break down a single file into three distinct parts, has several related terms in programming:

  1. Data Abstraction:

    • This pertains to abstracting data entities. It involves defining types, interfaces, and structures that represent data without exposing the internal implementation details. In our case, defining the types of inputs and outputs for creating a board falls under data abstraction.
  2. Process Abstraction:

    • Process abstraction hides the underlying implementation of a process or functionality. It focuses on how a task is performed rather than the specific details of how it's done. Our third part, the createBoard server action itself, aligns with process abstraction.
  3. Modularization:

    • Modularization is the practice of dividing a program into smaller, self-contained modules or components. Each module handles a specific aspect of functionality. In our scenario, splitting the createBoard.ts file into separate parts demonstrates modularization.
  4. Decomposition:

    • Decomposition involves breaking down a complex problem or system into smaller, manageable parts. By separating the validation rules, type definitions, and server action, we're effectively decomposing the original file.
  5. Separation of Concerns (SoC):

    • SoC is a design principle that advocates separating different aspects of a software system to improve maintainability and readability. Our approach aligns with SoC by clearly delineating responsibilities for validation, types, and server logic.

Remember that these terms are not mutually exclusive, and often, multiple concepts overlap when designing well-structured software.

Extra notes to help create server action abstraction

Approaches:

  • Planning to create a function called createServerAction.
  • Higher order function
  • Type safe on both inputs and outputs
  • Wrap each of these distinct segments with a wrapper
    • useSafeServerAction?
    • createAction?
    • useActionEffect<T, U, V>(input: T, schema: z.Schema<U>, handler: (output: U) => Promise<V>)

A function that creates another function is called a higher-order function. Higher-order functions are functions that can take other functions as arguments or return other functions as results. For example, in JavaScript, the map function is a higher-order function that takes a function and an array as arguments, and returns a new array with the function applied to each element.

A wrapper function is a specific kind of higher-order function that calls another function with little or no additional computation. Wrapper functions are used to simplify complex systems by hiding the details of the inner function and focusing on the essential features of the outer function. For example, in Java, the MouseAdapter class is a wrapper function that implements the MouseListener interface with empty methods, so that subclasses can override only the methods they need.

A factory in programming is a function or a method that creates and returns objects of different types or classes, without specifying the exact type or class of the object in advance. A factory can be useful for simplifying complex systems, reusing code, and increasing flexibility and modularity. A factory can be implemented in various ways, such as using abstract data types, subroutines, polymorphism, or software components. There are also different design patterns that use factories, such as the factory method pattern and the abstract factory pattern.

A factory for functions is a higher-order function that creates and returns other functions, without specifying the exact type or implementation of the function in advance. A factory for functions can be implemented in various ways, depending on the programming language and the design pattern. For example, in JavaScript, a factory for functions can be a function that takes some parameters and returns a closure that uses those parameters in its scope. In Java, a factory for functions can be a method that uses lambda expressions or method references to create instances of functional interfaces.

Implementation Attempt 1

Create a file named createServerAction.ts inside /lib.

It follows the convention of starting with create, which indicates that it is a factory function that returns a new object or function. It also uses a descriptive name that reflects the purpose of the function, which is to create a server action.

However, it does not specify what kind of server action it creates, or how it is different from other server actions. It also does not reflect the return value or type of the function, which could be useful for type checking and documentation.

An alternative could be:

createServerActionEffect<T, U, V>(input: T, schema: z.Schema<U>, handler: (output: U) => Promise<V>)

This name indicates that the function creates a server action effect, which is a async function that performs a side effect based on the input, schema, and handler parameters. It also uses generics to specify the types of the input, output, and return value of the function. The name is descriptive, concise, and follows the convention of factory functions.

For now let's keep it at createServerAction.

Implementation of createBoard with abstraction

  • Server Action Abstraction (Type-safe)
    1. Type Definitions: Inputs & Outputs
      • serverActionTypes.ts
    2. Validation Rules
      • serverActionSchema.ts
    3. Server Action
      • index.ts

Let's try re-creating createBoard server action with the abstraction.

Let's look at the createBoard.ts server action in its entirety and see how we can break it down to the 3 parts.

actions\createBoard.ts

"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";

import { database } from "@/lib/database";

export type State = {
  errors?: {
    title?: string[];
  },
  message?: string | null;
};

// Define the CreateBoard object schema
const CreateBoard = z.object({
  title: z.string().min(3, {
    message: "Must be 3 or more characters long",
  }),
});

export default async function createBoard(
  prevState: State,
  formData: FormData
) {
  // Validate the form data using the CreateBoard schema
  const validatedFields = CreateBoard.safeParse({
    title: formData.get("title"),
  });

  // If zod validation fails, then we will have an array of errors for a specific field
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: "Missing fields.",
    }
  }

  // Destructure the title property from the validated data
  const { title } = validatedFields.data;

  // Try to create a new board in the database
  try {
    await database.board.create({
      data: {
        title,
      },
    });
  } catch (error) {
    console.log(error);
    return {
      message: `Database Error: {error}`,
    }
  }

  // Revalidate and redirect to given path
  const pathname = `/org/org_yourOrgIdHere`;
  revalidatePath(pathname);
  redirect(pathname);
}

Inside of /actions create a folder createBoard. Inside we will create the following files:

  • Server Action: createBoard
    1. Input & Output (createBoardTypes.ts)
    2. Zod Validation (createBoardSchema.ts)
    3. Server Action (index.ts)

createBoard: schema that defines Validation Rules

createBoardSchema.ts is a zod object schema that defines validation rules for creating a board.

actions\createBoard\createBoardSchema.ts

import { z } from 'zod';

/**
 * Define the CreateBoard object schema.
 * 
 * Add custom error messages for: required fields, 
 * invalid type and minimum length.
 */
export const CreateBoard = z.object({
  title: z.string({
    required_error: "Title is required", 
    invalid_type_error: "Title is required", 
  }).min(3, {
    message: "Must be 3 or more characters long.", 
  }),
});

In this code snippet:

  • We import the z module from the Zod library.
  • We create a schema called CreateBoard using z.object().
  • The CreateBoard schema defines an object with a single property called title.
  • The title property is of type z.string(), which means it should be a string.
  • We customize the error messages for required fields and invalid types.
  • Additionally, we specify that the title must be at least 3 characters long.

createBoard: Type Definitions

createBoardTypes.ts defines the TypeScript types or interfaces that represent the inputs and outputs for creating a board. These types should capture the relevant data structures needed for the createBoard functionality.

actions\createBoard\createBoardTypes.ts

import { z } from 'zod';

// Import Board, the expected output type, from Prisma client
import { Board } from '@prisma/client';

// Import the 'CreateBoard' schema (validation rules)
import { CreateBoard } from './createBoardSchema';

// Define a TypeScript type named 'Input'
// The type is inferred from the 'CreateBoard' schema using 'z.infer'
export type Input = z.infer<typeof CreateBoard>;

Here's what each part does:

  1. Importing 'z' Module:

    • We import the z module from the Zod library. This module provides tools for defining validation schemas and working with data types.
  2. Importing 'Board' Type:

    • We import the Board type from the Prisma client. This type likely represents a database entity related to boards (e.g., a table in the database).
  3. Importing 'CreateBoard' Schema:

    • We import the CreateBoard schema from the local file createBoardSchema.ts. This schema likely defines validation rules for creating a board (e.g., title length, required fields).
  4. Defining 'Input' Type:

    • We create a TypeScript type called Input.
    • The type is inferred using z.infer<typeof CreateBoard>, which means it takes on the shape of the CreateBoard schema.
    • In other words, Input represents the expected input data structure when creating a board, based on the validation rules defined in CreateBoard.

Improve createServerAction

Currently, createServerAction.ts just has this:

lib\createServerAction.ts

/* Imports */
import { z } from "zod";

function createServerActionEffect<T, U, V>(input: T, schema: z.Schema<U>, handler: (output: U) => Promise<V>)

We need to define a type with generics which should be type-safe for every action we create. All we need is the Input type we expect the user to pass in, and the Output type which we expect the user to receive.

  • Output could either be a
    • success like data which can either be a Board type from prisma
    • error which is a string
    • fieldErrors which is an object with keys inside and values set to an array of errors (string)

Let's defines two types: FieldErrors<T> and ActionState<InputType, OutputType>.

feat: add FieldErrors and ActionState types

This commit adds the FieldErrors and ActionState<InputType, OutputType> types to the lib/createServerAction.ts file. These types will be used for handling validation errors and action outcomes in our application.

/* Types */
export type FieldErrors<T> = {
  [K in keyof T]?: string[];
};

export type ActionState<InputType, OutputType> = {
  fieldErrors?: FieldErrors<InputType>;
  error?: string | null;
  data?: OutputType;
};

Let's break it down:

  1. FieldErrors<T>:

    • This type is a generic type that takes another type T as a parameter.
    • It represents an object where each property key (denoted by K) corresponds to a key in the T type.
    • The value associated with each property key is an array of strings (denoted by string[]).
    • Essentially, it's a way to define error messages for specific fields in an input object.
  2. ActionState<InputType, OutputType>:

    • Another generic type that takes two type parameters: InputType and OutputType.
    • It represents the state of an action or operation.
    • The properties it can have are:
      • fieldErrors: An optional object of field errors (using the FieldErrors<InputType> type).
      • error: An optional string representing a general error message.
      • data: An optional value of type OutputType, which could be the result of the action.
    • This type is useful for handling responses from APIs, form submissions, or any other asynchronous operations.

In summary, this code snippet provides a foundation for handling errors and action states in a TypeScript application. It defines reusable types that can be used to structure data related to validation errors and action outcomes.

Create the createServerAction function

The type-safe createServerAction should accept schema and performAction as parameters. It then returns an async function. The async function has a data: InputType parameter and returns a Promise.

lib\createServerAction.ts

import { z } from "zod";

/* Types */
// Define a generic type for field errors.
export type FieldErrors<T> = {
  [K in keyof T]?: string[];
};

// Define a generic type for action state.
export type ActionState<InputType, OutputType> = {
  fieldErrors?: FieldErrors<InputType>; // Optional field errors
  error?: string | null; // Optional general error message
  data?: OutputType; // Optional output data
};

/**
 * Creates a type-safe server action, an async function that runs on the server
 * and can be invoked from the client using a special URL. This returns another
 * function  that takes the input data as a parameter and returns a promise 
 * that resolves to an object that contains the output data or any errors. 
 * 
 * This function does the following:
 * - It validates the input data using the provided schema. It uses Zod, a 
 *  library that allows defining and parsing TypeScript types at runtime.
 * - If the validation fails, it returns an object with a fieldErrors property
 *  that contains an array of error messages for each invalid field.
 * - If the validation succeeds, it invokes the handler function with the 
 *  validated data and returns the result of the handler function.
 * 
 * The createServerAction function can be used to create different server 
 * actions for different purposes. For example, the code you provided creates
 * a server action called createBoard that creates a new board in the database. 
 * 
 * @param schema - defines the shape and validation rules of the input data for the server action.
 * @param performAction - handler function performs the actual logic of the server action and 
 * returns an object that contains the output data or any errors.
 * @returns  another function that takes the input data as a parameter and 
 * returns a promise that resolves to an object that contains the output data
 * or any errors.
 */
export function createServerAction<InputType, OutputType>(
  // Input validation schema
  schema: z.Schema<InputType>,
  // Handler function
  performAction: (validatedData: InputType) => Promise<ActionState<InputType, OutputType>> 
): (data: InputType) => Promise<ActionState<InputType, OutputType>> {
  return async (data: InputType): Promise<ActionState<InputType, OutputType>> => {
    // Validate input data using the provided schema.
    const validation = schema.safeParse(data);

    if (!validation.success) {
      // If validation fails, return field errors.
      return {
        fieldErrors: validation.error.flatten().fieldErrors as FieldErrors<InputType>,
      };
    }

    // Otherwise, invoke the performAction handler with validated data.
    return performAction(validation.data);
  };
}

Let's break it down:

  1. createServerAction Function:
    • This function takes two parameters:
      • schema: A zod.Schema<InputType> representing a validation schema for the input data.
      • performAction: A function that takes validated data of type InputType and returns a promise of ActionState<InputType, OutputType>.
    • Inside the function:
      • It validates the input data using the provided schema.
      • If validation fails, it returns an object with field errors extracted from the validation error.
      • Otherwise, it invokes the performAction function with the validated data and returns its result.

In summary, this provides a function for creating server actions with input validation.

feat: add createServerAction function

This commit introduces the createServerAction function to the lib/createServerAction.ts file. The function validates input data using the provided schema and invokes the performAction handler. It enhances our error handling and action state management.

createBoard: Type Definitions (Inputs & Outputs) Pt. 2

We can now improve our type definitions for createBoard by defining the inputs and output types.

Define InputType and ReturnType

This commit defines the InputType (inferred from CreateBoard schema) and ReturnType (ActionState with Board as the output data type) in the createBoardTypes.ts file.

import { z } from 'zod';

// Import Board, the expected output type, from Prisma client
import { Board } from '@prisma/client';

import { ActionState } from '@/lib/createServerAction';

import { CreateBoard } from './createBoardSchema';

export type InputType = z.infer<typeof CreateBoard>;
export type ReturnType = ActionState<InputType, Board>;

createBoard: Server Action

Finally, create an index.ts file inside /createBoard. Here we create the server action.

In summary, this code handles user authentication, creates a board in the database, and provides appropriate responses based on the outcome.

Implement performAction function

This commit adds the performAction function to createBoard.ts. The function authenticates the user, creates a board in the database, and handles error cases. Additionally, it revalidates the path related to the newly created board.

"use server";

import { revalidatePath } from "next/cache";
import { auth } from "@clerk/nextjs";

import { database } from "@/lib/database";
import { createServerAction } from "@/lib/createServerAction";

import { InputType, ReturnType } from "./createBoardTypes";
import { CreateBoard } from "./createBoardSchema";

async function performAction (data: InputType): Promise<ReturnType> {
  // Verify that the user is logged in with Clerk & get their unique identifier
  const { userId } = auth();

  // If user is not logged-in, return an object with error property: Unauthorized
  if (!userId) {
    return {
      error: 'Unauthorized',
    }
  }

  // Destructure the title property from the validated data
  const { title } = data;

  let board;

  // Try to create a new board in the database
  try {
    board = await database.board.create({
      data: {
        title,
      }
    });
  } catch(error) {
    return {
      error: "Internal error: failed to create in database."
    }
  }

  // Invalidates the cache for a given path on the server 
  // and triggers a re-fetch of the data for that page
  revalidatePath(`/board/${board.id}`);

  // Return an object with a data property set to the board object, which 
  // contains the information about the newly created board
  return { data: board };
}

/**
 * Create and export a type-safe server action createBoard, that creates a 
 * Board in the database with validated input data from the user. 
 * 
 * createServerAction function with two arguments: CreateBoard and performAction.
 * 
 * First argument is the schema that validates the input data. 
 * Second argument is the function that performs the actual logic of the server action.
 * 
 * createServerAction takes two parameters: a schema and a handler function.
 * 
 * The schema defines the shape and validation rules of the input data for the 
 * server action. 
 * 
 * The handler function performs the actual logic of the server action and 
 * returns an object that contains the output data or any errors.
 */
export const createBoard = createServerAction(CreateBoard, performAction);

Let's break down the provided TypeScript code:

  1. Imports:

    • The code imports necessary modules and functions:
      • revalidatePath from "next/cache": A function used for revalidating a specific path in Next.js.
      • auth from "@clerk/nextjs": A function for authenticating users using Clerk authentication.
      • database from "@/lib/database": Presumably, this module provides access to database-related functionality.
  2. Function Definition:

    • The function is named performAction.
    • It takes an input of type InputType (inferred from the CreateBoard schema) and returns a promise of type ReturnType.
    • The purpose of this function is to perform an action (presumably related to creating a board) based on the provided input data.
  3. Authentication:

    • It authenticates the user by extracting the userId using the auth() function.
    • If the user is not logged in (no userId), it returns an error with the message 'Unauthorized'.
  4. Database Interaction:

    • The function extracts the title from the input data.
    • It attempts to create a new board in the database using the database.board.create method.
    • If successful, it assigns the created board to the board variable.
    • If an error occurs during database interaction, it returns an error message indicating an internal error.
  5. Revalidation:

    • It revalidates the path /board/${board.id} (presumably to update cached data related to the board).
  6. Return Value:

    • The function returns an object:
      • If there was an error during authentication or database interaction, it includes an error property.
      • Otherwise, it includes a data property containing the created board.
  7. Server Action:

    • The createServerAction function returns an object that contains the server action itself and some helper components for rendering forms and displaying errors
    • Two arguments: CreateBoard and performAction
    • The first argument is the schema that validates the input data, and the second argument is the function that performs the actual logic of the server action

useServerAction hook

With the server action created with the createServerAction abstraction, we now need to create a hook that accepts the newly created server action. The hook will give us access to callbacks such as onSuccess and onComplete.

Create the file useServerAction.ts in /hooks.

Then we define a custom hook called useServerAction that takes a server action as a parameter and returns a memoized callback function. A server action is a function that runs on the server and can be invoked from the client using a special URL. A memoized callback function is a function that is cached and does not get redefined on every render. This can improve performance and prevent unnecessary re-rendering of components.

import { useCallback, useState } from "react";

import { ActionState, FieldErrors } from "@/lib/createServerAction";

// A generic type alias for a server action function
// A server action function takes an input data of type InputType and returns a
// promise that resolves to an object of type ActionState<InputType, OutputType>
type ServerAction<InputType, OutputType> = (data: InputType) => 
  Promise<ActionState<InputType, OutputType>>;

// A custom hook that takes a server action function as a parameter and returns
// a memoized callback function
export const useServerAction = <InputType, OutputType>(
  action: ServerAction<InputType, OutputType>
) => {
  // Declare a constant called cachedFn and assign it to the result of calling the useCallback hook with a callback function and an array of dependencies
  // The useCallback hook returns a memoized version of the callback function that only changes if one of the dependencies has changed
  // The callback function simply returns the input data as it is
  // The only dependency is the action function that is passed as a parameter
  const cachedFn = useCallback(
    async (input) => {
      return input;
    }, [action]
  );

  return cachedFn;
}

useServerActionOptions interface

Define the interface for what we expect from this hook.

The UseServerActionOptions interface has three optional properties: onSuccess, onError, and onComplete. Each property is a function that can be passed as an option to a server action hook. A server action hook is a custom hook that lets you memoize a server action function. A server action function is a function that runs on the server and can be invoked from the client using a special URL.

  • The onSuccess property is a function that takes the output data as a parameter and returns nothing. This function is called when the server action succeeds and returns some data.
  • The onError property is a function that takes an error message as a parameter and returns nothing. This function is called when the server action fails and returns an error.
  • The onComplete property is a function that takes no parameters and returns nothing. This function is called when the server action is finished, regardless of whether it succeeded or failed.
interface UseServerActionOptions<OutputType> {
  onComplete?: () => void;
  onError?: (error: string) => void;
  onSuccess?: (data: OutputType) => void;
};

useServerAction state variables

Then we define our state variables: data, error, fieldErrors and isLoading

import { useCallback, useState } from "react";

import { ActionState, FieldErrors } from "@/lib/createServerAction";

type ServerAction<InputType, OutputType> = (data: InputType) => 
  Promise<ActionState<InputType, OutputType>>;

interface UseServerActionOptions<OutputType> {
  onComplete?: () => void;
  onError?: (error: string) => void;
  onSuccess?: (data: OutputType) => void;
};

export const useServerAction = <InputType, OutputType> (
  action: ServerAction<InputType, OutputType>,
  options: UseServerActionOptions<OutputType> = {},
) => {

  const [data, setData] = useState<OutputType | undefined>(undefined);
  const [error, setError] = useState<string | undefined>(undefined);

  const [fieldErrors, setFieldErrors] = useState<FieldErrors<InputType> | undefined>(
    undefined
  );
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const cachedFn = useCallback(
    async (input) => {
      return input;
    }, [action]
  );

  return cachedFn;
}

Add state and options to useServerAction hook

The useServerAction hook is a custom hook that lets you memoize a server action function. A server action is a function that runs on the server and can be invoked from the client using a special URL. The useServerAction hook takes a server action function and an optional object of options as parameters, and returns a memoized callback function. The options object can contain three functions: onSuccess, onError, and onComplete, which are called when the server action succeeds, fails, or finishes, respectively. The useServerAction hook also manages the state of the server action, such as the output data, the error message, the field errors, and the loading status. The useServerAction hook uses the useState hook from React to create and update the state variables, and the useCallback hook to create the memoized callback function.

useServerAction callback function

useCallback hook

  • useCallback | React Reference
  • useCallback is a React Hook that lets you cache a function definition between re-renders.
  • const cachedFn = useCallback(fn, dependencies)

The useCallback hook is a React hook that lets you memoize a callback function. Memoization is a technique that caches the result of a function so that it does not need to be recalculated every time it is called with the same arguments. This can improve the performance and avoid unnecessary re-rendering of components that depend on the callback function.

The useCallback hook takes two parameters: a callback function and an array of dependencies. The callback function is the function that you want to memoize. The array of dependencies is a list of values that the callback function depends on. The useCallback hook returns a memoized version of the callback function that only changes if one of the dependencies has changed.

You can use the useCallback hook when you have a component that passes a callback function to a child component as a prop. If the callback function is not memoized, it will be recreated on every render of the parent component, which will cause the child component to re-render as well, even if the props have not changed. By using the useCallback hook, you can prevent this unnecessary re-rendering and improve the performance of your application.

Inside useServerAction we call the useCallback hook and save it to a cached function called cachedFn like this:

  const cachedFn = useCallback(
    async (input) => {
      return input;
    }, [action]
  );

Rename this to executeServerAction, which we call inside our components, which calls an async function that takes the input argument. We send that input inside of our action. This action will run through our ActionState to be validated. Afterwards, it will follow the server action logic.

useServerAction logic

Now for the executeServerAction, inside the useCallback hook we want to have a an async function with input as the argument. Inside the function we immediately set the loading state to true. Then open up a try..catch..finally where we call action(input) and take save the result to actionOutput. Then we check for a series of conditions.

  • if actionOutput is falsy then we have an early return
  • Otherise, check the properties of actionOutput if they contain error, fieldErrors or data and set the state variables accordingly.

Next let's catch any errors and log it to the console. Then in the finally block we should set the loading state to false.

export const useServerAction = <InputType, OutputType> (
  action: ServerAction<InputType, OutputType>,
  options: UseServerActionOptions<OutputType> = {},
) => {

  const [data, setData] = useState<OutputType | undefined>(undefined);
  const [error, setError] = useState<string | undefined>(undefined);
  const [fieldErrors, setFieldErrors] = useState<FieldErrors<InputType> | undefined>(
    undefined
  );
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const executeServerAction = useCallback(
    async (input: InputType) => {
      setIsLoading(true);

      try {
        const actionOutput = await action(input);

        if (!actionOutput) {
          return;
        }

        if (actionOutput.error) {
          setError(actionOutput.error);
        }

        if (actionOutput.fieldErrors) {
          setFieldErrors(actionOutput.fieldErrors);
        }
        
        if(actionOutput.data) {
          setData(actionOutput.data);
        }

      } catch (error) {
        console.error(`An error occurred during a server action.\n${error}`);
      } finally {
        setIsLoading(false);
      }

      return input;
    }, [action]
  );

  return executeServerAction;
}

Add options object to specify callback functions

Lastly, we will use the options object to specify callback functions to handle the completion, error, or success of the server action.

We just need to add the callback functions to the error and data conditions, and the onComplete in the finally block. To recap:

  • If the server action had no output our results then we do an early return and have no callbacks.
  • If output has fieldErrors then something went wrong with validation
    • This has no callback function
  • If output has a error, which is a server error, we set the error state
    • Invoke options callback for onError since this is not an error we want for the input field but rather pass in that error to something else like an error notification such as toast
  • If output has data then it successfully created the record
    • Invoke options callback for onSuccess and pass in the the record so we can operate on the record (e.g., redirecting to a specific ID of a page, or success message)
  • In the finally block, we set the loading state to false
    • Invoke options callback for onComplete

feat: add callback functions to useServerAction

This commit modifies the useServerAction hook to accept an options object that can specify callback functions to handle the completion, error, or success of the server action. The hook will invoke the corresponding callback function depending on the outcome of the action. This allows the caller code to customize the behavior and side effects of the hook.

hooks\useServerAction.ts

import { useCallback, useState } from "react";

import { ActionState, FieldErrors } from "@/lib/createServerAction";

type ServerAction<InputType, OutputType> = (data: InputType) => 
  Promise<ActionState<InputType, OutputType>>;

interface UseServerActionOptions<OutputType> {
  onComplete?: () => void;
  onError?: (error: string) => void;
  onSuccess?: (data: OutputType) => void;
};

export const useServerAction = <InputType, OutputType> (
  action: ServerAction<InputType, OutputType>,
  options: UseServerActionOptions<OutputType> = {},
) => {

  const [data, setData] = useState<OutputType | undefined>(undefined);
  const [error, setError] = useState<string | undefined>(undefined);
  const [fieldErrors, setFieldErrors] = useState<FieldErrors<InputType> | undefined>(
    undefined
  );
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const executeServerAction = useCallback(
    async (input: InputType) => {
      setIsLoading(true);

      try {
        const actionOutput = await action(input);

        if (!actionOutput) {
          return;
        }

        if (actionOutput.error) {
          setError(actionOutput.error);
          options.onError?.(actionOutput.error);
        }

        if (actionOutput.fieldErrors) {
          setFieldErrors(actionOutput.fieldErrors);
        }
        
        if(actionOutput.data) {
          setData(actionOutput.data);
          options.onSuccess?.(actionOutput.data);
        }

      } catch (error) {
        console.error(`An error occurred during a server action.\n${error}`);
      } finally {
        setIsLoading(false);
        options.onComplete?.();
      }

      return input;
    }, [action, options]
  );

  return {
    executeServerAction,
    data,
    error,
    fieldErrors,
    isLoading,
  };
}

feat: add useServerAction hook to handle server actions

This commit adds a custom React hook that takes a server action function and an optional options object as parameters. The hook returns a cached function that can be called with an input to execute the server action. The hook also manages the state of the data, error, field errors, and loading status of the server action. The options object can specify callback functions to handle the completion, error, or success of the server action.

refactor: rename cachedFn to executeServerAction

This commit renames the variable cachedFn to executeServerAction in the useServerAction hook to better reflect the proper name and context it will be used in. The new name indicates that the variable is a function that executes a server action with a given input, rather than a generic cached function. This improves the readability and clarity of the code and the hook usage.

Summary: useServerAction hook

useServerAction is a custom hook for handling server actions with callbacks. Let's break down its key components:

  1. useServerAction Hook:

    • This hook encapsulates the logic for executing a server action with callbacks.
    • It takes an action (a function that represents the server action) and optional options (callbacks for different scenarios) as parameters.
    • The hook returns an object containing relevant state variables (data, error, fieldErrors, and isLoading) and the executeServerAction function.
  2. State Variables:

    • data: Represents the output data from the server action (if successful).
    • error: Holds any error message returned by the server action.
    • fieldErrors: Stores field-specific errors (if any).
    • isLoading: Indicates whether the server action is currently being executed.
  3. executeServerAction Function:

    • This function handles the execution of the server action.
    • It sets the loading state, calls the provided action, and processes the output.
    • If successful, it updates the state variables (data, error, and fieldErrors).
    • It also invokes the provided callbacks (onError, onSuccess, and onComplete).
  4. Callback Options:

    • onError: Invoked when an error occurs during the server action.
    • onSuccess: Called when the server action completes successfully.
    • onComplete: Executed regardless of success or failure.

BoardForm: use createBoard using createServerAction abstraction

Navigate back to BoardForm.tsx, as a reminder:

components\BoardForm.tsx

"use client";

import React from 'react';
import { useFormState } from 'react-dom';

import createBoard from '@/actions/createBoard';
import BoardFormInput from '@/components/BoardFormInput';
import BoardFormButton from '@/components/BoardFormButton';

/* Create a form for creating a new board */
export default function BoardForm() {

  const initialState = {
    errors: {},
    message: "",
  };

  // Use the useFormState hook to create a state and an action for the form
  const [state, formAction] = useFormState(createBoard, initialState);

  /* The state will be updated by the createBoard action when the form is submitted
  The createBoard action is a function that takes the previous state and the form 
  data as arguments and returns a new state */

  // Pass the formAction as the action prop to the form element
  return (
    <form action={formAction}>
      <BoardFormInput errors={state?.errors}/>
      <BoardFormButton type="submit" variant="default" size="default">
        Submit
      </BoardFormButton>
    </form>
  )
}

We can remove the createBoard import, the useFormState hook and the initialState too.

Then import and invoke useServerAction and destructure out { executeServerAction, fieldErrors }. Create an onSubmit function handler that extracts the title from the formData and passes title into executeServerAction.

Finally, pass in onSubmit and fieldErrors to their corresponding props.

"use client";

import React from 'react';

import { createBoard } from "@/actions/createBoard/index";
import BoardFormInput from '@/components/BoardFormInput';
import BoardFormButton from '@/components/BoardFormButton';
import { useServerAction } from '@/hooks/useServerAction';

/* Create a form for creating a new board */
export default function BoardForm() {
  const { executeServerAction, fieldErrors } = useServerAction(createBoard);

  function onSubmit(formData: FormData) {
    const title = formData.get('title') as string;

    executeServerAction({ title });
  }

  return (
    <form action={onSubmit}>
      <BoardFormInput errors={fieldErrors}/>
      <BoardFormButton type="submit" variant="default" size="default">
        Submit
      </BoardFormButton>
    </form>
  )
}

refactor: replace useFormState with useServerAction

This commit replaces the useFormState hook with the useServerAction hook in the BoardForm component. The useServerAction hook simplifies the logic and state management of the server action by handling the data, error, field errors, and loading status internally. The BoardForm component only needs to pass the createBoard action and the input to the executeServerAction function returned by the hook. This improves the readability and maintainability of the code and the component usage.

Next let's add the callback functions depending on the server action outcomes: onSuccess and onError

feat: add callback functions to useServerAction hook

This commit adds an options object to the useServerAction hook in the BoardForm component that specifies callback functions to handle the error and success of the createBoard action. The hook will invoke the corresponding callback function depending on the outcome of the action and log the error or data to the console. This allows the BoardForm component to customize the behavior and side effects of the hook.

export default function BoardForm() {
  const { executeServerAction, fieldErrors } = useServerAction(createBoard, {
    onError: (error) => { console.error(error); },
    onSuccess: (data) => { console.log(data, 'Successfully created Board!'); },
  });

  function onSubmit(formData: FormData) {
    const title = formData.get('title') as string;

    executeServerAction({ title });
  }

  return (
    <form action={onSubmit}>
      <BoardFormInput errors={fieldErrors}/>
      <BoardFormButton type="submit" variant="default" size="default">
        Submit
      </BoardFormButton>
    </form>
  )
}

Form components

Goal is to make a set of re-usable components for the Form. This will allow us to refactor BoardForm and other components accordingly.

Create /form folder in /components.

FormInput component

Inside /form/components/ create file FormInput.tsx.

components\form\FormInput.tsx

"use client";

import React from 'react'

export default function FormInput() {
  return (
    <div>FormInput</div>
  )
}

Let's add the FormInputProp interface with the properties we plan to have

interface FormInputProps {
  id: string;
  className?: string;
  defaultValue?: string;
  label?: string;
  placeholder?: string;
  type?: string;
  disabled?: boolean;
  required?: boolean;
  errors?: Record<string, string[] | undefined>;
  onBlur?: () => void;
}

forwardRef API

Before we assign the props, we have to learn about forwardRef and what it means to manipulate the DOM with Refs.

forwardRef in React is a utility function that allows you to pass a ref (a reference to a DOM element or a component instance) from a parent component to a child component. This is useful when you need to access or manipulate the child's DOM element or instance directly from the parent component. For example, you may want to focus an input field, scroll to a position, or measure the size and position of an element.

Manipulating the DOM with Refs means using refs to access and modify the DOM elements managed by React. React usually updates the DOM automatically to match your render output, but sometimes you may need to interact with the underlying HTML elements directly. For example, you may want to trigger an animation, integrate with a third-party library, or manage focus, text selection, or media playback.

To use refs in React, you need to import the useRef Hook and use it to create a ref object. Then, you can pass the ref object as the ref attribute to the JSX element that you want to get a reference to. You can then access the DOM node or instance from the ref object's current property and use any browser APIs on it.

Here is a simple example of using forwardRef and manipulating the DOM with refs:

// Import the useRef and forwardRef Hooks
import { useRef, forwardRef } from "react";

// Define a child component that takes a ref as the second argument
const MyInput = forwardRef(function MyInput(props, ref) {
  return <input type="text" ref={ref} />;
});

// Define a parent component that creates and passes a ref to the child
function Form() {
  // Create a ref object
  const inputRef = useRef(null);

  // Define a function that focuses the input using the ref
  function focusInput() {
    inputRef.current.focus();
  }

  // Render the child component and pass the ref as the ref attribute
  return (
    <div>
      <MyInput ref={inputRef} />
      <button onClick={focusInput}>Focus the input</button>
    </div>
  );
}

Here is a simple example of focusing a text input:

App.js

import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

FormInput and forwardRef

feat: use forwardRef for FormInput component

Use the forwardRef utility function to pass a ref from the parent component to the FormInput component. This allows the parent component to access or manipulate the input element directly. Add a displayName property to the component for debugging purposes.

Now let's use forwardRef and assign the props to FormInput component.

"use client";

import React, { forwardRef } from 'react'

interface FormInputProps {
  id: string;
  className?: string;
  defaultValue?: string;
  label?: string;
  placeholder?: string;
  type?: string;
  disabled?: boolean;
  required?: boolean;
  errors?: Record<string, string[] | undefined>;
  onBlur?: () => void;
}

const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
  id,
  className,
  defaultValue = "",
  label,
  placeholder,
  type,
  disabled,
  required,
  errors,
  onBlur,
}, ref) => {

  return (
    <div>FormInput</div>
  )
});

export default FormInput;

There is an issue if we hover over the return,

Component definition is missing display name eslint react/display-name

This error means that you have not specified a displayName property for your component, which is required by the ESLint rule react/display-name. This rule helps with debugging and testing by giving your component a human-readable name.

We can fix this by adding the following line after the component

FormInput.displayName = "FormInput";

FormInput output

Now onto the form output.

First lets extract the pending status with useFormStatus so we can disable the FormInput when loading.

Next install Label from shadcn/ui.

npx shadcn-ui@latest add label

Then we create a div within a div with vertical space between. Inside we conditionally render the Label component. Then assign the prop htmlFor to improve accessibility and usability of the form. The htmlFor attribute is an HTML attribute that is used with the <label> element. It specifies the id of the form element that the label is associated with. This way, when the user clicks on the label, the focus will move to the input field. After the Label render the Input component.

feat: add output for FormInput component

Use the forwardRef utility function to pass a ref from the parent component to the FormInput component. Render a Label and an Input element inside a div with spacing. Use the useFormStatus hook to get the status of the form submission.

const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
  // ...
}, ref) => {
  const { pending, data, method, action } = useFormStatus();

  return (
    <div className='space-y-2'>
      <div className='space-y-1'>
        {label ? (
          <Label 
            htmlFor={id}
          >
            {label}
          </Label>
        ) : null}
        <Input />
      </div>
    </div>
  )
});

feat: implement FormInput component with useFormStatus hook

Create a FormInput component that renders a Label and an Input element with spacing. Use the useFormStatus hook to get the status of the form submission. Use the forwardRef utility function to pass a ref from the parent component to the FormInput component.

Assign props to Input component

Use forwardRef to pass ref to Input component and add label, placeholder, type, disabled, required, errors, and onBlur props. Use useFormStatus hook to get pending state and disable input accordingly. Use cn utility to combine class names.

"use client";

import React, { forwardRef } from 'react'
import { useFormStatus } from 'react-dom';

import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

interface FormInputProps {
  id: string;
  className?: string;
  defaultValue?: string;
  label?: string;
  placeholder?: string;
  type?: string;
  disabled?: boolean;
  required?: boolean;
  errors?: Record<string, string[] | undefined>;
  onBlur?: () => void;
}

const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
  id,
  className,
  defaultValue = "",
  label,
  placeholder,
  type,
  disabled,
  required,
  errors,
  onBlur,
}, ref) => {
  const { pending } = useFormStatus();

  return (
    <div className='space-y-2'>
      <div className='space-y-1'>
        {label ? (
          <Label 
            htmlFor={id}
            className='text-xs font-semibold text-neutral-700'
          >
            {label}
          </Label>
        ) : null}
        <Input 
          id={id}
          defaultValue={defaultValue}
          name={id}
          placeholder={placeholder}
          type={type}
          disabled={pending || disabled}
          required={required}
          onBlur={onBlur}
          ref={ref}
          className={cn(
            'text-sm px-2 py-1 h-7',
            className,
          )}
        />
      </div>
    </div>
  )
});

FormInput.displayName = "FormInput";

export default FormInput;

FormErrors component

To assign the errors prop in FormInput, create a FormErrors component in /components/form.

components\form\FormErrors.tsx

import React from 'react'

export default function FormErrors() {
  return (
    <div>FormErrors</div>
  )
}

Then import and render FormErrors in FormInput, assign the props id and errors. While here we can also add the aria-describedby prop to the Input component, and set it to ${id}-error.

components\form\FormInput.tsx

const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
  // ...
}, ref) => {
  const { pending } = useFormStatus();

  return (
    <div className='space-y-2'>
      <div className='space-y-1'>
        {label ? (
          <Label 
            htmlFor={id}
            className='text-xs font-semibold text-neutral-700'
          >
            {label}
          </Label>
        ) : null}
        <Input 
          id={id}
          defaultValue={defaultValue}
          name={id}
          placeholder={placeholder}
          type={type}
          disabled={pending || disabled}
          required={required}
          onBlur={onBlur}
          ref={ref}
          className={cn(
            'text-sm px-2 py-1 h-7',
            className,
          )}
          aria-describedby={`${id}-error`}
        />
      </div>
      <FormErrors 
        id={id}
        errors={errors}
      />
    </div>
  )
});

Inside the output of FormErrors we want to map out the errors into a new array of JSX elements. The JSX element will be a div that contains the XCircle icon and the error string as children.

Implement FormErrors component

Use id prop to link input and error elements. Use aria-live attribute to announce errors to screen readers. Use XCircle icon from lucide-react to display errors for each input.

components\form\FormErrors.tsx

import { XCircle } from 'lucide-react';
import React from 'react'

interface FormErrorsProp {
  id: string;
  errors?: Record<string, string[] | undefined>;
};

export default function FormErrors({
  id, 
  errors,
}: FormErrorsProp) {
  
  if (!errors) {
    return null;
  }

  return (
    <div
      id={`${id}-error`}
      aria-live='polite'
      className='mt-2 text-xs text-rose-500'
    >
      {errors?.[id]?.map((error: string) => (
        <div
          key={error}
          className='flex items-center rounded-sm font-medium p-2 border border-rose-500 bg-rose-500/10'
        >
          <XCircle className='h-4 w-4 mr-2'/>
          {error}
        </div>
      ))}
    </div>
  )
}

With this complete, we can refactor our BoardForm to replace the BoardFormInput with the more re-usable FormInput component.

refactor: BoardForm to use FormInput component

Replace BoardFormInput component with FormInput component to avoid duplication and improve reusability. Pass errors, id and label props to FormInput component.

components\BoardForm.tsx

"use client";

import React from 'react';

import { createBoard } from "@/actions/createBoard/index";
import BoardFormButton from '@/components/BoardFormButton';
import { useServerAction } from '@/hooks/useServerAction';
import FormInput from '@/components/form/FormInput';

/* Create a form for creating a new board */
export default function BoardForm() {
  const { executeServerAction, fieldErrors } = useServerAction(createBoard, {
    onError: (error) => { console.error(error); },
    onSuccess: (data) => { console.log(data, 'Successfully created Board!'); },
  });

  function onSubmit(formData: FormData) {
    const title = formData.get('title') as string;

    executeServerAction({ title });
  }

  return (
    <form action={onSubmit}>
      <FormInput 
        errors={fieldErrors}
        id="title"
        label="Board Title"
      />
      <BoardFormButton type="submit" variant="default" size="default">
        Submit
      </BoardFormButton>
    </form>
  )
}

FormSubmitButton component

Create client component FormSubmitButton in /components/form. Then create the prop interface with {children, disabled, className, variant }.

feat: add FormSubmitProps interface

Add FormSubmitProps interface to define the props for the FormSubmitButton component. The interface includes the following properties:

  • children: the content of the button
  • className: an optional CSS class name
  • disabled: an optional boolean flag to disable the button
  • variant: an optional string to specify the button style

components\form\FormSubmitButton.tsx

"use client";

import React from 'react';

interface FormSubmitProps {
  children: React.ReactNode;
  className?: string;
  disabled?: boolean;
  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' 
    | 'link' | 'primary';
};

export default function FormSubmitButton({
  children,
  className,
  disabled,
  variant,
}: FormSubmitProps) {
  return (
    <div>FormSubmitButton</div>
  )
}

Now for the output let's get the pending from useFormStatus hook. Then we return a Button component which has the props assigned.

feat: add size prop to FormSubmitButton

Add size prop to FormSubmitButton component to allow different button sizes. The size prop can be one of 'default', 'sm', 'lg', or 'icon'. The component also uses the useFormStatus hook to disable the button when the form is pending. Also add cn utility function to merge Tailwind CSS class names.

"use client";

import React from 'react';
import { useFormStatus } from 'react-dom';

import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';

interface FormSubmitProps {
  children: React.ReactNode;
  className?: string;
  disabled?: boolean;
  size: 'default' | 'sm' | 'lg' | 'icon';
  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' 
    | 'link' | 'primary';
};

export default function FormSubmitButton({
  children,
  className,
  disabled,
  size,
  variant,
}: FormSubmitProps) {
  const { pending } = useFormStatus();

  return (
    <Button
      disabled={pending || disabled}
      size={size}
      type='submit'
      variant={variant}
      className={cn(className)}
    >
      {children}
    </Button>
  )
}

feat: Set FormSubmitButton variant to primary

This change applies the Tailwind styles: "bg-sky-500 hover:bg-sky-600/90 text-primary-foreground". It now applies to any FormSubmitButtons that do not specify a variant prop.

import { Button } from '@/components/ui/button';

interface FormSubmitProps {
  children: React.ReactNode;
  className?: string;
  disabled?: boolean;
  size?: 'default' | 'sm' | 'lg' | 'icon';
  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' 
    | 'link' | 'primary';
};

export default function FormSubmitButton({
  children,
  className,
  disabled,
  size = 'default',
  variant = 'primary',
}: FormSubmitProps) {

Use FormSubmitButton

Now add the re-usable FormSubmitButton inside the BoardForm.

refactor: BoardForm to use FormSubmitButton

Replace BoardFormButton component with FormSubmitButton component to avoid duplication and improve reusability.

components\BoardForm.tsx

"use client";

import React from 'react';

import { createBoard } from "@/actions/createBoard/index";
import { useServerAction } from '@/hooks/useServerAction';
import FormInput from '@/components/form/FormInput';
import FormSubmitButton from '@/components/form/FormSubmitButton';

/* Create a form for creating a new board */
export default function BoardForm() {
  const { executeServerAction, fieldErrors } = useServerAction(createBoard, {
    onError: (error) => { console.error(error); },
    onSuccess: (data) => { console.log(data, 'Successfully created Board!'); },
  });

  function onSubmit(formData: FormData) {
    const title = formData.get('title') as string;

    executeServerAction({ title });
  }

  return (
    <form action={onSubmit}>
      <FormInput 
        errors={fieldErrors}
        id="title"
        label="Board Title"
      />
      <FormSubmitButton 
        size="default" 
        variant="default" 
        className='p-1'
      >
        Save
      </FormSubmitButton>
    </form>
  )
}

Debug Issue: FormErrors component message on FormInput validation does not reset.

Steps to reproduce the error:

  1. Navigate to BoardForm
  2. Type an invalid title (i.e., less than 3 characters) for the Board inside the input
  3. Hit the submit button
  4. After form submission, type a valid title name for the Board and submit
  5. Error message displays below the input but does not go away after a valid input is placed

The error message can be traced back to the zod validation:

actions\createBoard\createBoardSchema.ts

/**
 * Define the CreateBoard object schema.
 * 
 * Add custom error messages for: required fields, 
 * invalid type and minimum length.
 */
export const CreateBoard = z.object({
  title: z.string({
    required_error: "Title is required", 
    invalid_type_error: "Title is required", 
  }).min(3, {
    message: "Must be 3 or more characters long.", 
  }),
});

Error message does not reset inside the FormInput component wwhich renders FormErrors

const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
// ...
}, ref) => {
  return (
    <div className='space-y-2'>
      <div className='space-y-1'>
        {label ? (
          <Label 
            htmlFor={id}
            className='text-xs font-semibold text-neutral-700'
          >
            {label}
          </Label>
        ) : null}
        <Input 
          id={id}
          defaultValue={defaultValue}
          name={id}
          placeholder={placeholder}
          type={type}
          disabled={pending || disabled}
          required={required}
          onBlur={onBlur}
          ref={ref}
          className={cn(
            'text-sm px-2 py-1 h-7',
            className,
          )}
          aria-describedby={`${id}-error`}
        />
      </div>
      <FormErrors 
        id={id}
        errors={errors}
      />
    </div>
  )
});

The issue can be traced back to

hooks\useServerAction.ts

  const executeServerAction = useCallback(
    async (input: InputType) => {
      setIsLoading(true);

      try {
        const actionOutput = await action(input);

        // ...

        if (actionOutput.fieldErrors) {
          setFieldErrors(actionOutput.fieldErrors);
        }

As we can see we invoke setFieldErrors(actionOutput.fieldErrors) IF we have the actionOutput.fieldErrors to be truthy.

HOWEVER, let's navigate back to our creator function createServerAction

lib\createServerAction.ts

export function createServerAction<InputType, OutputType>(
  schema: z.Schema<InputType>,
  performAction: (validatedData: InputType) => Promise<ActionState<InputType, OutputType>> 
): (data: InputType) => Promise<ActionState<InputType, OutputType>> {
  return async (data: InputType): Promise<ActionState<InputType, OutputType>> => {
    // Validate input data using the provided schema.
    const validation = schema.safeParse(data);

    if (!validation.success) {
      // If validation fails, return field errors.
      return {
        fieldErrors: validation.error.flatten().fieldErrors as FieldErrors<InputType>,
      };
    }

    return performAction(validation.data);
  };
}

Look closely where we return fieldErrors object only when validation is NOT successful.

This means that because of

lib\createServerAction.ts

    if (!validation.success) {
      // If validation fails, return field errors.
      return {
        fieldErrors: validation.error.flatten().fieldErrors as FieldErrors<InputType>,
      };
    }

The setFieldErrors will not update in useServerAction hook, because actionOutput.fieldErrors must be truthy. That means we ONLY update the fieldErrors state when we do have a fieldError object being returned.

hooks\useServerAction.ts

  const executeServerAction = useCallback(
    async (input: InputType) => {
      setIsLoading(true);

      try {
        const actionOutput = await action(input);

        // ...

        if (actionOutput.fieldErrors) {
          setFieldErrors(actionOutput.fieldErrors);
        }

Fix: always update the fieldErrors state variable, regardless if fieldErrors exists or not.

Field errors are usually returned by the server when the input data does not meet some validation criteria. If you want to display the field errors to the user, then you should always check for them and render them accordingly.

refactor: update fieldErrors state in useServerAction

Update the fieldErrors state in the useServerAction hook whenever the actionOutput is received. This ensures that the field errors are always in sync with the server response, regardless of whether there is an error or data.

import { useCallback, useState } from "react";

import { ActionState, FieldErrors } from "@/lib/createServerAction";

type ServerAction<InputType, OutputType> = (data: InputType) => 
  Promise<ActionState<InputType, OutputType>>;

interface UseServerActionOptions<OutputType> {
  onComplete?: () => void;
  onError?: (error: string) => void;
  onSuccess?: (data: OutputType) => void;
};

export const useServerAction = <InputType, OutputType> (
  action: ServerAction<InputType, OutputType>,
  options: UseServerActionOptions<OutputType> = {},
) => {

  const [data, setData] = useState<OutputType | undefined>(undefined);
  const [error, setError] = useState<string | undefined>(undefined);
  const [fieldErrors, setFieldErrors] = useState<FieldErrors<InputType> | undefined>(
    undefined
  );
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const executeServerAction = useCallback(
    async (input: InputType) => {
      setIsLoading(true);

      try {
        const actionOutput = await action(input);

        if (!actionOutput) {
          return;
        }

        setFieldErrors(actionOutput.fieldErrors);

        if (actionOutput.error) {
          setError(actionOutput.error);
          options.onError?.(actionOutput.error);
        }
        
        if(actionOutput.data) {
          setData(actionOutput.data);
          options.onSuccess?.(actionOutput.data);
        }

      } catch (error) {
        console.error(`An error occurred during a server action.\n${error}`);
      } finally {
        setIsLoading(false);
        options.onComplete?.();
      }

      return input;
    }, [action, options]
  );

  return {
    executeServerAction,
    data,
    error,
    fieldErrors,
    isLoading,
  };
}

fix: update FormErrors state on input submission

Update the FormErrors state in the FormInput component whenever a new input value is submitted. This also changes the useServerAction hook to update the state of the fieldErrors every time an actionOutput is receieved. This ensures that the form validation errors are always in sync with the user input.

refactor: update fieldErrors state in useServerAction

Update the fieldErrors state in the useServerAction hook whenever the actionOutput is received. This ensures that the field errors are always in sync with the server response, regardless of whether there is an error or data. This fixes the issue where the FormErrors component does not disappear on subsequent form submissions.

Redesign of organization ID page

The current org ID page:

app\(app)\(dashboard)\org\[orgId]\page.tsx

import React from 'react';
import { database } from '@/lib/database';
import Board from '@/components/Board';
import BoardForm from '@/components/BoardForm';

const OrganizationIdPage = async () => {
  // Fetch the boards from the database
  const boards = await database.board.findMany();

  return (
    <div className='flex flex-col space-y-4'>
      <BoardForm />
      {/* Create a div for displaying the boards */}
      <div className='space-y-2'>
        {/* Map over the boards and render a Board component for each one */}
        {boards.map((board) => (
          <Board
            key={board.id}
            id={board.id}
            title={board.title}
          />
        ))}
      </div>
    </div>
  );
};

export default OrganizationIdPage

Let's redesign it.

Going to build an Info component which describes a quick summary of the currently selected team, organization, or personal profile. Then another component BoardList that should render a list of boards. Make basic react functional components in /components and then use them in the org ID page.

refactor: org ID page with BoardList & Info

import BoardList from '@/components/BoardList';
import Info from '@/components/Info';
import { Separator } from '@/components/ui/separator';
import React from 'react';

const OrganizationIdPage = () => {

  return (
    <div className='flex flex-col w-full mb-20'>
      <Info />
      <Separator className='my-4'/>
      <div className='px-2 md:px-4'>
        <BoardList />
      </div>
    </div>
  );
};

export default OrganizationIdPage

Info component

In Info, we want to render an Image for the organization or team. We will use the useOrganization hook to get the organization data and the isLoaded. We will conditionally render a loading element when the isLoaded is false. In the output we create an imagee container to hold the organization image.

feat: display organization image in Info component

components\Info.tsx

"use client";

import React from 'react';
import Image from 'next/image';
import { useOrganization } from '@clerk/nextjs';

export default function Info() {
  const { organization, isLoaded } = useOrganization();

  if (!isLoaded) {
    return (
      <p>Loading...</p>
    )
  }

  return (
    <div className='flex items-center gap-x-4'>
      {/* Image container */}
      <div className='relative w-[60px] h-[60px]'>
        <Image
          fill
          src={organization?.imageUrl}
          alt="organization image"
          className='rounded-md object-cover'
          sizes="(max-width: 768px) 33vw, (max-width: 1200px) 30vw, 25vw"
        />
      </div>
    </div>
  )
}

Issue: the src prop in Image gives an error

Hovering over the src prop in the Image gives a type not assignable to type error.

Type 'string | undefined' is not assignable to type 'string | StaticImport'.
  Type 'undefined' is not assignable to type 'string | StaticImport'.ts(2322)
image-component.d.ts(7, 5): The expected type comes from property 'src' which is declared here on type 'IntrinsicAttributes & Omit<DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, "ref" | ... 5 more ... | "srcSet"> & { ...; } & RefAttributes<...>'
(property) src: string | StaticImport

This error means that the src prop of the Image component expects either a string or a StaticImport type, but the value you are passing to it (organization?.imageUrl) could be undefined if organization is null or undefined. TypeScript does not allow assigning a possibly undefined value to something that expects a specific type.

There are a few ways to fix this error, depending on your use case and preference. Here are some possible solutions:

  • Use the non-null assertion operator (!) to tell TypeScript that you are sure that organization and organization.imageUrl are not null or undefined. This will suppress the error, but it could cause runtime errors if your assumption is wrong. For example:
<Image
  fill
  src={organization!.imageUrl!} // use ! to assert non-null
  alt="organization image"
  className='rounded-md object-cover'
  sizes="(max-width: 768px) 33vw, (max-width: 1200px) 30vw, 25vw"
/>
  • Use the optional chaining operator (?.) and the nullish coalescing operator (??) to provide a fallback value for src in case organization or organization.imageUrl is null or undefined. This will ensure that src always receives a valid value, but you need to decide what the fallback value should be. For example:
<Image
  fill
  src={organization?.imageUrl ?? "/default.png"} // use ?? to provide a fallback
  alt="organization image"
  className='rounded-md object-cover'
  sizes="(max-width: 768px) 33vw, (max-width: 1200px) 30vw, 25vw"
/>
  • Use a type guard to check if organization and organization.imageUrl are defined before rendering the Image component. This will ensure that src only receives a valid value when organization and organization.imageUrl are not null or undefined, but it will also prevent the Image component from rendering otherwise. For example:
{organization && organization.imageUrl && ( // use && to check if both are defined
  <Image
    fill
    src={organization.imageUrl}
    alt="organization image"
    className='rounded-md object-cover'
    sizes="(max-width: 768px) 33vw, (max-width: 1200px) 30vw, 25vw"
  />
)}

Going with the second option by providing a fallback image with nullish coalescing.

fix: use default image when imageUrl is undefined

components\Info.tsx

export default function Info() {
  const { organization, isLoaded } = useOrganization();
  // ...
  return (
    <div className='flex items-center gap-x-4'>
      {/* Image container */}
      <div className='relative w-[60px] h-[60px]'>
        <Image
          fill
          src={organization?.imageUrl ?? '/logo.svg'}
          alt="organization image"
          className='rounded-md object-cover'
          sizes="(max-width: 768px) 33vw, (max-width: 1200px) 30vw, 25vw"
        />
      </div>
    </div>
  )
}

Finish up the output of the Info component with the org name and a icon next to text indicating whether its premium or free.

export default function Info() {
  const { organization, isLoaded } = useOrganization();

  if (!isLoaded) {
    return (
      <p>Loading...</p>
    )
  }

  return (
    <div className='flex items-center gap-x-4'>
      {/* Image container */}
      <div className='relative w-[60px] h-[60px]'>
        <Image
          fill
          src={organization?.imageUrl ?? '/logo.svg'}
          alt="organization image"
          className='rounded-md object-cover'
          sizes="(max-width: 768px) 33vw, (max-width: 1200px) 30vw, 25vw"
        />
      </div>
      {/* Organization Info */}
      <div className='space-y-1'>
        <p className='font-semibold text-xl'>
          {organization?.name}
        </p>
        {/* Premium or Free info is dynamically rendered */}
        <div className='flex items-center text-xs text-muted-foreground'>
          <CreditCard />
          Free
        </div>
      </div>
    </div>
  )
}

Info skeleton

Wrap the Info component up with a skeleton, a placeholder preview of the content before it fully loads. It mimics the layout and structure of the actual content, but without the details. It reduces the perceived loading time, keeps the user engaged improving the user experience.

Instead of creating a new component called InfoSkeleton, we are going to define a static property for the Info component.

Defining skeletons inside components as static properties

In React, a static property is a property that belongs to the component class or function, not to the instance. It can be accessed without creating an instance of the component. Static properties are useful for defining constants, default props, context types, or subcomponents.

There are different ways to define a static property in React, depending on whether you are using a class component or a function component, and whether you are using TypeScript or JavaScript. Here are some examples:

  • For a class component in JavaScript, you can define a static property inside the class body, using the static keyword. For example:
class Example extends React.Component {
  // Define a static property called displayName
  static displayName = "Example";

  render() {
    return <p>This is an example component.</p>;
  }
}
  • For a function component in JavaScript, you can define a static property on the function itself, using the dot notation. For example:
function Example(props) {
  return <p>This is an example component.</p>;
}

// Define a static property called displayName
Example.displayName = "Example";
  • For a class component in TypeScript, you can define a static property inside the class body, using the static keyword, and optionally provide a type annotation. For example:
class Example extends React.Component {
  // Define a static property called displayName with a string type
  static displayName: string = "Example";

  render() {
    return <p>This is an example component.</p>;
  }
}
  • For a function component in TypeScript, you can define a static property on the function itself, using the dot notation, and optionally provide a type annotation. You can also use a generic type parameter to specify the props type for the component. For example:
function Example<T>(props: T) {
  return <p>This is an example component.</p>;
}

// Define a static property called displayName with a string type
Example.displayName: string = "Example";

So let's create the Info.Skeleton

import { Skeleton } from '@/components/ui/skeleton';

export default function Info() {
  // ...
}

// Define a static property on the Info component called Skeleton and 
// assign a function component called InfoSkeleton
Info.Skeleton = function InfoSkeleton() {
  return (
    <Skeleton />
  )
}

Add the layout and structure inside the InfoSkeleton. Then when we check for the loading state, we render the Skeleton instead.

feat: add skeleton to Info component

Use the static property syntax to define a subcomponent called Skeleton on the Info component. The Skeleton component shows a placeholder preview of the content before it fully loads. This improves the user experience and reduces the perceived loading time.

"use client";

import React from 'react';
import Image from 'next/image';
import { useOrganization } from '@clerk/nextjs';
import { CreditCard } from 'lucide-react';

import { Skeleton } from '@/components/ui/skeleton';

export default function Info() {
  const { organization, isLoaded } = useOrganization();

  if (!isLoaded) {
    return (
      <Info.Skeleton />
    )
  }

  return (
    <div className='flex items-center gap-x-4'>
      {/* Image container */}
      <div className='relative w-[60px] h-[60px]'>
        <Image
          fill
          src={organization?.imageUrl ?? '/logo.svg'}
          alt="organization image"
          className='rounded-md object-cover'
          sizes="(max-width: 768px) 33vw, (max-width: 1200px) 30vw, 25vw"
        />
      </div>
      {/* Organization Info */}
      <div className='space-y-1'>
        <p className='font-semibold text-xl'>
          {organization?.name}
        </p>
        {/* Premium or Free info is dynamically rendered */}
        <div className='flex items-center text-xs text-muted-foreground'>
          <CreditCard />
          Free
        </div>
      </div>
    </div>
  )
}

/**
 * Shows a placeholder preview of the content before it fully loads. Improves
 * user experience by showing a layout & structure of the component content,
 * but withoout the details. Reduces the perceived loading time and keeps the
 * user engaged.
 * 
 * Uses React syntax to define a static property on the Info component and 
 * assign the function component which renders the skeleton.
 * @returns the skeleton of the Info component
 */
Info.Skeleton = function InfoSkeleton() {
  return (
    <div className='flex items-center gap-x-4'>
      {/* Image container */}
      <div className='relative w-[60px] h-[60px]'>
        <Skeleton className='absolute w-full h-full' />
      </div>
      {/* Organization Info */}
      <div className='space-y-2'>
        <Skeleton className='h-10 w-[200px]' />
        <div className='flex items-center'>
          <Skeleton className='h-4 w-4 mr-2' />
          <Skeleton className='h-4 w-[100px]' />
        </div>
      </div>
    </div>
  )
}

BoardList component

Before making the BoardList, we are going to make a BoardCreationButton. This will be a Button along with some text "Create new board" and text indicating how many free boards are left for the user. Also give it a hover effect and styling.

components\BoardCreationButton.tsx

import React from 'react';
import { Button } from '@/components/ui/button';

export default function BoardCreationButton() {
  return (
    <Button
      className='relative flex flex-col items-center h-full w-full rounded-sm aspect-video bg-muted gap-y-1 justify-center transition hover:opacity-75'
    >
      <p className='text-sm'>Create new board</p>
      <span className='text-xs'>
        15 remaining
      </span>
    </Button>
  )
}

Now import the BoardCreationButton inside BoardList.tsx.

Then inside add a board list header containing a header containg a user icon (UserRound icon from lucide-react) and text. Then after a div container for the responsive board of grids. Then render the BoardCreationButton inside the grid.

components\BoardList.tsx

import React from 'react';
import { UserRound } from 'lucide-react';
import BoardCreationButton from '@/components/BoardCreationButton';

export default function BoardList() {
  // Fetch boards here

  return (
    <div className='space-y-4'>
      {/* User icon header */}
      <div className='flex items-center text-lg text-neutral-700 font-semibold'>
        <UserRound className='h-6 w-6 mr-2' />
        Your boards
      </div>
      {/* Grid of boards */}
      <div className='grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4'>
        <BoardCreationButton />
      </div>

    </div>
  )
}

Issue: Hydration error when expected server HTML to contain a matching button

Unhandled Runtime Error
Error: Hydration failed because the initial UI does not match what was rendered on the server.

Warning: Expected server HTML to contain a matching <button> in <button>.

See more info here: https://nextjs.org/docs/messages/react-hydration-error

After debugging, the hydration issue seems to be with the BoardCreationButton.

components\BoardCreationButton.tsx

import React from 'react';
import { HelpCircle } from 'lucide-react';

import { Button } from '@/components/ui/button';
import BoardTooltip from '@/components/BoardTooltip';

const freeBoards = 15;

export default function BoardCreationButton() {
  return (
    <Button
      className='relative flex flex-col items-center h-full w-full rounded-sm aspect-video bg-muted gap-y-1 justify-center transition hover:opacity-75'
    >
      <p className='text-sm'>Create new board</p>
      <span className='text-xs'>
        {freeBoards} remaining
      </span>
      <BoardTooltip 
        sideOffset={40}
        description={`
          Free workspaces allow up to ${freeBoards} boards. 
          Upgrade this workspace to create unlimited boards.
        `}
      >
        <HelpCircle className='absolute bottom-2 right-2 h-[14px] w-[14px]'/>
      </BoardTooltip>
    </Button>
  )
}

Thhe error indicates that the initial UI rendered by the server does not match what was rendered on the client. This can cause problems with React's hydration process, which tries to attach event listeners and manage the state of the component.

One possible reason for this mismatch is that we are using the aspect-video class in Button component, which sets the padding-top property to 56.25%. This property is calculated based on the width of the parent element, which may be different on the server and the client.

Possible fixes:

To fix this error, we can try to use a fixed height for the Button component instead of relying on the aspect-video class. For example, we can use the h-40 class to set the height to 10rem. Alternatively, we can use a different way to achieve the aspect ratio we want, such as using an invisible image or a pseudo-element.

Fix: replace Button element with div with role of button
import React from 'react';
import { HelpCircle } from 'lucide-react';

import BoardTooltip from '@/components/BoardTooltip';

const freeBoards = 15;

export default function BoardCreationButton() {
  return (
    <div
      role='button'
      className='relative flex flex-col items-center h-full w-full rounded-sm aspect-video bg-muted gap-y-1 justify-center transition hover:opacity-75'
    >
      <p className='text-sm'>Create new board</p>
      <span className='text-xs'>
        {freeBoards} remaining
      </span>
      <BoardTooltip 
        sideOffset={40}
        description={`
          Free workspaces allow up to ${freeBoards} boards. 
          Upgrade this workspace to create unlimited boards.
        `}
      >
        <HelpCircle className='absolute bottom-2 right-2 h-[14px] w-[14px]'/>
      </BoardTooltip>
    </div>
  )
}

docs: Note button hydration error and its fix

  • Explain why Button component causes mismatch between server and client HTML
  • Replace Button component with div element with role='button' attribute
  • Prevent hydration error from React

fix: use div instead of Button component in BoardCreationButton

  • Replace Button component with div element with role='button' attribute
  • Avoid mismatch between server and client HTML caused by Button component rendering differently
  • Prevent hydration error from React

Board popover

Board tooltip

Now for the BoardCreationButton we added the hover effect because we want the user to hover over it and be able to see the hint or tooltip that gives the user more information on what it means to have boards remaining.

To do that we need to create another component BoardTooltip.

components\BoardTooltip.tsx

import React from 'react'

interface BoardTooltipProps {
  children: React.ReactNode;
};

export default function BoardTooltip({
  children,
}: BoardTooltipProps) {
  return (
    <div>
      {children}
    </div>
  )
}

Next install the Tooltip from shadcn/ui.

npx shadcn-ui@latest add tooltip

Now import and add the Tooltip component inside BoardTooltip.

import React from 'react'

import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip"

interface BoardTooltipProps {
  children: React.ReactNode;
};

export default function BoardTooltip({
  children,
}: BoardTooltipProps) {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger>{children}</TooltipTrigger>
        <TooltipContent>
          <p>Add to library</p>
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  )
}

We are going to need more props: {description, side, sideOffset}. The description is the content the tooltip will contain. The side will determine where the tooltip will be within the component, default at bottom. The sideOffset is the offset of the tooltip relative to the parent component, default at 0.

interface BoardTooltipProps {
  children: React.ReactNode;
  description: string;
  sideOffset?: number;
  side?: 'top' | 'right' | 'bottom' | 'left';
};

export default function BoardTooltip({
  children,
  description,
  sideOffset = 0,
  side = 'bottom',
}: BoardTooltipProps) {

feat: assign props & add styles to BoardTooltip

Create a reusable BoardTooltip component that accepts children, description, sideOffset, and side props. Use the Tooltip, TooltipContent, TooltipProvider, and TooltipTrigger components to implement the BoardTooltip component. The BoardTooltip component displays a tooltip with the given description on the specified side of the children element, with an optional sideOffset value.

Now we can add the BoardTooltip to the BoardCreationButton, and assign the right props.

feat: add BoardTooltip to BoardCreationButton component

Use the BoardTooltip component to display a tooltip with the number of remaining free boards and an upgrade option on the BoardCreationButton component. The tooltip appears on the bottom right corner of the button, with a 40px side offset. The tooltip uses the HelpCircle icon from lucide-react as a trigger.

import React from 'react';
import { HelpCircle } from 'lucide-react';

import { Button } from '@/components/ui/button';
import BoardTooltip from '@/components/BoardTooltip';

const freeBoards = 15;

export default function BoardCreationButton() {
  return (
    <Button
      className='relative flex flex-col items-center h-full w-full rounded-sm aspect-video bg-muted gap-y-1 justify-center transition hover:opacity-75'
    >
      <p className='text-sm'>Create new board</p>
      <span className='text-xs'>
        {freeBoards} remaining
      </span>
      <BoardTooltip 
        sideOffset={40}
        description={`
          Free workspaces allow up to ${freeBoards} boards. 
          Upgrade this workspace to create unlimited boards.
        `}
      >
        <HelpCircle className='absolute bottom-2 right-2 h-[14px] w-[14px]'/>
      </BoardTooltip>
    </Button>
  )
}

Form Popover

npx shadcn-ui@latest add popover

Now time to implement the Popover. Create FormPopover component inside /components/form, with the FormPropoverProps interface that contains children.

components\form\FormPopover.tsx

"use client";

import React from 'react';

interface FormPopoverProps {
  children: React.ReactNode;
}

export default function FormPopover({
  children,
}: FormPopoverProps) {
  return (
    <div>
      FormPopover
      {children}
    </div>
  )
}

Also add a align, side and sideOffset props. The default offset is 0 and side is bottom.

feat: Add more props to FormPopover component

Additional props to the FormPopover component are now available:

  • align: Specifies the alignment of the popover content (options: 'start', 'center', 'end').
  • sideOffset: Allows adjusting the distance between the popover and its target element.
  • side: Determines the side of the target element where the popover should appear (options: 'top', 'right', 'bottom', 'left').

These props enhance the flexibility and customization options for the FormPopover component.

interface FormPopoverProps {
  children: React.ReactNode;
  align?: 'start' | 'center' | 'end';
  sideOffset?: number;
  side?: 'top' | 'right' | 'bottom' | 'left';
};

export default function FormPopover({
  children,
  align,
  sideOffset = 0,
  side = 'bottom',
}: FormPopoverProps) {

Add the Popover imports and render them in the output.

  • return Popover
  • For PopoverTrigger
    • assign asChild prop
    • render children
  • PopoverContent
    • Assign props to align, side, sideOffset
    • Add className w-80 and pt-3 styles
    • Create a div inside
  • div will contain the text "Create board" with styles:
    • pb-4 font-medium text-sm text-center text-neutral-600

components\form\FormPopover.tsx

import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

interface FormPopoverProps {
  children: React.ReactNode;
  align?: 'start' | 'center' | 'end';
  sideOffset?: number;
  side?: 'top' | 'right' | 'bottom' | 'left';
};

export default function FormPopover({
  children,
  align,
  sideOffset = 0,
  side = 'bottom',
}: FormPopoverProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
      {children}
      </PopoverTrigger>
      <PopoverContent
        align={align}
        sideOffset={sideOffset}
        side={side}
        className='w-80 pt-3'
      >
        <div className='pb-4 font-medium text-sm text-center text-neutral-600'>
          Create board
        </div>
      </PopoverContent>
    </Popover>
  )
}

feat: add Popover functionality to FormPopover

This commit enhances the FormPopover component by incorporating Popover functionality. The following changes have been made:

  1. Added a Popover wrapper around the existing content.
  2. Utilized the PopoverTrigger component to trigger the popover display.
  3. Introduced the PopoverContent component with customizable props:
    • align: Determines the alignment of the popover content (options: 'start', 'center', 'end').
    • sideOffset: Allows adjusting the distance between the popover and its target element.
    • side: Specifies the side of the target element where the popover should appear (options: 'top', 'right', 'bottom', 'left').

This enhancement enhances the flexibility and user experience of the FormPopover component.

Use popover functionality

As we can see, we pass in the children to the PopoverTrigger. This means that we can wrap a component with the FormPopover component which modifies the functionality of the children to serve as a trigger.

Let's wrap the BoardCreationButton inside of BoardList:

components\BoardList.tsx

import FormPopover from '@/components/form/FormPopover';

export default function BoardList() {

  return (
    <div className='space-y-4'>
      <div className='flex items-center text-lg text-neutral-700 font-semibold'>
        <UserRound className='h-6 w-6 mr-2' />
        Your boards
      </div>
      <div className='grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4'>

        <FormPopover side='right' sideOffset={10}>
          <BoardCreationButton />
        </FormPopover>

      </div>

    </div>
  )
}

Now when we click the BoardCreationButton we should see a popover window to the right with the text "Create board".

Add close functionality to Popover component

One thing to add to to the current popover component is the ability to close it. We'd like to add a close button to the popover itself to make it behave like a window.

Let's look at the Popover component:

components\ui\popover.tsx

"use client"

import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

import { cn } from "@/lib/utils"

const Popover = PopoverPrimitive.Root

const PopoverTrigger = PopoverPrimitive.Trigger

const PopoverContent = React.forwardRef<
  React.ElementRef<typeof PopoverPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
  <PopoverPrimitive.Portal>
    <PopoverPrimitive.Content
      ref={ref}
      align={align}
      sideOffset={sideOffset}
      className={cn(
        "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        className
      )}
      {...props}
    />
  </PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName

export { Popover, PopoverTrigger, PopoverContent }

Let's add a PopoverClose and export it.

feat: extend Popover with close functionality

This commit enhances the Popover component by adding the PopoverClose component, allowing users to close the popover when needed.

"use client"

import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

import { cn } from "@/lib/utils"

const Popover = PopoverPrimitive.Root

const PopoverTrigger = PopoverPrimitive.Trigger

// Add close functionality to the Popover
const PopoverClose = PopoverPrimitive.Close

const PopoverContent = React.forwardRef<
  // ...
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName

export { Popover, PopoverTrigger, PopoverContent, PopoverClose }

Navigate back to FormPopover and import and render the PopoverClose component. Inside we can add a Button and an X icon.

feat: implement close feature for FormPopover

This commit enhances the FormPopover component by introducing the PopoverClose component, enabling users to easily close the popover. An 'X' icon button has also been included for improved user experience.

components\form\FormPopover.tsx

"use client";

import React from 'react';
import { X } from 'lucide-react';

import { Button } from '@/components/ui/button';
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

interface FormPopoverProps {
  children: React.ReactNode;
  align?: 'start' | 'center' | 'end';
  sideOffset?: number;
  side?: 'top' | 'right' | 'bottom' | 'left';
};

export default function FormPopover({
  children,
  align,
  sideOffset = 0,
  side = 'bottom',
}: FormPopoverProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
        {children}
      </PopoverTrigger>
      <PopoverContent
        align={align}
        sideOffset={sideOffset}
        side={side}
        className='w-80 pt-3'
      >
        <div className='pb-4 font-medium text-sm text-center text-neutral-600'>
          Create board
        </div>
        <PopoverClose asChild>
          <Button
            variant='destructive'
            className='absolute top-2 right-2 h-auto w-auto text-neutral-600'
          >
            <X className='h-4 w-4' />
          </Button>
        </PopoverClose>
      </PopoverContent>
    </Popover>
  )
}

Add form inputs to FormPopover

Now to make the popover into a FormPopover, we need to add the inputs, mainly the form element including the FormInput and FormSubmit components. Add these after the ending tag for PopoverClose.

feat: Add form to FormPopover component

This commit introduces a form within the FormPopover component. The form allows users to input a board title when creating a new board. The form includes a text input field for the title and a 'Create' button for submission.

"use client";

import FormInput from '@/components/form/FormInput';
import FormSubmitButton from '@/components/form/FormSubmitButton';

export default function FormPopover({
  // ...props
}: FormPopoverProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
        {children}
      </PopoverTrigger>
      <PopoverContent
        align={align}
        sideOffset={sideOffset}
        side={side}
        className='w-80 pt-3'
      >
        <div className='pb-4 font-medium text-sm text-center text-neutral-600'>
          Create board
        </div>
        <PopoverClose asChild>
          <Button
            variant='destructive'
            className='absolute top-2 right-2 h-auto w-auto text-neutral-600'
          >
            <X className='h-4 w-4' />
          </Button>
        </PopoverClose>
        <form className='space-y-4'>
          <div className='space-y-4'>
            <FormInput
              id='title'
              label='Board title'
              type='text'
            />
          </div>
          <FormSubmitButton 
            size='default'
            variant='default'
            className='w-full'
          >
            Create
          </FormSubmitButton>
        </form>
      </PopoverContent>
    </Popover>
  )
}

Now we can add the createBoard server action to the form.

  • Destructure executeServerAction and fieldErrors from useServerAction
  • Pass in createBoard as the first argument and the callback functions onSuccess and onError as the second argument to useServerAction
  • Create a submit handler function that takes formData, which extracts the title and invokes executeServerAction with it
  • Assign onSubmit handler to the form's action property
  • Assign fieldErrors to the FormInput's error property

components\form\FormPopover.tsx

"use client";

import React from 'react';
import { X } from 'lucide-react';

import { Button } from '@/components/ui/button';
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import FormInput from '@/components/form/FormInput';
import FormSubmitButton from '@/components/form/FormSubmitButton';

import { useServerAction } from '@/hooks/useServerAction';
import { createBoard } from "@/actions/createBoard/index";

interface FormPopoverProps {
  children: React.ReactNode;
  align?: 'start' | 'center' | 'end';
  sideOffset?: number;
  side?: 'top' | 'right' | 'bottom' | 'left';
};

export default function FormPopover({
  children,
  align,
  sideOffset = 0,
  side = 'bottom',
}: FormPopoverProps) {
  const { executeServerAction, fieldErrors } = useServerAction(createBoard, {
    onSuccess: (data) => { 
      console.log({ data }); 
    },
    onError: (error) => {
      console.log({ error });
    },
  });

  function onSubmit(formData: FormData){
    const title = formData.get('title') as string;

    executeServerAction({ title });
  }

  return (
    <Popover>
      <PopoverTrigger asChild>
        {children}
      </PopoverTrigger>
      <PopoverContent
        align={align}
        sideOffset={sideOffset}
        side={side}
        className='w-80 pt-3'
      >
        <div className='pb-4 font-medium text-sm text-center text-neutral-600'>
          Create board
        </div>
        <PopoverClose asChild>
          <Button
            variant='destructive'
            className='absolute top-2 right-2 h-auto w-auto text-neutral-600'
          >
            <X className='h-4 w-4' />
          </Button>
        </PopoverClose>
        <form action={onSubmit} className='space-y-4'>
          <div className='space-y-4'>
            <FormInput
              id='title'
              label='Board title'
              type='text'
              errors={fieldErrors}
            />
          </div>
          <FormSubmitButton 
            size='default'
            variant='default'
            className='w-full'
          >
            Create
          </FormSubmitButton>
        </form>
      </PopoverContent>
    </Popover>
  )
}

React toast component

React toast is a term that can refer to different packages or components that allow you to display notifications or messages to your users in a pop-up style. These notifications are often called toasts because they appear and disappear like a toast popping out of a toaster. Some examples of react toast packages are:

  • react-hot-toast: A lightweight and customizable package that supports RTL, swipe to close, emoji, animations, and more.
  • react-toastify: A popular package that offers easy setup, dark mode, pause on hover, promise API, and more.
  • sonner, an opinionated toast component with fluid animations for swiping and transitions.

Going to use sonner, an opinionated toast component for React. It has clean enter and exit animations. Here are the motivations to why sonner was made.

npm install sonner

Then render the toaster in the root of the app. For our Next.js 14 app router, it's inside the uppermost layout we need the toast notifications in. For some they may want it in the RootLayout (app\layout.tsx), but for this project we can put it inside the app\(app) layout.

Go ahead and import Toaster from sonner and render it right before the children inside ClerkProvider.

feat: add sonner toaster component to app layout

app\(app)\layout.tsx

import React from 'react';
import { ClerkProvider } from '@clerk/nextjs';
import { Toaster } from "sonner";

const AppLayout = ({
  children
}: {
  children: React.ReactNode;
}) => {
  return (
    <ClerkProvider>
      <Toaster />
      {children}
    </ClerkProvider>
  )
}

export default AppLayout

Now with this we can return to the FormPopover and add react toast notifications to the callback functions. Note that we can pass in error to toast.error() because we specify the type of there error that comes from the databse as a string.

Add toast notifications for createBoard action

This commit adds toast notifications for the createBoard action using the sonner library. The notifications show success or error within the callback functions.

import { toast } from 'sonner';

import { useServerAction } from '@/hooks/useServerAction';
import { createBoard } from "@/actions/createBoard/index";

export default function FormPopover({
  // ...
}: FormPopoverProps) {
  const { executeServerAction, fieldErrors } = useServerAction(createBoard, {
    onSuccess: (data) => { 
      console.log({ data });
      toast.success("Board created.")
    },
    onError: (error) => {
      console.log({ error });
      toast.error(error);
    },
  });

Now to test out the notifications for two scenarios:

  1. Create a board successfully
  2. Throw an error and display it successfully

We can simulate the error inside the createBoard server action.

actions\createBoard\index.ts

async function performAction (data: InputType): Promise<ReturnType> {
  // ...

  let board;

  try {
    // Add an error to test the toast notification
    throw new Error("Error_for_toast_notification");

    board = await database.board.create({
      data: {
        title,
      }
    });
  } catch(error) {
    return {
      error: "Internal error: failed to create in database."
    }
  }

With that we should see both toast notifications display on the bottom side of the page.

Board image selector

The next feature we'd want to focus on is to allow the user to pick an image for the Board. These images allow the users to customize their workspaces.

Unsplash Image API

Going to use Unsplash image API to gather images for the user to choose from. Go ahead and register as a developer at https://unsplash.com/developers

After registering, click "Your applications" and click "New Applications". Go ahead and checkbox all the guides and terms. Add app name and description, the create application.

Visionize

A kanban productivity app.

We are now in the page for "Apply for production". If you are going to make your application live then you can run this but for visionize, which is currently in development, we won't apply for production.

Add Unsplash API to environment variables

We can scroll down to find our Keys section which contains the:

  • Application ID
  • Access Key
  • Secret Key

Inside .env, create the environment variable: NEXT_PUBLIC_UNSPLASH_ACCESS_KEY and assign the Access Key to it.

Allow Next.js to expose the environment variable to the browser

Use NEXT_PUBLIC_UNSPLASH_ACCESS_KEY as the name of the environment variable.

The name of the environment variable is important here as it allows you to use the Unsplash API in your Next.js application without exposing your access key to the public.

The prefix NEXT_PUBLIC_ tells Next.js to expose the environment variable to the browser, where it can be used to fetch images from Unsplash. However, the access key is still hidden from the source code and the build output, as it is only injected at runtime

.env

<!-- ...environment variables and other sensitive data here -->

# Unsplash API
NEXT_PUBLIC_UNSPLASH_ACCESS_KEY="YOUR_ACCESS_KEY_HERE"

So if you named this environment variable anything different, you need the following fix.

fix: OAuth error, access token invalid w/ prefix

  • Fixes the OAuth error: access token invalid from Unsplash by telling Next.js to expose the environment variable to the browser
  • Use the NEXT_PUBLIC_ prefix to expose the access key to the browser
  • Hide the access key from the source code and the build output
  • Follow Unsplash API guidelines and security best practices

Install Unsplash API for JS

Let's install the official JS wrapper for Unsplash API.

npm i unsplash-js

Now what we need to do is create an instance of unsplash. Create the file unsplashAPI.ts in the /lib folder.

import { createApi } from 'unsplash-js';

/* Create API object from unsplash-js library, to access and manipulate photos
from https://unsplash.com
 */
export const unsplashApi = createApi({
  accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY,
  fetch: fetch,
});

/** 
 * 
 * @see https://github.com/unsplash/unsplash-js?tab=readme-ov-file#usage
*/
/* 

// on your node server
const serverApi = createApi({
  accessKey: 'MY_ACCESS_KEY',
  //...other fetch options
});

// in the browser
const browserApi = createApi({
  apiUrl: 'https://mywebsite.com/unsplash-proxy',
  //...other fetch options
});

*/

Issue: we get a type error under accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY.

Tried adding an apiUrl property but it still throws the error.

export const unsplashApi = createApi({
  accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY,
  fetch: fetch,
  apiUrl: 'https://api.unsplash.com',
});

So just added a ! and it fixed the issue.

fix: add non-null assertion operator access key

This commit adds a non-null assertion operator to the environment variable that stores the unsplash API access key. The operator excludes null and undefined from the type, which is useful for when we have knowledge that the TypeScript compiler lacks, such as the existence of an environment variable.

export const unsplashApi = createApi({
  accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY!,
  fetch: fetch,
});

This code works because it uses the non-null assertion operator (!) to tell TypeScript that the value of process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY is not null or undefined. This operator is a postfix expression that is used to exclude null and undefined from the type of a variable. It is useful when you have some knowledge that the TypeScript compiler lacks, such as the existence of an environment variable.

The non-null assertion operator is simply removed in the emitted JavaScript code, so it has no runtime effect. However, it can help you avoid type errors and unnecessary checks when you are confident that a value is not nullish.

FormSelector component

Create client component FormSelector.tsx in /components/form.

It will contain a prop interface that contains an id, and errors which will contain the fieldErrors from useServerAction.ts

feat: add prop interface to FormSelector

Define the FormSelectorProps interface with id and errors properties and use it as the prop type for the FormSelector component. This improves the type safety and readability of the code.

"use client";

import React from 'react';

interface FormSelectorProps {
  id: string;
  errors?: Record<string, string[] | undefined>;
};

export default function FormSelector({
  id,
  errors,
}: FormSelectorProps) {
  return (
    <div>FormSelector</div>
  )
}

With that we can import and use FormSelector inside FormPopover. Render it inside the form and above the FormInput.

import FormSelector from '@/components/form/FormSelector';

export default function FormPopover({
  // ...
}: FormPopoverProps) {
  const { executeServerAction, fieldErrors } = useServerAction(createBoard, {
    // ...
  });

  return (
    <Popover>
      <PopoverTrigger asChild>
        {children}
      </PopoverTrigger>
      <PopoverContent
        align={align}
        sideOffset={sideOffset}
        side={side}
        className='w-80 pt-3'
      >
        <div className='pb-4 font-medium text-sm text-center text-neutral-600'>
          Create board
        </div>
        <PopoverClose asChild>
          <Button
            variant='destructive'
            className='absolute top-2 right-2 h-auto w-auto text-neutral-600'
          >
            <X className='h-4 w-4' />
          </Button>
        </PopoverClose>

        <form action={onSubmit} className='space-y-4'>
          <div className='space-y-4'>

            <FormSelector 
              id='image'
              errors={fieldErrors}
            />

            <FormInput
              id='title'
              label='Board title'
              type='text'
              errors={fieldErrors}
            />
          </div>
          <FormSubmitButton 
            size='default'
            variant='default'
            className='w-full'
          >
            Create
          </FormSubmitButton>
        </form>

      </PopoverContent>
    </Popover>
  )
}

Now develop the FormSelector.

Start with the imports:

import React, { useEffect, useState } from 'react';
import { unsplashApi } from '@/lib/unsplashAPI';

Next what we want to attempt is to a set of images from a collection. Will use photos.getRandom() method from Unsplash.

  • create images state variable which is type Array<Record<string, any>>>
  • Inside a useEffect hook create an arrow function which contains a fetchImages async function
  • fetchImages opens up a try..catch where it uses unsplashApi to get 9 random photos from collection 317099.
  • Set the images to the result of the fetch's response property, if it exists
  • Otherwise, print an error to the console for failing to fetch images
  • catch any errors, reset the images array in these situations

The collectionId we will use is 317099 which leads to the Unsplash Editorial - 317099 a collection with the theme "The Road Less Traveled".

  1. It features photos of landscapes, nature and adventure from around the world
  2. Curated by the Unsplash Editorial team
  3. Wallpaper compatible photos, which fits the resolution and/or aspect ratio that we need to fit our boards
"use client";

import React, { useEffect, useState } from 'react';
import { unsplashApi } from '@/lib/unsplashAPI';

interface FormSelectorProps {
  id: string;
  errors?: Record<string, string[] | undefined>;
};

export default function FormSelector({
  id,
  errors,
}: FormSelectorProps) {
  const [images, setImages] = useState<Array<Record<string, any>>>([]);

  const selectionCount: number = 9;

  // Use useEffect hook to fetch images on component mount
  useEffect(() => {
    // Fetch images from collection 317099, curated by Unsplash Editorial
    const fetchImages = async () => {
      try {
        const result = await unsplashApi.photos.getRandom({
          collectionIds: ["317099"],
          count: selectionCount,
        });
        if (result && result.response) {
          const imageData = (result.response as Array<Record<string, any>>);
          setImages(imageData);
        } else {
          console.error("Failed to fetch images from Unsplash.")
        }

      } catch(error) {
        console.log(error);
        // Reset images array
        setImages([]);
      }
    }

    fetchImages();
  }, []);

  return (
    <div>FormSelector</div>
  )
}

feat: integrate Unsplash API with FormSelector

  • Use unsplashApi to fetch random images from collection 317099
  • Use useEffect hook to fetch images on component mount
  • Use useState hook to store images in local state
  • Display images in FormSelector component

Next add a isLoading state, true by default and set to false in the finally block. Then render a Loading2 from lucide-react with a spin animation to indicate to the user that the image fetch is still underway.

Add loading state & parameterize selectionCount

  • Add isLoading state to show a loader while fetching images from Unsplash API
  • Add selectionCount variable to control the number of images to fetch
  • Use selectionCount as a parameter in unsplashApi.photos.getRandom method
  • Handle errors and loading state in fetchImages function
"use client";

import React, { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';

import { unsplashApi } from '@/lib/unsplashAPI';

export default function FormSelector({
  // ...
}: FormSelectorProps) {
  const [images, setImages] = useState<Array<Record<string, any>>>([]);

  // Add isLoading state, true by default because fetch starts immediately
  const [isLoading, setIsLoading] = useState(true);

  const selectionCount: number = 9;

  useEffect(() => {
    const fetchImages = async () => {
      try {
        // Fetch images from collection 317099, curated by Unsplash Editorial
        // ...
      } catch(error) {
        console.log(error);
        setImages([]);
      } finally {
        setIsLoading(false);
      }
    };
    
    fetchImages();
  }, []);

  if (isLoading) {
    return (
      <div className='flex items-center justify-center p-6'>
        <Loader2 className='h-6 w-6 text-sky-700 animate-spin' />
      </div>
    )
  }

  return (
    <div>FormSelector</div>
  )
}

FormSelector output

For the output we want to display a grid of images we fetched from Unsplash and allow users to select one of them. Before we work on the return get two variables

  • pending from useFormStatus
  • selectedImageId state variable
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';

import { unsplashApi } from '@/lib/unsplashAPI';

export default function FormSelector({
  // ...
}: FormSelectorProps) {
  const { pending } = useFormStatus();
  
  const [images, setImages] = useState<Array<Record<string, any>>>([]);
  const [isLoading, setIsLoading] = useState(true);
  
  const [selectedImageId, setSelectedImageId] = useState(null);

Next create a div that contains a grid which will store the images. The images will be mapped to a div with an Image inside. The div will have the key set to image.id, an onClick that does returns early if pending state of form is true, otherwise it sets the selectedImageId.

feat: add image selection to FormSelector

  • Use useFormStatus hook to get the pending state of the form
  • Use useState hook to store images, isLoading, and selectedImageId in local state
  • Display images in a grid using Image and Loader2 components
  • Add onClick handler to select an image and update selectedImageId state
  • Use cn utility function to apply conditional class names based on the pending state

components\form\FormSelector.tsx

"use client";

import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';

import { unsplashApi } from '@/lib/unsplashAPI';
import { cn } from '@/lib/utils';

interface FormSelectorProps {
  id: string;
  errors?: Record<string, string[] | undefined>;
};

export default function FormSelector({
  id,
  errors,
}: FormSelectorProps) {
  // Use useFormStatus hook to get the pending state of the form
  const { pending } = useFormStatus();

  // Define images state variable as an array of objects
  const [images, setImages] = useState<Array<Record<string, any>>>([]);

  // Add isLoading state, true by default because fetch starts immediately
  const [isLoading, setIsLoading] = useState(true);

  // Define a state for storing the selected image
  const [selectedImageId, setSelectedImageId] = useState(null);

  // Selection count determines how many images to fetch
  const selectionCount: number = 9;

  // Use useEffect hook to fetch images on component mount
  useEffect(() => {
    const fetchImages = async () => {
      try {
        // Use unsplashApi to get random photos from collection 317099
        const result = await unsplashApi.photos.getRandom({
          collectionIds: ["317099"],
          count: selectionCount,
        });
        
        if (result && result.response) {
          // Cast result.response as an array of objects & assign it to imageData
          const imageData = (result.response as Array<Record<string, any>>);
          setImages(imageData);
        } else {
          console.error("Failed to fetch images from Unsplash.")
        }

      } catch(error) {
        console.log(error);
        // Reset images state to an empty array
        setImages([]);
      } finally {
        setIsLoading(false);
      }
    };

    fetchImages();
  }, []);

  // Return a loader component when isLoading state is true
  if (isLoading) {
    return (
      <div className='flex items-center justify-center p-6'>
        <Loader2 className='h-6 w-6 text-sky-700 animate-spin' />
      </div>
    )
  }

  // Return a div element with a grid of images
  return (
    <div className='relative'>
      <div className="grid grid-cols-3 gap-2 mb-2">
        {images.map((image) => (
          <div 
            key={image.id}
            onClick={() => {
              // Check if the form is pending and return early if true
              if (pending) {
                return;
              }
              setSelectedImageId(image.id)
            }}
            // Use cn function to apply conditional class names based on the pending state
            className={cn(
              'relative aspect-video bg-muted cursor-pointer group transition hover:opacity-75',
              pending && 'cursor-auto opacity-50 hover:opacity-50'
            )}
          >
            <Image
              src={image.urls.thumb} 
              alt="Image from Unsplash"
              className='object-cover rounded-sm'
              fill
            />
          </div>
        ))}
      </div>
    </div>
  )
}

So far:

feat: add FormSelector component with Unsplash API integration and image selection

  • Use unsplashApi to fetch random images from collection 317099
  • Use useEffect hook to fetch images on component mount
  • Use useState hook to store images, isLoading, and selectedImageId in local state
  • Use useFormStatus hook to get the pending state of the form
  • Display images in a grid using Image and Loader2 components
  • Add onClick handler to select an image and update selectedImageId state
  • Use cn utility function to apply conditional class names based on the pending state
Issue: Unhandled Runtime Error, Invalid src prop "images.unsplash.com" is not configred under images in your next.config.js

Add unsplash to remotePatterns in next.config.js

  • Resolve the Unhandled Runtime Error caused by invalid src prop for Next.js Image component
  • Allow Next.js Image component to optimize images from Unsplash
  • Add a new object with protocol and hostname properties to the remotePatterns array

Add another remote pattern to fix the error:

next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'img.clerk.com',
      },
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
    ],
  },
}

module.exports = nextConfig

Issue: Unsplash API requests are limited to 50 an hour, too little for development

To fix this issue we want to create a constant that stores 9 images that we can use as a fallback for when we run out of requests. We can create a constants folder.

constants folder

A constants folder in a Next.js project is a folder that contains files with constant values that can be used throughout the application. Constants are variables that have a fixed value and do not change during the execution of the program. They can be useful for storing configuration options, API keys, URLs, colors, themes, and other data that are not expected to change.

There is no official or standard way to create a constants folder in a Next.js project, but one common approach is to create a constants folder inside the src folder, which is an optional folder that can be used to organize the application source code. Inside the constants folder, one can create different files for different categories of constants, such as api.ts, colors.ts, routes.ts, etc. Each file can export one or more constants using the export keyword.

Using a constants folder can help to keep the code more organized, maintainable, and consistent. It can also make it easier to change the values of the constants in one place, without having to update them in multiple files. However, it is not a mandatory or enforced convention, and some developers may prefer to use other ways to manage their constants, such as environment variables, custom configuration files, or inline values. Ultimately, the choice of how to structure and use constants in a Next.js project depends on the preferences and needs of the developer and the project.

Use images constants as fallback
  • Create /constants/images.ts at the root of the project.
// An array of default images to use as a fallback
export const defaultImages = [];

We can get the default images from Unsplash like this:

  1. Open up the project and try to "create new board".
  2. When the FormSelector component loads, open up the developer tools (in Chrome, developer tools is [F12] key), and switch to Network tab
  3. In the filters (e.g., All, Fetch/XHR, Doc, CSS, JS, Font, Img, Media, Manifest), click "All".
  4. Then we can see in another pane the "Name", "Status", "Initiator", "Size", "Time" and "Waterfall". We want to find the API request whose "Name" starts with "random?"
  5. The API request is a fetch with the name starting with "random?count". Click it and go to the Response tab, and we can see the images in JSON format.
  6. Highlight the entire Response object and paste it into /constants/images.ts

Now we have a set of default images.

Use default images as fallback to image fetch error

Navigate back to FormSelector and we can display the defaultImages as the initial state of the images and also set it during the catch.

feat: add defaultImages as fallback to img fetch

  • Import defaultImages from /constants/images.ts
  • Set images state to defaultImages initially
  • Use defaultImages as fallback in case of fetch error

components\form\FormSelector.tsx

import { defaultImages } from '@/constants/images';

export default function FormSelector({
  id,
  errors,
}: FormSelectorProps) {
  const { pending } = useFormStatus();

  // Define images state variable as an array of objects
  const [images, setImages] = useState<Array<Record<string, any>>>(
    defaultImages
  );

  const [isLoading, setIsLoading] = useState(true);

  const [selectedImageId, setSelectedImageId] = useState(null);

  const selectionCount: number = 9;

  useEffect(() => {
    const fetchImages = async () => {
      try {
        const result = await unsplashApi.photos.getRandom({
          collectionIds: ["317099"],
          count: selectionCount,
        });
        
        if (result && result.response) {
          const imageData = (result.response as Array<Record<string, any>>);
          setImages(imageData);
        } else {
          console.error("Failed to fetch images from Unsplash.")
        }

      } catch(error) {
        console.log(error);
        // Use the images constant as fallback in case of fetch error
        setImages(defaultImages);
      } finally {
        setIsLoading(false);
      }
    };

    fetchImages();
  }, []);

Adhere to Unsplash API guidelines

According to Unsplash API guidelines, we need to hotlink to the original created.

Unlike most APIs, we require the image URLs returned by the API to be directly used or embedded in your applications (generally referred to as hotlinking).

So let's create a container at the bottom of every image to attribute to hotlink to the creators.

  • import Link from next/link
  • Add target='_blank' to open a new tab
  • add styles that indicate a black bar with white text
  • the group hover style will allow it to be visible when the parent contain div is also hovered over by the user

feat: add hotlink & image attribution

  • Import Link component from next/link
  • Add Link element with image.links.html as href and target='_blank'
  • Style the Link element with CSS classes and hover effects
  • Follow the Unsplash API guidelines on hotlinking and attribution

components\form\FormSelector.tsx

import Link from 'next/link';

export default function FormSelector({
  id,
  errors,
}: FormSelectorProps) {
  // ...
  return (
    <div className='relative'>
      <div className="grid grid-cols-3 gap-2 mb-2">
        {images.map((image) => (
          <div 
            key={image.id}
            onClick={() => {
              // Check if the form is pending and return early if true
              if (pending) {
                return;
              }
              setSelectedImageId(image.id)
            }}
            // Use cn function to apply conditional class names based on the pending state
            className={cn(
              'relative aspect-video bg-muted cursor-pointer group transition hover:opacity-75',
              pending && 'cursor-auto opacity-50 hover:opacity-50'
            )}
          >
            <Image
              src={image.urls.thumb} 
              alt="Image from Unsplash"
              className='object-cover rounded-sm'
              fill
            />
            <Link 
              href={image.links.html}
              target='_blank'
              className='absolute w-full bottom-0 p-1 bg-black/70 text-white text-[10px] truncate hover:underline opacity-0 group-hover:opacity-100'
            >
              {image.user.name}
            </Link>
          </div>
        ))}
      </div>
    </div>
  )

By adhering to Unsplash API guidelines, we can later apply for production to increates our rate limits (requests/hour).

Display to user the selected image with checkmark overlay

To display to the user that they selected an image, render a checkmark in the middle of the image. We check for the selectedImageId and if the current image.id are equal then render the overlay. Use Check from lucide-react.

feat: Display selected image to the user

Add functionality to show the selected image with a checkmark overlay.

"use client";

import Image from 'next/image';
import Link from 'next/link';

import { Check, Loader2 } from 'lucide-react';

export default function FormSelector({
  // ...
}: FormSelectorProps) {
  // ...
  return (
    <div className='relative'>
      <div className="grid grid-cols-3 gap-2 mb-2">
        {images.map((image) => (
          <div
            key={image.id}
            onClick={() => {
              if (pending) {
                return;
              }
              setSelectedImageId(image.id)
            }}
            className={cn(
              'relative aspect-video bg-muted cursor-pointer group transition hover:opacity-75',
              pending && 'cursor-auto opacity-50 hover:opacity-50'
            )}
          >
            <Image
              src={image.urls.thumb}
              alt="Image from Unsplash"
              className='object-cover rounded-sm'
              fill
            />
            {selectedImageId === image.id && (
              <div className='absolute flex items-center justify-center h-full w-full inset-y-0 bg-black/30'>
                <Check className='h-4 w-4 text-white' />
              </div>
            )}
            <Link
              href={image.links.html}
              target='_blank'
              className='absolute w-full bottom-0 p-1 bg-black/70 text-white text-[10px] truncate hover:underline opacity-0 group-hover:opacity-100'
            >
              {image.user.name}
            </Link>
          </div>
        ))}
      </div>
    </div>
  )
}

Display errors in FormSelector

The reason why we had the errors prop was to be able to display some form validation for the FormInput when user defines the board title. We want the error message to be right above the input and below the grid of images. We can re-use the FormErrors component to display this.

feat: Add FormErrors to display validation errors

import FormErrors from '@/components/form/FormErrors';

export default function FormSelector({
  // ...
}: FormSelectorProps) {
  // ...
  return (
    <div className='relative'>
      <div className="grid grid-cols-3 gap-2 mb-2">
        { /* ... */}
      </div>
      <FormErrors 
        id='image'
        errors={errors}
      />
    </div>
  )
}

Trigger an action when an image is selected in FormSelector

The next goal is to be able to trigger or activate an action when the user selects an image in FormSelector. One way to do this is to use a hidden native HTML input element with the type set to radio. The value however will be in a certain format so that we can parse it for later. How the value is determined is based on the network request when we fetch the images. If we look into the response, or we can check the /constants/images.ts we can see an example object:

constants\images.ts

export const defaultImages = [
  {
      "id": "trYGJ2qpwp0",
      "slug": "a-couple-of-boats-floating-on-top-of-a-lake-trYGJ2qpwp0",
      "created_at": "2023-03-23T15:58:09Z",
      "updated_at": "2024-02-26T06:04:04Z",
      "promoted_at": "2023-03-26T07:08:01Z",
      "width": 5981,
      "height": 3987,
      "color": "#c07340",
      "blur_hash": "LeJ=+6}rR+W;^PxFbGfkxZs.s.oe",
      "description": null,
      "alt_description": "a couple of boats floating on top of a lake",
      "breadcrumbs": [],
      "urls": {
          "raw": "...",
          "full": "...",
          "regular": "...",
          "small": "...",
          "thumb": "...",
          "small_s3": "..."
      },
      "links": {
          "self": "...",
          "html": "...",
          "download": "...",
          "download_location": "..."
      },
      "user": {
          "id": "4ajb4CE1HEI",
          "updated_at": "2024-01-31T22:39:33Z",
          "username": "gunderandson",
          "name": "Luna Berry",
          "first_name": "Luna",
          "last_name": "Berry",

We can see that an image object has a urls property where we can extract an object that contains the urls for certain sized images: { raw, full, regular, small, thumb, small_s3 }. We also have a user object that contains their name.

The following information we'd like to extract are:

  • image.id
  • image.urls.thumb
  • image.urls.full
  • image.links.html
  • image.user.name

We will pass this information down to value prop of the input, delimited by an |.

value={`${image.id}|${image.urls.thumb}|${image.urls.full}|${image.links.html}|${image.user.name}`}

Now for the hidden radio input inside thhe FormSelector, render this above the Image.

feat: Add hidden input for passing image data

Include a hidden input field to store image details (ID, URLs, and photographer name).

export default function FormSelector({
  id,
  errors,
}: FormSelectorProps) {
  // ...
 
  return (
    <div className='relative'>
      <div className="grid grid-cols-3 gap-2 mb-2">
        {images.map((image) => (
          <div
            key={image.id}
            onClick={() => {
              if (pending) {
                return;
              }
              setSelectedImageId(image.id)
            }}
            className={cn(
              'relative aspect-video bg-muted cursor-pointer group transition hover:opacity-75',
              pending && 'cursor-auto opacity-50 hover:opacity-50'
            )}
          >
            <input 
              type='radio'
              id={id}
              name={id}
              checked={selectedImageId === image.id}
              disabled={pending}
              className='hidden'
              value={`${image.id}|${image.urls.thumb}|${image.urls.full}|${image.links.html}|${image.user.name}`}
            />

            <Image
              src={image.urls.thumb}
              alt="Image from Unsplash"
              className='object-cover rounded-sm'
              fill
            />
            {selectedImageId === image.id && (
              <div className='absolute flex items-center justify-center h-full w-full inset-y-0 bg-black/30'>
                <Check className='h-4 w-4 text-white' />
              </div>
            )}
            <Link
              href={image.links.html}
              target='_blank'
              className='absolute w-full bottom-0 p-1 bg-black/70 text-white text-[10px] truncate hover:underline opacity-0 group-hover:opacity-100'
            >
              {image.user.name}
            </Link>
          </div>
        ))}
      </div>
      <FormErrors 
        id='image'
        errors={errors}
      />
    </div>
  )
}

Pass image data to server action in FormPopover

Now when the user fills out the form by selecting an image, filling out the board title in the form input then the app has access to the id of the image inside of the FormData.

We can extract the image data inside the onSubmit handler of the FormPopover.

feat: Extract image data from FormData

This commit improves the FormPopover component by extracting the 'title' and 'image' fields from the FormData object. It then uses these fields to execute a server action.

components\form\FormPopover.tsx

export default function FormPopover({
  children,
  align,
  sideOffset = 0,
  side = 'bottom',
}: FormPopoverProps) {
  const { executeServerAction, fieldErrors } = useServerAction(createBoard, {
    onSuccess: (data) => { 
      console.log({ data });
      toast.success("Board created.")
    },
    onError: (error) => {
      console.log({ error });
      toast.error(error);
    },
  });

  function onSubmit(formData: FormData){
    const title = formData.get('title') as string;
    const image = formData.get('image') as string;

    executeServerAction({ title, image });
  }

If we add a log statement inside onSubmit and print out the image variable we get an object that contains the actual value, with the id|urls.thumb|urls.full|links.html|user.name. Just as we assigned to value of the hidden radio input element from earlier:

value={`${image.id}|${image.urls.thumb}|${image.urls.full}|${image.links.html}|${image.user.name}`}

The example log statement:

{image: 'someImageId|https://images.unsplash...|Luna Berry'}

We can follow how the execution of this works:

  1. In FormPopover, assign "image" to id prop of FormSelector
export default function FormPopover({
  // ...
}: FormPopoverProps) {

  return (
    // ...
        <form action={onSubmit} className='space-y-4'>
          <div className='space-y-4'>
            <FormSelector 
              id='image'
              errors={fieldErrors}
            />
  1. Inside FormSelector, we pass the id prop data to the id and name of the hidden input
export default function FormSelector({
  id,
  errors,
}: FormSelectorProps) {
  // ...
 
  return (
    // ...
            <input 
              type='radio'
              id={id}
              name={id}
              checked={selectedImageId === image.id}
              disabled={pending}
              className='hidden'
              value={`${image.id}|${image.urls.thumb}|${image.urls.full}|${image.links.html}|${image.user.name}`}
            />
  1. The hidden input is a radio button which is checked when the user clicks an image and sets the selectedImageId

Update Board data model with new fields

Update schema to accept image data

Recall the values we can get from the image data:

value={`${image.id}|${image.urls.thumb}|${image.urls.full}|${image.links.html}|${image.user.name}`}

We can add the additional fields to the Board model.

Navigate to schema.prisma and add orgId, imageId, imageThumbUrl, imageFullUrl, imageUserName, imageLinkHTML, createdAt, updatedAt.

feat: Update Board model with new fields

model Board {
  id            String    @id @default(uuid())
  orgId         String
  title         String
  imageId       String
  imageThumbUrl String    @db.Text
  imageFullUrl  String    @db.Text
  imageUserName String    @db.Text
  imageLinkHTML String    @db.Text
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

Reset the development database

You can also reset the database yourself to undo manual changes or db push experiments by running:

npx prisma migrate reset

Warning: migrate reset is a development command and should never be used in a production environment.

This command:

  1. Drops the database/schema¹ if possible, or performs a soft reset if the environment does not allow deleting databases/schemas¹
  2. Creates a new database/schema¹ with the same name if the database/schema¹ was dropped
  3. Applies all migrations
  4. Runs seed scripts ¹ For MySQL and MongoDB this refers to the database, for PostgreSQL and SQL Server to the schema, and for SQLite to the database file.

Generate Prisma Client with prisma schema changes

docs: reset database & regenerate Prisma client

Here is what we want to do:

  • Reset the database in development (because we created Boards using the old model without the new fields)
npx prisma migrate reset
  • Push the new schema in the MySQL database
npx prisma db push
  • Generate Prisma Client
npx prisma generate

Validate image data

feat: Add image property to CreateBoard schema

This commit adds the image property to the CreateBoard object schema, which is used to validate the input for creating a new board. The image property is a string that is required and has no minimum length. The commit also adds custom error messages for the image property, similar to the title property.

actions\createBoard\createBoardSchema.ts

import { z } from 'zod';

/**
 * Define the CreateBoard object schema.
 * 
 * Add custom error messages for: required fields, 
 * invalid type and minimum length.
 */
export const CreateBoard = z.object({
  title: z.string({
    required_error: "Title is required", 
    invalid_type_error: "Title is required", 
  }).min(3, {
    message: "Must be 3 or more characters long.", 
  }),
  image: z.string({
    required_error: "Image is required",
    invalid_type_error: "Image is required",
  }),
});

Process image data in createBoard action

Navigate to /createBoard/index.ts, then process the image data and add error checking.

feat: Enhance image handling and error checking

  • Extract orgId in auth() and return an error if missing
  • Destructure image from data
  • Split image data by "|" and destructure values into an array
  • Return an error if any image field is missing

actions\createBoard\index.ts

async function performAction (data: InputType): Promise<ReturnType> {
  const { userId, orgId } = auth();

  if (!userId || !orgId) {
    return {
      error: 'Unauthorized',
    }
  }

  const { title, image } = data;

  const [
    imageId,
    imageThumbUrl,
    imageFullUrl,
    imageLinkHTML,
    imageUserName,
  ] = image.split("|");

  if (!imageId || !imageThumbUrl || !imageFullUrl 
  || !imageLinkHTML || !imageUserName) {
    return {
      error: 'Failed to create board. A field is missing.'
    };
  }

Now create the board in the database with the new fields

feat: createBoard in database w/ new image fields

async function performAction (data: InputType): Promise<ReturnType> {
  const { userId, orgId } = auth();

  if (!userId || !orgId) {
    return {
      error: 'Unauthorized',
    }
  }

  const { title, image } = data;

  const [
    imageId,
    imageThumbUrl,
    imageFullUrl,
    imageLinkHTML,
    imageUserName,
  ] = image.split("|");

  if (!imageId || !imageThumbUrl || !imageFullUrl 
  || !imageLinkHTML || !imageUserName) {
    return {
      error: 'Failed to create board. A field is missing.'
    };
  }

  let board;

  // Try to create a new board in the database
  try {
    board = await database.board.create({
      data: {
        title,
        orgId,
        imageId,
        imageThumbUrl,
        imageFullUrl,
        imageUserName,
        imageLinkHTML,
      }
    });
  } catch(error) {
    return {
      error: "Internal error: failed to create in database."
    }
  }

Now in order to test the code we can add a log statement right after we create the array containing the data. We will print it out as an object to make it easier to see a missing property.

  const [
    imageId,
    imageThumbUrl,
    imageFullUrl,
    imageLinkHTML,
    imageUserName,
  ] = image.split("|");

  // Print image data to the console
  console.log({
    imageId,
    imageThumbUrl,
    imageFullUrl,
    imageLinkHTML,
    imageUserName,
  });

When we print the data out to the console, we should that none of the properties are undefined.

The toast message should display a successful message of "Board created." which is found in the FormPopover.

Prisma Studio to view data in the database

Prisma Studio

After successfully creating a new board with the new image fields, we can view our data using the visual editor Prisma Studio.

npx prisma studio

Before adding the next feature (to close the FormPopover after successful board creation) we are going to have to dive deep on Ref. To skip the ref section, go here: Close FormPopover.

Ref

When you want a component to "remember" some information, but you don't want that information to trigger new renders, you can use a ref.

You can access the current value of that ref through the ref.current property. This value is intentionally mutable, meaning you can both read and write to it. It's like a secret pocket of your component that React doesn't track. (This is what makes it an "escape hatch" from React's one-way data flow).

Like state, refs are retained by React between re-renders. However, setting state re-renders a component. Changing a ref does not!

Ref Recap
  • Refs are an escape hatch to hold onto values that aren't used for rendering. You won't need them often.
  • A ref is a plain JavaScript object with a single property called current, which you can read or set.
  • You can ask React to give you a ref by calling the useRef Hook.
  • Like state, refs let you retain information between re-renders of a component.
  • Unlike state, setting the ref's current value does not trigger a re-render.
  • Don't read or write ref.current during rendering. This makes your component hard to predict.
Differences between refs and state

Perhaps you're thinking refs seem less "strict" than state -- you can mutate them instead of always having to use a state setting function, for instance. But in most cases, you'll want to use state. Refs are an "escape hatch" you won't need often. Here's how state and refs compare:

refs state
useRef(initialValue) returns { current: initialValue } useState(initialValue) returns the current value of a state variable and a state setter function ( [value, setValue])
Doesn't trigger re-render when you change it. Triggers re-render when you change it.
Mutable -- you can modify and update current's value outside of the rendering process. "Immutable" -- you must use the state setting function to modify state variables to queue a re-render.
You shouldn't read (or write) the current value during rendering. You can read state at any time. However, each render has its own snapshot of state which does not change.
Refs and the DOM

You can point a ref to any value. However, the most common use case for a ref is to access a DOM element. For example, this is handy if you want to focus an input programmatically. When you pass a ref to a ref attribute in JSX, like <div ref={myRef}>, React will put the corresponding DOM element into myRef.current. Once the element is removed from the DOM, React will update myRef.current to be null. You can read more about this in Manipulating the DOM with Refs.

Best practices for refs

Following these principles will make your components more predictable:

  • Treat refs as an escape hatch. Refs are useful when you work with external systems or browser APIs. If much of your application logic and data flow relies on refs, you might want to rethink your approach.

  • Don't read or write ref.current during rendering. If some information is needed during rendering, use state instead. Since React doesn't know when ref.current changes, even reading it while rendering makes your component's behavior difficult to predict. (The only exception to this is code like if (!ref.current) ref.current = new Thing() which only sets the ref once during the first render.)

Limitations of React state don't apply to refs. For example, state acts like a snapshot for every render and doesn't update synchronously. But when you mutate the current value of a ref, it changes immediately:

ref.current = 5;
console.log(ref.current); // 5

This is because the ref itself is a regular JavaScript object, and so it behaves like one.

You also don't need to worry about avoiding mutation when you work with a ref. As long as the object you're mutating isn't used for rendering, React doesn't care what you do with the ref or its contents.

Close FormPopover after a successful board creation

  • QoL in technology means allowing something to be done more intuitively/easier when it is already being done.

An extra QoL change to make is to close the FormPopover component automatically after a successful board creation.

To do that we can use the React hook: useRef.

  • A ref is an object that provides a way to reference a DOM node or a React component instance.
  • It allows you to access and interact with the underlying DOM elements directly.

Now to add the automatic close behavior, we want to close the FormPopover within the onSuccess callback function. We can add a ref to the button on the PopoverClose.

feat: automatically close popover on board creation

This commit enhances the user experience by automatically closing the FormPopover upon successful board creation. The following changes have been implemented:

  • Import ElementRef and useRef from React
  • Create closeRef a ref to the button that will close the FormPopover
  • Call the closeRef.current.?click() in the success callback function
  • Assign closeRef to the PopoverClose component

components\form\FormPopover.tsx

import React, { ElementRef, useRef } from 'react';

export default function FormPopover({
  //  ...props
}: FormPopoverProps) {
  
  const closeRef = useRef<ElementRef<"button">>(null);

  const { executeServerAction, fieldErrors } = useServerAction(createBoard, {
    onSuccess: (data) => { 
      toast.success("Board created.")
      closeRef.current?.click();
    },
    onError: (error) => {
      console.log({ error });
      toast.error(error);
    },
  });

  return (
    <Popover>
      <PopoverTrigger asChild>
        {children}
      </PopoverTrigger>
      <PopoverContent
        align={align}
        sideOffset={sideOffset}
        side={side}
        className='w-80 pt-3'
      >
        <div className='pb-4 font-medium text-sm text-center text-neutral-600'>
          Create board
        </div>
        <PopoverClose ref={closeRef} asChild>
          <Button
            variant='destructive'
            className='absolute top-2 right-2 h-auto w-auto text-neutral-600'
          >
            <X className='h-4 w-4' />
          </Button>
        </PopoverClose>

Now test the new close functionality.

  1. Click the BoardCreationButton
  2. Click an image for the board and fill out the title
  3. Click the "Create" button
  • A toast notification for successful board creation should appear and the Popover should close shortly after

Route user to new board page after successful Board creation

Another feature to add to the FormPopover is to perform client-side navigation to the board page after successful creation.

feat: Navigate to newly created board page

import { useRouter } from 'next/navigation';

export default function FormPopover({
  // ...props
}: FormPopoverProps) {
  const router = useRouter();

  const closeRef = useRef<ElementRef<"button">>(null);

  const { executeServerAction, fieldErrors } = useServerAction(createBoard, {
    onSuccess: (data) => { 
      toast.success("Board created.")
      closeRef.current?.click();
      router.push(`/board/${data.id}`);
    },

Link up FormPopover component with create button in Navbar

With FormPopover complete let's use it to wrap the create button from the Navbar.

feat: Wrap create button with popover in Navbar

app\(app)\(dashboard)\_components\Navbar.tsx

import FormPopover from '@/components/form/FormPopover';

export const Navbar = () => {
  return (
    <nav className='flex items-center fixed px-4 z-10 top-0 w-full h-14 border-b shadow-sm bg-white'>
      <MobileSidebar />
      <div className='flex items-center gap-x-4'>
        {/* For screens 768px and larger  */}
        <div className='hidden md:flex'>
          <Logo />
        </div>
        <FormPopover align='start' side='bottom' sideOffset={18}>
          <Button
            variant='primary'
            size='sm'
            className='rounded-sm py-1.5 px-2 h-auto'
          >
            <span className='hidden md:block'>Create</span>
            <Plus className='block md:pl-1 h-4 w-4' />
          </Button>
        </FormPopover>
      </div>

BoardList component revisited

Navigate to BoardList.tsx and import the database from /lib. Fetch the boards from the database using orgId. Then map out each board as a Link which uses imageThumbUrl as the background image and has an href to the board.id.

feat: Fetch & map out boards in BoardList

  • Import database from '/lib' which uses either an existing a global Prisma Client instance or a new instance
  • Fetch the boards from the database using orgId
  • Map out each board as a Link

components\BoardList.tsx

import React from 'react';
import Link from 'next/link';
import { auth } from '@clerk/nextjs';
import { redirect } from 'next/navigation';
import { UserRound } from 'lucide-react';

import { database } from '@/lib/database';
import BoardCreationButton from '@/components/BoardCreationButton';
import FormPopover from '@/components/form/FormPopover';

export default async function BoardList() {
  const { orgId } = auth();

  if (!orgId) {
    return redirect('/org-selection');
  }

  const boards = await database.board.findMany({
    where: {
      orgId,
    },
    orderBy: {
      createdAt: 'desc',
    }
  });

  return (
    <div className='space-y-4'>
      {/* User icon header */}
      <div className='flex items-center text-lg text-neutral-700 font-semibold'>
        <UserRound className='h-6 w-6 mr-2' />
        Your boards
      </div>
      {/* Grid of boards */}
      <div className='grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4'>
        {boards.map((board) => (
          <Link
            key={board.id}
            href={`/board/${board.id}`}
            style={{ backgroundImage: `url(${board.imageThumbUrl})` }}
          >
            <p>
              {board.title}
            </p>
          </Link>
        ))}
        <FormPopover side='right' sideOffset={10}>
          <BoardCreationButton />
        </FormPopover>
      </div>

    </div>
  )
}
  • Inside the Link add a void element div right before the paragraph, with the styles 'absolute inset-0 bg-black/30 group-hover:bg-black/40 transition'. Then give the p a 'relative font-semibold text-white' Finally the Link should have the className of 'group relative h-full w-full p-2 aspect-video bg-no-repeat bg-center bg-cover bg-sky-700 rounded-sm overflow-hidden'.

Now when we hover on a board we have a darken effect, clicking on one will redirect to the specific board ID page.

style: Add darken effect to board thumbnails

In the BoardList component, each board thumbnail is now displayed with a dark overlay on hover. The background image (imageThumbUrl) remains visible, but the overlay enhances readability of the board title.

// ...
export default async function BoardList() {
  // ...
  return (
    <div className='space-y-4'>
      {/* User icon header */}
      {/* Grid of boards */}
      <div className='grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4'>
        {boards.map((board) => (
          <Link
            key={board.id}
            href={`/board/${board.id}`}
            className='group relative h-full w-full p-2 aspect-video bg-no-repeat bg-center bg-cover bg-sky-700 rounded-sm overflow-hidden'
            style={{ backgroundImage: `url(${board.imageThumbUrl})` }}
          >
            <div
              className='absolute inset-0 bg-black/30 group-hover:bg-black/40 transition'
            />
            <p className="relative font-semibold text-white">
              {board.title}
            </p>
          </Link>
        ))}
        <FormPopover side='right' sideOffset={10}>
          <BoardCreationButton />
        </FormPopover>
      </div>

    </div>
  )
}

Handle loading state in BoardList

feat: Create BoardList skeleton placeholder

This commit adds a skeleton placeholder for the BoardList component. The placeholder emulates a grid of boards with approximately 8 individual board placeholders. Each board is represented by a Skeleton component.

import { Skeleton } from '@/components/ui/skeleton';

export default async function BoardList() {
  // ...
}

BoardList.Skeleton = function BoardListSkeleton() {
  return (
    <div className='grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4'>
      <Skeleton className='h-full w-full p-2 aspect-video' />
      <Skeleton className='h-full w-full p-2 aspect-video' />
      <Skeleton className='h-full w-full p-2 aspect-video' />
      <Skeleton className='h-full w-full p-2 aspect-video' />
      <Skeleton className='h-full w-full p-2 aspect-video' />
      <Skeleton className='h-full w-full p-2 aspect-video' />
      <Skeleton className='h-full w-full p-2 aspect-video' />
      <Skeleton className='h-full w-full p-2 aspect-video' />
    </div>
  );
}

Using React Suspense to display a fallback while content is loading

Next we navigate to where the BoardList will be rendered (i.e., the org ID page).

And here we will use Suspense from React.

Wrap the BoardList in the org ID page with the BoardList.Skeleton.

feat: Suspend BoardList in Org ID page

In the OrganizationIdPage, the BoardList component is now wrapped in a Suspense fallback. When loading, it displays a skeleton placeholder to enhance user experience.

app\(app)\(dashboard)\org\[orgId]\page.tsx

import React, { Suspense } from 'react';

import BoardList from '@/components/BoardList';
import Info from '@/components/Info';
import { Separator } from '@/components/ui/separator';

const OrganizationIdPage = () => {

  return (
    <div className='flex flex-col w-full mb-20'>
      <Info />
      <Separator className='my-4' />
      <div className='px-2 md:px-4'>
        <Suspense fallback={<BoardList.Skeleton />}>
          <BoardList />
        </Suspense>
      </div>
    </div>
  );
};

export default OrganizationIdPage

In this example, the BoardList component suspends while fetching the list of boards for the organization. Until it's ready to render, React switches the closest Suspense boundary above to show the fallback - the BoardList.Skeleton component. Then, when the data loads, React hides the BoardList.Skeleton fallback and renders the BoardList component with data.

Now when we can test the loading of the BoardList,

  • Refresh an organization page
  • Switch between organizations using the Sidebar

We should be able to see the placeholder preview of the content in these situations.

Dynamic Metadata

Dynamic metadata depends on dynamic information, such as the current route parameters, external data, or metadata in parent segments, can be set by exporting a generateMetadata function that returns a Metadata object.

Recall that our RootLayout had this:

import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

import { siteConfig } from '@/config/site'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: {
    default: siteConfig.name,
    template: `%s | ${siteConfig.name}`,
  },
  description: siteConfig.description,
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  )
}

Let's break down the metadata object in the RootLayout component in Next.js:

  1. metadata Object:

    • This object contains metadata information for the layout.
    • It affects how the page title and description appear in search engine results, browser tabs, and social media previews.
  2. title Property:

    • The title property specifies the page title.
    • It has two sub-properties:
      • default: Represents the default title (likely the site name).
      • template: A template string where %s is a placeholder for dynamic content (e.g., specific page titles).
  3. description Property:

    • The description property provides a brief description of the page.
    • It may be used by search engines or social media platforms to display additional context about the page.
  4. siteConfig:

    • The siteConfig object contains global configuration settings for the site (e.g., site name, description).

In summary, this metadata object helps manage page titles and descriptions dynamically based on the site configuration and specific page content. It's essential for SEO and user experience.

Generate Dynamic Metadata for Organization ID Layout

The goal here is to use the organization name to create the metadata object.

First let's make a function which ensures that the organization name is in a user-friendly letter-case.

We will go with "Start case", where the first letter of each word is capitalized.

Here is the function startCase:

/**
 * Takes a string (usually in camel case, snake case, or kebab case) 
 * and returns a new string where each word starts with an uppercase 
 * letter, and words are separated by spaces.
 * @param input the input string to convert to
 * @returns the input string in startCase form
 */
function startCase(input: string): string {
  // Convert the input string to lowercase
  const lowerCaseInput = input.toLowerCase();

  // Split the string into words, using regex
  // (/\s+/ matches one or more whitespace characters)
  const words = lowerCaseInput.split(/\s+/);

  // Capitalize the first letter of each word
  const startCasedWords = words.map((word) => word.charAt(0).
      toUpperCase() + word.slice(1)
  );

  // Join the words back together with spaces
  return startCasedWords.join(' ');
}

Now we can use the generateMetadata to call return the start case of orgSlug (we get from clerk auth object), which is the current user's active organization slug. Or a fallback "your group" in case there orgSlug is falsy.

feat: Generate metadata for OrganizationIdLayout

In the OrganizationIdLayout component, metadata is dynamically generated for the page title based on the organization slug. The startCase function ensures that the title is in proper case.

app\(app)\(dashboard)\org\[orgId]\layout.tsx

import React from 'react';
import { auth } from '@clerk/nextjs';

import URLMatcher from './_components/URLMatcher';

function startCase(input: string): string {
  const lowerCaseInput = input.toLowerCase();
  const words = lowerCaseInput.split(/\s+/);
  const startCasedWords = words.map((word) => word.charAt(0).
      toUpperCase() + word.slice(1)
  );

  return startCasedWords.join(' ');
}

export async function generateMetadata() {
  const { orgSlug } = auth();

  return {
    title: startCase(orgSlug || 'your group'),
  }
}

export default function OrganizationIdLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <>
      <URLMatcher />
      {children}
    </>
  )
}

Let's follow the execution: generateMetadata() returns an object with title property. Now back in the RootLayout we have the global metadata, where we defined the template:

export const metadata: Metadata = {
  title: {
    default: siteConfig.name,
    template: `%s | ${siteConfig.name}`,
  },
  description: siteConfig.description,
}

When we return a title using generateMetadata in anywhere else besides the root layout, it passes in the new title and is interpolated in the variable %s while appending the | ${siteConfigName}. So the output would be: "Organization Name | Visionize".

Board Page

Create the BoardIdPage.

app\(app)\(dashboard)\board\[boardId]\page.tsx

import React from 'react';

export default function BoardIdPage() {
  return (
    <div>
      BoardIdPage
    </div>
  )
}

BoardIdLayout

Create BoardIdLayout which accepts children

app\(app)\(dashboard)\board\[boardId]\layout.tsx

import React from 'react';

export default function BoardIdLayout({
  children
}: {
  children: React.ReactNode;
}) {
  return (
    <div>
      {children}
    </div>
  )
}

Now let's modify the layout a bit, wrap children in a main tag and move it so it is visible (not hidden behind the navbar). Then also add params prop. Get orgId, and fetch board in database.

feat: Retrieve ID & fetch board in BoardIdLayout

This commit introduces the BoardIdLayout component, which retrieves the organization ID (orgId) and fetches a board from the database based on the provided boardId. The component handles redirection to '/org-selection' if orgId is not available.

import React from 'react';
import { redirect } from 'next/navigation';
import { auth } from '@clerk/nextjs';

import { database } from '@/lib/database';

export default async function BoardIdLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: { boardId: string; }
}) {
  const { orgId } = auth();

  if (!orgId) {
    redirect('/org-selection');
  }

  const board = await database.board.findUnique({
    where: { 
      id: params.boardId,
      orgId,
    },
  });

  return (
    <div>
      <main className='relative h-full pt-28'>
        {children}
      </main>
    </div>
  )
}

Nextjs notFound can gracefully handle 404 errors

If the there is no board, we can use notFound from next/navigation to manually trigger a 404.

The notFound function allows you to render the not-found file within a route segment as well as inject a <meta name="robots" content="noindex" /> tag.

Invoking the notFound() function throws a NEXT_NOT_FOUND error and terminates rendering of the route segment in which it was thrown. Specifying a not-found file allows you to gracefully handle such errors by rendering a Not Found UI within the segment.

  • Good to know: notFound() does not require you to use return notFound() due to using the TypeScript never type.

Let's invoke notFound() when there is no board found from the fetch.

feat: Handle board not found error in layout

This commit adds error handling to the BoardIdLayout component. If a board with the specified boardId is not found in the database, the notFound() function is invoked. Additionally, redirection to '/org-selection' occurs if orgId is missing.

app\(app)\(dashboard)\board\[boardId]\layout.tsx

import React from 'react';
import { notFound, redirect } from 'next/navigation';
import { auth } from '@clerk/nextjs';

import { database } from '@/lib/database';

export default async function BoardIdLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: { boardId: string; }
}) {
  const { orgId } = auth();

  if (!orgId) {
    redirect('/org-selection');
  }

  const board = await database.board.findUnique({
    where: { 
      id: params.boardId,
      orgId,
    },
  });

  if (!board) {
    notFound();
  }

  return (
    <div>
      <main className='relative h-full pt-28'>
        {children}
      </main>
    </div>
  )
}
  • Create and style the not found page.

In /app, create a file named not-found.tsx.

feat: Implement custom 404 page (not-found.tsx)

/app/not-found.tsx

import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="flex flex-col items-center justify-center h-screen">
      <h1 className="text-4xl font-bold mb-4">Not Found - 404!</h1>
      <p className="text-gray-600">
        Oops! The page you&apos;re looking for doesn&apos;t exist.
      </p>
      <Link className="mt-4 text-blue-500 hover:underline" href="/">
        Return Home
      </Link>
    </div>
  );
}

BoardIdLayout output

Finally use the full board.imageFullUrl as the backdrop for every specific Board page.

feat: Set full background image for Board layout

export default async function BoardIdLayout(
  // ...
) {
  // ...
  return (
    <div
      className='relative h-full bg-cover bg-center bg-no-repeat'
      style={{ backgroundImage: `url(${board.imageFullUrl})` }}
    >
      <main className='relative h-full pt-28'>
        {children}
      </main>
    </div>
  )
}

Create dynamic metadata for each individual board.

feat: Implement metadata generation in each board

import { database } from '@/lib/database';

export async function generateMetadata({
  params
}: {
  params: { boardId: string; };
}) {
  const { orgId } = auth();

  if (!orgId) {
    return { title: 'Board' };
  }

  const board = await database.board.findUnique({
    where: { 
      id: params.boardId,
      orgId,
    },
  });

  return {
    title: board?.title || 'My Board',
  };
}

export default async function BoardIdLayout({

BoardNavbar component

Create BoardNavbar that accepts a data prop which is a type of Board.

app\(app)\(dashboard)\board\[boardId]\_components\BoardNavbar.tsx

import React from 'react';
import { Board } from '@prisma/client';

interface BoardNavbarProps {
  data: Board;
}

export default async function BoardNavbar({
  data
}: BoardNavbarProps) {

  return (
    <div>BoardNavbar</div>
  )
}

Then import and render it inside the BoardIdLayout, right above the main tag. Also pass in the board to the data prop.

feat: import and render BoardNavbar in BoardIdLayout

refactor: Pass board data to BoardNavbar as prop

In BoardIdLayout component, fetch board data and provide it as a prop to BoardNavbar. This change ensures that the necessary data is available for rendering the component.

import BoardNavbar from './_components/BoardNavbar';
// ...
export default async function BoardIdLayout(
  // ...
) {
  // ...
  const board = await database.board.findUnique({
    where: { 
      id: params.boardId,
      orgId,
    },
  });

  return (
    <div
      className='relative h-full bg-cover bg-center bg-no-repeat'
      style={{ backgroundImage: `url(${board.imageFullUrl})` }}
    >
      <BoardNavbar data={board} />
      <main className='relative h-full pt-28'>
        {children}
      </main>
    </div>
  )
}

feat: Add dark overlay to improve contrast

  • Introduced an absolute overlay with a semi-transparent black background.
  • Enhances readability and visual contrast.
import BoardNavbar from './_components/BoardNavbar';
// ...
export default async function BoardIdLayout(
  // ...
) {
  // ...
  return (
    <div
      className='relative h-full bg-cover bg-center bg-no-repeat'
      style={{ backgroundImage: `url(${board.imageFullUrl})` }}
    >
      <BoardNavbar data={board} />
      <div className='absolute inset-0 bg-black/20' />
      <main className='relative h-full pt-28'>
        {children}
      </main>
    </div>
  )
}

Back to BoardNavbar, let's add some styles that give it slightly dark overlay with white text.

style: Enhance visual presentation of BoardNavbar

In the BoardNavbar component, adjust styling for improved visual output. Now displays a fixed, transparent black background with white text for better contrast and readability.

export default async function BoardNavbar({
  data
}: BoardNavbarProps) {

  return (
    <div className='flex items-center fixed h-14 w-full top-14 z-[30] bg-black/50 px-6 gap-x-4 text-white'>
      BoardNavbar
    </div>
  )
}

BoardTitleForm component

The BoardTitleForm component will display the title of the board and also allow the user to update the title.

feat: Create BoardTitleForm to render board title

  • Mark as client component
  • Accept data prop of type Board
  • Interpolate the board title inside a Button

app\(app)\(dashboard)\board\[boardId]\_components\BoardTitleForm.tsx

"use client";

import React from 'react';
import { Board } from '@prisma/client';

import { Button } from '@/components/ui/button';

interface BoardTitleFormProps {
  data: Board;
};

export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {
  return (
    <Button>
      {data.title}
    </Button>
  )
}

feat: Use BoardTitleForm component in BoardNavbar

import BoardTitleForm from './BoardTitleForm';

interface BoardNavbarProps {
  data: Board;
}

export default async function BoardNavbar({
  data
}: BoardNavbarProps) {

  return (
    <div>
      <BoardTitleForm data={data} />
    </div>
  )
}

feat: Enhance BoardTitleForm to accurately reflect functionality

In the BoardTitleForm component, ensure that the button accurately represents its intended functionality. Styling adjustments have been made to align with the expected behavior.

feat: Add transparent variant to Button component

The new transparent variant includes a see-through background with white text. When hovered over, it subtly transitions to a semi-transparent white background, signaling user interactivity.

components\ui\button.tsx

const buttonVariants = cva(
  // ...
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline:
          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
        primary: "bg-sky-500 hover:bg-sky-600/90 text-primary-foreground",
        transparent: "bg-transparent text-white hover:bg-white/20",
      },
      // ...
    },
    // ...
  }
)

export interface ButtonProps
  // ...
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(

style: Add transparent button to signal user action

In the BoardTitleForm component, add a transparent button to display the board title. The button's styling includes auto height, auto width, padding, bold font, and a larger text size. Additionally, it is set to the transparent variant, which provides a subtle white background on hover to indicate user interaction.

export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {
  return (
    <Button
      variant='transparent'
      className='h-auto w-auto p-1 px-2 font-bold text-lg'
    >
      {data.title}
    </Button>
  );
}
Develop BoardTitleForm

Next, we add a isEditing state to the BoardTitleForm. Add two functions that disable and enable the editing state. And a check that if isEditing is true, return a form.

feat: Add isEditing state to BoardTitleForm

This commit introduces the isEditing state to the BoardTitleForm component. The state is used to track whether the user is currently editing the board title. When editing is disabled, the form is displayed, allowing users to modify the title.

The disableEditing function sets the isEditing state to false, while the enableEditing function sets it to true.

This change enhances the user experience by providing a dynamic editing feature for board titles.

"use client";

import React, { useState } from 'react';

export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {
  const [isEditing, setIsEditing] = useState(false);

  
  function disableEditing() {
    setIsEditing(false);
  }
  
  function enableEditing() {
    setIsEditing(true);
  }

  if (isEditing) {
    return (
      <form>
      </form>
    )
  }

  return (
    // ...
  );
}

Inside the form render a FormInput component while passing props to id, defaultValue, onBlur and className.

feat: Render FormInput component when editing

export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {
  const [isEditing, setIsEditing] = useState(false);

  if (isEditing) {
    return (
      <form className='flex items-center gap-x-2'>
        <FormInput 
          id='title'
          defaultValue={data.title}
          onBlur={() => {}}
          className=''
        />
      </form>
    )
  }
  // ...

style: Enhance visual appearance of FormInput

Refines the styling of the FormInput component within the form. The provided CSS classes ensure a transparent background, appropriate height, font weight, and focus behavior.

  if (isEditing) {
    return (
      <form className='flex items-center gap-x-2'>
        <FormInput 
          id='title'
          defaultValue={data.title}
          onBlur={() => {}}
          className='bg-transparent h-7 px-[7px] py-1 border-none text-lg font-bold focus-visible:outline-none focus-visible:ring-transparent'
        />
      </form>
    )
useRef to reference form and input DOM elements

Create formRef and inputRef, and assign them to the form and FormInput elements respectively. This allows direct interaction with the form and input elements.

feat: Add formRef and inputRef in BoardTitleForm

import React, { ElementRef, useRef, useState } from 'react';

export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {
  const formRef = useRef<ElementRef<"form">>(null);
  const inputRef = useRef<ElementRef<"input">>(null);
  // ...

  if (isEditing) {
    return (
      <form ref={formRef} className='flex items-center gap-x-2'>
        <FormInput
          ref={inputRef}
          id='title'
          defaultValue={data.title}
          onBlur={() => {}}
          className='bg-transparent h-7 px-[7px] py-1 border-none text-lg font-bold focus-visible:outline-none focus-visible:ring-transparent'
        />
      </form>
    )
  }

We use refs to reference specific DOM elements within the React component. Here's a breakdown:

  1. formRef:

    • This ref is associated with the <form> element.
    • By setting ref={formRef} on the <form> tag, you create a reference to the actual DOM node representing the form.
    • This allows you to access and manipulate the form directly (e.g., programmatically triggering form submission or focusing on form elements).
  2. inputRef:

    • This ref is associated with the <FormInput> component (assuming it's a custom component).
    • By setting ref={inputRef} on the <FormInput> component, you create a reference to the underlying input element (e.g., an <input> or <textarea>).
    • This allows you to interact with the input element directly (e.g., programmatically setting its value or focusing on it).
  3. Use Cases:

    • Form Validation: You can use formRef to access form properties (e.g., formRef.current.submit()) or validate form data.
    • Input Manipulation: With inputRef, you can focus on the input field (inputRef.current.focus()) or retrieve its current value (inputRef.current.value).
    • Custom Logic: Refs are often used for custom logic, such as handling user interactions or integrating with third-party libraries.

Remember that using refs should be done judiciously, as direct manipulation of the DOM can sometimes lead to less predictable behavior in React applications. However, in cases like form handling or integrating with external libraries, refs can be quite useful!

With this in place we can work on the feature to enabled editing.

Enable editing in BoardTitleForm

Now we can use the inputRef.current to invoke focus() and select() inside the enableEditing function. Let's wrap this in a setTimeout. Then assign the function to the onClick of the Button.

feat: Enable editing mode with focus & selection

When invoked, the enableEditing function sets the isEditing state to true. Within a timeout callback:

  1. It focuses on the input field, ensuring the cursor is inside for editing (inputRef.current?.focus()).
  2. It selects the entire input value, making it easy for the user to modify the existing title (inputRef.current?.select()).

The function is assigned to the onClick prop of the Button in the output.

export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {
  const formRef = useRef<ElementRef<"form">>(null);
  const inputRef = useRef<ElementRef<"input">>(null);

  const [isEditing, setIsEditing] = useState(false);
  
  /**
   * Enables editing mode and focus input.
   */
  function enableEditing() {
    setIsEditing(true);
    setTimeout(() => {
      inputRef.current?.focus();
      inputRef.current?.select();
    })
  }

  return (
    <Button
      onClick={enableEditing}
      variant='transparent'
      className='h-auto w-auto p-1 px-2 font-bold text-lg'
    >
      {data.title}
    </Button>
  );
}

Let's break down what enableEditing does:

  1. When the user triggers an action (such as clicking the button or interacting with a specific UI element), the enableEditing function is called.

  2. Inside the enableEditing function:

    • It sets the isEditing state to true, indicating that the form is now in edit mode.
    • After setting isEditing to true, it schedules a timeout using setTimeout.
    • Within the timeout callback:
      • It focuses on the input field (inputRef.current?.focus()), ensuring that the cursor is placed inside the input for immediate editing.
      • It also selects the entire input value (inputRef.current?.select()), making it convenient for the user to modify the existing title.

In summary, enableEditing prepares the form for user input by setting the isEditing state to true and focusing on the input field. This allows users to edit the board title effectively.

  1. On Render
  • If isEditing is true it renders a form with the <FormInput> component
  • Otherwise, it rendersa transparent button displaying the data.title

Now when we click on the BoardTitleForm on the page, it switches the component from a Button that displays the board title to an input type which enables editing mode.

Add form submission in BoardTitleForm

feat: Allow users to submit title edits in BoardNavbar

This commit enables users to edit and submit board titles directly from the BoardNavbar component. When editing, users can modify the title and trigger the submission process.

Create the onSubmit function with parameter formData and logs the title. Assign the onSubmit function to the form's action.

feat: Add form submission handling in board page

This commit introduces the onSubmit function, which receives formData as a parameter and logs the title. The function is assigned to the action attribute of the form. When editing the board title, users can submit the form by pressing the Enter key, triggering the onSubmit logic.

export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {
  const formRef = useRef<ElementRef<"form">>(null);
  const inputRef = useRef<ElementRef<"input">>(null);

  const [isEditing, setIsEditing] = useState(false);
  
  function onSubmit(formData: FormData) {
    const title = formData.get('title') as string;
    console.log(`Submitted: ${title}`);
  }

  if (isEditing) {
    return (
      <form action={onSubmit} ref={formRef} className='flex items-center gap-x-2'>
        <FormInput
          ref={inputRef}
          id='title'
          defaultValue={data.title}
          onBlur={() => {}}
          className='bg-transparent h-7 px-[7px] py-1 border-none text-lg font-bold focus-visible:outline-none focus-visible:ring-transparent'
        />
      </form>
    )
  }
  return (
    <Button
      onClick={enableEditing}
      variant='transparent'
      className='h-auto w-auto p-1 px-2 font-bold text-lg'
    >
      {data.title}
    </Button>
  );
}

With this implemented the user can click on the BoardTitleForm.

  • When clicked this enables editing mode which changes the element from a Button to a form
  • User can update the title of the board
  • After a title is finished, user can press the Enter key to submit the form.
  • The updated title is now displayed
BoardTitleForm feature: submit form on blur

feat: Submit the form on blur in BoardTitleForm

  • Implement form submission when input loses focus
export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {
  const formRef = useRef<ElementRef<"form">>(null);
  const inputRef = useRef<ElementRef<"input">>(null);

  // ...
  
  function onSubmit(formData: FormData) {
    const title = formData.get('title') as string;
    console.log(`Submitted: ${title}`);
  }

  function onBlur() {
    formRef.current?.requestSubmit();
  }

  if (isEditing) {
    return (
      <form 
        action={onSubmit} 
        ref={formRef} 
        className='flex items-center gap-x-2'
      >
        <FormInput
          ref={inputRef}
          id='title'
          defaultValue={data.title}
          onBlur={onBlur}
          className='bg-transparent h-7 px-[7px] py-1 border-none text-lg font-bold focus-visible:outline-none focus-visible:ring-transparent'
        />
      </form>
    )
  }

  return (
    <Button
      onClick={enableEditing}
      variant='transparent'
      className='h-auto w-auto p-1 px-2 font-bold text-lg'
    >
      {data.title}
    </Button>
  );
}

This improves the user experience by saving the title and submitting the form for the user when the input loses focus (e.g., if they click elsewhere after in editing mode).

Modify Input component for a more fluid experience

style: Modify input for a smoother user experience

  • Change focus-visible:ring-offset-2 to focus-visible:ring-offset-0
  • Update rounded-md to rounded-sm

UpdateBoard server action

In /actions create a folder /updateBoard then create the files:

  • updateBoardSchema.ts
  • updateBoardTypes.ts
  • index.ts

UpdateBoard schema

Let's define the schema:

It will have to validate two properties:

  • title
  • id

feat: Define UpdateBoard schema validation

actions\updateBoard\updateBoardSchema.ts

import { z } from 'zod';

/**
 * Define the UpdateBoard object schema.
 * 
 */
export const UpdateBoard = z.object({
  title: z.string({
    required_error: "Title is required", 
    invalid_type_error: "Title is required", 
  }).min(3, {
    message: "Must be 3 or more characters long.", 
  }),
  id: z.string(),
});

UpdateBoard types

Similarly to createBoardTypes.ts, we do the following:

  • Imports:
    • zod
    • Board from prisma/client
    • ActionState from createServerAction
      • ActionState is used to encapsulate the state of various actions (e.g., fetching data, submitting forms, etc.). It provides a structured way to handle errors and manage data flow.
      • It has the optional properties: fieldErrors, error and data
    • UpdateBoard schema validation rules
  • We use ActionState to represent the state of an API request, or in this case a server action where:
  • InputType - the request payload
  • OutputType - the response data

feat: Add UpdateBoard action schema

This commit introduces the UpdateBoard action schema, which defines the input and output types for server actions related to board updates. The schema is based on Zod validation and includes types for both input data and the resulting board state.

actions\updateBoard\updateBoardTypes.ts

import { z } from 'zod';

// Import Board, the expected output type, from Prisma client
import { Board } from '@prisma/client';

// Encapsulate the state of various actions (e.g., fetching data, submitting forms, etc.)
// Provides a structured way to handlee errors and manage data flow
import { ActionState } from '@/lib/createServerAction';

// Import the UpdateBoard schema (validation rules)
import { UpdateBoard } from './updateBoardSchema';

// Define the input type based on the UpdateBoard schema
export type InputType = z.infer<typeof UpdateBoard>;

// Define the output data type (ActionState) with Board
export type OutputType = ActionState<InputType, Board>;

UpdateBoard action

  • "use server"
  • import types
  • define performAction handler with input and output types defined

actions\updateBoard\index.ts

"use server";

import { InputType, OutputType } from "./updateBoardTypes";

async function performAction (data: InputType): Promise<OutputType> {
  // ...
}

feat: Implement update board as server action

"use server";
import { auth } from "@clerk/nextjs";
import { revalidatePath } from "next/cache";

import { createServerAction } from "@/lib/createServerAction";
import { database } from "@/lib/database";

import { UpdateBoard } from "./updateBoardSchema";
import { InputType, OutputType } from "./updateBoardTypes";

// Define an asynchronous function to perform the action
async function performAction (data: InputType): Promise<OutputType> {
  // Extract user and organization IDs from authentication
  const { userId, orgId } = auth();
// Check if user or organization IDs are missing
  if (!userId || !orgId) {
    return {
      error: 'Unauthorized',
    };
  }

  // Extract data properties
  const { title, id } = data;

  let board;

  try {
    // Update the board title in the database
    board = await database.board.update({
      where: {
        id,
        orgId,
      },
      data: {
        title,
      },
    });
  } catch (error) {
    return {
      error: 'Failed to update board.'
    }
  }

  // Revalidate the path for caching purposes
  revalidatePath(`/board/${id}`);

  // Return the updated board
  return {
    data: board
  };
}

// Create a server action using the defined function
export const updateBoard = createServerAction(UpdateBoard, performAction);

Use UpdateBoard action in BoardTitleForm

feat: updateBoard with useServerAction hook

import { useServerAction } from '@/hooks/useServerAction';
import { updateBoard } from '@/actions/updateBoard';
import { toast } from 'sonner';

export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {

  const { executeServerAction } = useServerAction(updateBoard, {
    onSuccess: (data) => {
      toast.success(`Board "${data.title} updated!`);
      disableEditing();
    }
  });

  const [isEditing, setIsEditing] = useState(false);
  
  function disableEditing() {
    setIsEditing(false);
  }
  
  function onSubmit(formData: FormData) {
    const title = formData.get('title') as string;
    console.log(`Submitted: ${title}`);
    executeServerAction({
      id: data.id,
      title,
    });
  }

  // ...
}

Optimistic State

Let's delve into optimistic updates in React with TypeScript.

Optimistic updates are a technique used to enhance user experience by making UI interactions feel more responsive. When an action (such as submitting a form or making an API request) is initiated, the UI is immediately updated with the expected outcome, even before the actual operation completes. This approach gives users the impression of speed and responsiveness.

In the context of React applications, optimistic updates can be achieved using various strategies. One common approach is to save a data property in state while an asynchronous action (like a network request) is underway. Here's how it works:

  1. Initial State:

    • You start with an initial state that represents the data you want to display.
    • This state is typically fetched from an API or set based on user input.
  2. Optimistic State:

    • When an action (e.g., form submission) triggers an async operation, you create an optimistic state.
    • The optimistic state is a copy of the original state, but it can be different during the duration of the async action.
    • You provide a function that takes the current state and an optimistic value (e.g., the user's input) and returns the optimistic state.
    • This optimistic state is used to immediately present the user with the expected result of the action, even though the actual action takes time to complete.
  3. Updating the State:

    • As the async action progresses (e.g., a network request), you update the optimistic state.
    • Once the action is complete, the actual state is updated with the final result (which may or may not match the optimistic state).

Here's an example of using the useOptimistic hook (available in React's Canary and experimental channels) to achieve optimistic updates:

import { useOptimistic } from 'react';

function MyComponent() {
  const [data, setData] = useState<MyData>(initialData); // Initial data
  const [message, setMessage] = useState<string>(''); // User input (e.g., form submission)

  // Define an update function for optimistic state
  const updateOptimisticState = (currentState: MyData, optimisticValue: string) => {
    // Merge and return new state with optimistic value
    // For example, update a message field in the data
    return { ...currentState, message: optimisticValue };
  };

  // Use the useOptimistic hook
  const [optimisticData, addOptimistic] = useOptimistic(data, updateOptimisticState);

  // Handle form submission
  const handleSubmit = async () => {
    // Show the optimistic message immediately
    addOptimistic(message);

    // Perform the actual async action (e.g., API request)
    try {
      const response = await api.post('/submit', { message });
      // Update the actual data with the response
      setData(response.data);
    } catch (error) {
      // Handle errors (e.g., show an error message)
    }
  };

  return (
    <div>
      <input type="text" value={message} onChange={(e) => setMessage(e.target.value)} />
      <button onClick={handleSubmit}>Submit</button>
      <p>Optimistic Message: {optimisticData.message}</p>
    </div>
  );
}

Remember that optimistic updates involve trade-offs. While they improve perceived performance, they may lead to inconsistencies if the actual action fails or returns different results. Therefore, use them judiciously based on your application's requirements.

For more details, refer to the official React documentation on useOptimistic.

Optimisitic state: save title data property in state

Let's save data.title in state so that we can have optimistic updates.

  1. First, create the titleData state variable.

In the useServerAction success callback function we can set the titleData with the newly updated data.title from the user. Inside the editing condition we replace the FormInput default prop to titleData. Likewise, in the output we render titleData.

refactor: Save title data property in state

To simulate optimistic state, the board title data is stored as a state variable named titleData. When the user edits the board title, the form is submitted which executes the updateBoard server action. On success, the tileData state is set.

"use client";

import React, { ElementRef, useRef, useState } from 'react';
import { Board } from '@prisma/client';

import { Button } from '@/components/ui/button';
import FormInput from '@/components/form/FormInput';
import { useServerAction } from '@/hooks/useServerAction';
import { updateBoard } from '@/actions/updateBoard';
import { toast } from 'sonner';

interface BoardTitleFormProps {
  data: Board;
};

export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {
  const [isEditing, setIsEditing] = useState(false);
  const [titleData, setTitleData] = useState(data.title);

  const formRef = useRef<ElementRef<"form">>(null);
  const inputRef = useRef<ElementRef<"input">>(null);

  const { executeServerAction } = useServerAction(updateBoard, {
    onSuccess: (data) => {
      toast.success(`Board "${data.title} updated!`);
      setTitleData(data.title);
      disableEditing();
    },
    onError: (error) => {
      toast.error(error);
    }
  });

  function disableEditing() {
    setIsEditing(false);
  }
  
  /**
   * Enables editing mode and focus input.
   */
  function enableEditing() {
    setIsEditing(true);
    setTimeout(() => {
      inputRef.current?.focus();
      inputRef.current?.select();
    })
  }

  function onSubmit(formData: FormData) {
    const title = formData.get('title') as string;
    console.log(`Submitted: ${title}`);
    executeServerAction({
      id: data.id,
      title,
    });
  }

  function onBlur() {
    formRef.current?.requestSubmit();
  }

  if (isEditing) {
    return (
      <form 
        action={onSubmit} 
        ref={formRef} 
        className='flex items-center gap-x-2'
      >
        <FormInput
          ref={inputRef}
          id='title'
          defaultValue={titleData}
          onBlur={onBlur}
          className='bg-transparent h-7 px-[7px] py-1 border-none text-lg font-bold focus-visible:outline-none focus-visible:ring-transparent'
        />
      </form>
    )
  }

  return (
    <Button
      onClick={enableEditing}
      variant='transparent'
      className='h-auto w-auto p-1 px-2 font-bold text-lg'
    >
      {titleData}
    </Button>
  );
}
  1. Now integrate useOptimistic hook to optimistically update the UI
  • Define the update function for optimistic state
  • useOptimistic hook for the title
  • Display the optimistic title

feat: Implement optimistic updates for board title

To enhance user experience, this commit introduces optimistic updates for the board title in the BoardTitleForm component. The titleData state now reflects the expected outcome of the action (e.g., form submission) even before the actual server response. Upon success or error, the actual title is updated accordingly.

Changes made:

  • Added useOptimistic hook to manage optimistic state.
  • Updated UI immediately with the optimistic title.
  • Executed the actual server action (e.g., updateBoard) afterward.
"use client";

import React, { ElementRef, useOptimistic, useRef, useState } from 'react';
import { Board } from '@prisma/client';

import { Button } from '@/components/ui/button';
import FormInput from '@/components/form/FormInput';
import { useServerAction } from '@/hooks/useServerAction';
import { updateBoard } from '@/actions/updateBoard';
import { toast } from 'sonner';

interface BoardTitleFormProps {
  data: Board;
};

export default function BoardTitleForm({
  data,
}: BoardTitleFormProps) {
  const [isEditing, setIsEditing] = useState(false);
  const [titleData, setTitleData] = useState(data.title);

  const formRef = useRef<ElementRef<"form">>(null);
  const inputRef = useRef<ElementRef<"input">>(null);

  // Define an update function for optimistic state
  const updateOptimisticTitle = (currentState: string, optimisticValue: string) => {
    return optimisticValue; // Simply update with the new title
  };

  // Use the useOptimistic hook
  const [optimisticTitle, setOptimisticTitle] = useOptimistic(titleData, updateOptimisticTitle);

  const { executeServerAction } = useServerAction(updateBoard, {
    onSuccess: (data) => {
      toast.success(`Board "${ data.title } updated!`);
      setTitleData(data.title);
      disableEditing();
    },
    onError: (error) => {
      toast.error(error);
    }
  });

  function disableEditing() {
    setIsEditing(false);
  }

  function enableEditing() {
    setIsEditing(true);
    setTimeout(() => {
      inputRef.current?.focus();
      inputRef.current?.select();
    })
  }

  function onSubmit(formData: FormData) {
    const title = formData.get('title') as string;

    // Show the optimistic title immediately
    setOptimisticTitle(title);

    executeServerAction({
      id: data.id,
      title,
    });
  }

  function onBlur() {
    formRef.current?.requestSubmit();
  }

  if (isEditing) {
    return (
      <form
        action={onSubmit}
        ref={formRef}
        className='flex items-center gap-x-2'
      >
        <FormInput
          ref={inputRef}
          id='title'
          defaultValue={titleData}
          onBlur={onBlur}
          className='bg-transparent h-7 px-[7px] py-1 border-none text-lg font-bold focus-visible:outline-none focus-visible:ring-transparent'
        />
      </form>
    )
  }

  return (
    <Button
      onClick={enableEditing}
      variant='transparent'
      className='h-auto w-auto p-1 px-2 font-bold text-lg'
    >
      {optimisticTitle} {/* Display the optimistic title */}
    </Button>
  );
}

Testing the optimistic UI:

  • Edit the board title, press Enter
    • In pending status, the new board title displays optimistically
    • A toast notification displays for the new board title
    • Refresh the page, the updated title remains
  • Edit the board title, on blur (click away from the input)
    • UI should display the new title

BoardOptions

Next action we want to implement is the ability to delete a board. We will do this with a BoardOptions component which will be rendered in the BoardNavbar.

We can pass in the board ID to this component as a prop.

app\(app)\(dashboard)\board\[boardId]\_components\BoardOptions.tsx

"use client";

import React from 'react'

interface BoardOptionsProps {
  id: string;
};

export default function BoardOptions({ id }: BoardOptionsProps) {
  return (
    <div>BoardOptions</div>
  )
}

feat: Use BoardOptions component in BoardNavbar

app\(app)\(dashboard)\board\[boardId]\_components\BoardNavbar.tsx

import React from 'react';
import { Board } from '@prisma/client';
import BoardTitleForm from './BoardTitleForm';
import BoardOptions from './BoardOptions';

interface BoardNavbarProps {
  data: Board;
}

export default async function BoardNavbar({
  data
}: BoardNavbarProps) {

  return (
    <div className='flex items-center fixed h-14 w-full top-14 z-[30] bg-black/50 px-6 gap-x-4 text-white'>
      <BoardTitleForm data={data} />
      <div className='ml-auto'>
        <BoardOptions
          id={data.id}
        />
      </div>
    </div>
  )
}

Develop the output of BoardOptions

We will make BoardOptions use the Popover component.

  • Return a Popover element
  • PopoverTrigger contains a transparent Button with an MoreHorizontal icon
  • PopoverContent with a div containing some text
    • PopoverClose contains a Button with an X icon

app\(app)\(dashboard)\board\[boardId]\_components\BoardOptions.tsx

"use client";

import React from 'react'

import { Button } from '@/components/ui/button';
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { MoreHorizontal, X } from 'lucide-react';

interface BoardOptionsProps {
  id: string;
};

export default function BoardOptions({ id }: BoardOptionsProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button
          variant='transparent'
          className='h-auto w-auto p-2'
        >
          <MoreHorizontal className='h-4 w-4' />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        align='start'
        side='bottom'
        className='px-0 pt-3 pb-3'
      >
        <div className='pb-4 text-sm text-center text-neutral-600 font-medium'>
          BoardOptions
        </div>
        <PopoverClose asChild>
          <Button
            variant='ghost'
            className='absolute h-auto w-auto p-2 top-2 right-2 text-neutral-600'
          >
            <X className='h-4 w-4' />
          </Button>
        </PopoverClose>
      </PopoverContent>
    </Popover>
  );
}

Delete Board

feat: Add delete button to list of BoardOptions

export default function BoardOptions({ id }: BoardOptionsProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
          {/* ... */}
      </PopoverTrigger>
      <PopoverContent>
          {/* ... */}
        <PopoverClose asChild>
          {/* ... */}
        </PopoverClose>
        {/* Board Options contain each action */}
        <Button
          variant='ghost'
          onClick={() => {}}
          className='justify-start h-auto w-full p-2 px-5 rounded-none font-normal text-sm'
        >
          Delete this board
        </Button>
      </PopoverContent>
    </Popover>
  );
}

Next create server action to delete our board.

DeleteBoard server action

Let's go through the process again to create the type-safe server action.

  1. Schema
  2. Types
  3. Server Action handler

DeleteBoard schema

We just need to validate the ID of the board to delete it.

import { z } from 'zod';

/**
 * Define the DeleteBoard object schema.
 * 
 */
export const DeleteBoard = z.object({
  id: z.string(),
});

DeleteBoard types

import { z } from 'zod';

// Import Board, the expected output type, from Prisma client
import { Board } from '@prisma/client';

// Encapsulate the state of various actions (e.g., fetching data, submitting forms, etc.)
// Provides a structured way to handle errors and manage data flow
import { ActionState } from '@/lib/createServerAction';

// Import the DeleteBoard schema (validation rules)
import { DeleteBoard } from './deleteBoardSchema';

// Define the input type based on the DeleteBoard schema
export type InputType = z.infer<typeof DeleteBoard>;

// Define the output data type (ActionState) with Board
export type OutputType = ActionState<InputType, Board>;

DeleteBoard server action handler

deleteBoard is a handler. It is a server-side action responsible for handling the deletion of a board. Let's break down its functionality:

  1. Authentication:

    • The handler first extracts the userId and orgId using the auth() function.
    • If either of these values is missing (i.e., the user is not authenticated), it returns an error response with the message "Unauthorized."
  2. Board Deletion:

    • Assuming the user is authorized, the handler proceeds to delete a board.
    • It uses the database.board.delete method to delete the board based on the provided id and orgId.
    • If the deletion fails (due to an exception), it returns an error response with the message "Failed to delete board."
  3. Cache Revalidation and Redirection:

    • After successful deletion, the handler sets a path variable (likely related to the organization) and revalidates the cache for that path.
    • Finally, it redirects the user to the specified path.

In summary, deleteBoard handles the entire process of deleting a board, including authentication, database operations, and cache management.

feat: Create deleteBoard server action handler

deleteBoard is a server-side action responsible for the deletion of the board. The handler covers:

  1. Authentication
  2. Board deletion in the database
  3. Cache revalidation and redirection to a path after deletion

actions\deleteBoard\index.ts

"use server";
import { auth } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";

import { createServerAction } from "@/lib/createServerAction";
import { database } from "@/lib/database";

import { DeleteBoard } from "./deleteBoardSchema";
import { InputType, OutputType } from "./deleteBoardTypes";

async function performAction (data: InputType): Promise<OutputType> {
  const { userId, orgId } = auth();

  if (!userId || !orgId) {
    return {
      error: 'Unauthorized',
    };
  }

  const { id } = data;

  let board;

  try {
    board = await database.board.delete({
      where: {
        id,
        orgId,
      },
    });
  } catch (error) {
    return {
      error: 'Failed to delete board.'
    }
  }

  const path = `/organization/${orgId}`;
  revalidatePath(path);
  redirect(path);
}

export const deleteBoard = createServerAction(DeleteBoard, performAction);

Add delete functionality to BoardOptions

Instantiate the deleteBoard with useServerAction hook.

  • Destructure { executeServerAction, isLoading } from useServerAction.
  • Pass in deleteBoard as the first argument and an object with the onError callback function as the second argument to useServerAction.
  • Create onDelete handler that invokes executeServerAction({ id })
  • Assign onDelete to the onClick prop of the Button
  • Assign isLoading to the disabled prop of the Button
import { toast } from 'sonner';

import { deleteBoard } from '@/actions/deleteBoard';
import { useServerAction } from '@/hooks/useServerAction';

export default function BoardOptions({ id }: BoardOptionsProps) {

  const { 
    executeServerAction, 
    isLoading,
  } = useServerAction(deleteBoard, {
    onError: (error) => {
      toast.error(error);
    }
  });

  function onDelete() {
    executeServerAction({ id });
  }

  return (
    <Popover>
      <PopoverTrigger asChild>
        {/* ... */}
      </PopoverTrigger>
      <PopoverContent>
        {/* ... */}
        <Button
          disabled={isLoading}
          onClick={onDelete}
          variant='ghost'
          className='justify-start h-auto w-full p-2 px-5 rounded-none font-normal text-sm'
        >
          Delete this board
        </Button>
      </PopoverContent>
    </Popover>
  );
}

List

List model

Navigate back to schema.prisma and create a List.

It will have the fields:

  • id
  • title
  • order (an integer that will define the order where it will be positioned, as the order can change during drag-and-drop)
  • createdAt, updatedAt

Also add a one-to-many relation with a Board. There will be many lists to one board.

Also define an index in the database for boardId within the List model

feat: Add List model to prisma schema

model Board {
  id            String @id @default(uuid())
  orgId         String
  title         String
  imageId       String
  imageThumbUrl String @db.Text
  imageFullUrl  String @db.Text
  imageUserName String @db.Text
  imageLinkHTML String @db.Text
  lists         List[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model List {
  id    String @id @default(uuid())
  title String
  order Int

  boardId String
  board   Board  @relation(fields: [boardId], references: [id], onDelete: Cascade)

  @@index([boardId])
}

Notes:

  • order field will be used to fetch the lists by which affects the placement within the board
  • Will not add onUpdate: Cascade in the List model in the @relation because when the Board title updates it should not affect the List

Card model

Let's also add the Card model.

fields:

  • id, title, order
  • description (an optional String)
  • createdAt, updatedAt

relation: one-to-many with List

feat: Add Card model to prisma schema

model List {
  id    String @id @default(uuid())
  title String
  order Int

  boardId String
  board   Board  @relation(fields: [boardId], references: [id], onDelete: Cascade)

  cards Card[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([boardId])
}

model Card {
  id          String  @id @default(uuid())
  title       String
  order       Int
  description String? @db.Text

  listId String
  list   List   @relation(fields: [listId], references: [id], onDelete: Cascade)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([listId])
}

Push to the database

  1. We may need to clear the database, since the Board model now has a relation with List
npx prisma migrate reset
  1. Push
npx prisma db push
  1. Generate
npx prisma generate

Modify individual board page

Create prop interface for individual board page that takes in params and props.

import React from 'react';

interface BoardIdPageProps {
  params: {
    boardId: string;
  };
};

export default async function BoardIdPage({
  params
}: BoardIdPageProps) {


  return (
    <div>
      BoardIdPage
    </div>
  )
}

Next handle authentication. Check for orgId and redirect to org-selection if it does not exist.

feat: Implement board authentication using orgId

import React from 'react';
import { auth } from '@clerk/nextjs';
import { redirect } from 'next/navigation';

interface BoardIdPageProps {
  params: {
    boardId: string;
  };
};

export default async function BoardIdPage({
  params
}: BoardIdPageProps) {
  const { orgId } = auth();

  if (!orgId) {
    return redirect('/org-selection');
  }

  return (
    <div>
      BoardIdPage
    </div>
  )
}

then we fetch the lists in the board from the database.

  • Fetch, in ascending order, the lists by the individual board ID
  • Also check if the related board that list is created in also has matching orgId of the current user
  • Include cards in ascending order

feat: Fetch lists for individual board pages

import { database } from '@/lib/database';

export default async function BoardIdPage({
  params
}: BoardIdPageProps) {
  const { orgId } = auth();

  if (!orgId) {
    return redirect('/org-selection');
  }

  // Retrieve lists for the individual board ID in ascending order.
  // Additionally, verify that the related board associated with each list
  // has a matching orgId for the current user.
  // Include cards within each list, also sorted in ascending order.
  const lists = await database.list.findMany({
    where: {
      boardId: params.boardId,
      board: {
        orgId,
      },
    },
    include: {
      cards: {
        orderBy: {
          order: 'asc',
        },
      },
    },
    orderBy: {
      order: 'asc',
    }
  });

  return (
    <div>
      BoardIdPage
    </div>
  )
}

ListContainer component

Create a ListContainer component in /components/list folder.

  • It will have a prop interface for boardId a string, and data a List array.

feat: Define prop types for ListContainer

components\list\ListContainer.tsx

import React from 'react';

import { List } from '@prisma/client';

interface ListContainerProps {
  boardId: string;
  data: List[];
}

export default function ListContainer({
  boardId,
  data,
}: ListContainerProps) {
  return (
    <div>ListContainer</div>
  )
}

Then pass the props to ListContainer within BoardIdPage.

feat: Use ListContainer component in BoardIdPage

import ListContainer from '@/components/list/ListContainer';

export default async function BoardIdPage({
  params
}: BoardIdPageProps) {
  // ...
  return (
    <div className='h-full p-4 overflow-x-auto'>
      <ListContainer 
        boardId={params.boardId}
        data={lists}
      />
    </div>
  )
}

refactor: Centralize ListContainer in components/list

  • Enhance modularity and reusability by consolidating all List components in a single folder.
  • Ensure consistent naming conventions, styling, and functionality by grouping related components.
  • Simplify maintenance, improve scalability, and facilitate testing and debugging.

Types for List

When organizing your Next.js project, structuring the types folder is essential for maintainability and readability. Let's explore some best practices:

  1. One File for All Types:

    • Advantages:
      • Simplicity: Having a single file (e.g., types.ts) with all your types and interfaces keeps things straightforward.
      • Easy Access: Developers can quickly find and reference types from a central location.
    • Disadvantages:
      • Clutter: As your project grows, this file might become unwieldy, especially if you have many types.
      • Potential Conflicts: If multiple developers work on the same file, conflicts may arise during merges.
    • Example:
      • Create a types.ts file at the root of your project and define all your types and interfaces there².
  2. One File per Type or Group of Related Types:

    • Advantages:
      • Modularity: Each type has its own file, making it easier to manage and locate specific definitions.
      • Scalability: As your project expands, you can add new type files without affecting existing ones.
    • Disadvantages:
      • Initial Complexity: Setting up separate files requires more upfront organization.
    • Example:
      • Create individual files for different types or groups of related types (e.g., user.ts, product.ts, etc.) within the types/ folder⁴.
  3. Hybrid Approach:

    • Combine both methods:
      • Use a central types.ts file for common types shared across the entire project.
      • Create separate files for specific types or modules (e.g., user.ts, product.ts) when needed.
    • This approach strikes a balance between simplicity and modularity.

Remember that there's no one-size-fits-all solution. Choose an approach that aligns with your project's size, complexity, and team preferences. Consistency and clear documentation are key to maintaining a well-organized codebase.

We will go with the Hybrid Approach.

In /types create a file named types.ts, a central file that contains reusable common types shared across the entire project.

In ListContainer we define the type for the data in the interface.

interface ListContainerProps {
  boardId: string;
  data: List[];
}

List[] is not the accurate type for the data, when we fetched the lists in BoardIdPage, hover over it in VSCode:

  const lists = await database.list.findMany({

we see that lists is a specific type:

  • A List with cards

We also need to define the Card type

  • Card with List

feat: Add reusable types for List and Card

types\types.ts

import { Card, List } from '@prisma/client';

export type ListWithCards = List & {
  cards: Card[]
};

export type CardWithList = Card & {
  list: List
};

Now use the ListWithCards type for ListContainer:

refactor: Use ListWithCards type in ListContainer

import { ListWithCards } from '@/types/types';

interface ListContainerProps {
  boardId: string;
  data: ListWithCards[];
}

ListForm

Create ListForm component inside /components/list.

"use client";

import React from 'react';

export default function ListForm() {
  return (
    <div>ListForm</div>
  )
}

In ListContainer, render a <ol> that contains a ListForm and a div.

import ListForm from '@/components/list/ListForm';

export default function ListContainer({
  boardId,
  data,
}: ListContainerProps) {
  return (
    <ol>
      <ListForm />
      <div>ListContainer</div>
    </ol>
  )
}

ListWrapper

Let's also create a reusable component ListWrapper in components/list.

It will have the prop for children and render it inside a li element.

feat: Define prop types for ListWrapper

components\list\ListWrapper.tsx

import React from 'react';

interface ListWrapperProps {
  children: React.ReactNode;
};

export default function ListWrapper({
  children
}: ListWrapperProps) {
  return (
    <li>
      {children}
    </li>
  )
}

Now back to the ListForm, it should return a ListWrapper that contains a button element with a Plus icon and "Add list" text as children.

feat: Render a ListWrapper, button and plus icon

"use client";

import React from 'react';
import { Plus } from 'lucide-react';

import ListWrapper from './ListWrapper';

export default function ListForm() {
  return (
    <ListWrapper>
      <button>
        <Plus />
        Add list
      </button>
    </ListWrapper>
  )
}
Styling the List components

feat: Add styles for list components

style: Enhance visual appearance of ListForm

export default function ListForm() {
  return (
    <ListWrapper>
      <button
        className='flex items-center w-full rounded-md p-3 font-medium text-sm bg-white/80 hover:bg-white/50 transition'
      >
        <Plus className='h-4 w-4 mr-2'/>
        Add list
      </button>
    </ListWrapper>
  )
}

style: Prevent shrink and text selection on li

The styles added ensures that the list item won't shrink, has a fixed width, and prevents text selection.

export default function ListWrapper({
  children
}: ListWrapperProps) {
  return (
    <li className='shrink-0 h-full w-72 select-none'>
      {children}
    </li>
  )
}

For the ListContainer, we can have a div at the end which represents the empty space at the end of the x-axis. When scrolling through the list that contains items, we will have a generic container element div that prevents itself from shrinking when there's limited space (flex-shrink-0) and has a fixed width of 1 (0.25rem or 4px in TailwindCSS).

This provides extra padding at the bottom when the overflow-x activates, when the normal behavior will have it flushed at the end of the screen.

style: Add extra padding for x-axis overflow

export default function ListContainer({
  boardId,
  data,
}: ListContainerProps) {
  return (
    <ol>
      <ListForm />
      <div className='flex-shrink-0 w-1' />
    </ol>
  )
}

ListForm development

Similar to BoardTitleForm, the ListForm will have -isEditing state

  • enableEditing and disableEditing function handlers -formRef and inputRef

feat: Enable editing mode for ListForm component

"use client";

import React, { ElementRef, useRef, useState } from 'react';
import { Plus } from 'lucide-react';

import ListWrapper from './ListWrapper';

export default function ListForm() {
  const [isEditing, setIsEditing] = useState(false);

  const formRef = useRef<ElementRef<"form">>(null);
  const inputRef = useRef<ElementRef<"input">>(null);

  function disableEditing() {
    setIsEditing(false);
  }

  /**
   * Enables editing mode and focus input.
   */
  function enableEditing() {
    setIsEditing(true);
    setTimeout(() => {
      inputRef.current?.focus();
      inputRef.current?.select();
    })
  }

  return (
    <ListWrapper>
      <button
        className='flex items-center w-full rounded-md p-3 font-medium text-sm bg-white/80 hover:bg-white/50 transition'
      >
        <Plus className='h-4 w-4 mr-2' />
        Add list
      </button>
    </ListWrapper>
  )
}

feat: Allow user to disable editing with Escape key

  /**
   * When user clicks "Escape" key, it disables editing mode.
   * @param event the key press event
   */
  function handleEscapeKey(event: KeyboardEvent) {
    if (event.key === "Escape") {
      disableEditing();
    }
  }

feat: Handle escape key events and outside click docs: Add comments to event and click handlers

Now we need to add the event listener to listen for the key event on the entire document (window level). When the user presses a key, the handleEscapeKey function is called, allowing us to respond to the "Escape" key press globally.

  /**
   * When user clicks "Escape" key, it disables editing mode.
   * @param event the key press event
   */
  function handleEscapeKey(event: KeyboardEvent) {
    if (event.key === "Escape") {
      disableEditing();
    }
  }

  // Custom hook that attaches event listeners to DOM elements, the window, or media query lists.
  // Listen for the 'keydown' event on the entire document (window level)
  useEventListener('keydown', handleEscapeKey);

Next also disable editing when the user clicks outside the form.

  const formRef = useRef<ElementRef<"form">>(null);

  function disableEditing() {
    setIsEditing(false);
  }

  // Custom hook that handles clicks outside a specified element.
  // Disable editing when user clicks outside the form
  useOnClickOutside(formRef, disableEditing);

ListForm editing mode

When isEditing is true, the ListForm component returns a different JSX element which contains a form wrapped by ListWrapper. Inside is the FormInput component, which has the id prop set to 'title'.

feat: Render FormInput for ListForm in editing mode

"use client";

import React, { ElementRef, useRef, useState } from 'react';
import { Plus } from 'lucide-react';
import { useEventListener, useOnClickOutside } from 'usehooks-ts';

import ListWrapper from './ListWrapper';
import FormInput from '@/components/form/FormInput';

export default function ListForm() {
  const [isEditing, setIsEditing] = useState(false);

  const formRef = useRef<ElementRef<"form">>(null);
  const inputRef = useRef<ElementRef<"input">>(null);

  function disableEditing() {
    setIsEditing(false);
  }

  /**
   * Enables editing mode and focus input.
   */
  function enableEditing() {
    setIsEditing(true);
    setTimeout(() => {
      inputRef.current?.focus();
      inputRef.current?.select();
    })
  }

  /**
   * When user clicks "Escape" key, it disables editing mode.
   * @param event the key press event
   */
  function handleEscapeKey(event: KeyboardEvent) {
    if (event.key === "Escape") {
      disableEditing();
    }
  }

  // Custom hook that attaches event listeners to DOM elements, the window, or media query lists.
  // Listen for the 'keydown' event on the entire document (window level)
  useEventListener('keydown', handleEscapeKey);

  // Custom hook that handles clicks outside a specified element.
  // Disable editing when user clicks outside the form
  useOnClickOutside(formRef, disableEditing);

  /* Editing mode */
  if (isEditing) {
    return (
      <ListWrapper>
        <form
          ref={formRef}
          className='w-full p-3 space-y-4 rounded-md bg-white shadow-md'
        >
          <FormInput 
            ref={inputRef}
            id='title'
            placeholder='Edit list title...'
            className='px-2 py-1 h-7 font-medium text-sm border-transparent focus:border-input hover:border-input transition'
          />
        </form>
      </ListWrapper>
    )
  }

  return (
    <ListWrapper>
      <button
        onClick={enableEditing}
        className='flex items-center w-full rounded-md p-3 font-medium text-sm bg-white/80 hover:bg-white/50 transition'
      >
        <Plus className='h-4 w-4 mr-2' />
        Add list
      </button>
    </ListWrapper>
  )
}

style: Make transition seamless for FormInput

This commit ensures a seamless transition for ListForm's editing form, enhancing the user experience. When the user clicks on the ListForm button, it enables editing mode and renders a FormInput. The FormInput component maintains consistent spacing, font weight, and border behavior. The input field's border will exhibit different behaviors based on its normal state, hover state, focus state, and transitions.

          <FormInput 
            ref={inputRef}
            id='title'
            placeholder='Edit list title...'
            className='px-2 py-1 h-7 font-medium text-sm border-transparent focus:border-input hover:border-input transition'
          />

Test:

  • User clicks on ListForm button to enter editing mode
  • Editing mode returns a FormInput where user can update the list title
  • User can press Escape key to exit editing mode
  • User can click outside the form to exit editing mode
Storing board ID in ListForm editing mode

There are two approaches to storing the board ID.

  1. Fetch the board ID in the submit handler
  2. Extract board ID in params and store it inside an input element
    • The benefit of this approach allows us to work purely with FormData

Let's store the boardId in a hidden input element during editing mode.

feat: Store boardId in hidden input on list edit

import { useParams } from 'next/navigation';

export default function ListForm() {
  const params = useParams();

  /* Editing mode */
  if (isEditing) {
    return (
      <ListWrapper>
        <form
          ref={formRef}
          className='w-full p-3 space-y-4 rounded-md bg-white shadow-md'
        >
          <FormInput 
            ref={inputRef}
            id='title'
            placeholder='Edit list title...'
            className='px-2 py-1 h-7 font-medium text-sm border-transparent focus:border-input hover:border-input transition'
          />
          {/* Hidden input that stores Board ID */}
          <input 
            hidden
            name='boardId'
            value={params.boardId}
          />

        </form>
      </ListWrapper>
    )
  }
Submit and Exit buttons in editing mode

Next let's render a FormSubmitButton and a Button with an X icon to disabled editing.

feat: Add submit and exit buttons during editing

import { Plus, X } from 'lucide-react';

import FormSubmitButton from '@/components/form/FormSubmitButton';
import { Button } from '@/components/ui/button';

export default function ListForm() {

  if (isEditing) {
    return (
      <ListWrapper>
        <form
          ref={formRef}
          className='w-full p-3 space-y-4 rounded-md bg-white shadow-md'
        >
          <FormInput 
            ref={inputRef}
            id='title'
            placeholder='Edit list title...'
            className='px-2 py-1 h-7 font-medium text-sm border-transparent focus:border-input hover:border-input transition'
          />
          {/* Hidden input that stores Board ID */}
          <input 
            hidden
            name='boardId'
            value={params.boardId}
          />
          {/* Submit and Exit buttons */}
          <div className='flex items-center gap-x-1'>
            <FormSubmitButton>
              Add list
            </FormSubmitButton>
            <Button
              onClick={disableEditing}
              size='sm'
              variant='ghost'
            >
              <X className='h-5 w-5' />
            </Button>
          </div>
        </form>
      </ListWrapper>
    )
  }

CreateList server action

Make createList folder inside /actions and add the following:

  1. Schema
  2. Types
  3. Server Action handler

createList schema

We expect the user to input a title and we also expect a boardId (which we extract from the params and store in the hidden input). We can change the minimum to 1 character as we should allow the user to make lists with one character names.

actions\createList\createListSchema.ts

import { z } from 'zod';

/**
 * Define the CreateList object schema.
 * 
 */
export const CreateList = z.object({
  title: z.string({
    required_error: "Title is required", 
    invalid_type_error: "Title is required", 
  }).min(1, {
    message: "Must be 1 or more characters long.", 
  }),
  boardId: z.string(),
});

createList types

Similarly, create the types we expect to have for the createList server action.

feat: Define types for createList server action

actions\createList\createListTypes.ts

import { z } from 'zod';

// Import Board, the expected output type, from Prisma client
import { Board } from '@prisma/client';

// Encapsulate the state of various actions (e.g., fetching data, submitting forms, etc.)
// Provides a structured way to handle errors and manage data flow
import { ActionState } from '@/lib/createServerAction';

// Import the CreateList schema (validation rules)
import { CreateList } from './createListSchema';

// Define the input type based on the CreateList schema
export type InputType = z.infer<typeof CreateList>;

// Define the output data type (ActionState) with Board
export type OutputType = ActionState<InputType, Board>;

createList server action

To create a list we will authenticate, then extract title and boardId data, then open up a try..catch block. Inside the try block we want to fetch the board where we want to create the list, fetch the most recent list created in the database and calculate the order number. Create the list in the database with the new order assigned. Revalidate the board id page, and return the data of newly created list.

feat: Implement createList action with order sequencing

"use server";
import { auth } from "@clerk/nextjs";
import { revalidatePath } from "next/cache";

import { createServerAction } from "@/lib/createServerAction";
import { database } from "@/lib/database";

import { CreateList } from "./createListSchema";
import { InputType, OutputType } from "./createListTypes";

async function performAction(data: InputType): Promise<OutputType> {
  const { userId, orgId } = auth();

  if (!userId || !orgId) {
    return {
      error: "Unauthorized",
    };
  }

  const { title, boardId } = data;

  let list;

  try {
    // Fetch the board
    const board = await database.board.findUnique({
      where: {
        id: boardId,
        orgId,
      },
    });

    if (!board) {
      return {
        error: "Board not found",
      };
    }

    // Fetch the most recent list in the board to properly assign the newest order to the list
    const mostRecentList = await database.list.findFirst({
      where: { boardId: boardId },
      orderBy: { order: "desc" },
      select: { order: true },
    });

    // Get the next order depending on whether a mostRecentList is present or not
    const nextOrder = mostRecentList ? mostRecentList.order + 1 : 1;

    // Create the list in the database
    list = await database.list.create({
      data: {
        title,
        boardId,
        order: nextOrder,
      },
    });
  } catch (error) {
    return {
      error: "Failed to create list.",
    };
  }

  revalidatePath(`/board/${boardId}`);

  // Return the list
  return {
    data: list,
  };
}

export const createList = createServerAction(CreateList, performAction);

Issue: modify output type to match the data we expect

When we return the { data: list }, we get an error when we hover over it in VSCode.

Type '{ id: string; title: string; order: number; boardId: string; createdAt: Date; updatedAt: Date; }' is missing the following properties from type '{ id: string; orgId: string; title: string; imageId: string; imageThumbUrl: string; imageFullUrl: string; imageUserName: string; imageLinkHTML: string; createdAt: Date; updatedAt: Date; }': orgId, imageId, imageThumbUrl, imageFullUrl, and 2 more.ts(2740)
createServerAction.ts(13, 3): The expected type comes from property 'data' which is declared here on type 'OutputType'

(property) data?: {
    id: string;
    orgId: string;
    title: string;
    imageId: string;
    imageThumbUrl: string;
    imageFullUrl: string;
    imageUserName: string;
    imageLinkHTML: string;
    createdAt: Date;
    updatedAt: Date;
} | undefined

We just need to update the types in createListTypes to use List instead of Board.

fix: createList to return correct data type

Changed the output data type of the createList action from Board to List to match the expected data structure.

actions\createList\createListTypes.ts

import { z } from 'zod';

// Import List, the expected output type, from Prisma client
import { List } from '@prisma/client';

// Encapsulate the state of various actions (e.g., fetching data, submitting forms, etc.)
// Provides a structured way to handle errors and manage data flow
import { ActionState } from '@/lib/createServerAction';

// Import the CreateList schema (validation rules)
import { CreateList } from './createListSchema';

// Define the input type based on the CreateList schema
export type InputType = z.infer<typeof CreateList>;

// Define the output data type (ActionState) with List
export type OutputType = ActionState<InputType, List>;

Ensure createList returns List type as per schema

This update modifies the createList function to correctly return an instance of the List type, aligning with the defined schema and resolving type inconsistencies.

Implement createList server action

Let's import what we need and implement the createList server action in ListForm.

feat: Add createList server action to ListForm

Implemented a new server action, createList, in the ListForm component to handle list creation with success toast notification.

components\list\ListForm.tsx

import { toast } from 'sonner';
import { createList } from '@/actions/createList';
import { useServerAction } from '@/hooks/useServerAction';

export default function ListForm() {

  const { executeServerAction, fieldErrors } = useServerAction(createList, {
    onSuccess: (data) => {
      toast.success(`List "${data.title}" created`);
      disableEditing();
    }
  });

}

Let's also add router to refresh the page in order to refresh all of the server components in onSuccess callback.

feat: Integrate useRouter for state refresh in ListForm

Enhanced the ListForm component with useRouter hook to enable state refresh after successful list creation, ensuring up-to-date server component data.

import { useParams, useRouter } from 'next/navigation';

export default function ListForm() {

  const router = useRouter();

  const { executeServerAction, fieldErrors } = useServerAction(createList, {
    onSuccess: (data) => {
      toast.success(`List "${data.title}" created`);
      disableEditing();
      // Refresh the router to refetch all the server components
      router.refresh();
    }
  });

}

Develop the ListForm component

Let's also add the error handling for the callback function

Feat: Implement error handling for list creation

Added robust error handling to the ListForm component to display toast notifications and log errors during list creation failures.

  const { executeServerAction, fieldErrors } = useServerAction(createList, {
    onSuccess: (data) => {
      toast.success(`List "${data.title}" created`);
      disableEditing();
      // Refresh the router to refetch all the server components
      router.refresh();
    },
    onError: (error) => {
      toast.error(error);
      console.log(error);
    },
  });

Now implement the submit handler, create the list with form data, and assign the submit function to the action prop of the form.

  • The action prop is used in plain HTML to specify the URL where the form data is sent when submitted

feat: Implement onSubmit handler for list creation

Implemented an onSubmit handler in the ListForm component to process form data and trigger list creation with server action integration.

  function onSubmit(formData: FormData) {
    // Extract title of the list from FormInput
    const title = formData.get('title') as string;

    // Extract boardId found in the hidden input
    const boardId = formData.get('boardId') as string;

    // Create the list with the given form data
    executeServerAction({
      title,
      boardId,
    });
  }

  /* Editing mode */
  if (isEditing) {
    return (
      <ListWrapper>
        <form
          action={onSubmit}
          ref={formRef}
          className='w-full p-3 space-y-4 rounded-md bg-white shadow-md'
        >

feat: Integrate field error display in ListForm's FormInput

Enhanced the FormInput component within ListForm to display validation errors, improving user feedback on form submission.

<FormInput 
  ref={inputRef}
  errors={fieldErrors}
  id='title'
  placeholder='Edit list title...'
  className='px-2 py-1 h-7 font-medium text-sm border-transparent focus:border-input hover:border-input transition'
/>

Develop the ListContainer

With the functionality to create the list in place, we now should develop a way to display the lists.

We fetch the list data from the database in individual board ID page. There we render a ListContainer.

Let's create a state variable orderedListData to have a local optimistic mutation for the order of the list. Optimistic updates improves the user experience, especially for the drag-and-drop feature of our lists.

Implement ListContainer with ordered list display

Next, map out the orderListData to a div that contains the list information.

feat: Add ordered list display to ListContainer

"use client";

import React, { useState } from 'react';

import { ListWithCards } from '@/types/types';
import ListForm from '@/components/list/ListForm';

interface ListContainerProps {
  boardId: string;
  data: ListWithCards[];
}

export default function ListContainer({
  boardId,
  data,
}: ListContainerProps) {
  const [orderedListData, setOrderedListData] = useState(data);

  return (
    <ol>
      {
        orderedListData.map((list, index) => {
          return (
            <div
              key={list.id}
            >
              {list.id}
            </div>
          )
        })
      }
      <ListForm />
      <div className='flex-shrink-0 w-1' />
    </ol>
  )
}

Display list data

Instead of a div let's map out a ListItem component, which accepts the props { key, index, data }.

feat: Integrate ListItem for dynamic list rendering

import ListItem from '@/components/list/ListItem';

export default function ListContainer({
  boardId,
  data,
}: ListContainerProps) {
  const [orderedListData, setOrderedListData] = useState(data);

  return (
    <ol>
      {
        orderedListData.map((list, index) => {
          return (
            <ListItem
              key={list.id}
              index={index}
              data={list}
            />
          )
        })
      }
      <ListForm />
      <div className='flex-shrink-0 w-1' />
    </ol>
  )
}

style: Apply flex layout and horizontal spacing to list elements

export default function ListContainer({
  boardId,
  data,
}: ListContainerProps) {
  const [orderedListData, setOrderedListData] = useState(data);

  return (
    <ol className='flex h-full gap-x-3'>
      {
        orderedListData.map((list, index) => {
          return (
            <ListItem
              key={list.id}
              index={index}
              data={list}
            />
          )
        })
      }
      <ListForm />
      <div className='flex-shrink-0 w-1' />
    </ol>
  )
}

ListItem

Let's create the ListItem component with the prop interface that contains data and index.

feat: Define prop types for ListItem component

components\list\ListItem.tsx

"use client";

import React from 'react';

import { ListWithCards } from '@/types/types';

interface ListItemProps{
  data: ListWithCards;
  index: number;
}

export default function ListItem({
  data,
  index,
}: ListItemProps) {
  return (
    <div>ListItem</div>
  )
}
ListHeader

Before working on the output of ListItem, let's create a quick ListHeader component.

feat: Define prop types for ListHeader component

components\list\ListHeader.tsx

"use client";

import React from 'react'

import { List } from '@prisma/client';

interface ListHeaderProps {
  data: List;
}

export default function ListHeader({
  data,
}: ListHeaderProps) {
  return (
    <div>{data.title}</div>
  )
}

style: Enhance ListHeader layout & style

  • Refine layout with padding, font adjustments, and flexbox alignment.

Enhanced the ListHeader component's visual appeal and user experience with refined padding, font adjustments for improved readability, and flexbox alignment for a cleaner layout.

export default function ListHeader({
  data,
}: ListHeaderProps) {
  return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      <div className='h-7 w-full px-2.5 py-1 text-sm font-medium border-transparent'>
        {data.title}
      </div>
    </div>
  )
}

For ListItem, render a li > div > ListHeader.

feat: Add ListHeader rendering within li element

style: Enhance visual appearance of ListItem

  • Increase width for better content fit
  • Adjust padding for improved spacing
  • Enhance background color for higher contrast
export default function ListItem({
  data,
  index,
}: ListItemProps) {
  return (
    <li className='h-full w-72 shrink-0 select-none'>
      <div className='w-full rounded-md bg-[#f1f2f4] shadow-md pb-2'>
        <ListHeader />
      </div>
    </li>
  )
}

feat(ListItem): Integrate ListHeader with list data

  • Pass data prop to ListHeader for dynamic content rendering.
  • Ensure ListHeader receives necessary information for display.
"use client";

import React from 'react';

import { ListWithCards } from '@/types/types';
import ListHeader from '@/components/list/ListHeader';

interface ListItemProps {
  data: ListWithCards;
  index: number;
}

export default function ListItem({
  data,
  index,
}: ListItemProps) {
  return (
    <li className='h-full w-72 shrink-0 select-none'>
      <div className='w-full rounded-md bg-[#f1f2f4] shadow-md pb-2'>
        <ListHeader data={data} />
      </div>
    </li>
  )
}

Next, in ListHeader let's create a state for title.

It will also have isEditing state along with the formRef and inputRef.

feat: Implement title editing & refs in ListHeader

  • Initialize state for title and editing mode.
  • Create form and input refs for managing focus.

components\list\ListHeader.tsx

"use client";

import React, { ElementRef, useRef, useState } from 'react';

import { List } from '@prisma/client';

interface ListHeaderProps {
  data: List;
}

export default function ListHeader({
  data,
}: ListHeaderProps) {

  const [title, setTitle] = useState(data.title);
  const [isEditing, setIsEditing] = useState(false);

  const formRef = useRef<ElementRef<'form'>>(null);
  const inputRef = useRef<ElementRef<'input'>>(null);

  return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      <div className='h-7 w-full px-2.5 py-1 text-sm font-medium border-transparent'>
        {data.title}
      </div>
    </div>
  )
}
Editing mode for ListHeader

Next create the enableEditing and disableEditing functions.

feat: Implement interactive title editing

  • Introduce enableEditing to activate edit mode and focus on the input field.
  • Add disableEditing to exit edit mode and preserve changes.
  • Enhance user interaction by streamlining title editing within the list item.
"use client";

import React, { ElementRef, useRef, useState } from 'react';

import { List } from '@prisma/client';

interface ListHeaderProps {
  data: List;
}

export default function ListHeader({
  data,
}: ListHeaderProps) {

  const [title, setTitle] = useState(data.title);
  const [isEditing, setIsEditing] = useState(false);

  const formRef = useRef<ElementRef<'form'>>(null);
  const inputRef = useRef<ElementRef<'input'>>(null);

  function disableEditing() {
    setIsEditing(false);
  }

  // Enables editing mode and focus input
  function enableEditing() {
    setIsEditing(true);
    setTimeout(() => {
      inputRef.current?.focus();
      inputRef.current?.select();
    });
  }

  return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      <div className='h-7 w-full px-2.5 py-1 text-sm font-medium border-transparent'>
        {data.title}
      </div>
    </div>
  )
}

Now in the output of ListHeader let's try to implement editing mode. It should conditionally render a form in editing mode, otherwise a div with the list title if isEditing is false.

feat(ListHeader): Enhance UX with clickable title and edit mode toggle

  • Introduce conditional rendering to switch between a form for editing and a static view of the list title.
  • Implement an onClick event on the title div that triggers enableEditing, allowing users to enter edit mode directly by clicking on the title.
  • The update enhances the user experience by making the title interaction more intuitive and the transition to edit mode seamless.
export default function ListHeader({
  data,
}: ListHeaderProps) {
  const [title, setTitle] = useState(data.title);

  return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      {isEditing ? (
        <p>Form</p>
      ) : (
        <div 
          onClick={enableEditing}
          className='h-7 w-full px-2.5 py-1 text-sm font-medium border-transparent'
        >
          {title}
        </div>
      )}
    </div>
  )
}

feat: Add 'Escape' key functionality to exit edit mode

  • Implement an escape key event handler within ListHeader to enhance keyboard accessibility.
  • The handleEscapeKey function listens for the 'Escape' key press, allowing users to quickly exit the editing mode without mouse interaction.
  • This feature contributes to a more intuitive and efficient user experience by streamlining the editing process.
import { useEventListener } from 'usehooks-ts';

export default function ListHeader({
  data,
}: ListHeaderProps) {

  const [isEditing, setIsEditing] = useState(false);

  function disableEditing() {
    setIsEditing(false);
  }

  /**
   * When user clicks "Escape" key, it disables editing mode.
   * @param event the key press event
   */
  function handleEscapeKey(event: KeyboardEvent) {
    if (event.key === "Escape") {
      disableEditing();
    }
  }

  // Custom hook that attaches event listeners to DOM elements, the window, or media query lists.
  // Listen for the 'keydown' event on the entire document (window level)
  useEventListener('keydown', handleEscapeKey);

Next, instead of rendering a <p>Form</p> we want to render a form element with two hidden inputs that store the id and boardId. It also contains the FormInput with the proper props passed into it.

feat: Introduce form rendering for title editing

  • Enable a form with FormInput component to appear in ListHeader when in edit mode.
  • Include hidden fields to maintain list id and boardId for potential submission handling.
  • Offer an interactive, user-friendly interface for editing list titles directly within the header.
import FormInput from '@/components/form/FormInput';

export default function ListHeader({
  data,
}: ListHeaderProps) {
  const [isEditing, setIsEditing] = useState(false);

  const formRef = useRef<ElementRef<'form'>>(null);

  return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      {isEditing ? (
        <form className='flex-1 px-[2px]'>
          <input hidden id='id' name='id' value={data.id} />
          <input hidden id='boardId' name='boardId' value={data.boardId} />
          <FormInput
            id='title'
            defaultValue={title}
            placeholder='Enter list title...'
            onBlur={() => {}}
            ref={inputRef}
          />
        </form>
      ) : (
        <div
          onClick={enableEditing}
          className='h-7 w-full px-2.5 py-1 text-sm font-medium border-transparent'
        >
          {title}
        </div>
      )}
    </div>
  )
}

Issue: View and editing mode isn't consistent

We have two modes (i.e., a particular functioning condition or arrangement), a display and edit mode.

When we switch between modes form and div to render input or title, we want the styles and content to match. We also want the text and focus to remain consistent.

feat: Standardize ListHeader edit/display styles for better UX

  • Ensure consistent styling between edit and display modes in ListHeader.
  • Improve user experience by making the transition between states seamless.

components\list\ListHeader.tsx

  return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      {isEditing ? (
        <form className='flex-1 px-[2px]'>
          <input hidden id='id' name='id' value={data.id} />
          <input hidden id='boardId' name='boardId' value={data.boardId} />
          <FormInput
            id='title'
            defaultValue={title}
            placeholder='Enter list title...'
            onBlur={() => {}}
            ref={inputRef}
            className='h-7 px-[7px] py-1 text-sm font-medium border-transparent hover:border-input focus:border-input transition truncate bg-transparent focus:bg-white'
          />
        </form>
      ) : (
        <div
          onClick={enableEditing}
          className='h-7 w-full px-2.5 py-1 text-sm font-medium border-transparent'
        >
          {title}
        </div>
      )}
    </div>
  )

UpdateList server action

Make updateList folder inside /actions and add the following:

  1. Schema
  2. Types
  3. Server Action handler

UpdateList schema

For the zod schema validation we ask: what data do we expect?

  • title
  • id
  • boardId

feat: Implement validation for UpdateList schema with Zod

  • Introduce Zod schema validation to ensure data integrity for UpdateList.
  • Enforce minimum length requirement for 'title' to enhance data quality.
import { z } from 'zod';

/**
 * Define the UpdateList object schema.
 * 
 */
export const UpdateList = z.object({
  title: z.string({
    required_error: "Title is required", 
    invalid_type_error: "Title is required", 
  }).min(3, {
    message: "Must be 3 or more characters long.", 
  }),
  id: z.string(),
  boardId: z.string(),
});

UpdateList types

feat: Establish type definitions for UpdateList

  • Set up type definitions for UpdateList server action
  • Introduce Zod-based type inference for input validation, ensuring reliable data handling.
  • Define ActionState types to streamline error management and data flow in server interactions.
import { z } from 'zod';

// Import List, the expected output type, from Prisma client
import { List } from '@prisma/client';

// Encapsulate the state of various actions (e.g., fetching data, submitting forms, etc.)
// Provides a structured way to handle errors and manage data flow
import { ActionState } from '@/lib/createServerAction';

// Import the UpdateList schema (validation rules)
import { UpdateList } from './updateListSchema';

// Define the input type based on the UpdateList schema
export type InputType = z.infer<typeof UpdateList>;

// Define the output data type (ActionState) with List
export type OutputType = ActionState<InputType, List>;

UpdateList server action

feat: Implement UpdateList handler for secure list updates

  • Add server-side action handler to process list updates with authorization checks.
  • Utilize 'auth' for user and organization ID verification to ensure secure transactions.
  • Integrate 'revalidatePath' to refresh list paths post-update for immediate UI consistency.

docs: Enhance updateList with descriptive comments

  • Added comprehensive comments to the updateList handler for better maintainability.
  • Described the functionality of server-side authentication and cache revalidation.
  • Clarified the purpose and usage of each imported module and function.
// Enforce server-side execution context for security and performance
"use server";

import { auth } from "@clerk/nextjs"; // Authentication module
import { revalidatePath } from "next/cache"; // Cache revalidation module

import { createServerAction } from "@/lib/createServerAction"; // Server action creator
import { database } from "@/lib/database"; // Database interface

import { UpdateList } from "./updateListSchema"; // Input validation schema
import { InputType, OutputType } from "./updateListTypes"; // Type definitions

/**
 * Defines the server action to update a list.
 * @param data an object that contains the data needed to update the list
 * @returns the updated list
 */
async function performAction (data: InputType): Promise<OutputType> {
  // Authenticate the user and get their organization ID
  const { userId, orgId } = auth();

  // If authentication fails, return an error
  if (!userId || !orgId) {
    return {
      error: 'Unauthorized',
    };
  }

  // Destructure the necessary data from the input
  const { 
    title, 
    id,
    boardId,
  } = data;

  // Declare a variable to store the updated list
  let list;

  try {
    // Attempt to update the list in the database
    list = await database.list.update({
      where: {
        id,
        boardId,
        board: {
          // Organization ID for additional security check
          orgId, 
        },
      },
      data: {
        title,
      },
    });
  } catch (error) {
    // If the update fails, return an error
    return {
      error: 'Failed to update list.'
    }
  }

  // Revalidate the cache for the updated board path 
  // to ensure immediate UI consistency post-update
  revalidatePath(`/board/${boardId}`);

  // Return the updated list
  return {
    data: list
  };
}

// Export the server action for external use
export const updateList = createServerAction(UpdateList, performAction);

A recap for "use server" directive, which marks the async performAction as a server action.

// The "use server" directive ensures that this file runs in a server-side context only,
// enhancing security by preventing exposure of sensitive logic to the client side,
// and improving performance by leveraging server resources for execution.
"use server";

ListHeader continued

Navigate back to ListHeader and let's create the server action to update list.

Use updateList server action

feat: Integrate updateList Action in ListHeader

  • Leveraged updateList action within ListHeader for list renaming functionality.
  • Implemented success and error toasts for immediate user feedback on list update status.

components\list\ListHeader.tsx

import { toast } from 'sonner';
import { updateList } from '@/actions/updateList';
import { useServerAction } from '@/hooks/useServerAction';

export default function ListHeader({
  data,
}: ListHeaderProps) {
  // ...
  
  const { executeServerAction } = useServerAction(updateList, {
    onSuccess(data) {
      toast.success(`Renamed to "${data.title}"`);
      setTitle(data.title);
      disableEditing();
    },
    onError(error) {
      toast.error(error);
    },
  });

ListHeader submit handler

Let's implement list title editing in ListHeader.

feat: Implement submit handler for title editing

  • Developed a submit handler in ListHeader.tsx for list title updates.
  • Utilized updateList server action for changes, with feedback via toasts.
  function onSubmit(formData: FormData) {
    // Extract title of the list from FormInput
    const title = formData.get('title') as string;

    // Extract list id and boardId found in the hidden inputs
    const id = formData.get('id') as string;
    const boardId = formData.get('boardId') as string;

    if (title === data.title) {
      return disableEditing();
    }

    // Update the list with the given form data
    executeServerAction({
      title,
      id,
      boardId,
    });
  }

ListHeader onBlur function

feat: Implement onBlur submission for ListHeader editing

  • Added onBlur event handler to ListHeader form input for automatic submission.
  • Ensures list title changes are submitted when input loses focus, streamlining the update process.
  function onBlur() {
    formRef.current?.requestSubmit();
  }

export default function ListHeader({
  data,
}: ListHeaderProps) {
// ...
  return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      {isEditing ? (
        <form className='flex-1 px-[2px]'>
          <input hidden id='id' name='id' value={data.id} />
          <input hidden id='boardId' name='boardId' value={data.boardId} />
          <FormInput
            id='title'
            defaultValue={title}
            placeholder='Enter list title...'
            onBlur={onBlur}
            ref={inputRef}
            className='h-7 px-[7px] py-1 text-sm font-medium border-transparent hover:border-input focus:border-input transition truncate bg-transparent focus:bg-white'
          />
        </form>
      ) : (
        <div
          onClick={enableEditing}
          className='h-7 w-full px-2.5 py-1 text-sm font-medium border-transparent'
        >
          {title}
        </div>
      )}
    </div>
  )
}

Bind ref and onSubmit to form element in ListHeader's edit mode

feat: Bind ref and onSubmit to form in edit mode

  • Bound formRef to the form element to facilitate programmatic actions in edit mode.
  • Attached onSubmit handler to the form to process list title updates on submission.
export default function ListHeader({
  data,
}: ListHeaderProps) {
// ...

  return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      {isEditing ? (

        <form 
          ref={formRef}
          action={onSubmit}
          className='flex-1 px-[2px]'
        >

ListHeader hidden submit button

The hidden submit button in the form (<button type='submit' hidden />) serves a specific purpose in the context of a web application where form submission is intended to be triggered by an event other than the user clicking a submit button. Here's a breakdown of its role:

  • Programmatic Submission: The hidden button allows the form to be submitted programmatically. In your code, the onBlur event handler on the FormInput component calls formRef.current?.requestSubmit(); when the input field loses focus. This method simulates a submit button click, which is why the hidden submit button is necessary.

  • Non-Interactive: Since the button is hidden, it doesn't provide any visual interface or interaction point for the user. It's purely functional and not meant to be interacted with directly.

  • Fallback Mechanism: In some cases, especially with complex forms or dynamic content, having a submit button (even if hidden) ensures that the form can be submitted in various scenarios, such as pressing the Enter key while focusing on a field.

In summary, the hidden submit button is a non-interactive, functional element that enables the form to be submitted through JavaScript without requiring a visible button that the user must click. It's a common technique used to improve user experience by allowing forms to be submitted as a result of custom logic or user actions other than the traditional button click.

feat: Add hidden submit button for streamlined form submission

  • Implemented a hidden submit button in ListHeader to enhance form submission UX.
  • Facilitates automatic form submission on input field's blur event, improving user interaction.
export default function ListHeader({
  data,
}: ListHeaderProps) {
// ...

  return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      {isEditing ? (
        <form 
          ref={formRef}
          action={onSubmit}
          className='flex-1 px-[2px]'
        >
          <input hidden id='id' name='id' value={data.id} />
          <input hidden id='boardId' name='boardId' value={data.boardId} />
          <FormInput
            id='title'
            defaultValue={title}
            placeholder='Enter list title...'
            onBlur={onBlur}
            ref={inputRef}
            className='h-7 px-[7px] py-1 text-sm font-medium border-transparent hover:border-input focus:border-input transition truncate bg-transparent focus:bg-white'
          />
          {/* Hidden submit button */}
          <button type='submit' hidden />
        </form>
      ) : (
        <div
          onClick={enableEditing}
          className='h-7 w-full px-2.5 py-1 text-sm font-medium border-transparent'
        >
          {title}
        </div>
      )}
    </div>
  )
}

ListHeader tests

Create a List then update the title with these actions:

  1. Change the list title, press Enter
  2. Change the list title, click away and activate the on blur
  3. Change the list title, press "Esc" key

Each should trigger the toast notification and submit button to activate the server action to update list title.

ListOptions

We want to add a component that can provide a list of options within the ListHeader. The ListOptions component will open a Popover and allows the user to perform multiple actions or options. Options include adding a card to the list, delete the list, or copy the list.

feat: Add ListOptions for list interactions

Introduce the ListOptions component, enabling users to interact with lists. This component provides functionalities such as adding a card to a list, deleting a list, and copying a list.

Create a ListOptions component with the prop interface that accepts data a List and handleAddCardToList a function that returns void.

feat: Define prop types for ListOptions

components\list\ListOptions.tsx

"use client";

import React from 'react';
import { List } from '@prisma/client';

interface ListOptionsProps {
  data: List;
  handleAddCardToList: () => void;
};

export default function ListOptions({
  data,
  handleAddCardToList,
}: ListOptionsProps) {
  return (
    <div>ListOptions</div>
  )
}

feat(ListHeader): Integrate ListOptions for enhanced list management

Embed the ListOptions component within ListHeader to provide users with interactive capabilities such as adding cards, deleting lists, and copying list data. This update enriches the user interface by facilitating direct list manipulation from the ListHeader component.

import ListOptions from '@/components/list/ListOptions';

export default function ListHeader({
  data,
}: ListHeaderProps) {

  return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      { /* ListHeader display... */ }
      <ListOptions
        data={data}
        handleAddCardToList={() => {}}
      />
    </div>
  )
}

ListOptions output

feat: Add Popover for interactive list management

feat(ListOptions): Implement Popover for interactive list management options

Incorporate a Popover component into ListOptions to provide a dynamic and user-friendly interface for list interactions. This enhancement allows users to perform actions such as adding cards and managing lists within a neatly contained overlay, improving the overall user experience.

"use client";

import React from 'react';
import { List } from '@prisma/client';
import { MoreHorizontal, X } from 'lucide-react';

import { Button } from '@/components/ui/button';
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

interface ListOptionsProps {
  data: List;
  handleAddCardToList: () => void;
};

export default function ListOptions({
  data,
  handleAddCardToList,
}: ListOptionsProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
        {/* Open button */}
        <Button>
          <MoreHorizontal />
        </Button>
      </PopoverTrigger>
      <PopoverContent>
        <div>
          List actions
        </div>
        {/* Close button */}
        <PopoverClose asChild>
          <Button>
            <X />
          </Button>
        </PopoverClose>
        {/* List Actions */}
      </PopoverContent>
    </Popover>
  )
}

style(ListOptions): Enhance UI with refined Popover styling

Elevate the visual design of the ListOptions component by applying a more sophisticated styling to the Popover. This update includes ghost buttons for a cleaner look, precise alignment for better structure, and subtle color adjustments for improved readability.

export default function ListOptions({
  data,
  handleAddCardToList,
}: ListOptionsProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
        {/* Open button */}
        <Button variant='ghost' className='h-auto w-auto p-2'>
          <MoreHorizontal className='h-4 w-4'/>
        </Button>
      </PopoverTrigger>
      <PopoverContent align='start' side='bottom' className='px-0 pt-3 pb-3'>
        <div className='pb-4 text-center text-sm font-medium text-neutral-600'>
          List actions
        </div>
        {/* Close button */}
        <PopoverClose asChild>
          <Button 
            variant='ghost'
            className='absolute top-2 right-2 h-auto w-auto p-2 text-neutral-600'
          >
            <X className='h-4 w-4'/>
          </Button>
        </PopoverClose>
        {/* List Actions */}
      </PopoverContent>
    </Popover>
  )
}

ListOptions actions

Let's first add the button which adds the card to the List.

Add card to list

feat: Add button for user-initiated list updates

Introduce a new button within the ListOptions component that allows users to add cards to their lists directly. This feature streamlines the process of updating list content, making it more intuitive and accessible from the user interface.

export default function ListOptions({
  data,
  handleAddCardToList,
}: ListOptionsProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
        {/* ... */}
      </PopoverTrigger>
      <PopoverContent align='start' side='bottom' className='px-0 pt-3 pb-3'>
        {/* ... */}

        {/* List Actions */}
        <Button
          onClick={handleAddCardToList}
          variant='ghost'
          className='justify-start w-full h-auto p-2 px-5 rounded-none font-normal text-sm'
        >
          Add card +
        </Button>

      </PopoverContent>
    </Popover>
  )
}

User can copy or delete list

Then we want to add the actions that allows the user to copy or delete the list.

There are a few approaches to this.

  • Add a button and activate the useServerAction hook and call an executeServerAction on click
  • Use server actions with form element

We will go with the latter.

Render two form elements which contains a two hidden input elements and a FormSubmitButton. The hidden input elements will extract the id and boardId respectively.

feat(ListOptions): Integrate form-based list management actions

Enhance the ListOptions component with form-driven 'Copy list' and 'Delete list' actions. These server-supported operations empower users to manage their lists more effectively, directly from the UI, with seamless server interaction.

"use client";

import React from 'react';
import { List } from '@prisma/client';
import { MoreHorizontal, X } from 'lucide-react';

import { Button } from '@/components/ui/button';
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from '@/components/ui/separator';
import FormSubmitButton from '@/components/form/FormSubmitButton';

interface ListOptionsProps {
  data: List;
  handleAddCardToList: () => void;
};

export default function ListOptions({
  data,
  handleAddCardToList,
}: ListOptionsProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
        {/* Open button */}
        <Button variant='ghost' className='h-auto w-auto p-2'>
          <MoreHorizontal className='h-4 w-4' />
        </Button>
      </PopoverTrigger>
      <PopoverContent align='start' side='bottom' className='px-0 pt-3 pb-3'>
        <div className='pb-4 text-center text-sm font-medium text-neutral-600'>
          List actions
        </div>
        {/* Close button */}
        <PopoverClose asChild>
          <Button
            variant='ghost'
            className='absolute top-2 right-2 h-auto w-auto p-2 text-neutral-600'
          >
            <X className='h-4 w-4' />
          </Button>
        </PopoverClose>
        {/* List Actions */}
        <Button
          onClick={handleAddCardToList}
          variant='ghost'
          className='justify-start w-full h-auto p-2 px-5 rounded-none font-normal text-sm'
        >
          Add card +
        </Button>
        <form>
          <input hidden id='id' name='id' value={data.id} />
          <input hidden id='boardId' name='boardId' value={data.boardId} />
          <FormSubmitButton>
            Copy list
          </FormSubmitButton>
        </form>
        <Separator />
        <form>
          <input hidden id='id' name='id' value={data.id} />
          <input hidden id='boardId' name='boardId' value={data.boardId} />
          <FormSubmitButton>
            Delete list
          </FormSubmitButton>
        </form>
      </PopoverContent>
    </Popover>
  )
}

CopyList

Make copyList folder inside /actions and add the following:

  1. Schema
  2. Types
  3. Server Action handler

CopyList schema

The CopyList object schema specifies the expected structure of data for deleting a list.

Let's define it using the zod library:

feat: Add CopyList schema validation

  • Introduce Zod schema validation to ensure data integrity for CopyList
  • Schema enforces that both id and boardId are of type string

actions\copyList\copyListSchema.ts

import { z } from 'zod';

/**
 * Define the CopyList object schema.
 * 
 */
export const CopyList = z.object({
  id: z.string(),
  boardId: z.string(),
});

CopyList types

feat: Establish type definitions for CopyList

  • Set up type definitions for CopyList server action
  • Introduce Zod-based type inference for input validation, ensuring reliable data handling.
  • Define ActionState types to streamline error management and data flow in server interactions.

actions\copyList\copyListTypes.ts

import { z } from 'zod';

// Import List, the expected output type, from Prisma client
import { List } from '@prisma/client';

// Encapsulate the state of various actions (e.g., fetching data, submitting forms, etc.)
// Provides a structured way to handle errors and manage data flow
import { ActionState } from '@/lib/createServerAction';

// Import the CopyList schema (validation rules)
import { CopyList } from './copyListSchema';

// Define the input type based on the CopyList schema
export type InputType = z.infer<typeof CopyList>;

// Define the output data type (ActionState) with List
export type OutputType = ActionState<InputType, List>;

CopyList server action

feat: Create copyList server action handler

"use server";

import { auth } from "@clerk/nextjs";
import { revalidatePath } from "next/cache";

import { createServerAction } from "@/lib/createServerAction";
import { database } from "@/lib/database";

import { CopyList } from "./copyListSchema";
import { InputType, OutputType } from "./copyListTypes";

/**
 * Defines a server action to copy a list.
 *
 * @param {InputType} data - An object containing the data needed to copy the list.
 * @returns {Promise<OutputType>} - The copied list or an error message.
 */
async function performAction (data: InputType): Promise<OutputType> {
  const { userId, orgId } = auth();

  if (!userId || !orgId) {
    return {
      error: 'Unauthorized',
    };
  }

  const { 
    id,
    boardId,
  } = data;

  let list;

  // try..catch block to fetch and copy list

  revalidatePath(`/board/${boardId}`);

  return {
    data: list
  };
}

export const copyList = createServerAction(CopyList, performAction);

Let's work on the try block. Let's first fetch the list.

feat: Fetch the list to copy

  try {
    // Find the list to copy
    const foundList = await database.list.findUnique({
      where: {
        id,
        boardId,
        board: {
          orgId,
        },
      },
      include: {
        cards: true,
      },
    });

    if (!foundList) {
      return { error: 'List not found.' };
    }

Next find the latest list order in the board to calculate the nextOrder.

feat: Calculate order of most recent list in board

    // Fetch the most recent list in the board to properly assign the newest order to the list
    const mostRecentList = await database.list.findFirst({
      where: { boardId: boardId },
      orderBy: { order: "desc" },
      select: { order: true },
    });

    // Get the next order depending on whether a mostRecentList is present or not
    const nextOrder = mostRecentList ? mostRecentList.order + 1 : 1;

Create a new copy of the list in the database, including a copy of all the cards.

feat: Implement list copying functionality

    // Create a new copy of the list in the database
    list = await database.list.create({
      data: {
        boardId: foundList.boardId,
        title: `${foundList.title} - Copy`,
        order: nextOrder,
        cards: {
          createMany: {
            data: foundList.cards.map((card) => ({
              title: card.title,
              description: card.description,
              order: card.order
            })),
          },
        },
      },
      include: {
        cards: true,
      },
    });

docs: Update copyList with descriptive comments

feat: Implement copyList server action

This commit introduces a server-side action handler, 'copyList', that securely copies lists. Key features include:

  • Authorization Checks: The handler incorporates stringent authorization checks to validate user and organization IDs before proceeding with list copies.
  • User Verification: Utilizes the 'auth' module from '@clerk/nextjs' to authenticate user sessions and ensure that only authorized users can copy lists.
  • Database Interaction: Employs Prisma ORM for database operations, enabling type-safe transactions and streamlined list copies.
  • UI Consistency: Integrates 'revalidatePath' from 'next/cache' to update list paths dynamically, maintaining immediate consistency across the user interface post-copy.
"use server";

import { auth } from "@clerk/nextjs"; // Authentication module
import { revalidatePath } from "next/cache"; // Cache revalidation module

import { createServerAction } from "@/lib/createServerAction"; // Server action creator
import { database } from "@/lib/database"; // Database interface

import { CopyList } from "./copyListSchema"; // Input validation schema
import { InputType, OutputType } from "./copyListTypes"; // Type definitions

/**
 * Defines a server action to copy a list.
 *
 * @param {InputType} data - An object containing the data needed to copy the list.
 * @returns {Promise<OutputType>} - The copied list or an error message.
 */
async function performAction(data: InputType): Promise<OutputType> {
  // Authenticate the user and get their organization ID
  const { userId, orgId } = auth();

  // If authentication fails, return an error
  if (!userId || !orgId) {
    return {
      error: "Unauthorized",
    };
  }

  // Destructure the necessary data from the input
  const { id, boardId } = data;

  // Declare a variable to store the copied list
  let list;

  try {
    // Find the list to copy
    const foundList = await database.list.findUnique({
      where: {
        id,
        boardId,
        board: {
          orgId,
        },
      },
      include: {
        cards: true,
      },
    });

    // Return an error message if the list to copy is not found
    if (!foundList) {
      return { error: "List not found." };
    }

    // Fetch the most recent list in the board to properly assign the newest order to the list
    const mostRecentList = await database.list.findFirst({
      where: { boardId: boardId },
      orderBy: { order: "desc" },
      select: { order: true },
    });

    // Get the next order depending on whether a mostRecentList is present or not
    const nextOrder = mostRecentList ? mostRecentList.order + 1 : 1;

    // Create a new copy of the list in the database
    list = await database.list.create({
      data: {
        boardId: foundList.boardId,
        title: `${foundList.title} - Copy`,
        order: nextOrder,
        cards: {
          createMany: {
            data: foundList.cards.map((card) => ({
              title: card.title,
              description: card.description,
              order: card.order,
            })),
          },
        },
      },
      include: {
        cards: true,
      },
    });
  } catch (error) {
    // If the copy fails, return an error
    return {
      error: "Failed to copy list.",
    };
  }

  // Revalidate the cache for the board path where list was copied to
  // to ensure immediate UI consistency post-copy
  revalidatePath(`/board/${boardId}`);

  // Return the copied list
  return {
    data: list,
  };
}

// Export the server action for external use
export const copyList = createServerAction(CopyList, performAction);

Use copyList in ListOptions

feat(ListOptions): Implement list copy server action

Create the server action to copy a list, which implements success and error callbacks to display appropriate toasts.

  const { executeServerAction: executeCopyServerAction } = useServerAction(copyList, {
    onSuccess(data) {
      toast.success(`List "${ data.title }" copied.`);
      closeRef.current?.click();
    },
    onError(error) {
      toast.error(error);
    },
  });

Next create the onCopy function to handle the action.

feat: Implement onCopy handler in ListOptions component

This commit introduces the "onCopy" handler within the ListOptions component, which facilitates list management. The handler extracts the list ID and board ID from form data and triggers the copy action. Additionally, error handling has been improved to handle cases where IDs are missing or invalid.

export default function ListOptions({
  data,
  handleAddCardToList,
}: ListOptionsProps) {
  const closeRef = useRef<ElementRef<'button'>>(null);

  /* Copy list server action */
  const { executeServerAction: executeCopyServerAction } = useServerAction(copyList, {
    onSuccess(data) {
      toast.success(`List "${ data.title }" copied.`);
      closeRef.current?.click();
    },
    onError(error) {
      toast.error(error);
    },
  });

  function onCopy(formData: FormData) {
    // Extract list id and boardId found in the hidden inputs
    const id = formData.get('id') as string;
    const boardId = formData.get('boardId') as string;

    executeCopyServerAction({ id, boardId });
  }

feat: Assign onCopy action to corresponding form

This commit updates the ListOptions component to assign the "onCopy" action to the corresponding form. The form includes hidden input fields for the list ID and board ID, which are extracted from the provided data. Additionally, a FormSubmitButton triggers the copy list action.

Changes made:

  • Assigned "onCopy" action to the form
export default function ListOptions({
  data,
  handleAddCardToList,
}: ListOptionsProps) {
  // ...
  return (
    <Popover>
      <PopoverContent align='start' side='bottom' className='px-0 pt-3 pb-3'>

        <form action={onCopy}>
          <input hidden id='id' name='id' value={data.id} />
          <input hidden id='boardId' name='boardId' value={data.boardId} />
          <FormSubmitButton>
            Copy list
          </FormSubmitButton>
        </form>

      </PopoverContent>
    </Popover>
  )
}

copyList simple tests

  1. Create a list
  2. Click the ListOptions component to copy the list
  3. Click the "Copy list" action

This should trigger a toast notification that list was created, then creates a new list with a title 'Original list - Copy'.

DeleteList

Make deleteList folder inside /actions and add the following:

  1. Schema
  2. Types
  3. Server Action handler

DeleteList schema

The DeleteList object schema specifies the expected structure of data for deleting a list.

Let's define it using the zod library:

feat: Implement validation for DeleteList schema with Zod

  • Introduce Zod schema validation to ensure data integrity for DeleteList
  • Schema enforces that both id and boardId are of type string

actions\deleteList\deleteListSchema.ts

import { z } from 'zod';

/**
 * Define the DeleteList object schema.
 * 
 */
export const DeleteList = z.object({
  id: z.string(),
  boardId: z.string(),
});

DeleteList types

feat: Establish type definitions for DeleteList

  • Set up type definitions for DeleteList server action
  • Introduce Zod-based type inference for input validation, ensuring reliable data handling.
  • Define ActionState types to streamline error management and data flow in server interactions.

actions\deleteList\deleteListTypes.ts

import { z } from 'zod';

// Import List, the expected output type, from Prisma client
import { List } from '@prisma/client';

// Encapsulate the state of various actions (e.g., fetching data, submitting forms, etc.)
// Provides a structured way to handle errors and manage data flow
import { ActionState } from '@/lib/createServerAction';

// Import the DeleteList schema (validation rules)
import { DeleteList } from './deleteListSchema';

// Define the input type based on the DeleteList schema
export type InputType = z.infer<typeof DeleteList>;

// Define the output data type (ActionState) with List
export type OutputType = ActionState<InputType, List>;

DeleteList server action

feat: Implement deleteList server action

This commit introduces a server-side action handler, 'deleteList', that securely process list deletions. Key features include:

  • Authorization Checks: The handler incorporates stringent authorization checks to validate user and organization IDs before proceeding with list deletions.
  • User Verification: Utilizes the 'auth' module from '@clerk/nextjs' to authenticate user sessions and ensure that only authorized users can delete lists.
  • Database Interaction: Employs Prisma ORM for database operations, enabling type-safe transactions and streamlined list deletion processes.
  • UI Consistency: Integrates 'revalidatePath' from 'next/cache' to update list paths dynamically, maintaining immediate consistency across the user interface post-deletion.

actions\deleteList\index.ts

// Enforce server-side execution context for security and performance
"use server";

import { auth } from "@clerk/nextjs"; // Authentication module
import { revalidatePath } from "next/cache"; // Cache revalidation module

import { createServerAction } from "@/lib/createServerAction"; // Server action creator
import { database } from "@/lib/database"; // Database interface

import { DeleteList } from "./deleteListSchema"; // Input validation schema
import { InputType, OutputType } from "./deleteListTypes"; // Type definitions

/**
 * Defines the server action to delete a list.
 * @param data an object that contains the data needed to delete the list
 * @returns the deleted list
 */
async function performAction (data: InputType): Promise<OutputType> {
  // Authenticate the user and get their organization ID
  const { userId, orgId } = auth();

  // If authentication fails, return an error
  if (!userId || !orgId) {
    return {
      error: 'Unauthorized',
    };
  }

  // Destructure the necessary data from the input
  const { 
    id,
    boardId,
  } = data;

  // Declare a variable to store the deleted list
  let list;

  // Attempt to delete the list in the database using Prisma ORM
  try {
    list = await database.list.delete({
      where: {
        id,
        boardId,
        board: {
          // Organization ID for additional security check
          orgId, 
        },
      },
    });
  } catch (error) {
    // If the delete fails, return an error
    return {
      error: 'Failed to delete list.'
    }
  }

  // Revalidate the cache for the deleted board path 
  // to ensure immediate UI consistency post-delete
  revalidatePath(`/board/${boardId}`);

  // Return the deleted list
  return {
    data: list
  };
}

// Export the server action for external use
export const deleteList = createServerAction(DeleteList, performAction);

Use deleteList in ListOptions

feat(ListOptions): Implement list delete server action

Create the server action to delete a list, which implements success and error callbacks to display appropriate toasts.

import { toast } from 'sonner';
import { deleteList } from '@/actions/deleteList';
import { useServerAction } from '@/hooks/useServerAction';

export default function ListOptions({
  data,
  handleAddCardToList,
}: ListOptionsProps) {

  /* Delete server action */
  const { executeServerAction: executeDeleteServerAction } = useServerAction(deleteList, {
    onSuccess(data) {
      toast.success(`List "${ data.title }" deleted.`);
    },
    onError(error) {
      toast.error(error);
    },
  });

Now create the delete handler and assign it as the action prop to the form element that corresponds to the list deletion.

feat: Create onDelete handler and assign to form

export default function ListOptions({
  data,
  handleAddCardToList,
}: ListOptionsProps) {

  /* Delete server action */
  const { executeServerAction: executeDeleteServerAction } = useServerAction(deleteList, {
    onSuccess(data) {
      toast.success(`List "${ data.title }" deleted.`);
    },
    onError(error) {
      toast.error(error);
    },
  });

  function onDelete(formData: FormData) {
    // Extract list id and boardId found in the hidden inputs
    const id = formData.get('id') as string;
    const boardId = formData.get('boardId') as string;

    executeDeleteServerAction({ id, boardId });
  }

  return (
    <Popover>
      <PopoverTrigger asChild>
        {/* Open button... */}
      </PopoverTrigger>
      <PopoverContent align='start' side='bottom' className='px-0 pt-3 pb-3'>
        {/* ... */}
        <Separator />
        <form action={onDelete}>
          <input hidden id='id' name='id' value={data.id} />
          <input hidden id='boardId' name='boardId' value={data.boardId} />
          <FormSubmitButton>
            Delete list
          </FormSubmitButton>
        </form>
      </PopoverContent>
    </Popover>
  )

Closing a Popover on action/user interaction.

We want to close the Popover when user calls an action such as deleteList. Create a closeRef, in the onSuccess callback of the server action add closeRef.current?.click(). Then assign the closeRef to the PopoverClose component ref prop.

feat(ListOptions): Automatically close Popover after server action

import React, { ElementRef, useRef } from 'react';

export default function ListOptions({
  data,
  handleAddCardToList,
}: ListOptionsProps) {
  const closeRef = useRef<ElementRef<'button'>>(null);

  const { executeServerAction: executeDeleteServerAction } = useServerAction(deleteList, {
    onSuccess(data) {
      toast.success(`List "${ data.title }" deleted.`);
      
      closeRef.current?.click();
    },
    onError(error) {
      toast.error(error);
    },
  });

    return (
    <Popover>
      <PopoverTrigger asChild>
        {/* Open button */}
        <Button variant='ghost' className='h-auto w-auto p-2'>
          <MoreHorizontal className='h-4 w-4' />
        </Button>
      </PopoverTrigger>
      <PopoverContent align='start' side='bottom' className='px-0 pt-3 pb-3'>
        <div className='pb-4 text-center text-sm font-medium text-neutral-600'>
          List actions
        </div>
        {/* Close button */}
        <PopoverClose ref={closeRef} asChild>
          <Button
            variant='ghost'
            className='absolute top-2 right-2 h-auto w-auto p-2 text-neutral-600'
          >
            <X className='h-4 w-4' />
          </Button>
        </PopoverClose>

ListItem setup

Let's setup ListItem component with isEditing state and an input ref named textAreaRef. Define the enableEditing and disableEditing functions. Then pass the enableEditing to the handleAddCardToList prop of ListHeader.

feat: Add textAreaRef and isEditing state to ListItem

This commit enhances the ListItem component by introducing the following features:

  1. textAreaRef: A reference to a <textarea> element, allowing better control over focus and interaction.
  2. isEditing state: A boolean state that tracks whether the component is in an editing mode.
  3. disableEditing: A function to disable editing mode.
  4. enableEditing: A function to enable editing mode and focus on the text area.

Additionally, the enableEditing function is now passed as a prop to the ListHeader component, enabling the ability to trigger editing behavior.

Changes made:

  • Added textAreaRef and isEditing state
  • Improved focus handling with setTimeout
  • Added enableEditing and disableEditing functions
  • Passed enableEditing prop to ListHeader

components\list\ListItem.tsx

"use client";

import React, { ElementRef, useRef, useState } from 'react';

import { ListWithCards } from '@/types/types';
import ListHeader from '@/components/list/ListHeader';

interface ListItemProps {
  data: ListWithCards;
  index: number;
}

export default function ListItem({
  data,
  index,
}: ListItemProps) {
  const textAreaRef = useRef<ElementRef<"textarea">>(null);
  const [isEditing, setIsEditing] = useState(false);

  function disableEditing() {
    setIsEditing(false);
  }

  function enableEditing() {
    setIsEditing(true);
    setTimeout(() => {
      textAreaRef.current?.focus();
    });
  }

  return (
    <li className='h-full w-72 shrink-0 select-none'>
      <div className='w-full rounded-md bg-[#f1f2f4] shadow-md pb-2'>
        <ListHeader 
          data={data} 
          handleAddCardToList={enableEditing}
        />
      </div>
    </li>
  )
}

Why are we defining the state and ref in ListItem? Recall that we have a method in ListOptions which is used to add a new card.

Pass editing state across List components

feat: Propagate handleAddCardToList action in ListHeader

This enhancement enables communication between the ListItem and ListOptions components by passing the handleAddCardToList action. The action facilitates the editing state management.

Changes made:

  • Passed handleAddCardToList prop from ListHeader
interface ListHeaderProps {
  data: List;
  handleAddCardToList: () => void;
}

export default function ListHeader({
  data,
  handleAddCardToList,
}: ListHeaderProps) {
  // ...

    return (
    <div className='flex pt-2 px-2 text-sm font-semibold justify-between items-start gap-x-2'>
      {/* ... */}
      <ListOptions
        data={data}
        handleAddCardToList={handleAddCardToList}
      />
    </div>
  )
}

CardForm component

With the List components setup, we can now work on the first card component.

Create components\card\CardForm.tsx component.

feat: Define prop types for CardForm component

"use client";

import React from 'react';

interface CardFormProps {
  listId: string;
  isEditing: boolean;
  disableEditing: () => void;
  enableEditing: () => void;
}

export default function CardForm({
  listId,
  isEditing,
  disableEditing,
  enableEditing,
}: CardFormProps) {
  return (
    <div>CardForm</div>
  )
}

Now import and use CardForm in ListItem, render it below the ListHeader.

feat: Render CardForm in ListItem component

export default function ListItem({
  data,
  index,
}: ListItemProps) {
  const textAreaRef = useRef<ElementRef<"textarea">>(null);
  const [isEditing, setIsEditing] = useState(false);

  function disableEditing() {
    setIsEditing(false);
  }

  function enableEditing() {
    setIsEditing(true);
    setTimeout(() => {
      textAreaRef.current?.focus();
    });
  }

  return (
    <li className='h-full w-72 shrink-0 select-none'>
      <div className='w-full rounded-md bg-[#f1f2f4] shadow-md pb-2'>
        <ListHeader 
          data={data} 
          handleAddCardToList={enableEditing}
        />
        <CardForm 
          listId={data.id}
          isEditing={isEditing}
          enableEditing={enableEditing}
          disableEditing={disableEditing}
        />
      </div>
    </li>
  )
}

Configure CardForm with forwardRef

Let's first add an additional prop to the CardForm which accepts the textAreaRef.

feat(ListItem): Forward textAreaRef to CardForm

export default function ListItem({
  data,
  index,
}: ListItemProps) {
  const textAreaRef = useRef<ElementRef<"textarea">>(null);

  return (
    <li className='h-full w-72 shrink-0 select-none'>
      <div className='w-full rounded-md bg-[#f1f2f4] shadow-md pb-2'>
        { /* ListHeader... */ }

        <CardForm
          ref={textAreaRef}
          listId={data.id}
          isEditing={isEditing}
          enableEditing={enableEditing}
          disableEditing={disableEditing}
        />

      </div>
    </li>
  )
}

Next we need to configure CardForm with forwardRef to let the component expose a DOM node to a parent component with a ref.

feat: Enable CardForm to receive and forward a ref

"use client";

import React, { forwardRef } from 'react';

interface CardFormProps {
  listId: string;
  isEditing: boolean;
  disableEditing: () => void;
  enableEditing: () => void;
}

const CardForm = forwardRef<HTMLTextAreaElement, CardFormProps>(({
  listId,
  isEditing,
  disableEditing,
  enableEditing,
}, ref) => {
  return (
    <div>CardForm</div>
  )
});

CardForm.displayName="CardForm";

export default CardForm;

Add displayName when forwarding a ref

Notice that a problem occurs if we don't specify the displayName

Component definition is missing display name eslintreact/display-name

The error message "Component definition is missing display name" typically occurs in React when you define a component (usually a functional component) without specifying a displayName. Let's break it down:

  1. What Causes the Error?

    • When you create a functional component using an arrow function, it doesn't automatically get a displayName.
    • The displayName is used by tools like React DevTools to provide meaningful names for components during debugging.
    • If a component lacks a displayName, it may appear as <Unknown /> in the DevTools.
  2. How to Fix It?

    • To resolve this error, you can assign a displayName to your component. There are a few ways to do this:
      • Named Function Expression:
        export default function MyComponent() {
          // Component implementation...
        }
        In this case, the function name (MyComponent) becomes the displayName.
      • Assign Manually:
        const MyComponent = () => {
          // Component implementation...
        };
        MyComponent.displayName = 'MyComponent';
        export default MyComponent;
        Here, we explicitly set the displayName property.
      • Higher-Order Components (HOCs): If you're using an HOC, ensure that the wrapped component has a displayName.
  3. Why Does displayName Matter?

    • The displayName is mainly used by developer tools for debugging purposes.
    • It helps identify components in the component tree during development.
    • Without a displayName, components may appear as generic placeholders (e.g., <Unknown />).
  4. Example Usage: Suppose you have a LoadingSpinner component. You can set its displayName like this:

    export default function LoadingSpinner() {
      // Component implementation...
    }
    LoadingSpinner.displayName = 'LoadingSpinner';

Remember that while displayName is not strictly required for your application to function correctly, it's good practice to provide meaningful names for better debugging and maintainability.

Another solution is to rewrite CardForm to a function declaration. Here's how to do that while being able to receieve and forward a ref:

import React, { forwardRef, Ref } from 'react';

interface CardFormProps {
  listId: string;
  isEditing: boolean;
  disableEditing: () => void;
  enableEditing: () => void;
}

function CardForm({
  listId,
  isEditing,
  disableEditing,
  enableEditing,
}: CardFormProps, ref: Ref<HTMLDivElement>) {
  // Your CardForm component implementation...
  // Use the ref as needed (e.g., attach it to a DOM element)
  return (
    <div ref={ref}>CardForm</div>
  );
}

export default forwardRef(CardForm);

CardForm output

feat(CardForm): Implement "Add Card" button

import React, { forwardRef } from 'react';
import { Plus } from 'lucide-react';

import { Button } from '@/components/ui/button';

const CardForm = forwardRef<HTMLTextAreaElement, CardFormProps>(({
  listId,
  isEditing,
  disableEditing,
  enableEditing,
}, ref) => {
  return (
    <div>
      <Button onClick={enableEditing}>
        <Plus />
        Add card
      </Button>
    </div>
  )
});

style: Make subtle, compact button for adding new cards

The styles create a compact, subtle button with appropriate spacing and visual cues for adding a new card.

const CardForm = forwardRef<HTMLTextAreaElement, CardFormProps>(({
// ...
}, ref) => {
  return (
    <div className='pt-2 px-2'>
      <Button 
        onClick={enableEditing}
        size='sm'
        variant='ghost'
        className='justify-start h-auto px-2 py-1.5 w-full text-sm text-muted-foreground'
      >
        <Plus className='h-4 w-4 mr-2'/>
        Add card
      </Button>
    </div>
  )
});

FormTextArea component

feat: Define prop types for FormTextArea component

components\form\FormTextArea.tsx

"use client";

import React, { KeyboardEventHandler } from 'react';

interface FormTextAreaProps {
  id: string;
  label?: string;
  value?: string;
  defaultValue?: string;
  placeholder?: string;
  required?: boolean;
  disabled?: boolean;
  errors?: Record<string, string[] | undefined>;
  className?: string;
  onBlur?: () => void;
  onClick?: () => void;
  onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
  onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement> | undefined;
}

export default function FormTextArea() {
  return (
    <div>FormTextArea</div>
  )
}

Configure FormTextArea with forwardRef

Let's also setup the forwardRef, this time pass in a named function to forwardRef() so we don't need to add a displayName.

feat: Enable FormTextArea to receive and forward a ref

"use client";

import React, { KeyboardEventHandler, Ref, forwardRef } from 'react';

interface FormTextAreaProps {
  id: string;
  label?: string;
  value?: string;
  defaultValue?: string;
  placeholder?: string;
  required?: boolean;
  disabled?: boolean;
  errors: Record<string, string[] | undefined>;
  className?: string;
  onBlur?: () => void;
  onClick?: () => void;
  onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
  onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement> | undefined;
}

const FormTextArea = forwardRef<HTMLTextAreaElement, FormTextAreaProps>(
  function FormTextArea(
    {
      id,
      label,
      value,
      defaultValue,
      placeholder,
      required,
      disabled,
      errors,
      className,
      onBlur,
      onClick,
      onChange,
      onKeyDown,
    }: FormTextAreaProps,
    ref: Ref<HTMLTextAreaElement>
  ) {
    return (
      <div>
        {/* Render FormTextArea component */}
        <textarea
          id={id}
          ref={ref} // Attach the ref to the textarea
          value={value} 
          onChange={onChange} 
          placeholder={placeholder}
        // Add other necessary props
        />
      </div>
    );
  }
);

export default FormTextArea;

FormTextArea output

npx shadcn-ui@latest add textarea

Create a nested div, then render a Label component on the conditionally using label.

feat: Conditionally render Label in FormTextArea

import { Label } from '@/components/ui/label';

const FormTextArea = forwardRef<HTMLTextAreaElement, FormTextAreaProps>(
  function FormTextArea(
    {
      // ...props
    }: FormTextAreaProps,
    ref: Ref<HTMLTextAreaElement>
  ) {
    return (
      <div className='w-full space-y-2'>
        <div className='w-full space-y-1'>
          {label ? (
            <Label
              htmlFor={id}
              className='text-xs text-neutral-700 font-semibold'
            >
              {label}
            </Label>
          ): null}
        </div>
      </div>
    );
  }
);

Add Textarea component

After that add the Textarea component passing in all the props.

feat: Render Textarea component in FormTextArea

import { Textarea } from '@/components/ui/textarea';

interface FormTextAreaProps {
  id: string;
  label?: string;
  value?: string;
  defaultValue?: string;
  placeholder?: string;
  required?: boolean;
  disabled?: boolean;
  errors: Record<string, string[] | undefined>;
  className?: string;
  onBlur?: () => void;
  onClick?: () => void;
  onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
  onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement> | undefined;
}

const FormTextArea = forwardRef<HTMLTextAreaElement, FormTextAreaProps>(
  function FormTextArea(
    {
      id,
      label,
      value,
      defaultValue,
      placeholder,
      required,
      disabled,
      errors,
      className,
      onBlur,
      onClick,
      onChange,
      onKeyDown,
    }: FormTextAreaProps,
    ref: Ref<HTMLTextAreaElement>
  ) {
    return (
      <div className='w-full space-y-2'>
        <div className='w-full space-y-1'>
          {label ? (
            <Label
              htmlFor={id}
              className='text-xs text-neutral-700 font-semibold'
            >
              {label}
            </Label>
          ): null}
          <Textarea 
            ref={ref}
            id={id}
            name={id}
            value={value}
            defaultValue={defaultValue}
            placeholder={placeholder}
            required={required}
            disabled={disabled}
            onBlur={onBlur}
            onClick={onClick}
            onChange={onChange}
            onKeyDown={onKeyDown}
            aria-describedby={`${id}-error`}
          />
        </div>
      </div>
    );
  }
);

feat: Add more props to Textarea component

Added the following props to the Textarea component:

  • value: To control the input value programmatically.
  • defaultValue: To set the initial value when the component mounts.
  • aria-describedby: To associate the input with an error message (if any).

feat: Enable customizable styles for the Textarea

Introduce cn utility function to enable customizable styles for the Textarea component. This enhancement promotes flexibility and maintainability in styling.

style: Apply consistent base styles to Textarea

Applied base styles to the Textarea component to achieve consistency across browsers and enhance usability. The following changes were made:

  • Disable resizing of the textarea element (resize-none)
  • Remove focus rings (ring-0 and focus-visible:ring-0)
  • Add a subtle shadow (shadow-sm)
import { cn } from '@/lib/utils';

const FormTextArea = forwardRef<HTMLTextAreaElement, FormTextAreaProps>(
  function FormTextArea(
    {
      // ...props
    }: FormTextAreaProps,
    ref: Ref<HTMLTextAreaElement>
  ) {
    return (
      <div className='w-full space-y-2'>
        <div className='w-full space-y-1'>
          { /* Label... */ }
          <Textarea 
            className={cn(
              'resize-none shadow-sm ring-0 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0',
              className
            )}
          />
        </div>
      </div>
    );
  }
);

Display errors in FormTextArea

feat: Add FormErrors to display validation errors

Add the FormErrors component to handle validation errors for the FormTextArea component. The FormErrors component receives the id and errors as props, allowing it to display relevant error messages associated with the textarea input field.

By integrating FormErrors, we enhance the user experience by providing clear feedback when form validation fails.

import FormErrors from '@/components/form/FormErrors';

const FormTextArea = forwardRef<HTMLTextAreaElement, FormTextAreaProps>(
  function FormTextArea(
    {
      // ...props
    }: FormTextAreaProps,
    ref: Ref<HTMLTextAreaElement>
  ) {
    return (
      <div className='w-full space-y-2'>
        <div className='w-full space-y-1'>
          { /* Label... */ }
          { /* Textarea... */ }
        </div>
        <FormErrors 
          id={id}
          errors={errors}
        />
      </div>
    );
  }
);

Disable textarea input with useFormStatus

Let's import useFormStatus from react-dom and use the pending status to disable the Textarea.

feat: Specify textarea behavior on form submission

This commit enhances the behavior of the textarea input with the useFormStatus hook. The disabled attribute is set to true when the form is in a pending state.

By disabling the input during form submission, we prevent users from making further changes until the operation completes. This behavior enhances the overall user experience.

import { useFormStatus } from 'react-dom';

const FormTextArea = forwardRef<HTMLTextAreaElement, FormTextAreaProps>(
  function FormTextArea(
    {
      // ...props
    }: FormTextAreaProps,
    ref: Ref<HTMLTextAreaElement>
  ) {
    const { pending } = useFormStatus();

    return (
      <div className='w-full space-y-2'>
        <div className='w-full space-y-1'>
          { /* Label... */ }
          <Textarea 
            ref={ref}
            id={id}
            name={id}
            value={value}
            defaultValue={defaultValue}
            placeholder={placeholder}
            required={required}
            disabled={pending || disabled}
            onBlur={onBlur}
            onClick={onClick}
            onChange={onChange}
            onKeyDown={onKeyDown}
            aria-describedby={`${id}-error`}
            className={cn(
              'resize-none shadow-sm ring-0 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0',
              className
            )}
          />
        </div>
          { /* Form Errors... */ }
      </div>
    );
  }
);

CardForm edit mode

Back in CardForm, when in editing mode render the FormTextArea.

feat(CardForm): Render FormTextArea in editing mode

components\card\CardForm.tsx

import FormTextArea from '@/components/form/FormTextArea';

const CardForm = forwardRef<HTMLTextAreaElement, CardFormProps>(({
  listId,
  isEditing,
  disableEditing,
  enableEditing,
}, ref) => {

  if (isEditing) {
    return (
      <form>
        <FormTextArea />
      </form>
    )
  }

  return (
    { /* ... */ }
  )
});

style(CardForm): Add padding and spacing to form

const CardForm = forwardRef<HTMLTextAreaElement, CardFormProps>(({
  // ...
}, ref) => {

  if (isEditing) {
    return (
      <form className='px-1 py-0.5 m-1 space-y-4'>
        <FormTextArea />
      </form>
    )
  }

feat: Add inputs in CardForm edit mode

  • Introduce the FormTextArea component within CardForm to facilitate editing
  • Include a hidden input element to store the listId for internal reference

feat(CardForm): Pass props to FormTextArea

const CardForm = forwardRef<HTMLTextAreaElement, CardFormProps>(({
  // ...
}, ref) => {

  if (isEditing) {
    return (
      <form className='px-1 py-0.5 m-1 space-y-4'>
        <FormTextArea 
          id='title'
          label='title'
          value='title'
          defaultValue='title'
          placeholder="Enter a title for this card..."
          required={false}
          errors={fieldErrors}
          className={''}
          onBlur={() => { }}
          onClick={() => { }}
          onChange={() => { }}
          onKeyDown={onTextAreaKeyDown}
          ref={ref}
        />
      </form>
    )
  }

}

refactor: Remove unneeded props from FormTextArea

This change ensures that the CardForm component behaves correctly, allowing initial card editing before creation.

const CardForm = forwardRef<HTMLTextAreaElement, CardFormProps>(({
  // ...
}, ref) => {

  if (isEditing) {
    return (
      <form className='px-1 py-0.5 m-1 space-y-4'>
        <FormTextArea 
          id='title'
          placeholder="Enter a title for this card..."
          errors={}
          onKeyDown={() => {}}
   

Clone this wiki locally