Skip to content

subrotoice/next-crud

Repository files navigation

NEXT-CRUD Chapters

Ch-1: Getting Started
Ch-2: Creating Issues
Ch-3: Viewing Issues
Ch-4: Updating Issues
Ch-5: Deleting Issues
Ch-6: Authentication
Ch-7: Assigning Issues to Users
Ch-8: Filtering, Sorting, and Pagination
Ch-9: Dashboard
Ch-10: Going to Production

Ch-1: Getting Started

- How to start and finish a Project

Start from Core feature ahead to Advance feature

Tables

CORE (Must have) ADVANCED (Nice to have)
Createing an issue User authentication
Viewing issues Assigning issues
Updating an issues Sorting issues
Deleting an issues Filtering issues
Pagination
Dashboard

Key: Focus one feature at a time. Goal is not for "perfect" solution.
Goal is not for "perfect" solution. Make it work first. Then Improve it step by step (Refactoring)

- Installation

npx create-next-app@latest

Manual Installation

npm install next@latest react@latest react-dom@latest

- Build the navbar

Install react icon

npm i react-icons
// layout.tsx (<main>{children}</main>)
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import NavBar from "./NavBar";

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

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

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

// app/NavBar.tsx  (AiFillBug icon, Dynamic Menu from array of object)
import Link from "next/link";
import { AiFillBug } from "react-icons/ai";

const NavBar = () => {
  const links = [
    { label: "Dashboard", href: "/" },
    { label: "Issues", href: "/issues" },
  ];
  return (
    <nav className="flex space-x-6 border-b px-5 mb-5 h-14 items-center">
      <Link href="/">
        <AiFillBug />
      </Link>
      <ul className="flex space-x-6">
        {links.map((link) => (
          <Link
            key={link.href}
            className="text-zinc-500 hover:text-zinc-800 transition-colors"
            href={link.href}
          >
            {link.label}
          </Link>
        ))}
      </ul>
    </nav>
  );
};

- Styling the Active Link (Navbar.tsx)

Conditional CSS rendering & Classnames: Code will be cleaner and what classes will render under what condition

npm i classnames@2.3.2
// app/NavBar.tsx | Conditional CSS rendering
<Link
  key={link.href}
  className={`${
    link.href === currentPath ? "text-zinc-900" : "text-zinc-500"
  } hover:text-zinc-800 transition-colors`}
  href={link.href}
>
  {link.label}
</Link>

// Using ClassName (Code is cleaner)
<Link
key={link.href}
className={classNames({
    "text-zinc-900": link.href === currentPath,
    "text-zinc-500": link.href !== currentPath,
    "hover:text-zinc-800 transition-colors": true,
})}
href={link.href}
>

Ch-2: Creating Issues

- Install mysql

Wamp for testing

- Setting Up Prisma

npm i prisma

Initializing Prisma - npx create folder

npx prisma init

Now in prisma/schema.prisma (datasource>provider to mysql)

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

.env file Connection String Format For MySql Database

DATABASE_URL = "mysql://root:@localhost:3306/issue-tracker"  ----Connection String no ";" @end----

- Creating the issue model

  • Just create simple model. Not assignin issue to user so no relationship
// schema.prisma (Model: Pascale Case and singular name)
model Issue {
  id          Int      @id @default(autoincrement())
  title       String   @db.VarChar(255)
  description String   @db.Text
  status      Status   @default(OPEN)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

enum Status {
  OPEN
  IN_PROGRESS
  CLOSED
}
npx prisma format
npx prisma migrate dev

- Building an API

npm i zod@3.22.2

Best Practice: Make sure create only one instance of Prisma Client

// prisma/client.ts (Only Once)
import { PrismaClient } from "@prisma/client";

const prismaClientSingleton = () => {
  return new PrismaClient();
};

declare global {
  var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>;
}

const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();

export default prisma;

if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;

Make sure async - await is properly placed when building API

// app/api/issues/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import prisma from "@/prisma/client";

const createIssueSchema = z.object({
  title: z.string().min(1).max(255),
  description: z.string().min(1),
});
export async function POST(request: NextRequest) {
  const body = await request.json();

  const validation = createIssueSchema.safeParse(body);

  if (!validation.success)
    return NextResponse.json(validation.error.errors, { status: 400 });

  const newIssue = await prisma.issue.create({
    data: {
      title: body.title,
      description: body.description,
    },
  });

  return NextResponse.json(newIssue, { status: 201 });
}

- Setting up Radix UI

radix-ui.com

  1. Install Radix Themes
npm install @radix-ui/themes
  1. Import the CSS root app/layout.ts
// app/layout.ts
import "@radix-ui/themes/styles.css";
  1. Add the Theme component - root app/layout.ts
// app/layout.ts
import { Theme } from "@radix-ui/themes";

export default function () {
  return (
    <html>
      <body>
        <Theme>
          <MyApp />
        </Theme>
      </body>
    </html>
  );
}

// issues/page.ts
import React from "react";
import { Button } from "@radix-ui/themes";

const IssuesPage = () => {
  return (
    <div>
      <Button>New Issue</Button>
    </div>
  );
};

- Build the New Issue Page (Look and feel of the page not function)

// issues/page.ts
<Button>
  <Link href="/issues/new">New Issue</Link>
</Button>

This page has form handling with client "use client"

// issues/new/page.ts
"use client";
import { Button, TextArea, TextField } from "@radix-ui/themes";
import React from "react";

const NewIssuePage = () => {
  return (
    <div className="max-w-xl space-y-3">
      <TextField.Root placeholder="Title" />
      <TextArea placeholder="Description" />
      <Button>Submit New Issue</Button>
    </div>
  );
};

- Customizing Radix UI Theme

< ThemePanel: See and customized

// app/layout.ts
return (
  <html lang="en">
    <body className={inter.className}>
      <Theme accentColor="violet">
        <NavBar />
        <main className="p-5">{children}</main>
        <ThemePanel />
      </Theme>
    </body>
  </html>
);

Change font: Inter font is not applying here because of Radix Typographoy You can keep css in globals.css/theme-config.css, add this syntax !important;

import "@radix-ui/themes/styles.css";
import "./theme-config.css";

import { Inter } from "next/font/google";
import { Theme, ThemePanel } from "@radix-ui/themes";

const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter",
});

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode,
}>) {
  return (
    <html lang="en">
      <body className={inter.variable}>
        <Theme accentColor="violet">
          <NavBar />
          <main className="p-5 radix-themes">{children}</main>
        </Theme>
      </body>
    </html>
  );
}

// globals.css ( !important is urgent )
.radix-themes {
  --default-font-family: var(--font-inter) !important;
}

- Adding a Markdown Editor

React Simplemde Editor Install

npm install --save react-simplemde-editor easymde
// issues/new/page.tsx (Use SimpleMDE instade of Textarea)
"use client";
import { Button, TextArea, TextField } from "@radix-ui/themes";
import SimpleMDE from "react-simplemde-editor"; // added 1
import "easymde/dist/easymde.min.css"; // added 2

const NewIssuePage = () => {
  return (
    <div className="max-w-xl space-y-3">
      <TextField.Root placeholder="Title" />
      <SimpleMDE />
      <Button>Submit New Issue</Button>
    </div>
  );
};

- Handling Form Submission (issues/new/page.tsx)

Install: "axios": "^1.6.8", "react-hook-form": "^7.51.3", (Hook form can not work with < SimpleMDE so use controller )

// issues/new/page.tsx  (import { useRouter } from "next/navigation"; not next/router)
"use client";
import { Button, TextArea, TextField } from "@radix-ui/themes";
import SimpleMDE from "react-simplemde-editor";
import "easymde/dist/easymde.min.css";
import { useForm, Controller } from "react-hook-form";
import axios from "axios";
import { useRouter } from "next/navigation";

interface IssueForm {
  title: string;
  description: string;
}

const NewIssuePage = () => {
  const router = useRouter();
  const { register, control, handleSubmit } = useForm<IssueForm>();

  return (
    <form
      className="max-w-xl space-y-3"
      onSubmit={handleSubmit(async (data) => {
        await axios.post("/api/issues", data);
        router.push("/issues");
      })}
    >
      <TextField.Root placeholder="Title" {...register("title")} />
      <Controller
        name="description"
        control={control}
        render={({ field }) => (
          <SimpleMDE placeholder="Description" {...field} />
        )}
      />

      <Button type="submit">Submit New Issue</Button>
    </form>
  );
};

- Handling Errors | Server side validaion (Server side - API)

  • Server and Client both validation is must needed. First need to build Server side validation then Client side. Because If we build Client side validation then it will difficult to test server side validation.
// issues/new/page.tsx
return (
  <div className="max-w-xl">
    {error && (
      <Callout.Root color="red" className="mb-3">
        <Callout.Text>{error}</Callout.Text>
      </Callout.Root>
    )}
    <form
      className="space-y-3"
      onSubmit={handleSubmit(async (data) => {
        try {
          await axios.post("/api/issues", data);
          router.push("/issues");
        } catch (error) {
          console.log(error); // debugge here
          setError("An unexpected error occurred.");
        }
      })}
    >
      <TextField.Root placeholder="Title" {...register("title")} />
      <Controller
        name="description"
        control={control}
        render={({ field }) => (
          <SimpleMDE placeholder="Description" {...field} />
        )}
      />

      <Button type="submit">Submit New Issue</Button>
    </form>
  </div>
);

// api/issues/page.tsx
const createIssueSchema = z.object({
  title: z.string().min(1, "Title is required.").max(255), // Added MSG
  description: z.string().min(1, "Description is required."),
});
export async function POST(request: NextRequest) {
  const body = await request.json();

  const validation = createIssueSchema.safeParse(body);

  if (!validation.success)
    return NextResponse.json(validation.error.format(), { status: 400 }); // Added .format()

  const newIssue = await prisma.issue.create({
    data: {
      title: body.title,
      description: body.description,
    },
  });

  return NextResponse.json(newIssue, { status: 201 });
}

- Implement Client-Side Validation

  1. Install Resolvers
  2. createIssueSchema object bring Out of api/issues/page.ts using refactor because we can not export other thing from a route.ts file to use it in issues/new/page.tsx. Click
  3. Work on issues/new/page.tsx
npm i @hookform/resolvers

Refactor createIssueSchema and move to new file. Because we can export this variable but route.ts only export GET, POST, PUT, DELETE

// api/issues/page.ts
import { z } from "zod";
import prisma from "@/prisma/client";

const createIssueSchema = z.object({
  title: z.string().min(1, "Title is required.").max(255),
  description: z.string().min(1, "Description is required."),
});

export async function POST(request: NextRequest) {
  const body = await request.json();
........

to----->

import prisma from "@/prisma/client";
import { createIssueSchema } from "../../validationSchema";

export async function POST(request: NextRequest) {
  const body = await request.json();

// ValidationSchema.ts
import { z } from "zod";

export const createIssueSchema = z.object({
  title: z.string().min(1, "Title is required.").max(255),
  description: z.string().min(1, "Description is required."),
});

// issues/new/page.tsx (need not create redundent interface we can grab type from zod interface)
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { createIssueSchema } from "@/app/validationSchema";
import { z } from "zod";

type IssueForm = z.infer<typeof createIssueSchema>;

const NewIssuePage = () => {
  const router = useRouter();
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<IssueForm>({
    resolver: zodResolver(createIssueSchema),
  });
  const [error, setError] = useState("");

  return (
    <div className="max-w-xl">
      {error && (
        <Callout.Root color="red" className="mb-3">
          <Callout.Text>{error}</Callout.Text>
        </Callout.Root>
      )}
      <form
        className="space-y-3"
        onSubmit={handleSubmit(async (data) => {
          try {
            await axios.post("/api/issues", data);
            router.push("/issues");
          } catch (error) {
            console.log(error);
            setError("An unexpected error occurred.");
          }
        })}
      >
        <TextField.Root placeholder="Title" {...register("title")} />
        {errors.title && (
          <Text color="red" as="p">
            {errors.title.message}
          </Text>
        )}
        <Controller
          name="description"
          control={control}
          render={({ field }) => (
            <SimpleMDE placeholder="Description" {...field} />
          )}
        />
        {errors.description && (
          <Text color="red" as="p">
            {errors.description.message}
          </Text>
        )}
        <Button type="submit">Submit New Issue</Button>
      </form>
    </div>
  );
};
..........

- Extractiong the ErrorMessage Component

  • Create seperate component for displaying error message to make it consistant and well organized.
  • NB: If a component is only need for a page then create it localy, If you want to reuse it different pages then create in /app/components/myCommponent.tsx
// issues/new/page.tsx
<ErrorMessage>{errors.title?.message}</ErrorMessage>;

// app/components/ErrorMessage.tsx
import { Text } from "@radix-ui/themes";
import React, { PropsWithChildren } from "react";

const ErrorMessage = ({ children }: PropsWithChildren) => {
  return (
    <>
      {children && (
        <Text color="red" as="p">
          {children}
        </Text>
      )}
    </>
  );
};

- Adding a Spinner (When submitting form)

Google: Tailwind Elements Spinner

// issues/new/page.tsx
const [isSubmitting, setSubmitting] = useState(false);
<Button type="submit">Submit New Issue {isSubmitting && <Spinner />}</Button>;
<form
  className="space-y-3"
  onSubmit={handleSubmit(async (data) => {
    try {
      setSubmitting(true);
      await axios.post("/api/issues", data);
      router.push("/issues");
    } catch (error) {
      // console.log(error);
      setSubmitting(false);
      setError("An unexpected error occurred.");
    }
  })}
>

// app/conponents/Spinner.tsx
import React from "react";

const Spinner = () => {
  return (
    <div
      className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-e-transparent align-[-0.125em] text-surface motion-reduce:animate-[spin_1.5s_linear_infinite] dark:text-white"
      role="status"
    >
      <span className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]">
        Loading...
      </span>
    </div>
  );
};

- Code Organization: Refactoring (Inline function to outside function defination)

  • onSubmit: just cut inline function and paste it
  • Separation of Concerns: Separate a program into distinct modules each having a separate concern. If concerns are well separated, there are more opportunities for code reuse.
  • Software Engineering is not Black and White. "This is the best practice! You should always do things this way!" Not like this
// issues/new/page.tsx
import ErrorMessage from "@/app/components/ErrorMessage";

  const onSubmit = handleSubmit(async (data) => {
    try {
      setSubmitting(true);
      await axios.post("/api/issues", data);
      router.push("/issues");
    } catch (error) {
      // console.log(error);
      setSubmitting(false);
      setError("An unexpected error occurred.");
    }
  });

  return (
      <form className="space-y-3" onSubmit={onSubmit}>
        <TextField.Root placeholder="Title" {...register("title")} />
        <ErrorMessage>{errors.title?.message}</ErrorMessage>
        <Controller
          name="description"
          control={control}
          render={({ field }) => (
            <SimpleMDE placeholder="Description" {...field} />
          )}
        />

Ch-3: Viewing Issues

- Showing the Issues

// issues/page.tsx
import prisma from "@/prisma/client";

const IssuesPage = async () => {
  const issues = await prisma.issue.findMany(); // One line fetch all data

  return (
    <div>
      <div className="mb-5">
        <Button>
          <Link href="/issues/new">New Issue</Link>
        </Button>
      </div>
      <Table.Root variant="surface">
        <Table.Header>
          <Table.Row>
            <Table.ColumnHeaderCell>Issue</Table.ColumnHeaderCell>
            <Table.ColumnHeaderCell className="hidden md:table-cell">
              Status
            </Table.ColumnHeaderCell>
            <Table.ColumnHeaderCell className="hidden md:table-cell">
              Created
            </Table.ColumnHeaderCell>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {issues.map((issue) => (
            <Table.Row key={issue.id}>
              <Table.Cell>
                {issue.title}
                <div className="md:hidden">{issue.status}</div>
              </Table.Cell>
              <Table.Cell className="hidden md:table-cell">
                {issue.status}
              </Table.Cell>
              <Table.Cell className="hidden md:table-cell">
                {issue.createdAt.toDateString()}
              </Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Root>
    </div>
  );
};

- Building the Issue Status Badge

  • You can grab any Prisma Model type/interface. Click
  • Record is typescript concept using for key-value pair
// issues/page.tsx
<IssueStatusBadge status={issue.status} />;

// IssueStatusBadge.tsx
import { Status } from "@prisma/client";
import { Badge } from "@radix-ui/themes";
import React from "react";

const statusMap: Record<
  Status,
  { label: string, color: "red" | "violet" | "green" }
> = {
  OPEN: { label: "Open", color: "red" },
  IN_PROGRESS: { label: "In Progress", color: "violet" },
  CLOSED: { label: "Closed", color: "green" },
};
const IssueStatusBadge = ({ status }: { status: Status }) => {
  return (
    <Badge color={statusMap[status].color}>{statusMap[status].label}</Badge>
  );
};

- Adding Loading Skeletons

  • use delay to watch loading skeletons properly.
npm i delay
// in issues/page.tsx (before return)
await delay(2000);
npm i react-loading-skeleton
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
  • add a new component IssueAction.tsx in local folder
// issues/IssueActions.tsx
import { Button } from "@radix-ui/themes";
import Link from "next/link";
import React from "react";

const IssueActions = () => {
  return (
    <div className="mb-5">
      <Button>
        <Link href="/issues/new">New Issue</Link>
      </Button>
    </div>
  );
};

// issues/loading.tsx
import { Table } from "@radix-ui/themes";
import React from "react";
import IssueStatusBadge from "../components/IssueStatusBadge";
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import IssueActions from "./IssueActions";

const LoadingIssuePage = () => {
  const issues = [1, 2, 3, 4, 5];
  return (
    <>
      <IssueActions />
      <Table.Root variant="surface">
        <Table.Header>
          <Table.Row>
            <Table.ColumnHeaderCell>Issue</Table.ColumnHeaderCell>
            <Table.ColumnHeaderCell className="hidden md:table-cell">
              Status
            </Table.ColumnHeaderCell>
            <Table.ColumnHeaderCell className="hidden md:table-cell">
              Created
            </Table.ColumnHeaderCell>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {issues.map((issue) => (
            <Table.Row key={issue}>
              <Table.Cell>
                <Skeleton />
                <div className="md:hidden">
                  <Skeleton />
                </div>
              </Table.Cell>
              <Table.Cell className="hidden md:table-cell">
                <Skeleton />
              </Table.Cell>
              <Table.Cell className="hidden md:table-cell">
                <Skeleton />
              </Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Root>
    </>
  );
};

NB: page.tsx, loading.tsx, layout.tsx anything could be make client component, if it not contain any server function like prisma.model.findMany()

- Showing Issue Details - Only fetch data not style

  • Add loading page both in new and [id] folder to get ride of skeleton loading of issues folder
// issues/[id]/page.tsx
import prisma from "@/prisma/client";
import { notFound } from "next/navigation";

const SingleIssuePage = async ({
  params: { id },
}: {
  params: { id: string },
}) => {
  if (typeof id !== "number") notFound();

  const issue = await prisma.issue.findUnique({
    where: { id: parseInt(id) },
  });

  if (!issue) notFound(); // don't use return notFound(); It return null

  return (
    <div>
      <p>{issue.title}</p>
      <p>{issue.description}</p>
      <p>{issue.status}</p>
      <p>{issue.createdAt.toDateString()}</p>
    </div>
  );
};

// issues/page.tsx
<Link href={`/issues/${issue.id}`}>{issue.title}</Link>;

- Styling the Issue Detail Page

// issues/[id]/page.tsx
import IssueStatusBadge from "@/app/components/IssueStatusBadge";
import prisma from "@/prisma/client";

const SingleIssuePage = async ({
  params: { id },
}: {
  params: { id: string },
}) => {
  const issue = await prisma.issue.findUnique({
    where: { id: parseInt(id) },
  });

  if (!issue) notFound(); // don't use return notFound(); It return null

  return (
    <div>
      <Heading>{issue.title}</Heading>
      <Flex className="space-x-3" my="2">
        <IssueStatusBadge status={issue.status} />
        <Text>{issue.createdAt.toDateString()}</Text>
      </Flex>
      <Card>
        <p>{issue.description}</p>
      </Card>
    </div>
  );
};

- Adding Markdown Preview

All description show as a paragraph but If we install packege react-markdown then it shows like heading, list, bold etc

npm i react-markdown

Now everything comes properly See. Only problem is tailwind by default desable some style. So we need to install a packeg which will give beautiful style.


Step 1: Install

npm install -D @tailwindcss/typography

Step 2: Add to plugins require('@tailwindcss/typography') put this in plagins array of tailwind.config.ts

// tailwind.config.ts
plugins: [require("@tailwindcss/typography")],

Step 3: add prose

<Card className="prose">
  <ReactMarkdown>{issue.description}</ReactMarkdown>
</Card>
// issues/[id]/page.tsx
return (
  <div>
    <Heading>{issue.title}</Heading>
    <Flex className="space-x-3" my="2">
      <IssueStatusBadge status={issue.status} />
      <Text>{issue.createdAt.toDateString()}</Text>
    </Flex>
    <Card className="prose" mt="4">
      <ReactMarkdown>{issue.description}</ReactMarkdown>
    </Card>
  </div>
);

- Buliding a Styled Link Component (components/Link.tsx)

  • Custom link creation. Combine next link which has client side navigation. Radix link has beautiful look and feel. Hear combine both
import NextLink from "next/link";
import { Link as RadixLink } from "@radix-ui/themes";

interface Props {
  href: string;
  children: string;
}

const Link = ({ href, children }: Props) => {
  // next link pass two props
  return (
    <NextLink href={href} passHref legacyBehavior>
      <RadixLink>{children}</RadixLink>
    </NextLink>
  );
};

- Additional Loading Skeletons (Single issue loading page)

  • Copy orginal markup of single issue then place loading Skeleton.
  • Use await delay(3000) to watch it poperly.
// issues/[id]/loading.tsx
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";

const LoadingIssueDetailsPage = () => {
  return (
    <Box className="max-w-xl">
      <Skeleton />
      <Flex className="space-x-3" my="2">
        <Skeleton width="3rem" />
        <Skeleton width="5rem" />
      </Flex>
      <Card className="prose" mt="4">
        <Skeleton count={5} />
      </Card>
    </Box>
  );
};

// issues/new/loading.tsx
import { Box } from "@radix-ui/themes";
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";

const LoadingNewIssuePage = () => {
  return (
    <Box className="max-w-xl">
      <Skeleton />
      <Skeleton height="20rem" />
    </Box>
  );
};

- Disabling Server-side Rendering

import dynamic from "next/dynamic";

const SimpleMDE = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});

- Refactoring: Organizing Imports

  • Run "Organized Import"
  • In components folder many component. We can combine in components/index.js
// components/index.js
import Link from "./Link";
import ErrorMessage from "./ErrorMessage";
import IssueStatusBadge from "./IssueStatusBadge";
import Spinner from "./Spinner";

export { Link };
export { ErrorMessage };
export { IssueStatusBadge };
export { Spinner };

// Or One Line export-import
export { default as Link } from "./Link";
export { default as ErrorMessage } from "./ErrorMessage";
export { default as Skeleton } from "./Skeleton";

// components/Skeleton.ts
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";

export default Skeleton;

// components/index.js
export { default as Skeleton } from "./Skeleton";

// issues/page.tsx
import { IssueStatusBadge, Link } from "@/app/components"; // index.js so not need to add it
// loading.tsx
import { Skeleton } from "@/app/components";

Ch-4: Updating Issues

- Add the edit Button (Just creating UI no functionality)

  • Radix ui Grid component with dynamic colums by passing object
  • Install radix-ui icons for pencil icon
npm i @radix-ui/react-icons
// issues/page.tsx
return (
  <Grid columns={{ initial: "1", md: "2" }}>
    <Box>
      <Heading>{issue.title}</Heading>
      <Flex className="space-x-3" my="2">
        <IssueStatusBadge status={issue.status} />
        <Text>{issue.createdAt.toDateString()}</Text>
      </Flex>
      <Card className="prose" mt="4">
        <ReactMarkdown>{issue.description}</ReactMarkdown>
      </Card>
    </Box>
    <Box>
      <Button>
        <Pencil2Icon />
        <Link href={`/issues/${issue.id}/edit`}> Edit Issue</Link>
      </Button>
    </Box>
  </Grid>
);

- Refactor: Applying the single responsibility Principle

  • Software entity sholud have single responsibility, then the code will more maintainable, Reuseable, Extendable.
  • If you have a page with a lot of import statement that is a signal violating single responsibility principle.
  • We break down issues/[id]/page.tsx into EditIssueButton.tsx and IssueDetails.tsx keep same folder because it will not reuse any other pages and this is the pieces of this page.
  • If only one props then inline props is good.
  • By doing this break down of page.tsx, in future if we want to change th layout of the only file we have to touch page.tsx, similarly if we want to change the layout of issiue details IssueDetails.tsx is the only file we need to modify. This is the benefit of applying single responsibility principle.
// issues/[id]/page.tsx (Just break it down)
return (
  <Grid columns={{ initial: "1", md: "2" }}>
    <Box>
      <IssueDetails issue={issue} />
    </Box>
    <Box>
      <EditIssueButton issueId={issue.id} />
    </Box>
  </Grid>
);

// IssueDetails.tsx
import { IssueStatusBadge } from "@/app/components";
import { Issue } from "@prisma/client";
import { Heading, Flex, Card, Text } from "@radix-ui/themes";
import React from "react";
import ReactMarkdown from "react-markdown";

const IssueDetails = ({ issue }: { issue: Issue }) => {
  return (
    <>
      <Heading>{issue.title}</Heading>
      <Flex className="space-x-3" my="2">
        <IssueStatusBadge status={issue.status} />
        <Text>{issue.createdAt.toDateString()}</Text>
      </Flex>
      <Card className="prose" mt="4">
        <ReactMarkdown>{issue.description}</ReactMarkdown>
      </Card>
    </>
  );
};

// EditIssueButton.tsx
import { Pencil2Icon } from "@radix-ui/react-icons";
import { Button } from "@radix-ui/themes";
import Link from "next/link";
import React from "react";

const EditIssueButton = ({ issueId }: { issueId: number }) => {
  return (
    <Button>
      <Pencil2Icon />
      <Link href={`/issues/${issueId}/edit`}> Edit Issue</Link>
    </Button>
  );
};

- Building the edit issue page (Very smart work: Just copy new issue form)

  1. Create folder "edit" in [id] and another folder components in Issues folder, here use "" to keep it out from routing system ie. you can not access here.
  2. Cut everything from issues/new/page.tsx and paste it directly in _components/IssueForm.tsx (change the component name and interface type name) and import this component in new/page.tsx and test application.
  3. Create another file in edit/page.tsx and import IssueForm with issue props and in _components/IssueForm.tsx populate input field with defaultValue props.
// new/page.tsx
import IssueForm from "../_components/IssueForm";

const NewIssuePage = () => {
  return <IssueForm />;
};

export default NewIssuePage;

// [id]/edit/page.tsx
import IssueForm from "../../_components/IssueForm";
import prisma from "@/prisma/client";
import { notFound } from "next/navigation";

interface Props {
  params: { id: string };
}

const EditIssuePage = async ({ params }: Props) => {
  const issue = await prisma.issue.findUnique({
    where: { id: parseInt(params.id) },
  });

  if (!issue) notFound();

  return <IssueForm issue={issue} />;
};

// issues/_components/IssueForm.tsx (First copy exactly from new/page.tsx then some rename and receive some props and put that as value of input fields)
(" use client ");
import ErrorMessage from "@/app/components/ErrorMessage";
import Spinner from "@/app/components/Spinner";
import { createIssueSchema } from "@/app/validationSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import { Issue } from "@prisma/client";
import { Button, Callout, TextField } from "@radix-ui/themes";
import axios from "axios";
import "easymde/dist/easymde.min.css";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";

const SimpleMDE = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});

type IssueFormData = z.infer<typeof createIssueSchema>;

const IssueForm = ({ issue }: { issue?: Issue }) => {
  const router = useRouter();
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm <
  IssueFormData >
  {
    resolver: zodResolver(createIssueSchema),
  };
  // console.log(errors);
  const [error, setError] = useState(""); // to handel error comes form server
  const [isSubmitting, setSubmitting] = useState(false);

  const onSubmit = handleSubmit(async (data) => {
    try {
      setSubmitting(true);
      await axios.post("/api/issues", data);
      router.push("/issues");
    } catch (error) {
      // console.log(error);
      setSubmitting(false);
      setError("An unexpected error occurred.");
    }
  });

  return (
    <div className="max-w-xl">
      {error && (
        <Callout.Root color="red" className="mb-3">
          <Callout.Text>{error}</Callout.Text>
        </Callout.Root>
      )}
      <form className="space-y-3" onSubmit={onSubmit}>
        <TextField.Root
          defaultValue={issue?.title}
          placeholder="Title"
          {...register("title")}
        />
        <ErrorMessage>{errors.title?.message}</ErrorMessage>
        <Controller
          name="description"
          control={control}
          defaultValue={issue?.description}
          render={({ field }) => (
            <SimpleMDE placeholder="Description" {...field} />
          )}
        />
        <ErrorMessage>{errors.description?.message}</ErrorMessage>
        <Button type="submit">
          Submit New Issue {isSubmitting && <Spinner />}
        </Button>
      </form>
    </div>
  );
};

- Building an API for updating Issue (api/issues/[id]/route.ts)

PUT: Replacing entire Object
PATCH: Updating one or more property

  • Careful: add "await" before request.json() and return before NextResponse.json()
  1. Check request body
  2. Check issue in DB
  3. Update and Return
// api/issues/[id]/route.ts
import { IssueSchema } from "@/app/validationSchema";
import prisma from "@/prisma/client";
import { NextRequest, NextResponse } from "next/server";

export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const body = await request.json();
  const validation = IssueSchema.safeParse(body);

  if (!validation.success)
    return NextResponse.json(validation.error.format(), { status: 400 });

  const issue = await prisma.issue.findUnique({
    where: { id: parseInt(params.id) },
  });

  if (!issue)
    return NextResponse.json({ error: "Invald Issue" }, { status: 404 });

  const updatedIssue = await prisma.issue.update({
    where: { id: issue?.id },
    data: {
      title: body.title,
      description: body.description,
    },
  });

  return NextResponse.json(updatedIssue);
}

- Updating Issues (_components/IssueForm.tsx)

Two Places need to update

  1. if (issue) await axios.patch(/api/issues/${issue.id}, data); else await axios.post("/api/issues", data);
  2. {issue ? "Update Issue" : "Submit New Issue"}
"use client";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";

const SimpleMDE = dynamic(() => import("react-simplemde-editor"), {
  ssr: false,
});

type IssueFormData = z.infer<typeof IssueSchema>;

const IssueForm = ({ issue }: { issue?: Issue }) => {
  const router = useRouter();
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm <
  IssueFormData >
  {
    resolver: zodResolver(IssueSchema),
  };
  // console.log(errors);
  const [error, setError] = useState(""); // to handel error comes form server
  const [isSubmitting, setSubmitting] = useState(false);

  const onSubmit = handleSubmit(async (data) => {
    try {
      setSubmitting(true);
      if (issue) await axios.patch(`/api/issues/${issue.id}`, data);
      else await axios.post("/api/issues", data);
      router.push("/issues");
    } catch (error) {
      // console.log(error);
      setSubmitting(false);
      setError("An unexpected error occurred.");
    }
  });

  return (
    <div className="max-w-xl">
      {error && (
        <Callout.Root color="red" className="mb-3">
          <Callout.Text>{error}</Callout.Text>
        </Callout.Root>
      )}
      <form className="space-y-3" onSubmit={onSubmit}>
        <TextField.Root
          defaultValue={issue?.title}
          placeholder="Title"
          {...register("title")}
        />
        <ErrorMessage>{errors.title?.message}</ErrorMessage>
        <Controller
          name="description"
          control={control}
          defaultValue={issue?.description}
          render={({ field }) => (
            <SimpleMDE placeholder="Description" {...field} />
          )}
        />
        <ErrorMessage>{errors.description?.message}</ErrorMessage>
        <Button type="submit">
          {issue ? "Update Issue" : "Submit New Issue"}{" "}
          {isSubmitting && <Spinner />}
        </Button>
      </form>
    </div>
  );
};

- Understanding Caching (3 types- Data(Server), Full Route(Server), Router(Client)) VVI

  1. Data Cache: When we fetch data using fetch(): Stored in file system, Permanent untill re-deploy (Cache on the server)
fetch("...", { cache: "no-store" });
fetch("...", { next: { revalidate: 3600 } }); // 3600sec
  1. Full Route Cache (Cache on the server): Used to store the output of statically rendered routes. (o static rendiring - build time) (λ dynamic routing - request time)
    NextJS Don't have a parameter static route by default. Click
    Build application(npm run build) and Start builded app(npm start)
    o Static rendiring: Data will not refetching no metter what how many refresh you are doing. Need to re-build
    Export force-dynamic to make static page dynamic. Result
// issues/page.tsx
      </Table.Root>
    </div>
  );
};

export const dynamic = "force-dynamic"; // TO make a static page dynamic
// export const revalidate = 10; // revalidate every 10 sec

export default IssuesPage;
  1. Router Cache (Client Cache): To store the payload of pages in browser, refreshed when we reload. If one page is fetched then need not to make network request.
    For, o Static route client cache invalidation period is 5 min (After this period refetch from network)
    For, λ Dynamic route client cache invalidation period is 30 sec
// IssueForm.tsx (Tell next js refresh the content of the current route)
router.push("/issues");
router.refresh();

- Improving the Loading Experience. Lazy Loading

  • Load add or update IssueForm lazy and at a time.
  • Use dynamic() for lazy loading
// new/page.tsx
import dynamic from "next/dynamic";
import IssueFormSkeleton from "../_components/IssueFormSkeleton";

const IssueForm = dynamic(() => import("@/app/issues/_components/IssueForm"), {
  ssr: false,
  loading: () => <IssueFormSkeleton />,
});

const NewIssuePage = () => {
  return <IssueForm />;
};

// IssueForm.tsx (Import directly because it will load using dynamic funciton and ssr: false)
const SimpleMDE = dynamic(() => import("react-simplemde-editor"), { // old
  ssr: false,
});
to--->
import SimpleMdeReact from "react-simplemde-editor"; // New
  • Create _components/IssueFormSkeleton.tsx, it repeate several places
// _components/IssueFormSkeleton.tsx
import { Box } from "@radix-ui/themes";
import { Skeleton } from "@/app/components";

const IssueFormSkeleton = () => {
  return (
    <Box className="max-w-xl">
      <Skeleton height="2rem" />
      <Skeleton height="20rem" />
    </Box>
  );
};

// in loading.tsx
import IssueFormSkeleton from "../../_components/IssueFormSkeleton";
export default IssueFormSkeleton;

Ch-5: Deleting Issues

- Adding a delete button (DeleteIssueButton.tsx)

  • Here some important critical markup is present. Need to understand.
// issues/[id]/page.tsx | md in taiwind is equvalent to sm in redix
return (
  <Grid columns={{ initial: "1", sm: "5" }} gap="3">
    <Box className="md:col-span-4">
      <IssueDetails issue={issue} />
    </Box>
    <Box>
      <Flex direction="column" gap="4">
        <EditIssueButton issueId={issue.id} />
        <DeleteIssueButton issueId={issue.id} />
      </Flex>
    </Box>
  </Grid>
);

// issues/[id]/DeleteIssueButton.tsx
import { Button } from "@radix-ui/themes";
import React from "react";

const DeleteIssueButton = ({ issueId }: { issueId: number }) => {
  return <Button color="red">Delete Issue</Button>;
};

// app/layout.tsx (Add container so that no matter what screen, it provide a fixed size and certer the page)
<main className="p-5 radix-themes">
  <Container>{children}</Container>
</main>;

- Adding a Confirmation Dialog Box

// issues/[id]/DeleteissueButton.tsx
return (
  <AlertDialog.Root>
    <AlertDialog.Trigger>
      <Button color="red">Delete Issue</Button>
    </AlertDialog.Trigger>
    <AlertDialog.Content>
      <AlertDialog.Title>Delete Confirmation</AlertDialog.Title>
      <AlertDialog.Description>
        Are you sure you want to delete this issue? This action can not be
        undone.
      </AlertDialog.Description>
      <Flex gap="3" mt="4">
        <AlertDialog.Cancel>
          <Button variant="soft" color="gray">
            Cancel
          </Button>
        </AlertDialog.Cancel>
        <AlertDialog.Action>
          <Button variant="solid" color="red">
            Delete Issue
          </Button>
        </AlertDialog.Action>
      </Flex>
    </AlertDialog.Content>
  </AlertDialog.Root>
);

- Building an API & Delete an issue

  • First build api then add api link to markup using axios
// api/issues/[id]/route.ts (Just copy from PUTCH)
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const issue = await prisma.issue.findUnique({
    where: { id: parseInt(params.id) },
  });

  if (!issue)
    return NextResponse.json({ error: "Invald Issue" }, { status: 404 });

  await prisma.issue.delete({
    where: { id: issue?.id },
  });

  return NextResponse.json({});
}

// issues/[id]/DeleteIssueButton.tsx (useRouter form navigatioin)
("use client");
import { Button, AlertDialog, Flex, Link } from "@radix-ui/themes";
import axios from "axios";
import { useRouter } from "next/navigation";

const DeleteIssueButton = ({ issueId }: { issueId: number }) => {
  const router = useRouter();

  return (
    <AlertDialog.Root>
      <AlertDialog.Trigger>
        <Button color="red">Delete Issue</Button>
      </AlertDialog.Trigger>
      <AlertDialog.Content>
        <AlertDialog.Title>Delete Confirmation</AlertDialog.Title>
        <AlertDialog.Description>
          Are you sure you want to delete this issue? This action can not be
          undone.
        </AlertDialog.Description>
        <Flex gap="3" mt="4">
          <AlertDialog.Cancel>
            <Button variant="soft" color="gray">
              Cancel
            </Button>
          </AlertDialog.Cancel>
          <AlertDialog.Action>
            <Button
              color="red"
              onClick={async () => {
                await axios.delete("/api/issues/" + issueId);
                router.push("/issues");
                router.refresh(); // Cache refresh
              }}
            >
              Delete Issue
            </Button>
          </AlertDialog.Action>
        </Flex>
      </AlertDialog.Content>
    </AlertDialog.Root>
  );
};

- Handling Errors

  • If there is less code then one line when increase code line then separate code. No need to do over engineering for one liner.
// issues/[id]/DeleteIssueButon.tsx (If less code then one line)
<Button
  color='red'
  onClick={async () => {
    await axios.delete("/api/issues/" + issueId);
    router.push("/issues");
    router.refresh();
  }}
>
  Delete Issue
</Button>

// This code could be with error handeliing
const deleteIssue = async () => {
  try {
    // throw new Error();
    await axios.delete("/api/issues/" + issueId);
    router.push("/issues");
    router.refresh();
  } catch (error) {
    setError(true);
  }
};

return(
  <Button color='red' onClick={deleteIssue}>
    Delete Issue
  </Button>;
)

- Improving the User Experience (Showing Spinner while deleting)

// issues/[id]/DeleteIssueButton.tsx (<Spinner> theat we create in component)
<Button color='red' disabled={isDeleting}>
  Delete Issue {isDeleting && <Spinner />}
</Button>

// api/issues/[id]/route.ts
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  delay(3000); // To simulate delay
  ............
}

- Removing Duplicate Skeletons (Commonn loading is not get loded for skeleton. )

Restructure file-folder to get ride of load common loading.tsx
issues/allFiles -> issues/list
issues/[id]/edit -> issues/edit/[id]
Search and Replace link Click

// EditIssueButton.tsx (Update link also)
<Link href={`/issues/edit/${issueId}`}> Edit Issue</Link>;

// NavBar.tsx
const links = [
  { label: "Dashboard", href: "/" },
  { label: "Issues", href: "/issues/list" },
];

// DeleteIssueButton.tsx (router.push("/issues/list"))
const deleteIssue = async () => {
  try {
    // throw new Error();
    setDeleting(true);
    await axios.delete("/api/issues/" + issueId);
    router.push("/issues/list");
    router.refresh();
  } catch (error) {
    setDeleting(false);
    setError(true);
  }
};

Ch-6: Authentication

- Setting Up NextAuth

npm install next-auth

In NextAuth we need to pass configaration objects like providers, adapters

// /app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";

const handler = NextAuth({
  providers: [],
});

export { handler as GET, handler as POST };

To encrypt sign authentication key using a random character. To generate ramdom string run this in terminal or openssl

openssl rand -base64 32 // https://prnt.sc/HBzZGF62p2Y8

- Configuring Google Provider (Add .env to .gitignore)

  1. Consent Screen setup (External, App name & Support & Developer Email, Scopes-Email+Profile, No Test User, Publish)
  2. Credentials -> Create Credentials -> OAuth client ID. See
// /app/auth/token/route.ts (OutPut: https://prnt.sc/-wnwXozYhnhP)
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const token = await getToken({ req: request });

  return NextResponse.json(token);
}
// .env file
// DATABASE_URL = "mysql://root:@localhost:3306/issue-tracker"
// NEXTAUTH_URL = "http://localhost:3000"
// NEXTAUTH_SECRET = wdyN1qkDG5W0lESPiEMkj6UweqVm1vgnIvSQ8tEOjsE=
// GOOGLE_CLIENT_ID = 8etmfb88qp1runte5v6la30.apps.googleusercontent.com
// GOOGLE_CLIENT_SECRET = drrXvcMlrKsm2XmX

- Adding Prisma Adapter (Google User store Local site in DB)

Go to https://authjs.dev/getting-started/adapters/prisma

  • Run this commands
npm install @next-auth/prisma-adapter
npx prisma format
npx prisma migrate dev
  • Use this if you get prisma migration error
// @db.VarChar(100), default: @db.VarChar(191)
model Account {
provider          String  @db.VarChar(100)
providerAccountId String  @db.VarChar(100)

model VerificationToken {
identifier String   @db.VarChar(100)
token      String   @unique @db.VarChar(100)
// /app/api/auth/[...nextauth]/route.ts (Neet to change strategy to jwt)
import GoogleProvider from "next-auth/providers/google";
import NextAuth from "next-auth";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import prisma from "@/prisma/client";

const handler = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  session: {
    strategy: "jwt",
  },
});

export { handler as GET, handler as POST };

- Add the login and logout links (NabBar.tsx with some fixes for useSession)

  • If we use useSession then we need to do 2 thing, 1: Create AuthProvider, 2: Wrap Layout with AuthProvider
"use client";

import { useSession } from "next-auth/react";

const NavBar = () => {
  const currentPath = usePathname();
  const { status, data: session } = useSession();
  // console.log(currentPath);
  const links = [
    { label: "Dashboard", href: "/" },
    { label: "Issues", href: "/issues/list" },
  ];

  return (
      <ul className='flex space-x-6'>
        {links.map((link) => (
          <li key={link.href}>
            <Link
              className={classNames({
                "text-zinc-900": link.href === currentPath,
                "text-zinc-500": link.href !== currentPath,
                "hover:text-zinc-800 transition-colors": true,
              })}
              href={link.href}
            >
              {link.label}
            </Link>
          </li>
        ))}
      </ul>
      <Box>
        {status === "unauthenticated" ? (
          <Link href='/api/auth/signin'>Login</Link>
        ) : (
          <Link href='/api/auth/signout'>Logout</Link>
        )}
      </Box>
    </nav>
  );
};

// app/auth/Provider.tsx
"use client";

import { SessionProvider } from "next-auth/react";
import React, { PropsWithChildren } from "react";

const AuthProvider = ({ children }: PropsWithChildren) => {
  return <SessionProvider>{children}</SessionProvider>;
};

export default AuthProvider;


// app/layout.tsx (wrap what inside body with <AuthProvider>)
import AuthProvider from "./auth/Provider";

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang='en'>
      <body className={inter.variable}>
        <AuthProvider> // Added
          <Theme accentColor='violet'>
            <NavBar />
            <main className='p-5 radix-themes'>
              <Container>{children}</Container>
            </main>
          </Theme>
        </AuthProvider>
      </body>
    </html>
  );
}

- Changing Layout of the NavBar (NavBar.tsx) Many thing there need to learn about CSS

// NavBar.tsx
"use client";

const NavBar = () => {
  const currentPath = usePathname();
  const { status, data: session } = useSession();
  // console.log(currentPath);
  const links = [
    { label: "Dashboard", href: "/" },
    { label: "Issues", href: "/issues/list" },
  ];
  return (
    <nav className="space-x-6 border-b mb-5 py-3">
      <Container>
        <Flex justify="between">
          <Flex align="center" gap="3">
            <Link href="/">
              <AiFillBug />
            </Link>
            <ul className="flex space-x-6">
              {links.map((link) => (
                <li key={link.href}>
                  <Link
                    className={classNames({
                      "text-zinc-900": link.href === currentPath,
                      "text-zinc-500": link.href !== currentPath,
                      "hover:text-zinc-800 transition-colors": true,
                    })}
                    href={link.href}
                  >
                    {link.label}
                  </Link>
                </li>
              ))}
            </ul>
          </Flex>
          <Box>
            {status === "unauthenticated" ? (
              <Link href="/api/auth/signin">Login</Link>
            ) : (
              <Link href="/api/auth/signout">Logout</Link>
            )}
          </Box>
        </Flex>
      </Container>
    </nav>
  );
};

- Add a drop-down menu to show the current user (NavBar.tsx)

  • Just used radix DropDownMenu
// NavBar.tsx
const NavBar = () => {
  const currentPath = usePathname();
  const { status, data: session } = useSession();

  const links = [
    { label: "Dashboard", href: "/" },
    { label: "Issues", href: "/issues/list" },
  ];

  return (
    <nav className='border-b mb-5 px-5 py-3'>
      <Container>
        <Flex justify='between'>
          <Flex align='center' gap='3'>
            <Link href='/'>
              <AiFillBug />
            </Link>
            <ul className='flex space-x-6'>
              {links.map((link) => (
                <li key={link.href}>
                  <Link
                    className={classnames({
                      "text-zinc-900": link.href === currentPath,
                      "text-zinc-500": link.href !== currentPath,
                      "hover:text-zinc-800 transition-colors": true,
                    })}
                    href={link.href}
                  >
                    {link.label}
                  </Link>
                </li>
              ))}
            </ul>
          </Flex>
          <Box>
            {status === "unauthenticated" ? (
              <Link href='/api/auth/signin'>Login</Link>
            ) : (
              <DropdownMenu.Root>
                <DropdownMenu.Trigger>
                  <Avatar
                    src={session?.user!.image!}
                    fallback='?'
                    size='3'
                    radius='full'
                    className='cursor-pointer'
                  />
                </DropdownMenu.Trigger>
                <DropdownMenu.Content>
                  <DropdownMenu.Label>
                    <Text size='2'>{session?.user?.email}</Text>
                  </DropdownMenu.Label>
                  <DropdownMenu.Item>
                    <Link href='/api/auth/signout'>Logout</Link>
                  </DropdownMenu.Item>
                </DropdownMenu.Content>
              </DropdownMenu.Root>
            )}
          </Box>
        </Flex>
      </Container>
    </nav>
  );
};

- Troubleshooting: Avatar Not Loading

  • Normally avater load autometically. If not then follow step1 if not work then together with step2
// Step1: NavBar.tsx
<Avatar
  src={session?.user!.image!}
  fallback='?'
  size='3'
  radius='full'
  className='cursor-pointer'
  referrerPolicy='no-referrer'
/>

// Step2: next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [{ key: "referrer-policy", value: "no-referrer" }],
      },
    ];
  },
};

export default nextConfig;

- Refactoring the NavBar (NavBar.tsx) Create component inside single file

Create two components AuthStatus & NavLinks inside same file. You can do it in another file. Illustrating just another way. There is no right or wrong here.
By doing so, NavBar has now single responsibility of laying out NavBar only
In AuthStatus very professional way of return in 3 conditions
In globals.css create single .nav-link class combining 3 tailwind class
! in tailwind is !important modifire in css

// NavBar.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
import { AiFillBug } from "react-icons/ai";
import classnames from "classnames";
import { useSession } from "next-auth/react";
import {
  Avatar,
  Box,
  Container,
  DropdownMenu,
  Flex,
  Text,
} from "@radix-ui/themes";

const NavBar = () => {
  return (
    <nav className='border-b mb-5 px-5 py-3'>
      <Container>
        <Flex justify='between'>
          <Flex align='center' gap='3'>
            <Link href='/'>
              <AiFillBug />
            </Link>
            <NavLinks />
          </Flex>
          <AuthStatus />
        </Flex>
      </Container>
    </nav>
  );
};

const NavLinks = () => {
  const currentPath = usePathname();

  const links = [
    { label: "Dashboard", href: "/" },
    { label: "Issues", href: "/issues/list" },
  ];

  return (
    <ul className='flex space-x-6'>
      {links.map((link) => (
        <li key={link.href}>
          <Link
            className={classnames({
              "nav-link": true,
              "!text-zinc-900": link.href === currentPath,
            })}
            href={link.href}
          >
            {link.label}
          </Link>
        </li>
      ))}
    </ul>
  );
};

const AuthStatus = () => {
  const { status, data: session } = useSession();

  if (status === "loading") return null;

  if (status === "unauthenticated")
    return (
      <Link className='nav-link' href='/api/auth/signin'>
        Login
      </Link>
    );

  return (
    <Box>
      <DropdownMenu.Root>
        <DropdownMenu.Trigger>
          <Avatar
            src={session?.user!.image!}
            fallback='?'
            size='3'
            radius='full'
            className='cursor-pointer'
            referrerPolicy='no-referrer'
          />
        </DropdownMenu.Trigger>
        <DropdownMenu.Content>
          <DropdownMenu.Label>
            <Text size='2'>{session?.user?.email}</Text>
          </DropdownMenu.Label>
          <DropdownMenu.Item>
            <Link href='/api/auth/signout'>Logout</Link>
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Root>
    </Box>
  );
};
export default NavBar;
/* globals.css */
@layer utilities {
  .nav-link {
    @apply text-zinc-500 hover:text-zinc-800 transition-colors;
  }
}

- Adding a Login Loading Skeleton

// NavBar.tsx (This Skeleton contains both js and css in one place)
import { Skeleton } from "@/app/components";

if (status === "loading") return <Skeleton width="3rem" />;

- Securing the Application (Couple of thing included to provide security for both fontend and backend )

  • In middleware.ts we just need to add all url in matcher array. Next-Auth redirect if to login page if not login.
// middleware.ts (in root directory not in App directory)
export { default } from "next-auth/middleware";

export const config = {
  matcher: ["/issues/new", "/issues/edit/:id+"],
};
  • To getServerSession(), create app/auth/authOptions.ts just configaratin object which is just cut from api/auth/[...nextauth]/router.ts
// app/auth/authOptions.ts
import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import prisma from "@/prisma/client";
import { NextAuthOptions } from "next-auth";

const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  session: {
    strategy: "jwt",
  },
};

export default authOptions;

// api/auth/[...nextauth]/router.ts
import authOptions from "@/app/auth/authOptions";
import NextAuth from "next-auth";

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };
  • Using server session on Issue Page. app/issues/[id]/page.tsx. Tricks: For conditional rendering some elements first wrap inside <>Ok</> -> {<>Ok</>} -> {session && <>Ok</>}
// app/issues/[id]/page.tsx
const session = await getServerSession(authOptions);

{
  session && (
    <Box>
      <Flex direction="column" gap="4">
        <EditIssueButton issueId={issue.id} />
        <DeleteIssueButton issueId={issue.id} />
      </Flex>
    </Box>
  );
}
  • Securing API | Here also getServerSession(authOptions). Postman Output
// api/issues/route.ts [@POST]
export async function POST(request: NextRequest) {
  const session = await getServerSession(authOptions);
  if (!session) return NextResponse.json({}, { status: 401 });

  const body = await request.json();

  const validation = IssueSchema.safeParse(body);

  if (!validation.success)
    return NextResponse.json(validation.error.format(), { status: 400 });

  const newIssue = await prisma.issue.create({
    data: {
      title: body.title,
      description: body.description,
    },
  });

  return NextResponse.json(newIssue, { status: 201 });
}

Ch-7: Assigning Issues to Users

- Building the Assignee Select Component (AssigneeSelect.tsx) Just use radix < Select >

// issues/[id]/AssigneeSelect.tsx
import { Select } from "@radix-ui/themes";

const AssigneeSelect = () => {
  return (
    <Select.Root>
      <Select.Trigger placeholder="Assign..." />
      <Select.Content>
        <Select.Group>
          <Select.Label>Suggestion</Select.Label>
          <Select.Item value="1">Subroto Biswas</Select.Item>
        </Select.Group>
      </Select.Content>
    </Select.Root>
  );
};

// issues/[id]/page.tsx (just use upper component)
<Box>
  <Flex direction="column" gap="4">
    <AssigneeSelect />
    <EditIssueButton issueId={issue.id} />
    <DeleteIssueButton issueId={issue.id} />
  </Flex>
</Box>;

- Populating the Assignee Select Component

  • Building API and access data from AssigneeSelect.tsx
// api/users/route.ts (Building API)
import prisma from "@/prisma/client";
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const user = await prisma.user.findMany();
  return NextResponse.json(user);
}

// issues/[id]/AssigneeSelect.tsx
import { User } from "@prisma/client";
import { Select } from "@radix-ui/themes";

const AssigneeSelect = async () => {
  const res = await fetch("http://localhost:3000/api/users");
  const users: User[] = await res.json();

  return (
    <Select.Root>
      <Select.Trigger placeholder="Assign..." />
      <Select.Content>
        <Select.Group>
          <Select.Label>Suggestion</Select.Label>
          {users.map((user) => (
            <Select.Item value={user.id}>{user.name}</Select.Item>
          ))}
        </Select.Group>
      </Select.Content>
    </Select.Root>
  );
};

NB: If you want to access API from client component (use client) need to use axios and keep data in state variable

- Add Assigned Issues to Prisma Schema (schema.prisma)

  • Making relathonship
// prisma/schema.prisma
model Issue {
  ......
  assignedToUserId String?  @db.VarChar(100) // foriegn key
  assignedToUser   User?    @relation(fields: [assignedToUserId], references: [id]) // For make it happen in prisma
}

model User {
  .......
  assignedIssues Issue[] // for make it happen in prisma
}

- Enhance the API to assign issues (validationSchema.ts, api/issues/[id]/route.ts)

  • Added new zod schema(patchIssueSchema) which allow request body {title, description, assignedToUserId} optional. Because sometimes title and description are given but not assignedToUserId and vice versa
// validationSchema.ts (add new schema patchIssueSchema for PATCH function )
import { z } from "zod";

export const IssueSchema = z.object({
  title: z.string().min(1, "Title is required.").max(255),
  description: z.string().min(1, "Description is required.").max(65535),
});

export const patchIssueSchema = z.object({
  title: z.string().min(1, "Title is required.").max(255).optional(),
  description: z
    .string()
    .min(1, "Description is required.")
    .max(65535)
    .optional(),
  assignedToUserId: z
    .string()
    .min(1, "AssignedToUserId is required.")
    .max(100)
    .optional()
    .nullable(),
});

// api/issues/[id]/route.ts (If assignedToUserId is null then it also updated as null. Will not catch in checking)
export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const session = await getServerSession(authOptions);
  if (!session) return NextResponse.json({}, { status: 401 });

  const body = await request.json();
  const validation = patchIssueSchema.safeParse(body);

  if (!validation.success)
    return NextResponse.json(validation.error.format(), { status: 400 });

  const { assignedToUserId, title, description } = body;

  if (assignedToUserId) {
    const user = await prisma.user.findUnique({
      where: {
        id: assignedToUserId,
      },
    });
    if (!user)
      return NextResponse.json({ error: "Invalid User" }, { status: 400 });
  }

  const issue = await prisma.issue.findUnique({
    where: { id: parseInt(params.id) },
  });

  if (!issue)
    return NextResponse.json({ error: "Invald Issue" }, { status: 404 });

  const updatedIssue = await prisma.issue.update({
    where: { id: issue?.id },
    data: {
      title,
      description,
      assignedToUserId,
    },
  });

  return NextResponse.json(updatedIssue);
}

- Assigning an Issue to a User (AssigneeSelect.tsx)

// app/issues/[id]/page.tsx
<IssueDetails issue={issue} />

// app/issues/[id]/AssigneeSelect.tsx
"use client";
import { Issue, User } from "@prisma/client";
import { Select } from "@radix-ui/themes";
import axios from "axios";
import { useEffect, useState } from "react";

const AssigneeSelect = ({ issue }: { issue: Issue }) => {
  const [users, setUsers] = useState<User[]>();
  // const res = await fetch("http://localhost:3000/api/users");
  useEffect(() => {
    axios
      .get<User[]>("http://localhost:3000/api/users")
      .then((res) => {
        setUsers(res.data);
        console.log(res.data);
      })
      .catch((err) => console.log(err));
  }, []);

  return (
    <Select.Root
      defaultValue={issue.assignedToUserId || "unassigned"}
      onValueChange={(userId) => {
        const valueUserId = userId === "unassigned" ? null : userId;
        fetch("http://localhost:3000/api/issues/" + issue.id, {
          method: "PATCH",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            assignedToUserId: valueUserId,
          }),
        });
      }}
    >
      <Select.Trigger placeholder="Assign..." />
      <Select.Content>
        <Select.Group>
          <Select.Label>Suggestion</Select.Label>
          <Select.Item value="unassigned">Unassigned</Select.Item> // here value='' can not assign so we do this
          {users?.map((user) => (
            <Select.Item key={user.id} value={user.id}>
              {user.name}
            </Select.Item>
          ))}
        </Select.Group>
      </Select.Content>
    </Select.Root>
  );
};

- Showing Toast Notifications for error/success

npm install react-hot-toast
// app/issues/[id]/AssigneeSelect.tsx
"use client";
import toast, { Toaster } from "react-hot-toast"; // added 1

const AssigneeSelect = ({ issue }: { issue: Issue }) => {
  return (
    <>
      <Select.Root
        defaultValue={issue.assignedToUserId || "unassigned"}
        onValueChange={(userId) => {
          const valueUserId = userId === "unassigned" ? null : userId;
          fetch("/api/issues/" + issue.id, {
            method: "PATCH",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              assignedToUserId: valueUserId,
            }),
          })
            .then((res) => {
              if (!res.ok) {
                // if you use axios then you need to to do this only .catch() work directly
                throw new Error("Network response was not ok");
              }
              toast.success("Successfully saved!");
            })
            .catch((error) => toast.error("Could not be saved.")); // added 2
        }}
      >
        <Select.Trigger placeholder="Assign..." />
        <Select.Content>
          <Select.Group>
            <Select.Label>Suggestion</Select.Label>
            <Select.Item value="unassigned">Unassigned</Select.Item>
            {users?.map((user) => (
              <Select.Item key={user.id} value={user.id}>
                {user.name}
              </Select.Item>
            ))}
          </Select.Group>
        </Select.Content>
      </Select.Root>
      <Toaster /> // Added 3
    </>
  );
};

- Refactoring the Assigenee Select Component (onChange has multiple line so take it outside)

// app/issues/[id]/AssigneeSelect.tsx
"use client";
import toast, { Toaster } from "react-hot-toast";

const AssigneeSelect = ({ issue }: { issue: Issue }) => {
  const [users, setUsers] = useState<User[]>();
  // const res = await fetch("http://localhost:3000/api/users");
  useEffect(() => {
    axios
      .get<User[]>("http://localhost:3000/api/users")
      .then((res) => {
        setUsers(res.data);
        // console.log(res.data);
      })
      .catch((err) => console.log(err.message));
  }, []);

  const assignIssue = (userId: string) => {
    const valueUserId = userId === "unassigned" ? null : userId;
    fetch("/api/issues/" + issue.id, {
      method: "PATCH",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        assignedToUserId: valueUserId,
      }),
    })
      .then((res) => {
        if (!res.ok) {
          // if you use axios then you need to to do this only .catch() work directly
          throw new Error("Network response was not ok");
        }
        toast.success("Successfully saved!");
      })
      .catch((error) => toast.error("Could not be saved."));
  };

  return (
    <>
      <Select.Root
        defaultValue={issue.assignedToUserId || "unassigned"}
        onValueChange={assignIssue}
      >
        <Select.Trigger placeholder="Assign..." />
        <Select.Content>
          <Select.Group>
            <Select.Label>Suggestion</Select.Label>
            <Select.Item value="unassigned">Unassigned</Select.Item>
            {users?.map((user) => (
              <Select.Item key={user.id} value={user.id}>
                {user.name}
              </Select.Item>
            ))}
          </Select.Group>
        </Select.Content>
      </Select.Root>
      <Toaster />
    </>
  );
};

- Setting up React Query (QueryClientProvider.tsx, layout.tsx)

  1. For conventional data fetching we use, useState + useEffect. But the porblem is no error handeling, no Call to backend fails and retry, No caching (every time call backend). Of course We can build this by hand but it is time consuming. So We will use react-query
  2. QueryClient contain cache for storing data that we get from backend. We use QueryClientProvider to pass this data to component tree (as it is using react Context so it should be client component).
$ npm i react-query

All about react query

const queryClient = new QueryClient(); // hold data cache
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; // spread to component tree

React Context is only available in client component
Next-Crud: Project

// app/QueryClientProvider.tsx (This component uses react context to share queryClient with our component tree )
"use client";
import {
  QueryClient,
  QueryClientProvider as ReactQueryClientProvider, // to avoide clash with this component name
} from "@tanstack/react-query";
import { PropsWithChildren } from "react";

const queryClient = new QueryClient();

const QueryClientProvider = ({ children }: PropsWithChildren) => {
  return (
    <ReactQueryClientProvider client={queryClient}>
      {children}
    </ReactQueryClientProvider>
  );
};

// Layout.tsx
return (
  <html lang="en">
    <body className={inter.variable}>
      <QueryClientProvider>
        <AuthProvider>
          <Theme accentColor="violet">
            <NavBar />
            <main className="p-5 radix-themes">
              <Container>{children}</Container>
            </main>
          </Theme>
        </AuthProvider>
      </QueryClientProvider>
    </body>
  </html>
);

- Fetching Data with React Query (AssigneeSelectReactQuery.tsx to page.tsx)

Here we properly handel error and isLoading with Skeleton. We can do caching and retring by our hand, of course it is do able but we have to solve a problem that is already solved.

// app/issues/[id]/AssigneeSelectReactQuery.tsx
"use client";
import { Skeleton } from "@/app/components";

const AssigneeSelectReactQuery = ({ issue }: { issue: Issue }) => {
  const {
    data: users,
    error,
    isLoading,
  } = useQuery<User[]>({
    queryKey: ["users"],
    queryFn: () => axios.get("/xapi/users").then((res) => res.data),
    staleTime: 60 * 1000,
    retry: 3,
  });

  if (isLoading) return <Skeleton />; // Skeleton parent er full width ney

  if (error) return null;

  return (
    <>
      <Select.Root
        defaultValue={issue.assignedToUserId || "unassigned"}
        onValueChange={assignIssue}
      >
        <Select.Trigger placeholder="Assign..." />
        <Select.Content>
          <Select.Group>
            <Select.Label>Suggestion</Select.Label>
            <Select.Item value="unassigned">Unassigned</Select.Item>
            {users?.map((user) => (
              <Select.Item key={user.id} value={user.id}>
                {user.name}
              </Select.Item>
            ))}
          </Select.Group>
        </Select.Content>
      </Select.Root>
      <Toaster />
    </>
  );
};

NB: Before we forget remove x from "/xapi/users"

Ch-8: Filtering, Sorting & Pagination

8.1 Building the filter component - Layout (IssueStatusFilter.tsx - List issue page)

Create filter component(IssueStatusFilter.tsx) and add this to IssueActions.tsx component

// app/issues/list/IssueStatusFilter.tsx
"use client";
import { Status } from "@prisma/client";
import { Select } from "@radix-ui/themes";

// Here typescrip will guide you, so that you can not set wrong value
const statuses: { label: string, value?: Status }[] = [
  { label: "All" },
  { label: "Open", value: "OPEN" },
  { label: "In Progress", value: "IN_PROGRESS" },
  { label: "Closed", value: "CLOSED" },
];

const IssueStatusFilter = () => {
  return (
    <Select.Root>
      <Select.Trigger placeholder="Filter by status..." />
      <Select.Content>
        {statuses.map((status) => (
          <Select.Item key={status.value} value={status.value ?? "All"}>
            {status.label}
          </Select.Item>
        ))}
      </Select.Content>
    </Select.Root>
  );
};

// app/issues/list/IssueActions.tsx
return (
  <Flex mb="5" justify="between">
    <IssueStatusFilter />
    <Button>
      <Link href="/issues/new">New Issue</Link>
    </Button>
  </Flex>
);

- 8.2 Filtering Issues

onChange pass value , using useRouter(next/Nevigation), Then received it by searchParam of page.tsx and pass this value to Prisma

// app/list/IssueStatusFilter.tsx
"use client";
import { Status } from "@prisma/client";

const IssueStatusFilter = () => {
  const router = useRouter(); // added

  return (
    <Select.Root // added
      onValueChange={(status) => {
        const url = status ? `?status=${status}` : "";
        router.push(url);
      }}
    >
      <Select.Trigger placeholder="Filter by status..." />
      <Select.Content>
        {statuses.map((status) => (
          <Select.Item key={status.value} value={status.value ?? "All"}>
            {status.label}
          </Select.Item>
        ))}
      </Select.Content>
    </Select.Root>
  );
};

// app/list/page.tsx
import { Status } from "@prisma/client";

interface Props {
  searchParams: { status: Status };
}

const IssuesPage = async ({ searchParams }: Props) => {
  console.log(searchParams.status); // Checking if it comming or not
  const statuses = Object.values(Status); // Getting values of an object
  const status =
    statuses.includes(searchParams.status)
      ? searchParams.status
      : undefined; // Very tricky, If undefined then Prisma will not included it.

  const issues = await prisma.issue.findMany({
    where: { status },
  });

  return (

  );
};

- 8.3 Making Columns Sortable - Layout (list/page.tsx)

Create columns array of object ie.[{},{}] with: label, value, className and map it to create table header markup. And make sure filter searchParams does not overwrite.

//list/page.tsx
import { ArrowUpIcon } from "@radix-ui/react-icons";

interface Props {
  searchParams: { status: Status; orderBy: keyof Issue };
}

const IssuesPage = async ({ searchParams }: Props) => {
  const columns: { label: string; value: keyof Issue; className?: string }[] = [
    { label: "Issue", value: "title" },
    { label: "Status", value: "status", className: "hidden md:table-cell" },
    { label: "Created", value: "createdAt", className: "hidden md:table-cell" },
  ];

  const statuses = Object.values(Status);
  const status = statuses.includes(searchParams.status)
    ? searchParams.status
    : undefined;

  const issues = await prisma.issue.findMany({
    where: { status },
  });

  return (
    <div>
      <IssueActions />
      <Table.Root variant="surface">
        <Table.Header>
          <Table.Row>
            {columns.map((column) => (
              <Table.ColumnHeaderCell key={column.value}>
                {/* <NextLink href={`/issues/list?orderBy=${column.value}`}> */}
                // so that filter query does not overwrite
                <NextLink
                  href={{
                    query: { ...searchParams, orderBy: column.value },
                  }}
                >
                  {column.label}
                </NextLink>
                {column.value === searchParams.orderBy && (
                  <ArrowUpIcon className="inline" />
                )}
              </Table.ColumnHeaderCell>
            ))}
          </Table.Row>
        </Table.Header>
      </Table.Root>
    </div>
  );
};

- 8.4 Sorting Issues (HomeWork: DESC)

// Method 1: Simple and Hardcoded
const issues = await prisma.issue.findMany({
  where: { status },
  orderBy: { title: "asc" },
});

// Method 2: make sortParam dynamic | let orderBy="title", [orderBy] is title
const issues = await prisma.issue.findMany({
  where: { status },
  orderBy: { [searchParams.orderBy]: "asc" },
});

// Method 3: First check orderBy param is exist or not then pass it to Pirsma as status filter
const orderBy = searchParams.orderBy
  ? { [searchParams.orderBy]: "asc" }
  : undefined;

const issues = await prisma.issue.findMany({
  where: { status },
  orderBy,
});

// Method 4: First check valid orderyBy in searchParams ie titlex in stade of title.
const columns = [
  { label: "Issue", value: "title" },
  { label: "Status", value: "status", className: "hidden md:table-cell" },
  { label: "Created", value: "createdAt", className: "hidden md:table-cell" },
];

// Here the idea is orderBy must be columns.value, so first we convert it array of object to array of string using map() then use includes()
const orderBy = columns
  .map((column) => column.value)
  .includes(searchParams.orderBy)
  ? { [searchParams.orderBy]: "asc" }
  : undefined;

const issues = await prisma.issue.findMany({
  where: { status },
  orderBy,
});

- 8.5 Fix Filtering Bugs (list/IssueStatusFilter.tsx)

  • When filter change then it remove sortOrder. To keep both on searchParams
  • Keep selected item when refresh from url, defaultValue
// list/IssueStatusFilter.tsx
// Step1: to get searchParams
const searchParams = useSearchParams();
console.log(searchParams.get("orderBy"));

// Step2: to transfer object to '?status=CLOSED&orderBy=title'
// params is special object that use toString and make it like queryString
const params = new URLSearchParams();
if (status) params.append("status", status);
if (searchParams.get("orderBy"))
  params.append("orderBy", searchParams.get("orderBy")!);

const query = params.size ? "?" + params.toString() : "";
// console.log(query);

router.push("/issues/list" + query);
// list/IssueStatusFilter.tsx
// Keep selected item when refresh from url
<Select.Root
      defaultValue={searchParams.get("status") || ""}
      onValueChange={(status) => {

- 8.6 Generating Dummy Data (Use ChatGPT)

Prompt: Here Prisma model

model Issue {
  id               Int      @id @default(autoincrement())
  title            String   @db.VarChar(255)
  description      String   @db.Text
  status           Status   @default(OPEN)
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt
}

Generate SQL statement to insert 20 records in Issue table. Use real-world titles and descriptions for issues. Status can be OPEN, IN_PROGRESS or CLOSED. Descripton should be a paragraph long. Provide different values for the createdAt and UpdatedAt columns.

INSERT INTO issue (title, description, status, createdAt, updatedAt) VALUES
('Website loading slowly', 'Some users are reporting that the website is loading very slowly, especially during peak hours. This issue needs to be addressed urgently to prevent user dissatisfaction and potential loss of traffic.', 'OPEN', '2024-05-10 08:00:00', '2024-05-10 08:00:00'),
('Mobile app crashes on startup', 'Several users have reported that the mobile app crashes immediately upon startup. This is affecting user experience and needs immediate attention to prevent further frustration and negative reviews.', 'IN_PROGRESS', '2024-05-10 09:30:00', '2024-05-10 09:30:00'),
('Inconsistent display of product categories', 'Product categories are displayed inconsistently across different pages, leading to confusion among users. Standardizing the display of product categories is necessary to improve user experience and navigation.', 'OPEN', '2024-05-11 10:00:00', '2024-05-11 10:00:00');

- 8.7 Building the Layout of Pagination Component - Layout (app/components/Pagination.tsx) Done in 3 steps

  • To avoid distruction just add Pagination component to page.tsx and pass different values ie. itemCount, pageSize, currentPage as hardcoded for testing
// app/page.tsx (For testing)
import Image from "next/image";
import Pagination from "./components/Pagination";

export default function Home() {
  return (
    <main>
      <Pagination itemCount={100} pageSize={10} currentPage={10} />
    </main>
  );
}

// app/components/Pagination.tsx
import {
  ChevronLeftIcon,
  ChevronRightIcon,
  DoubleArrowLeftIcon,
  DoubleArrowRightIcon,
} from "@radix-ui/react-icons";
import { Button, Flex, Text } from "@radix-ui/themes";

interface Props {
  itemCount: number;
  pageSize: number;
  currentPage: number;
}

const Pagination = ({ itemCount, pageSize, currentPage }: Props) => {
  const pageCount = Math.ceil(itemCount / pageSize);
  if (pageCount <= 1) return null;

  return (
    <Flex align="center" gap="2">
      <Text size="2">
        Page {currentPage} of {pageCount}
      </Text>
      <Button color="gray" variant="soft" disabled={currentPage === 1}>
        <DoubleArrowLeftIcon />
      </Button>
      <Button color="gray" variant="soft" disabled={currentPage === 1}>
        <ChevronLeftIcon />
      </Button>
      <Button color="gray" variant="soft" disabled={currentPage === pageCount}>
        <ChevronRightIcon />
      </Button>
      <Button color="gray" variant="soft" disabled={currentPage === pageCount}>
        <DoubleArrowRightIcon />
      </Button>
    </Flex>
  );
};

- 8.8 Implementing Pagination (app/components/Pagination.tsx)

  • From page.tsx you can grab searchPrams directly but from other components you have to use useSearchParams() to get queryString.
  • searchParams received by page.tsx and reues it for finding next/previous page.
// app/page.tsx
import Pagination from "./components/Pagination";

export default function Home({
  searchParams,
}: {
  searchParams: { page: string },
}) {
  return (
    <main>
      <Pagination
        itemCount={100}
        pageSize={10}
        currentPage={parseInt(searchParams.page)}
      />
    </main>
  );
}

// app/components/Pagination.tsx
const Pagination = ({ itemCount, pageSize, currentPage }: Props) => {
  const router = useRouter();
  const searchParams = useSearchParams(); // to get queryString

  // page = currentPage-1
  const changePage = (page: number) => {
    const params = new URLSearchParams(searchParams);
    params.set("page", page.toString());
    router.push("?" + params.toString());
  };

  return (
    <Flex align="center" gap="2">
      <Text size="2">
        Page {currentPage} of {pageCount}
      </Text>
      <Button
        onClick={() => changePage(1)}
        color="gray"
        variant="soft"
        disabled={currentPage === 1}
      >
        <DoubleArrowLeftIcon />
      </Button>
      <Button
        onClick={() => {
          changePage(currentPage - 1);
        }}
        color="gray"
        variant="soft"
        disabled={currentPage === 1}
      >
        <ChevronLeftIcon />
      </Button>
      <Button
        onClick={() => {
          changePage(currentPage + 1);
        }}
        color="gray"
        variant="soft"
        disabled={currentPage === pageCount}
      >
        <ChevronRightIcon />
      </Button>
      <Button
        onClick={() => {
          changePage(pageCount);
        }}
        color="gray"
        variant="soft"
        disabled={currentPage === pageCount}
      >
        <DoubleArrowRightIcon />
      </Button>
    </Flex>
  );
};

- 8.9 Paginating Issues - Now Next-CRUD Project (app/issues/list/page.tsx)

status, orderBy, page this 3 searchParams pass and received from same page.tsx

// app/issues/list/page.tsx
import Pagination from "@/app/components/Pagination";

// Step1: Grabing 3 current queryString from URL
interface Props {
  searchParams: { status: Status; orderBy: keyof Issue; page: string };
}

const IssuesPage = async ({ searchParams }: Props) => {
  const columns: { label: string; value: keyof Issue; className?: string }[] = [
    { label: "Issue", value: "title" },
    { label: "Status", value: "status", className: "hidden md:table-cell" },
    { label: "Created", value: "createdAt", className: "hidden md:table-cell" },
  ];

  // Step2: Refine 3 searchParams and then pass it to Prisma. Very high level of work done here
  // searchParam1: status
  const statuses = Object.values(Status);
  const status = statuses.includes(searchParams.status)
    ? searchParams.status
    : undefined;

  // searchParam2: orderBy
  const orderBy = columns
    .map((column) => column.value)
    .includes(searchParams.orderBy)
    ? { [searchParams.orderBy]: "asc" }
    : undefined;

  // searchParam3: page
  const page = parseInt(searchParams.page) || 1;
  const pageSize = 10; // Later on it may be dynamic like 10, 20, 50, 100
  const where = { status }; // It will be need in two places so make it central

  const issues = await prisma.issue.findMany({
    where,
    orderBy,
    skip: (page - 1) * pageSize,
    take: pageSize,
  });

  const issueCount = await prisma.issue.count({ where });

  return (
    <div>
      <IssueActions />
      <Table.Root variant="surface">
        <Table.Header>
          <Table.Row>
            {columns.map((column) => (
              <Table.ColumnHeaderCell
                key={column.value}
                className={column.className}
              >
                {/* <NextLink href={`/issues/list?orderBy=${column.value}`}> */}
                <NextLink
                  href={{
                    query: { ...searchParams, orderBy: column.value },
                  }}
                >
                  {column.label}
                </NextLink>
                {column.value === searchParams.orderBy && (
                  <ArrowUpIcon className="inline" />
                )}
              </Table.ColumnHeaderCell>
            ))}
          </Table.Row>
        </Table.Header>
        <Table.Body>
          ......
        </Table.Body>
      </Table.Root>
      <Pagination
        pageSize={pageSize}
        currentPage={page}
        itemCount={issueCount}
      />
    </div>
  );
};

- 8.10 Refactoring: Extracting IssueTable Component - (receive searchParams and Issues as Props)

  • At this point list/page.tsx violating single responsibility principle which is laying out. So take table to another component in same 'list' folder.
  • Shift + Alt + Right Arrow(Sevarel times) To Select a block then Cut-Paste
  • Alt + Right/Left Arrow to move cursour where it were
  • 66.9 - Refactoring the Assignee Select Componen[Watch]
// app/issues/list/page.tsx
import Pagination from "@/app/components/Pagination";
import prisma from "@/prisma/client";
import { Status } from "@prisma/client";
import IssueActions from "./IssueActions";
import IssueTable, { columnNames, IssueQuery } from "./IssueTable";
import { Flex } from "@radix-ui/themes";

interface Props {
  searchParams: IssueQuery;
}

const IssuesPage = async ({ searchParams }: Props) => {
  const statuses = Object.values(Status);
  const status = statuses.includes(searchParams.status)
    ? searchParams.status
    : undefined;

  const orderBy = columnNames.includes(searchParams.orderBy)
    ? { [searchParams.orderBy]: "asc" }
    : undefined;

  const page = parseInt(searchParams.page) || 1;
  const pageSize = 10;
  const where = { status };

  const issues = await prisma.issue.findMany({
    where,
    orderBy,
    skip: (page - 1) * pageSize,
    take: pageSize,
  });

  const issueCount = await prisma.issue.count({ where });

  return (
    <Flex direction="column" gap="3">
      <IssueActions />
      <IssueTable searchParams={searchParams} issues={issues} />
      <Pagination
        pageSize={pageSize}
        currentPage={page}
        itemCount={issueCount}
      />
    </Flex>
  );
};

export const dynamic = "force-dynamic";

export default IssuesPage;
  • Import all missing imports
  • Export columnNames with only value property. We can export coumn itself but it will be a violation of encapsulation. All column properties is only necessary with this component.
  • Column and ColumnName move to bottom of this file, because someone come into this page and see all this thing but this is not main responsibility of this page.
// app/issues/list/IssueTable.tsx
import { IssueStatusBadge } from "@/app/components";
import { Issue, Status } from "@prisma/client";
import { ArrowUpIcon } from "@radix-ui/react-icons";
import { Table } from "@radix-ui/themes";
import Link from "next/link";
import NextLink from "next/link";

export interface IssueQuery {
  status: Status;
  orderBy: keyof Issue;
  page: string;
}

interface Props {
  searchParams: IssueQuery;
  issues: Issue[];
}

const IssueTable = ({ searchParams, issues }: Props) => {
  return (
    <Table.Root variant="surface">
      <Table.Header>
        <Table.Row>
          {columns.map((column) => (
            <Table.ColumnHeaderCell
              key={column.value}
              className={column.className}
            >
              {/* <NextLink href={`/issues/list?orderBy=${column.value}`}> */}
              <NextLink
                href={{
                  query: { ...searchParams, orderBy: column.value },
                }}
              >
                {column.label}
              </NextLink>
              {column.value === searchParams.orderBy && (
                <ArrowUpIcon className="inline" />
              )}
            </Table.ColumnHeaderCell>
          ))}
        </Table.Row>
      </Table.Header>
      <Table.Body>
        {issues.map((issue) => (
          <Table.Row key={issue.id}>
            <Table.Cell>
              <Link href={`/issues/${issue.id}`}>{issue.title}</Link>
              <div className="md:hidden">
                <IssueStatusBadge status={issue.status} />
              </div>
            </Table.Cell>
            <Table.Cell className="hidden md:table-cell">
              <IssueStatusBadge status={issue.status} />
            </Table.Cell>
            <Table.Cell className="hidden md:table-cell">
              {issue.createdAt.toDateString()}
            </Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table.Root>
  );
};

const columns: { label: string; value: keyof Issue; className?: string }[] = [
  { label: "Issue", value: "title" },
  { label: "Status", value: "status", className: "hidden md:table-cell" },
  { label: "Created", value: "createdAt", className: "hidden md:table-cell" },
];

export const columnNames = columns.map((column) => column.value);

export default IssueTable;

Ch-9: Dashboard

- 9.1 Building the LatestIssues Component (Some tricky work Flex for layouting & prisma fetch assignedUser)

Nested reads/Eager loading via select and include:

This allow you to read related data from multiple tables in your database - such as a user and that user's posts. You can:

Use include to include related records, such as a user's posts or profile, in the query response.
Use a nested select to include specific fields from a related record. You can also nest select inside an include.

// app/page.tsx
import LatestIssues from "./LatestIssues";

export default function Home() {
  return (
    <main>
      <LatestIssues />
    </main>
  );
}

// app/LatestIssues.tsx
import prisma from "@/prisma/client";
import { Avatar, Card, Flex, Heading, Table } from "@radix-ui/themes";
import { IssueStatusBadge } from "./components";
import Link from "next/link";

const LatestIssues = async () => {
  const issues = await prisma.issue.findMany({
    orderBy: { createdAt: "desc" },
    take: 5,
    include: { assignedToUser: true },
  });

  return (
    <Card>
      <Heading size="4" mb="5">
        Latest Issues
      </Heading>
      <Table.Root>
        <Table.Body>
          {issues.map((issue) => (
            <Table.Row key={issue.id}>
              <Table.Cell>
                <Flex justify="between">
                  <Flex direction="column" align="start" gap="2">
                    <Link href={`/issues/${issue.id}`}>{issue.title}</Link>
                    <IssueStatusBadge status={issue.status} />
                  </Flex>
                  {issue.assignedToUser && (
                    <Avatar
                      src={issue.assignedToUser.image!}
                      fallback="?"
                      size="2"
                      radius="full"
                    />
                  )}
                </Flex>
              </Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Root>
    </Card>
  );
};

- 9.2 Building the IssueSummary Component (app/IssueSummary.tsx)

  • page.tsx passes 3 props to IssueSummary
// app/page.tsx
import prisma from "@/prisma/client";
import IssueSummary from "./IssueSummary";
import LatestIssues from "./LatestIssues";

export default async function Home() {
  const open = await prisma.issue.count({ where: { status: "OPEN" } });
  const inProgress = await prisma.issue.count({
    where: { status: "IN_PROGRESS" },
  });
  const closed = await prisma.issue.count({ where: { status: "CLOSED" } });

  return (
    <main className="flex flex-col space-y-5">
      <IssueSummary open={open} inProgress={inProgress} closed={closed} />
      <LatestIssues />
    </main>
  );
}
  • Received 3 props & map to Card so need not to repeat Card in markup. Very unique work and high thought
// app/IssueSummary.tsx
import { Status } from "@prisma/client";
import { Card, Flex, Text } from "@radix-ui/themes";
import Link from "next/link";

interface Props {
  open: number;
  inProgress: number;
  closed: number;
}

const IssueSummary = ({ open, inProgress, closed }: Props) => {
  const containers: { label: string, value: number, status: Status }[] = [
    { label: "Open Issues", value: open, status: "OPEN" },
    { label: "IN Progress Issues", value: inProgress, status: "IN_PROGRESS" },
    { label: "Closed Issues", value: closed, status: "CLOSED" },
  ];

  return (
    <Flex gap="4">
      {containers.map((container) => (
        <Card key={container.value}>
          <Flex direction="column" gap="1">
            <Link
              className="text-sm font-medium"
              href={`/issues/list?status=${container.status}`}
            >
              {container.label}
            </Link>
            <Text size="5" className="font-bold">
              {container.value}
            </Text>
          </Flex>
        </Card>
      ))}
    </Flex>
  );
};

- 9.3 Building the BarChart Component - recharts.org

npm install recharts
  • There is data object with two prperty. One will set for xaxis using dataKey, and another for Bar. Bar size and fill for color.
  • To get theme color, Inspect element -> Root (data-is-root-theme) -> In style(right side) section search for 'accent'. Click here
// app/IssueChart.tsx
"use client";
import { Card } from "@radix-ui/themes";
import {
  ResponsiveContainer,
  BarChart,
  XAxis,
  YAxis,
  Bar,
  Label,
} from "recharts";

interface Props {
  open: number;
  inProgress: number;
  closed: number;
}

const IssueChart = ({ open, inProgress, closed }: Props) => {
  const data = [
    { Label: "Open", value: open },
    { Label: "In Progress", value: inProgress },
    { Label: "Closed", value: closed },
  ];
  return (
    <Card>
      <ResponsiveContainer width="100%" height={300}>
        <BarChart data={data}>
          <XAxis dataKey="label" />
          <YAxis />
          <Bar
            dataKey="value"
            barSize={60}
            style={{ fill: "var(--violet-9)" }}
          />
        </BarChart>
      </ResponsiveContainer>
    </Card>
  );
};

- 9.4 Laying Out the Dashboard (Grid, Flex combination)

  • Now we have all buiding blocks of homepage. So final step is put them on homepage and laying them out.
// app/page.tsx
import prisma from "@/prisma/client";
import IssueSummary from "./IssueSummary";
import LatestIssues from "./LatestIssues";
import IssueChart from "./IssueChart";
import { Flex, Grid } from "@radix-ui/themes";

export default async function Home() {
  const open = await prisma.issue.count({ where: { status: "OPEN" } });
  const inProgress = await prisma.issue.count({
    where: { status: "IN_PROGRESS" },
  });
  const closed = await prisma.issue.count({ where: { status: "CLOSED" } });

  return (
    <Grid columns={{ initial: "1", md: "2" }} gap="5">
      <Flex direction="column">
        <IssueSummary open={open} inProgress={inProgress} closed={closed} />
        <IssueChart open={open} inProgress={inProgress} closed={closed} />
      </Flex>
      <LatestIssues />
    </Grid>
  );
}

Ch-10: Going to Production

- 10.1 Adding Metadata

// app/page.tsx  (End of file)
export const metadata: Metadata = {
  title: "Issue Tracker - Dashboard",
  description: "View a summary of project issues",
};

// app/issues/list/page.tsx  (End of file)
export const metadata: Metadata = {
  title: "Issue Tracker - Dashboard",
  description: "View a summary of project issues",
};

// app/issues/[id]/page.tsx  (Dynamic MetaData)
export async function generateMetadata({ params }: { params: { id: string } }) {
  const issue = await prisma.issue.findUnique({
    where: { id: parseInt(params.id) },
  });

  return {
    title: issue?.title,
    description: "Description of issue " + issue?.id,
  };
}
// HW
openGraph: {
  title: 'Acme',
  description: 'Acme is a...',
},

- 10.2 Optimizing Performance Using React Cache

  • If use react cache() then need not to fetch second times as we do here one form component and another for metadata.
  • cache() take a callback function and return promish so here we use callback function all are default.
// app/issues/[id]/page.tsx
import { cache } from "react";

// Outside the component. Returning promish
const fetchIssue = cache((issueId: number) =>
  prisma.issue.findUnique({ where: { id: issueId } })
);

const SingleIssuePage = async ({
  params: { id },
}: {
  params: { id: string },
}) => {
  const session = await getServerSession(authOptions);

  const issue = await fetchIssue(parseInt(id));

  if (!issue) notFound(); // don't use return notFound(); It return null

  // md in taiwind is equvalent to sm in redix
  return (
    <Grid columns={{ initial: "1", sm: "5" }} gap="3">
      <Box className="md:col-span-4">
        <IssueDetails issue={issue} />
      </Box>
      {session && (
        <Box>
          <Flex direction="column" gap="4">
            {/* <AssigneeSelect issue={issue} /> */}
            <AssigneeSelectReactQuery issue={issue} />
            <EditIssueButton issueId={issue.id} />
            <DeleteIssueButton issueId={issue.id} />
          </Flex>
        </Box>
      )}
    </Grid>
  );
};

export async function generateMetadata({ params }: { params: { id: string } }) {
  const issue = await fetchIssue(parseInt(params.id));

  return {
    title: issue?.title,
    description: "Description of issue " + issue?.id,
  };
}
  • To see the prove in the log. After watching remove log
// prisma/client.ts
import { PrismaClient } from "@prisma/client";

const prismaClientSingleton = () => {
  return new PrismaClient({
    log: ["query"], // added
  });
};

declare global {
  var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>;
}

const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();

export default prisma;

if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;

See Log: Without Cache With Cache

- 10.3 Removing.env File

  • Create '.env.example' so that one can understand which variable need to create
  • Go to .gitignore
  • There is a way to remove .env file form history. git-filter-repo. See: 83.3 - Removing.env File
# local env files
.env*.local
.env

- 10.4 Setting Up Error Tracking sentry.io

It is for error tracking and monitoring. Because when other people use our application we dont know, they might be encounter.
Create account in sentry and create project by selecting platform "Next.js"

- 10.5 Setting Up the Production Database

console.aiven.io Used for MySql
First used in localhost if all ok then for live

DATABASE_URL = "mysql://avnadmin:Password@mysql-1f3dbfe6-bappakst-ea82.i.aivencloud.com:23195/defaultdb"

- 10.6 Deploying to Vercel

  1. Delete old migration
  2. npx prisma migrate dev
  3. Push to GitHub
  4. Connect Vercel & GitHub Repo
  5. prisma generate && prisma migrate deploy && next build
  6. Set Environment variables. (Copy all and Paste)
  7. Hit Deploy
  8. Chenge NEXTAUTH_URL to Live Domain (Setting -> Environment Variable)
  9. Add Live Domain to Google Console(OAuth) [APIs & Services -> Credentials -> OAuth 2.0(edit) -> Add Domain to Authorized JavaScript Origins & Authorized redirect URIs]
  10. Check all environment variable has https