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
Start from Core feature ahead to Advance feature
| 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)
npx create-next-app@latestManual Installation
npm install next@latest react@latest react-dom@latestInstall 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>
);
};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}
>Wamp for testing
npm i prismaInitializing Prisma - npx create folder
npx prisma initNow 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----- 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 devnpm i zod@3.22.2Best 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 });
}- Install Radix Themes
npm install @radix-ui/themes- Import the CSS root app/layout.ts
// app/layout.ts
import "@radix-ui/themes/styles.css";- 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>
);
};// 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>
);
};< 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;
}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>
);
};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>
);
};- 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 });
}- Install Resolvers
- 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
- Work on issues/new/page.tsx
npm i @hookform/resolversRefactor 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>
);
};
..........- 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>
)}
</>
);
};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>
);
};- 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} />
)}
/>// 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>
);
};- 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>
);
};- use delay to watch loading skeletons properly.
npm i delay// in issues/page.tsx (before return)
await delay(2000);npm i react-loading-skeletonimport 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()
- 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>;// 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>
);
};All description show as a paragraph but If we install packege react-markdown then it shows like heading, list, bold etc
npm i react-markdownNow 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/typographyStep 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>
);- 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>
);
};- 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>
);
};import dynamic from "next/dynamic";
const SimpleMDE = dynamic(() => import("react-simplemde-editor"), {
ssr: false,
});- 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";- 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>
);- 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>
);
};- 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.
- 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.
- 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>
);
};PUT: Replacing entire Object
PATCH: Updating one or more property
- Careful: add "await" before request.json() and return before NextResponse.json()
- Check request body
- Check issue in DB
- 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);
}Two Places need to update
- if (issue) await axios.patch(
/api/issues/${issue.id}, data); else await axios.post("/api/issues", data); - {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>
);
};- 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- 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;- 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();- 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;- 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>;// 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>
);- 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>
);
};- 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>;
)// 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
............
}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);
}
};npm install next-authIn 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- Consent Screen setup (External, App name & Support & Developer Email, Scopes-Email+Profile, No Test User, Publish)
- Credentials -> Create Credentials -> OAuth client ID. See
-
Authorized JavaScript origins - http://localhost:3000 or Production site name
-
Authorized redirect URIs - http://localhost:3000/api/auth/callback/google
-
Add link to NavBar { label: "Login", href: "/api/auth/signin" }
To see the login user. JSON Web Token
// /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 = drrXvcMlrKsm2XmXGo to https://authjs.dev/getting-started/adapters/prisma
- Run this commands
npm install @next-auth/prisma-adapternpx 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 };- 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>
);
}// 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>
);
};- 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>
);
};- 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;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;
}
}// 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 });
}// 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>;- 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
- 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
}- 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);
}- Fetch API (GET, POST, PUT, PATCH, DELETE)
- Connect UI to API
// 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>
);
};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
</>
);
};// 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 />
</>
);
};- 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
- 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-queryAll about react query
const queryClient = new QueryClient(); // hold data cache
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; // spread to component treeReact 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>
);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"
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>
);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 (
);
};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>
);
};// 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,
});- 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) => {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>
);
};- 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>
);
};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>
);
};- 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;- 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>
);
};- 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>
);
};- 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>
);
}// 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...',
},- 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
- 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"
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"- Delete old migration
- npx prisma migrate dev
- Push to GitHub
- Connect Vercel & GitHub Repo
- prisma generate && prisma migrate deploy && next build
- Set Environment variables. (Copy all and Paste)
- Hit Deploy
- Chenge NEXTAUTH_URL to Live Domain (Setting -> Environment Variable)
- Add Live Domain to Google Console(OAuth) [APIs & Services -> Credentials -> OAuth 2.0(edit) -> Add Domain to Authorized JavaScript Origins & Authorized redirect URIs]
- Check all environment variable has https