DEV Community

Cover image for Task tracker application using NextJS and SurrealDB
Sourab Pramanik
Sourab Pramanik

Posted on

Task tracker application using NextJS and SurrealDB

In this article, I have shared how I have built a simple task-tracking full-stack application using NextJS and SurrealDB.

I wanted to explore SurrealDB and all of its features by building this task-tracking application. In this application, all the tasks can be stored, fetched, updated, and deleted in the SurrealDB. On top of it, I have also used NextAuth to authenticate users and store the user records in SurrealDB.

All these features can be achieved using any database but I specifically used SurrealDB to test their Live Query feature and SurrealQL which is their query language.

SurrealQL is just like SQL with similar syntax but with a better approach to writing query statements which is very straightforward. Especially when writing statements for creating relations between records is very easy and intuitive. They use graph relations to relate the records which is very interesting to me.

The Live Query feature helps get real-time updates whenever a CREATE, UPDATE, or DELETE event is triggered.

Interesting right!!

Setup SurrealDB server

There are many ways you can install and run SurrealDB server based on your operating system so please check the documentation. I am going to use Docker to run the SurrealDB server for this project.

To run SurrealDB server using docker you first need docker to be installed in your machine so check the process of installation.

Run the below command to run the SurrealDB server on port 8000.

docker run --rm --pull always -p 8000:8000 -u root surrealdb/surrealdb:latest start --auth --user root --pass root file:/container-dir/dev.db
Enter fullscreen mode Exit fullscreen mode

This command will run the server which will have a database with the name dev, the username of the admin is root and the password is also root

After the server has successfully started you will see this in your terminal window.

terminal screenshot

Project setup

Install and configure NextJS application

Run this command to create a new NextJS application

npx create-next-app@latest task-tracker --typescript --tailwind --eslint
Enter fullscreen mode Exit fullscreen mode

Configure your application by selecting these options when prompted

Would you like to use `src/` directory? No
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? Yes
What import alias would you like configured? @/*
Enter fullscreen mode Exit fullscreen mode

And go inside the project

cd task-tracker
Enter fullscreen mode Exit fullscreen mode

Install and configure Shandcn UI

I have used Shadcn UI in this project for creating components. I like Shadcn UI because it offers lots of components with minimal setup, great looking elements and uses Tailwind.

To setup Shadcn UI run the below command

npx shadcn-ui@latest init
Enter fullscreen mode Exit fullscreen mode

Configure Shadcn by selecting these options when prompted

Would you like to use TypeScript (recommended)? yes
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › › app/globals.css
Do you want to use CSS variables for colors? › 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? › yes
Enter fullscreen mode Exit fullscreen mode

Add components from Shadcn

Runs these commands to add all the components I have used in this project

npx shadcn-ui@latest add form button avatar dropdown-menu sonner table badge checkbox command popover separator select radio-group 
Enter fullscreen mode Exit fullscreen mode

Install JavaScript SDK for SurrealDB

Run this below command to install JavaScript SDK for SurrealDB

npm install --save surrealdb.js
Enter fullscreen mode Exit fullscreen mode

Install NextAuth

I am using NextAuth to create and authenticate the users with the help of GitHub, Google, and Email providers. It has a wide range of authentication providers and since it is an open-source project I use it for most of my projects.

Run the below command to install NextAuth in your application

npm install next-auth
Enter fullscreen mode Exit fullscreen mode

Install SurrealDB adapter for NextAuth

Adapters are used to connect your databases to NextAuth so that we don’t have to write any queries to store or access user data during authentication process. NextAuth will create a non-existing user in our database and creates a session for the user.

Run the below command to install the adapter

npm install @auth/surrealdb-adapter
Enter fullscreen mode Exit fullscreen mode

Install other required packages

Zod and react-hook-form are used to define schemas, validate user input in the forms, and handle form submissions.

npm install zod react-hook-form
Enter fullscreen mode Exit fullscreen mode

To create the task table I have used [@tanstack/react-table](https://tanstack.com/table/v8) as it has many features like searching, pagination, sorting, and filtering. As it is a Headless table library it handles most of the complex tasks on its own.

npm install @tanstack/react-table
Enter fullscreen mode Exit fullscreen mode

Zustand for global state management and syncing state during hydration

npm install zustand
Enter fullscreen mode Exit fullscreen mode

SWR for client-side data fetching and revalidation. It has a good caching strategy.

npm i swr
Enter fullscreen mode Exit fullscreen mode

Nanoid for generation unique short ID

npm i nanoid
Enter fullscreen mode Exit fullscreen mode

I have used Lucide icons for icons

npm install lucide
Enter fullscreen mode Exit fullscreen mode

Next themes is used for themes switching

npm install next-themes
Enter fullscreen mode Exit fullscreen mode

These are all the dependencies that I have used in this project.

Connect NextJS application to SurrealDB server

Now we need to connect the NextJS application to the SurrealDB server instance running on port 8000.

Create .env.local in the root of your project and add these variables

NEXT_PUBLIC_DB_CONNECTION_URL=http://localhost:8000
NEXT_PUBLIC_NAMESPACE=dev
NEXT_PUBLIC_DB_NAME=dev
NEXT_PUBLIC_DB_USER=root
NEXT_PUBLIC_DB_PASSWORD=root
Enter fullscreen mode Exit fullscreen mode

Make sure to use the same values you used for running the SurrealDB server

Create /app/api/lib/surreal.ts

import { Surreal } from "surrealdb.js";

const connectionString = process.env.NEXT_PUBLIC_DB_CONNECTION_URL as string;
const user = process.env.NEXT_PUBLIC_DB_USER as string;
const pass = process.env.NEXT_PUBLIC_DB_PASSWORD as string;
const ns = process.env.NEXT_PUBLIC_NAMESPACE as string;
const db = process.env.NEXT_PUBLIC_DB_NAME as string;

export const surrealDatabase = new Surreal();

export const surrealConnection = new Promise<Surreal>(async (resolve, reject) => {

    try {
        await surrealDatabase.connect(`${connectionString}/rpc`, {
            ns, db, auth: { user, pass }
        })
        resolve(surrealDatabase)
    } catch (e) {
        reject(e)
    }
});
Enter fullscreen mode Exit fullscreen mode

That’s it the NextJS application is now connected to the SurrealDB server.

Setup NextAuth in your application

Setup GitHub for authentication

Create a new GitHub application for authentication and generate a Client ID and Client Secret by following this documentation.

Setup Google for authentication

Create a new project in the Google Cloud console for authentication and generate a Client ID and Client Secret by following this documentation.

Add Authorized redirect URIs
For production: https://{YOUR_DOMAIN}/api/auth/callback/google

For development: http://localhost:3000/api/auth/callback/google

Add the credential to .env.local

NEXTAUTH_SECRET=gsfasd4234523dffsds21q1
NEXTAUTH_URL=http://localhost:3000

NEXT_PUBLIC_BASE_URL=http://localhost:3000

GOOGLE_CLIENT_ID=17855432281717-cor3fulo8g9gxxxxxxxxxxxvk775h.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-YmfXBxxxxxxxxxxuctcpx0uoo

GITHUB_CLIENT_ID=6438xxxxxxxa0b
GITHUB_CLIENT_SECRET=d09d35bf86fcxxxxxxxxxxx498dc62690ad
Enter fullscreen mode Exit fullscreen mode

Create an authentication route handler

Create a new route handler which will be used by NextAuth to create and authenticate users. I have used the JWT strategy to validate user sessions

Add GitHub and Google providers by using the credentials generated earlier and also add an Email provider to let the users create and access their account using only their email address.

Usually when using Email provider we have to send a verification link to the users email address to verify the user if the user does not exists in our database. But in this case we will print the verification link in the terminal window to keep the process short.

I will create another article later on how to setup email provider to send verification links to user’s email address.

Create /app/api/auth/[…nextauth]/route.ts

import NextAuth, { AuthOptions, User } from "next-auth"

import GoogleProvider from "next-auth/providers/google";
import GitHubProvider from "next-auth/providers/github";
import EmailProvider from "next-auth/providers/email";
import { SurrealDBAdapter } from "@auth/surrealdb-adapter"
import { surrealConnection } from "../../lib/surreal";

export const authOptions: AuthOptions = {
    pages: {
        error: "/auth",
        signIn: '/auth',
    },
    providers: [
        GitHubProvider({
            clientId: process.env.GITHUB_CLIENT_ID as string,
            clientSecret: process.env.GITHUB_CLIENT_SECRET as string
        }),
        GoogleProvider({
            clientId: process.env.GOOGLE_CLIENT_ID as string,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
        }),
        EmailProvider({
            async sendVerificationRequest({ url }) {
                console.log(url);
            },
        }),
    ],
    adapter: SurrealDBAdapter(surrealConnection),
    session: { strategy: "jwt" },
    cookies: {
        sessionToken: {
            name: `next-auth.session-token`,
            options: {
                httpOnly: true,
                sameSite: "lax",
                path: "/",
                domain: undefined,
                secure: false,
            },
        },
    },
    callbacks: {
        jwt: async ({ token, user }) => {
            if (!token.email) {
                return {};
            }
            if (user) {
                token.user = user;
            }
            return token;
        },
        session: async ({ session, token }) => {
            (session.user as User) = {
                id: token.sub,
                // @ts-ignore
                ...(token || session).user,
            };
            // console.log("session", session);
            return session;
        },
    },
};

const handler = NextAuth(authOptions)

export { handler as GET, handler as POST }
Enter fullscreen mode Exit fullscreen mode

You can see that I have used an option adapter and provided SurrealDB adapter which takes the connection configuration we created earlier so that now NextAuth can create, read, update, and delete users and manage their sessions.

Cool right!!

Create /types/nextauth.d.ts to update the Session interface used by NextAuth

import NextAuth from "next-auth"

declare module "next-auth" {
    interface Session {
        user: {
            id: string
            email: string,
            name: string,
            image: string,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Create authentication form

Create /components/form/user-auth-form.tsx

"use client";

import * as React from "react";

import { signIn } from "next-auth/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";

import { cn } from "@/lib/utils";
import { Github, Loader } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import {
  FormField,
  FormItem,
  FormControl,
  FormMessage,
  Form,
} from "../ui/form";

interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {
  next: any;
}

const formSchema = z.object({
  email: z.string().min(2).max(50).email("Invalid email address"),
});

export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
  const [isLoginWithEmail, setIsLoginWithEmail] =
    React.useState<boolean>(false);
  const [isLoginWithGithub, setIsLoginWithGithub] =
    React.useState<boolean>(false);
  const [isLoginWithGoogle, setIsLoginWithGoogle] =
    React.useState<boolean>(false);

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      email: "",
    },
  });

  function onSubmit(values: z.infer<typeof formSchema>) {
    setIsLoginWithEmail(true);
    signIn("email", {
      email: values.email,
      redirect: false,
      ...(props.next && props.next.length > 0
        ? { callbackUrl: props.next }
        : {}),
    }).then((res) => {
      if (res?.ok && !res?.error) {
        toast.success("Check your terminal");
      } else {
        toast.error("Something went wrong");
      }
      setIsLoginWithEmail(false);
    });
  }

  function onLoginWithGoogle() {
    setIsLoginWithGoogle(true);
    signIn("google", {
      redirect: false,
      ...(props.next && props.next.length > 0
        ? { callbackUrl: props.next }
        : {}),
    })
      .then(() => {
        toast.success("Redirecting...");
      })
      .catch(() => {
        toast.error("Something went wrong.");
      })
      .finally(() => {
        setIsLoginWithGoogle(false);
      });
  }

  function onLoginWithGithub() {
    setIsLoginWithGithub(true);
    signIn("github", {
      redirect: false,
      ...(props.next && props.next.length > 0
        ? { callbackUrl: props.next }
        : {}),
    })
      .then(() => {
        toast.success("Redirecting...");
      })
      .catch(() => {
        toast.error("Something went wrong.");
      })
      .finally(() => {
        setIsLoginWithGoogle(false);
      });
  }

  return (
    <div className={cn("grid gap-6", className)} {...props}>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormControl>
                  <Input placeholder="Enter your email" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button disabled={isLoginWithEmail} className="w-full">
            {isLoginWithEmail && (
              <Loader className="mr-2 h-4 w-4 animate-spin" />
            )}
            Sign In with Email
          </Button>
        </form>
      </Form>
      <div className="relative">
        <div className="absolute inset-0 flex items-center">
          <span className="w-full border-t" />
        </div>
        <div className="relative flex justify-center text-xs uppercase">
          <span className="bg-background px-2 text-muted-foreground">
            Or continue with
          </span>
        </div>
      </div>
      <Button
        onClick={() => onLoginWithGoogle()}
        variant="outline"
        type="button"
        disabled={isLoginWithGoogle}
      >
        {isLoginWithGoogle ? (
          <Loader className="mr-2 h-4 w-4 animate-spin" />
        ) : (
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 488 512"
            fill="currentColor"
            className="mr-2 h-4 w-4"
          >
            <path d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z" />
          </svg>
        )}{" "}
        Google
      </Button>
      <Button
        onClick={() => onLoginWithGithub()}
        variant="outline"
        type="button"
        disabled={isLoginWithGithub}
      >
        {isLoginWithGithub ? (
          <Loader className="mr-2 h-4 w-4 animate-spin" />
        ) : (
          <Github className="mr-2 h-4 w-4" />
        )}{" "}
        GitHub
      </Button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create a new route for the authentication page /app/auth/page.tsx

import { Metadata } from "next";

import { UserAuthForm } from "@/components/forms/user-auth-form";
import { useParams } from "next/navigation";

export const metadata: Metadata = {
  title: "Authentication",
  description: "Authentication forms built using the components.",
};

export default function AuthenticationPage() {
  const { next } = useParams as { next?: string };

  return (
    <>
      <div className="container relative h-screen flex items-center justify-center">
        <div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
          <div className="flex flex-col space-y-2 text-center">
            <h1 className="text-2xl font-semibold tracking-tight">
              Get Started
            </h1>
            <p className="text-sm text-muted-foreground">
              Enter your email below to get started
            </p>
          </div>
          <UserAuthForm next={next} />
        </div>
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Protecting routes using middleware

Middleware is used to run any code before any requests or responses can be completed, make modifications to their headers, rewrite the response body, and redirect to another route. In this project I have created a middleware to check if a user has a valid session then they are allowed to access protected routes like the task board page, create and edit task pages, or else they will be redirected to the login page.

Create /lib/app-middleware.ts

import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";

export default async function AppMiddleware(req: NextRequest) {
    const path = req.nextUrl.pathname;
    const token = (await getToken({
        req,
        secret: process.env.NEXTAUTH_SECRET,
    })) as {
        email?: string;
        user?: {
            createdAt?: string;
        };
    };

    if (!token?.email && path !== "/auth") {
        return NextResponse.redirect(
            new URL(
                `/auth${path !== "/" ? `?next=${encodeURIComponent(path)}` : ""}`,
                req.url,
            ),
        );

        // if there's a token
    } else if (token?.email) {
        if (
            token?.user?.createdAt &&
            new Date(token?.user?.createdAt).getTime() > Date.now() - 10000 &&
            path !== "/"
        ) {
            return NextResponse.redirect(new URL("/", req.url));

        } else if (path === "/auth") {
            return NextResponse.redirect(new URL("/", req.url));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Create /middleware.ts and import AppMiddleware

import { NextRequest } from "next/server";
import AppMiddleware from "./lib/app-middleware";

export const config = {
    matcher: [
        "/((?!api/|_next/|_proxy/|_auth/|_static|_vercel|favicon.ico|sitemap.xml).*)",
    ],
};

export default async function middleware(req: NextRequest) {

    return AppMiddleware(req);
}
Enter fullscreen mode Exit fullscreen mode

Use session provider to get user session on the client side

Create /lib/provider/nextAuthSessionProvider.ts

"use client";
import React from "react";
import { Session } from "next-auth";
import { SessionProvider } from "next-auth/react";

const NextAuthSessionProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  return <SessionProvider>{children}</SessionProvider>;
};
export default NextAuthSessionProvider;
Enter fullscreen mode Exit fullscreen mode

Update /app/layout.tsx to use session provider wrapper

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

import NextAuthSessionProvider from "@/lib/provider/nextAuthSessionProvider";

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

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

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

Create logout and theme toggle components

Logout component using sign-out function from NextAuth

Create /components/profile.tsx

"use client";

import { signOut, useSession } from "next-auth/react";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "./ui/dropdown-menu";

export default function Profile() {
  const { data: session, status } = useSession();
  return (
    status === "authenticated" && (
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" className="relative h-8 w-8 rounded-full">
            <Avatar className="h-8 w-8">
              <AvatarImage
                src={session?.user?.image ?? ""}
                alt={session?.user?.name ?? ""}
              />
              <AvatarFallback>
                {session?.user?.name
                  ? session?.user?.name.slice(0, 2).toUpperCase()
                  : ""}
              </AvatarFallback>
            </Avatar>
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent className="w-56" align="end" forceMount>
          <DropdownMenuLabel className="font-normal">
            <div className="flex flex-col space-y-1">
              <p className="text-sm font-medium leading-none">
                {session?.user?.name}
              </p>
              <p className="text-xs leading-none text-muted-foreground">
                {session?.user?.email}
              </p>
            </div>
          </DropdownMenuLabel>
          <DropdownMenuSeparator />
          <DropdownMenuItem
            onClick={() =>
              signOut({
                callbackUrl: `${window.location.origin}`,
              })
            }
          >
            Log out
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

Create theme toggle (optional)

Use next-theme to create a theme provider and wrap the root layout with it and then create a new toggle component to switch themes

Create /lib/provider/theme-provider.tsx

"use client";

import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
Enter fullscreen mode Exit fullscreen mode

Create theme toggle /components/theme-toggle.tsx

"use client";

import * as React from "react";
import { useTheme } from "next-themes";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { MoonIcon, SunIcon } from "lucide-react";

export default function ThemeToggle() {
  const { setTheme } = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
Enter fullscreen mode Exit fullscreen mode

Add theming and logout feature to your application

Update /app/layout.tsx to use ThemeProvider and theme-toggle component

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

import { ThemeProvider } from "@/lib/provider/theme-provider";
import ThemeToggle from "@/components/theme-toggle";
import NextAuthSessionProvider from "@/lib/provider/nextAuthSessionProvider";
import Profile from "@/components/profile";
import { Toaster } from "@/components/ui/sonner";

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

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          <NextAuthSessionProvider>
            <div className="fixed top-8 right-8 z-20 flex items-center gap-5">
              <Profile />
              <ThemeToggle />
            </div>
            <main className="bg-background text-foreground">{children}</main>
            <Toaster />
          </NextAuthSessionProvider>
        </ThemeProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

To test the authentication is working, start the NextJS application using npm run dev command. It should run at port 3000 and open the URL in your browser.

Add schemas for the table in the database and keep our application type safe

Create /lib/schema.ts

import { z } from "zod"

export function record<Table extends string = string>(table?: Table) {
    return z.custom<`${Table}:${string}`>(
        (val) =>
            typeof val === 'string' && table
                ? val.startsWith(table + ':')
                : true,
        {
            message: ['Must be a record', table && `Table must be: "${table}"`]
                .filter((a) => a)
                .join('; '),
        }
    );
}

export const taskSchema = z.object({
    id: z.string().optional(),
    title: z.string().min(2, "Add a descriptive title"),
    description: z.string().optional(),
    status: z.enum(["todo", "inprogress", "canceled", "done"], {
        required_error: "You need to select a status.",
    }),
    label: z.enum(["bug", "feature", "documentation"], {
        required_error: "You need to select a label.",
    }),
    priority: z.enum(["high", "low", "moderate"], {
        required_error: "You need to set the priority.",
    }),
    author: record('user'),
})

export const userSchema = z.object({
    id: z.string().readonly(),
    name: z.string().optional().readonly(),
    email: z.string().readonly(),
    image: z.string().optional().readonly(),
})

export type Task = z.infer<typeof taskSchema>
export type User = z.infer<typeof userSchema>
Enter fullscreen mode Exit fullscreen mode

To set the author from the user table in the database for each task I have used a record() which creates a custom type using Zod and this was taken from SurrealDB’s example repo in GitHub

Create task request route handlers

Create /app/api/task/route.ts to create a new task and fetch all tasks

import { Task } from "@/lib/schema";
import { surrealDatabase } from "../lib/surreal";
import { nanoid } from "nanoid";
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "../auth/[...nextauth]/route";

// Create new task
export async function POST(request: Request) {
    const payload = await request.json();
    const taskId = nanoid(5)
    const session = await getServerSession(authOptions);

    const response = await surrealDatabase.query<[Task]>(
        `CREATE task:${taskId} SET title='${payload.title}', description='${payload.description}', status='${payload.status}', label='${payload.label}', priority='${payload.priority}', author='user:${session?.user.id}';`
    );

    return NextResponse.json({
        success: true,
        data: response[0],
    });
}

// Fetch all tasks
export async function GET() {
    const response = await surrealDatabase.query<Task[]>(
        "SELECT * FROM type::table($tb);",
        {
            tb: "task",
        }
    );

    return NextResponse.json({
        success: true,
        data: response[0].result,
    });
}
Enter fullscreen mode Exit fullscreen mode

Create /app/api/task/[id]/route.ts to fetch, update, and delete tasks by the ID

import { Task } from "@/lib/schema";
import { surrealDatabase } from "../../lib/surreal";
import { NextResponse } from "next/server";

// Fetch task by id
export async function GET(_: Request, { params }: { params: { id: string } }) {

    const response = await surrealDatabase.select<Task>(
        'task:' + params.id
    );

    return NextResponse.json({
        success: true,
        data: response[0],
    });
}

// Update task by id
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
    const payload = await request.json();

    const response = await surrealDatabase.merge<Task>(
        'task:' + params.id, payload
    );

    return NextResponse.json({
        success: true,
        data: response[0],
    });
}

// Delete task by id
export async function DELETE(_: Request, { params }: { params: { id: string } }) {
    const response = await surrealDatabase.delete<Task>(
        'task:' + params.id
    );

    return NextResponse.json({
        success: true,
        data: response[0],
    });
}
Enter fullscreen mode Exit fullscreen mode

Create task API handlers

Create /lib/task/handler.ts to consume all the APIs created earlier and abstract away the internal working.

import { Task } from "../schema";

const endpoint = process.env.NEXT_PUBLIC_BASE_URL + '/api/task'
// Create a new task
export const createTask = async (payload: Task) => {
    const res: {
        success: boolean;
        data?: Task;
    } = await (
        await fetch(endpoint, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(payload),
        })
    ).json();

    return res;
}

// Fetch all tasks
export const getAllTasks = async () => {
    const res: {
        success: boolean;
        data?: Task[];
    } = await (
        await fetch(endpoint)
    ).json();

    return res;
};

// Fetch task by id
export const getTaskById = async (id: string) => {
    const res: {
        success: boolean;
        data?: Task;
    } = await (
        await fetch(endpoint + "/" + id)
    ).json();

    return res;
};

// Update task by id
export const updateTaskById = async (id: string, payload: Task) => {
    console.log(id);

    const res: {
        success: boolean;
        data?: Task;
    } = await (
        await fetch(endpoint + "/" + id, {
            method: 'PATCH',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(payload),
        })
    ).json();

    return res;
};

// Delete task by id
export const deleteTaskById = async (id: string) => {
    await fetch(endpoint + "/" + id, { method: "DELETE" })
};
Enter fullscreen mode Exit fullscreen mode

Create user request route handler

Create /app/api/user/[id]/route.ts to fetch user by the ID

import { User } from "@/lib/schema";
import { surrealDatabase } from "../../lib/surreal";
import { NextResponse } from "next/server";

export async function GET(_: Request, { params }: { params: { id: string } }) {

    const response = await surrealDatabase.select<User>(
        params.id
    );

    return NextResponse.json({
        success: true,
        data: response[0],
    });
}
Enter fullscreen mode Exit fullscreen mode

Create author API handler and hook

Create /lib/author/handler.ts this uses the user API created before.

import { User } from "../schema";

const endpoint = process.env.NEXT_PUBLIC_BASE_URL + '/api/user'

export const getAuthorById = async (id: string) => {
    const res: {
        success: boolean;
        data?: User;
    } = await (
        await fetch(endpoint + "/" + id)
    ).json();

    return res;
};
Enter fullscreen mode Exit fullscreen mode

Create /lib/author/hook.ts this uses the getAuthorById handler function as a fetcher in the useSWR hook. So that we can fetch user data on the client side and also cache the data.

import useSWR from "swr";
import { getAuthorById } from "./handler";

export const useAuthor = (id: string) => {
    return useSWR(`/api/user/${id}`, async () => await getAuthorById(
        id
    ));
};
Enter fullscreen mode Exit fullscreen mode

Create a Zustand store to sync server and client-side

I have created a hook to read the state from the server on the client before hydration and synchronization between the server/client.

Create /lib/store/zustand.ts

import { StoreApi, UseBoundStore, create } from 'zustand'
import { useMemo, useRef } from 'react';

export const useStoreSync = <T>(
    useStore: UseBoundStore<StoreApi<T>>,
    state: T
): UseBoundStore<StoreApi<T>> => {
    const unsynced = useRef(true);
    const useServerStore = useMemo(() => create<T>(() => state), []);

    if (unsynced.current) {
        useStore.setState(state);
        unsynced.current = false;
    }
    return window !== undefined ? useStore : useServerStore;
};
Enter fullscreen mode Exit fullscreen mode

Setup SurrealDB Live Query using Zustand

I have created a Zustand store for tasks. In this store, the state will be updated whenever the task is CREATED, UPDATED, or DELETED. live function from SurrealDB SDK is used for listening to these events and for each event different state setters are called to update the state instantly.

Create /lib/store/task.ts

import { surrealDatabase } from '@/app/api/lib/surreal';
import { Task } from '../schema';
import { create } from 'zustand';

type TaskStore = {
    tasks: Task[],
    appendTask?: (newTasks: Task) => void,
    removeTask?: (newTask: Task) => void,
    updateTask?: (newTask: Task) => void,
}

export const useTaskStore = create<TaskStore>((set) => {
    const store = {
        tasks: [],
        appendTask: (newTask: Task) => set((state) => ({ tasks: [...state.tasks, newTask] })),
        removeTask: (removedTask: Task) => set((state) => ({
            tasks: state.tasks.reduce((arr: Task[], task) => {
                if (task.id !== removedTask.id) {
                    arr.push(task)
                }
                return arr
            }, [])
        })),
        updateTask: (modifiedTask: Task) => set((state) => ({
            tasks: state.tasks.reduce((arr: Task[], task) => {
                if (task.id === modifiedTask.id) {
                    arr.push(modifiedTask)
                } else {
                    arr.push(task)
                }
                return arr
            }, [])
        })),
    }

    surrealDatabase.live<Task>('task', ({ action, result }) => {
        switch (action) {
            case 'CREATE':
                store.appendTask(result);
                break;
            case 'DELETE':
                store.removeTask(result);
                break;
            case 'UPDATE':
                store.updateTask(result);
                break;

        }
    });
    return store;
});
Enter fullscreen mode Exit fullscreen mode

Create a task form and pages

Create /components/forms/task-form that can be reused in both create task and edit task page

"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";

import { Button } from "../ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "../ui/form";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
import { toast } from "sonner";
import { Task, record, taskSchema } from "@/lib/schema";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { useState } from "react";
import { Loader, SquarePen } from "lucide-react";
import { createTask, updateTaskById } from "@/lib/task/handler";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";

interface TaskFormProps {
  editRecord?: Task;
}

export function TaskForm(props: TaskFormProps) {
  const { editRecord } = props;

  const form = useForm<Task>({
    resolver: zodResolver(taskSchema),
    defaultValues: editRecord,
    mode: "onChange",
  });
  const [isSubmitting, setSubmitting] = useState<boolean>(false);
  const router = useRouter();
  const { data: session } = useSession();

  async function onSubmit(data: Task) {
    setSubmitting(true);
    try {
      if (editRecord?.id) {
        await updateTaskById(editRecord.id.replace("task:", ""), data);
        toast.success("Task updated successfully!");
      } else {
        await createTask({
          ...data,
          author: record("user").parse("user:" + session?.user.id),
        });
        toast.success("Task created successfully!");
      }
    } catch (error) {
      console.log(error);
      toast.success("Something went wrong. Try again");
    } finally {
      setSubmitting(false);
      router.push("/");
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Title</FormLabel>
              <FormControl>
                <Input placeholder="Enter title" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="description"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Description</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="Enter a brief description about the task"
                  className="resize-none"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="status"
          render={({ field }) => (
            <FormItem className="space-y-3">
              <FormLabel>Select a status</FormLabel>
              <FormControl>
                <RadioGroup
                  onValueChange={field.onChange}
                  defaultValue={field.value}
                  className="flex flex-col space-y-1"
                >
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="todo" />
                    </FormControl>
                    <FormLabel className="font-normal">Todo</FormLabel>
                  </FormItem>
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="inprogress" />
                    </FormControl>
                    <FormLabel className="font-normal">Inprogress</FormLabel>
                  </FormItem>
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="done" />
                    </FormControl>
                    <FormLabel className="font-normal">Done</FormLabel>
                  </FormItem>
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="canceled" />
                    </FormControl>
                    <FormLabel className="font-normal">Cancel</FormLabel>
                  </FormItem>
                </RadioGroup>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="label"
          render={({ field }) => (
            <FormItem className="space-y-3">
              <FormLabel>Select a label</FormLabel>
              <FormControl>
                <RadioGroup
                  onValueChange={field.onChange}
                  defaultValue={field.value}
                  className="flex flex-col space-y-1"
                >
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="bug" />
                    </FormControl>
                    <FormLabel className="font-normal">Bug</FormLabel>
                  </FormItem>
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="feature" />
                    </FormControl>
                    <FormLabel className="font-normal">Feature</FormLabel>
                  </FormItem>
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="documentation" />
                    </FormControl>
                    <FormLabel className="font-normal">Documentation</FormLabel>
                  </FormItem>
                </RadioGroup>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="priority"
          render={({ field }) => (
            <FormItem className="space-y-3">
              <FormLabel>Set priority for the task</FormLabel>
              <FormControl>
                <RadioGroup
                  onValueChange={field.onChange}
                  defaultValue={field.value}
                  className="flex flex-col space-y-1"
                >
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="high" />
                    </FormControl>
                    <FormLabel className="font-normal">High</FormLabel>
                  </FormItem>
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="moderate" />
                    </FormControl>
                    <FormLabel className="font-normal">Moderate</FormLabel>
                  </FormItem>
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="low" />
                    </FormControl>
                    <FormLabel className="font-normal">Low</FormLabel>
                  </FormItem>
                </RadioGroup>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" disabled={isSubmitting} className="float-end">
          {isSubmitting ? (
            <Loader className="mr-2 h-4 w-4 animate-spin" />
          ) : (
            <SquarePen className="mr-2 h-4 w-4" />
          )}
          {editRecord?.id ? "Update task" : "Create task"}
        </Button>
      </form>
    </Form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create /components/nav-back.tsx

"use client";

import { ChevronLeft, Home } from "lucide-react";
import { useRouter } from "next/navigation";
import React from "react";
import { Button } from "./ui/button";

const NavBack = () => {
  const router = useRouter();
  return (
    <div className="w-full flex items-center justify-start -ml-5 my-3">
      <Button onClick={() => router.back()} variant={"ghost"}>
        <ChevronLeft className="h-4 w-4" />
      </Button>
      <Button onClick={() => router.push("/")} variant={"ghost"}>
        <Home className="h-4 w-4" />
      </Button>
    </div>
  );
};

export default NavBack;
Enter fullscreen mode Exit fullscreen mode

Create /app/task/new/page.tsx

import { TaskForm } from "@/components/forms/task-form";
import { Separator } from "@/components/ui/separator";

export default function Page() {
  return (
    <div className="space-y-6">
      <div>
        <h3 className="text-lg font-medium">Create Task</h3>
        <p className="text-sm text-muted-foreground">
          Enter all the details below.
        </p>
      </div>
      <Separator />
      <TaskForm />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create /app/task/edit/[id]/page.tsx

import { TaskForm } from "@/components/forms/task-form";
import { Separator } from "@/components/ui/separator";
import { getTaskById } from "@/lib/task/handler";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import React from "react";

export default async function EditTaskPage({
  params,
}: {
  params: { id: string };
}) {
  const { data } = await getTaskById(params.id);
  const session = await getServerSession();

  if (!data || session?.user.id !== data.author.replace("user:", "")) {
    redirect("/");
  }

  return (
    <div className="space-y-6">
      <div>
        <h3 className="text-lg font-medium">Edit Task:{params.id}</h3>
        <p className="text-sm text-muted-foreground">Edit the details below.</p>
      </div>
      <Separator />
      <TaskForm editRecord={data} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here I have added extra check to allow authors to edit their created own tasks.

Build the table

Create table components

Create column header /components/task-table/column-header.tsx

import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react";
import { Column } from "@tanstack/react-table";

import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "../ui/dropdown-menu";

interface DataTableColumnHeaderProps<TData, TValue>
  extends React.HTMLAttributes<HTMLDivElement> {
  column: Column<TData, TValue>;
  title: string;
}

export function DataTableColumnHeader<TData, TValue>({
  column,
  title,
  className,
}: DataTableColumnHeaderProps<TData, TValue>) {
  if (!column.getCanSort()) {
    return <div className={cn(className)}>{title}</div>;
  }

  return (
    <div className={cn("flex items-center space-x-2", className)}>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button
            variant="ghost"
            size="sm"
            className="-ml-3 h-8 data-[state=open]:bg-accent"
          >
            <span>{title}</span>
            {column.getIsSorted() === "desc" ? (
              <ArrowDown className="ml-2 h-4 w-4" />
            ) : column.getIsSorted() === "asc" ? (
              <ArrowUp className="ml-2 h-4 w-4" />
            ) : (
              <ChevronsUpDown className="ml-2 h-4 w-4" />
            )}
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="start">
          <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
            <ArrowUp className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
            Asc
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => column.toggleSorting(true)}>
            <ArrowDown className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
            Desc
          </DropdownMenuItem>
          <DropdownMenuSeparator />
          <DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
            <EyeOff className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
            Hide
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create columns /components/task-table/columns.tsx to define the structure and content of each column

"use client";

import { ColumnDef } from "@tanstack/react-table";
import { Badge } from "../ui/badge";
import { labels, priorities, statuses } from "./data";
import { DataTableColumnHeader } from "./column-header";
import { DataTableRowActions } from "./row-actions";
import { Task } from "@/lib/schema";
import { Avatar, AvatarImage } from "../ui/avatar";
import { useAuthor } from "@/lib/author/hook";
import { Loader } from "lucide-react";

export const columns: ColumnDef<Task>[] = [
  {
    accessorKey: "author",
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Author" />
    ),
    cell: ({ row }) => {
      const userId = row.original.author as string;
      const { data, isLoading } = useAuthor(userId);

      return (
        <div className="w-[30px]">
          {isLoading ? (
            <Loader className="w-5 h-5 animate-spin" />
          ) : (
            <>
              {data && (
                <Avatar className="h-8 w-8">
                  <AvatarImage
                    src={
                      data.data?.image ?? "https://avatar.vercel.sh/" + userId
                    }
                    alt={userId + " image"}
                  />
                </Avatar>
              )}
            </>
          )}
        </div>
      );
    },
    enableSorting: false,
    enableHiding: false,
  },
  {
    accessorKey: "id",
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Task" />
    ),
    cell: ({ row }) => <div className="w-[80px]">{row.getValue("id")}</div>,
    enableSorting: false,
    enableHiding: false,
  },
  {
    accessorKey: "title",
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Title" />
    ),
    cell: ({ row }) => {
      const label = labels.find((label) => label.value === row.original.label);

      return (
        <div className="flex space-x-2 w-[100px] lg:w-[500px]">
          {label && <Badge variant="outline">{label.label}</Badge>}
          <span className="max-w-[300px] truncate font-medium">
            {row.getValue("title")}
          </span>
        </div>
      );
    },
  },
  {
    accessorKey: "status",
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Status" />
    ),
    cell: ({ row }) => {
      const status = statuses.find(
        (status) => status.value === row.getValue("status")
      );

      if (!status) {
        return null;
      }

      return (
        <div className="flex w-[100px] items-center">
          {status.icon && (
            <status.icon className="mr-2 h-4 w-4 text-muted-foreground" />
          )}
          <span>{status.label}</span>
        </div>
      );
    },
    filterFn: (row, id, value) => {
      return value.includes(row.getValue(id));
    },
  },
  {
    accessorKey: "priority",
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Priority" />
    ),
    cell: ({ row }) => {
      const priority = priorities.find(
        (priority) => priority.value === row.getValue("priority")
      );

      if (!priority) {
        return null;
      }

      return (
        <div className="flex items-center">
          {priority.icon && (
            <priority.icon className="mr-2 h-4 w-4 text-muted-foreground" />
          )}
          <span>{priority.label}</span>
        </div>
      );
    },
    filterFn: (row, id, value) => {
      return value.includes(row.getValue(id));
    },
  },
  {
    id: "actions",
    cell: ({ row }) => <DataTableRowActions row={row} />,
  },
];
Enter fullscreen mode Exit fullscreen mode

Create filter data /components/task-table/data.ts

import {
  ArrowDownIcon,
  ArrowRightIcon,
  ArrowUpIcon,
  CheckCircle2,
  CircleIcon,
  XCircle,
  Timer,
} from "lucide-react";

export const labels = [
  {
    value: "bug",
    label: "Bug",
  },
  {
    value: "feature",
    label: "Feature",
  },
  {
    value: "documentation",
    label: "Documentation",
  },
];

export const statuses = [
  {
    value: "todo",
    label: "Todo",
    icon: CircleIcon,
  },
  {
    value: "in progress",
    label: "In Progress",
    icon: Timer,
  },
  {
    value: "done",
    label: "Done",
    icon: CheckCircle2,
  },
  {
    value: "canceled",
    label: "Canceled",
    icon: XCircle,
  },
];

export const priorities = [
  {
    label: "Low",
    value: "low",
    icon: ArrowDownIcon,
  },
  {
    label: "Medium",
    value: "medium",
    icon: ArrowRightIcon,
  },
  {
    label: "High",
    value: "high",
    icon: ArrowUpIcon,
  },
];
Enter fullscreen mode Exit fullscreen mode

Create filter component /components/task-table/faceted-filter.tsx to filter based on the task label, status, and priority

import * as React from "react";
import { CheckIcon, PlusCircle } from "lucide-react";
import { Column } from "@tanstack/react-table";

import { cn } from "@/lib/utils";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "../ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Separator } from "../ui/separator";

interface DataTableFacetedFilterProps<TData, TValue> {
  column?: Column<TData, TValue>;
  title?: string;
  options: {
    label: string;
    value: string;
    icon?: React.ComponentType<{ className?: string }>;
  }[];
}

export function DataTableFacetedFilter<TData, TValue>({
  column,
  title,
  options,
}: DataTableFacetedFilterProps<TData, TValue>) {
  const facets = column?.getFacetedUniqueValues();
  const selectedValues = new Set(column?.getFilterValue() as string[]);

  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="outline" size="sm" className="h-8 border-dashed">
          <PlusCircle className="mr-2 h-4 w-4" />
          {title}
          {selectedValues?.size > 0 && (
            <>
              <Separator orientation="vertical" className="mx-2 h-4" />
              <Badge
                variant="secondary"
                className="rounded-sm px-1 font-normal lg:hidden"
              >
                {selectedValues.size}
              </Badge>
              <div className="hidden space-x-1 lg:flex">
                {selectedValues.size > 2 ? (
                  <Badge
                    variant="secondary"
                    className="rounded-sm px-1 font-normal"
                  >
                    {selectedValues.size} selected
                  </Badge>
                ) : (
                  options
                    .filter((option) => selectedValues.has(option.value))
                    .map((option) => (
                      <Badge
                        variant="secondary"
                        key={option.value}
                        className="rounded-sm px-1 font-normal"
                      >
                        {option.label}
                      </Badge>
                    ))
                )}
              </div>
            </>
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-[200px] p-0" align="start">
        <Command>
          <CommandInput placeholder={title} />
          <CommandList>
            <CommandEmpty>No results found.</CommandEmpty>
            <CommandGroup>
              {options.map((option) => {
                const isSelected = selectedValues.has(option.value);
                return (
                  <CommandItem
                    key={option.value}
                    onSelect={() => {
                      if (isSelected) {
                        selectedValues.delete(option.value);
                      } else {
                        selectedValues.add(option.value);
                      }
                      const filterValues = Array.from(selectedValues);
                      column?.setFilterValue(
                        filterValues.length ? filterValues : undefined
                      );
                    }}
                  >
                    <div
                      className={cn(
                        "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
                        isSelected
                          ? "bg-primary text-primary-foreground"
                          : "opacity-50 [&_svg]:invisible"
                      )}
                    >
                      <CheckIcon className={cn("h-4 w-4")} />
                    </div>
                    {option.icon && (
                      <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
                    )}
                    <span>{option.label}</span>
                    {facets?.get(option.value) && (
                      <span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
                        {facets.get(option.value)}
                      </span>
                    )}
                  </CommandItem>
                );
              })}
            </CommandGroup>
            {selectedValues.size > 0 && (
              <>
                <CommandSeparator />
                <CommandGroup>
                  <CommandItem
                    onSelect={() => column?.setFilterValue(undefined)}
                    className="justify-center text-center"
                  >
                    Clear filters
                  </CommandItem>
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create row action component /components/task-table/row-actions.tsx to edit, delete and copy tasks

"use client";

import { CopyPlusIcon, EditIcon, Loader, Trash2 } from "lucide-react";
import { Row } from "@tanstack/react-table";

import { Button } from "../ui/button";

import { Task, record, taskSchema } from "@/lib/schema";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createTask, deleteTaskById } from "@/lib/task/handler";
import { toast } from "sonner";
import { useSession } from "next-auth/react";

interface DataTableRowActionsProps<TData> {
  row: Row<TData>;
}

export function DataTableRowActions<TData>({
  row,
}: DataTableRowActionsProps<TData>) {
  const task: Task = taskSchema.parse(row.original);
  const router = useRouter();
  const [isCopying, setCopying] = useState<boolean>(false);
  const [isDeleting, setDeleting] = useState<boolean>(false);
  const { data: session } = useSession();

  const isAuthor = task.author.replace("user:", "") === session?.user.id;

  const handleEditClick = () => {
    task.id && router.push("/task/edit/" + task.id.replace("task:", ""));
  };

  const handleCreateCopy = async () => {
    setCopying(true);
    try {
      await createTask({
        title: task.title,
        description: task.description,
        status: task.status,
        label: task.label,
        priority: task.priority,
        author: record("user").parse("user:" + session?.user.id),
      });
      toast.success("Created a copy successfully!");
    } catch (error) {
      console.log(error);
      toast.success("Failed to copy!");
    } finally {
      setCopying(false);
    }
  };

  const handleDelete = async () => {
    setDeleting(true);
    if (task.id) {
      try {
        await deleteTaskById(task.id.replace("task:", ""));
        toast.success("Successfully deleted!");
      } catch (error) {
        console.log(error);
        toast.success("Failed to delete!");
      } finally {
        setDeleting(false);
      }
    } else {
      toast.success("Trouble deleting!");
    }
  };

  return (
    <div className="flex items-center">
      {isAuthor && (
        <Button variant={"ghost"} onClick={handleEditClick}>
          <EditIcon className="w-4 h-4" />
        </Button>
      )}
      {isAuthor && (
        <Button className="relative min-w-14" variant={"ghost"}>
          {isDeleting ? (
            <Loader className="w-4 h-4 absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 animate-spin" />
          ) : (
            <Trash2 className="w-4 h-4" onClick={handleDelete} />
          )}
        </Button>
      )}

      <Button className="relative min-w-14" variant={"ghost"}>
        {isCopying ? (
          <Loader className="w-4 h-4 absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 animate-spin" />
        ) : (
          <CopyPlusIcon className="w-4 h-4" onClick={handleCreateCopy} />
        )}
      </Button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create pagination component /components/task-table/table-pagination.tsx

import {
  ChevronLeftIcon,
  ChevronRightIcon,
  ChevronsLeft,
  ChevronsRight,
} from "lucide-react";
import { Table } from "@tanstack/react-table";

import { Button } from "../ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "../ui/select";

interface DataTablePaginationProps<TData> {
  table: Table<TData>;
}

export function DataTablePagination<TData>({
  table,
}: DataTablePaginationProps<TData>) {
  return (
    <div className="flex items-center justify-between px-2">
      <div className="flex-1 text-sm text-muted-foreground">
        {table.getFilteredSelectedRowModel().rows.length} of{" "}
        {table.getFilteredRowModel().rows.length} row(s) selected.
      </div>
      <div className="flex items-center space-x-6 lg:space-x-8">
        <div className="flex items-center space-x-2">
          <p className="text-sm font-medium">Rows per page</p>
          <Select
            value={`${table.getState().pagination.pageSize}`}
            onValueChange={(value) => {
              table.setPageSize(Number(value));
            }}
          >
            <SelectTrigger className="h-8 w-[70px]">
              <SelectValue placeholder={table.getState().pagination.pageSize} />
            </SelectTrigger>
            <SelectContent side="top">
              {[10, 20, 30, 40, 50].map((pageSize) => (
                <SelectItem key={pageSize} value={`${pageSize}`}>
                  {pageSize}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <div className="flex w-[100px] items-center justify-center text-sm font-medium">
          Page {table.getState().pagination.pageIndex + 1} of{" "}
          {table.getPageCount()}
        </div>
        <div className="flex items-center space-x-2">
          <Button
            variant="outline"
            className="hidden h-8 w-8 p-0 lg:flex"
            onClick={() => table.setPageIndex(0)}
            disabled={!table.getCanPreviousPage()}
          >
            <span className="sr-only">Go to first page</span>
            <ChevronsLeft className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            className="h-8 w-8 p-0"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            <span className="sr-only">Go to previous page</span>
            <ChevronLeftIcon className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            className="h-8 w-8 p-0"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            <span className="sr-only">Go to next page</span>
            <ChevronRightIcon className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            className="hidden h-8 w-8 p-0 lg:flex"
            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
            disabled={!table.getCanNextPage()}
          >
            <span className="sr-only">Go to last page</span>
            <ChevronsRight className="h-4 w-4" />
          </Button>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create table toolbar component /components/task-table/table-toolbar.tsx

"use client";

import { Cross, Plus } from "lucide-react";
import { Table } from "@tanstack/react-table";

import { Button } from "../ui/button";
import { Input } from "../ui/input";

import { priorities, statuses } from "./data";
import { DataTableFacetedFilter } from "./faceted-filter";
import { useRouter } from "next/navigation";

interface DataTableToolbarProps<TData> {
  table: Table<TData>;
}

export function DataTableToolbar<TData>({
  table,
}: DataTableToolbarProps<TData>) {
  const isFiltered = table.getState().columnFilters.length > 0;
  const router = useRouter();

  return (
    <div className="flex items-center justify-between">
      <div className="flex flex-1 items-center space-x-2">
        <Input
          placeholder="Filter tasks..."
          value={(table.getColumn("title")?.getFilterValue() as string) ?? ""}
          onChange={(event) =>
            table.getColumn("title")?.setFilterValue(event.target.value)
          }
          className="h-8 w-[150px] lg:w-[250px]"
        />
        {table.getColumn("status") && (
          <DataTableFacetedFilter
            column={table.getColumn("status")}
            title="Status"
            options={statuses}
          />
        )}
        {table.getColumn("priority") && (
          <DataTableFacetedFilter
            column={table.getColumn("priority")}
            title="Priority"
            options={priorities}
          />
        )}
        {isFiltered && (
          <Button
            variant="ghost"
            onClick={() => table.resetColumnFilters()}
            className="h-8 px-2 lg:px-3"
          >
            Reset
            <Cross className="ml-2 h-4 w-4" />
          </Button>
        )}
      </div>
      <Button
        variant="default"
        onClick={() => router.push("/task/new")}
        className="h-8 px-2 lg:px-3"
      >
        <Plus className="mr-2 h-4 w-4" />
        Create a new task
      </Button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Bringing all the pieces together to build the whole table

Create /components/task-table/index.tsx

"use client";

import * as React from "react";
import {
  ColumnDef,
  ColumnFiltersState,
  SortingState,
  VisibilityState,
  flexRender,
  getCoreRowModel,
  getFacetedRowModel,
  getFacetedUniqueValues,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "../ui/table";

import { DataTablePagination } from "./table-pagination";
import { DataTableToolbar } from "./table-toolbar";
import { useStoreSync } from "@/lib/store/zustand";
import { useTaskStore } from "@/lib/store/task";
import { z } from "zod";
import { Task, taskSchema } from "@/lib/schema";
import { columns } from "./columns";

interface TableProps {
  data: Task[];
}

export default function TaskTable({ data }: TableProps) {
  const taskStore = useStoreSync(useTaskStore, { tasks: data })();
  const tasks = z.array(taskSchema).parse(taskStore.tasks);
  return <DataTable data={tasks} columns={columns} />;
}

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
}

export function DataTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const [rowSelection, setRowSelection] = React.useState({});
  const [columnVisibility, setColumnVisibility] =
    React.useState<VisibilityState>({});
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    []
  );
  const [sorting, setSorting] = React.useState<SortingState>([]);

  const table = useReactTable({
    data,
    columns,
    state: {
      sorting,
      columnVisibility,
      rowSelection,
      columnFilters,
    },
    enableRowSelection: true,
    onRowSelectionChange: setRowSelection,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onColumnVisibilityChange: setColumnVisibility,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: getFacetedUniqueValues(),
  });

  return (
    <div className="space-y-4">
      <DataTableToolbar table={table} />
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  return (
                    <TableHead key={header.id} colSpan={header.colSpan}>
                      {header.isPlaceholder
                        ? null
                        : flexRender(
                            header.column.columnDef.header,
                            header.getContext()
                          )}
                    </TableHead>
                  );
                })}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows?.length ? (
              table.getRowModel().rows.map((row) => {
                return (
                  <TableRow
                    key={row.id}
                    data-state={row.getIsSelected() && "selected"}
                  >
                    {row.getVisibleCells().map((cell) => (
                      <TableCell key={cell.id}>
                        {flexRender(
                          cell.column.columnDef.cell,
                          cell.getContext()
                        )}
                      </TableCell>
                    ))}
                  </TableRow>
                );
              })
            ) : (
              <TableRow>
                <TableCell
                  colSpan={columns.length}
                  className="h-24 text-center"
                >
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      <DataTablePagination table={table} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Add the table to the homepage

Update /app/page.tsx

import { getAllTasks } from "@/lib/task/handler";
import TaskTable from "@/components/task-table";
import { surrealDatabase } from "./api/lib/surreal";

export default async function Home() {
  const { data } = await getAllTasks();

  return (
    <div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
      <div className="flex items-center justify-between space-y-2">
        <div>
          <h2 className="text-2xl font-bold tracking-tight">Bonjour!</h2>
        </div>
      </div>

      <TaskTable data={data ?? []} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

That’s it now run the server and use the application!!!

Thank you for reading and coding till the end. Peace out ✌️

Here's the GitHub repository for the whole project

Top comments (0)