First draft

This commit is contained in:
lcbtan 2024-01-24 03:04:18 +08:00
parent 18be2ec837
commit df0739bb8b
36 changed files with 3342 additions and 3501 deletions

View file

@ -1,22 +0,0 @@
import type { StorybookConfig } from "@storybook/nextjs"
const config: StorybookConfig = {
stories: ["../components/**/*.stories.mdx", "../components/**/*.stories.@(js|jsx|ts|tsx)"],
addons: ["@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-interactions"],
framework: {
name: "@storybook/nextjs",
options: {},
},
docs: {
autodocs: "tag",
},
typescript: {
check: false,
checkOptions: {},
reactDocgen: false,
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
},
},
}
export default config

View file

@ -1,17 +0,0 @@
import type { Preview } from "@storybook/react"
import "../styles/tailwind.css"
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
}
export default preview

View file

@ -1,5 +0,0 @@
{
"tailwindCSS.experimental.classRegex": [
["cva(?:<[^>]*>)?(([^)]*))", "[\"'`]([^\"'`]*).*?[\"'`]", "(?:twMerge|twJoin)\\(([^\\);]*)[\\);]"]
]
}

View file

@ -1,3 +0,0 @@
export async function GET() {
return Response.json({ status: "ok" })
}

View file

@ -1,9 +0,0 @@
import "styles/tailwind.css"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

View file

@ -1,65 +0,0 @@
import { Metadata } from "next"
import { Button } from "components/Button/Button"
import { LP_GRID_ITEMS } from "lp-items"
export const metadata: Metadata = {
title: "Next.js Enterprise Boilerplate",
twitter: {
card: "summary_large_image",
},
openGraph: {
url: "https://next-enterprise.vercel.app/",
images: [
{
width: 1200,
height: 630,
url: "https://raw.githubusercontent.com/Blazity/next-enterprise/main/.github/assets/project-logo.png",
},
],
},
}
export default function Web() {
return (
<>
<section className="bg-white dark:bg-gray-900">
<div className="mx-auto grid max-w-screen-xl px-4 py-8 text-center lg:py-16">
<div className="mx-auto place-self-center">
<h1 className="mb-4 max-w-2xl text-4xl font-extrabold leading-none tracking-tight dark:text-white md:text-5xl xl:text-6xl">
Next.js Enterprise Boilerplate
</h1>
<p className="mb-6 max-w-2xl font-light text-gray-500 dark:text-gray-400 md:text-lg lg:mb-8 lg:text-xl">
Jumpstart your enterprise project with our feature-packed, high-performance Next.js boilerplate!
Experience rapid UI development, AI-powered code reviews, and an extensive suite of tools for a smooth and
enjoyable development process.
</p>
<Button href="https://github.com/Blazity/next-enterprise" className="mr-3">
Get started
</Button>
<Button
href="https://vercel.com/new/git/external?repository-url=https://github.com/Blazity/next-enterprise"
intent="secondary"
>
Deploy Now
</Button>
</div>
</div>
</section>
<section className="bg-white dark:bg-gray-900">
<div className="mx-auto max-w-screen-xl px-4 py-8 sm:py-16 lg:px-6">
<div className="justify-center space-y-8 md:grid md:grid-cols-2 md:gap-12 md:space-y-0 lg:grid-cols-3">
{LP_GRID_ITEMS.map((singleItem) => (
<div key={singleItem.title} className="flex flex-col items-center justify-center text-center">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 p-1.5 text-blue-700 dark:bg-primary-900 lg:h-12 lg:w-12">
{singleItem.icon}
</div>
<h3 className="mb-2 text-xl font-bold dark:text-white">{singleItem.title}</h3>
<p className="text-gray-500 dark:text-gray-400">{singleItem.description}</p>
</div>
))}
</div>
</div>
</section>
</>
)
}

View file

@ -1,31 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react"
import { Button } from "./Button"
const meta: Meta<typeof Button> = {
title: "Button",
component: Button,
args: {
intent: "primary",
underline: false,
children: "Button",
size: "lg",
},
argTypes: {
intent: {
options: ["primary", "secondary"],
control: { type: "select" },
},
size: {
options: ["sm", "lg"],
control: { type: "select" },
},
},
}
type Story = StoryObj<typeof Button>
export const Default: Story = {
render: (args) => <Button {...args} />,
}
export default meta

View file

@ -1,47 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority"
import { twMerge } from "tailwind-merge"
const button = cva(
[
"justify-center",
"inline-flex",
"items-center",
"rounded-xl",
"text-center",
"border",
"border-blue-400",
"transition-colors",
"delay-50",
],
{
variants: {
intent: {
primary: ["bg-blue-400", "text-white", "hover:enabled:bg-blue-700"],
secondary: ["bg-transparent", "text-blue-400", "hover:enabled:bg-blue-400", "hover:enabled:text-white"],
},
size: {
sm: ["min-w-20", "h-full", "min-h-10", "text-sm", "py-1.5", "px-4"],
lg: ["min-w-32", "h-full", "min-h-12", "text-lg", "py-2.5", "px-6"],
},
underline: { true: ["underline"], false: [] },
},
defaultVariants: {
intent: "primary",
size: "lg",
},
}
)
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLAnchorElement>, VariantProps<typeof button> {
underline?: boolean
href: string
}
export function Button({ className, intent, size, underline, ...props }: ButtonProps) {
return (
<a className={twMerge(button({ intent, size, className, underline }))} {...props}>
{props.children}
</a>
)
}

View file

@ -1,73 +0,0 @@
import * as RadixTooltip from "@radix-ui/react-tooltip"
import { cva, VariantProps } from "class-variance-authority"
import React from "react"
import { twMerge } from "tailwind-merge"
const tooltipContent = cva([], {
variants: {
intent: {
primary: ["rounded-0.5md", "bg-zinc-700", "font-open-sans", "text-white"],
},
size: {
md: ["px-4", "py-2.5", "text-2xs"],
},
},
defaultVariants: {
intent: "primary",
size: "md",
},
})
const tooltipArrow = cva([], {
variants: {
intent: {
primary: ["fill-zinc-700"],
},
size: {
md: ["w-4", "h-2"],
},
},
defaultVariants: {
intent: "primary",
size: "md",
},
})
export interface TooltipProps extends VariantProps<typeof tooltipContent>, RadixTooltip.TooltipProps {
explainer: React.ReactElement | string
children: React.ReactElement
className?: string
withArrow?: boolean
side?: "top" | "right" | "bottom" | "left"
}
export function Tooltip({
children,
explainer,
open,
defaultOpen,
onOpenChange,
intent,
size,
side = "top",
className,
withArrow,
}: TooltipProps) {
return (
<RadixTooltip.Provider>
<RadixTooltip.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} delayDuration={200}>
<RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger>
<RadixTooltip.Portal>
<RadixTooltip.Content
side={side}
sideOffset={5}
className={twMerge(tooltipContent({ intent, size, className }))}
>
{explainer}
{withArrow ? <RadixTooltip.Arrow className={twMerge(tooltipArrow({ intent, size, className }))} /> : null}
</RadixTooltip.Content>
</RadixTooltip.Portal>
</RadixTooltip.Root>
</RadixTooltip.Provider>
)
}

View file

@ -1,7 +0,0 @@
import { expect, test } from "@playwright/test"
test("has title", async ({ page }) => {
await page.goto("./")
await expect(page).toHaveTitle(/Next.js Enterprise Boilerplate/)
})

View file

@ -9,6 +9,7 @@ const customJestConfig = {
testEnvironment: "jest-environment-jsdom", testEnvironment: "jest-environment-jsdom",
modulePathIgnorePatterns: ["<rootDir>/dist/"], modulePathIgnorePatterns: ["<rootDir>/dist/"],
testPathIgnorePatterns: ["<rootDir>/e2e"], testPathIgnorePatterns: ["<rootDir>/e2e"],
moduleDirectories: ["node_modules", "<rootDir>/"],
} }
module.exports = createJestConfig(customJestConfig) module.exports = createJestConfig(customJestConfig)

View file

@ -1,416 +0,0 @@
export const LP_GRID_ITEMS = [
{
title: "Next.js",
description: "Fast by default, with config optimized for performance.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"
/>
</svg>
),
},
{
title: "Tailwind CSS",
description: "A utility-first CSS framework for rapid UI development.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12a7.5 7.5 0 0015 0m-15 0a7.5 7.5 0 1115 0m-15 0H3m16.5 0H21m-1.5 0H12m-8.457 3.077l1.41-.513m14.095-5.13l1.41-.513M5.106 17.785l1.15-.964m11.49-9.642l1.149-.964M7.501 19.795l.75-1.3m7.5-12.99l.75-1.3m-6.063 16.658l.26-1.477m2.605-14.772l.26-1.477m0 17.726l-.26-1.477M10.698 4.614l-.26-1.477M16.5 19.794l-.75-1.299M7.5 4.205L12 12m6.894 5.785l-1.149-.964M6.256 7.178l-1.15-.964m15.352 8.864l-1.41-.513M4.954 9.435l-1.41-.514M12.002 12l-3.75 6.495"
/>
</svg>
),
},
{
title: "ESlint & Prettier",
description: "For clean, consistent, and error-free code.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
),
},
{
title: "Extremely strict TypeScript",
description: "With `ts-reset` library for ultimate type safety.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
),
},
{
title: "Bundle analyzer plugin",
description: "Keep an eye on your bundle size.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
),
},
{
title: "Jest & React Testing Library",
description: "For rock-solid unit and integration tests.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
/>
</svg>
),
},
{
title: "Playwright",
description: "Write end-to-end tests like a pro.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6"
/>
</svg>
),
},
{
title: "Storybook",
description: "Create, test, and showcase your components.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
),
},
{
title: "Smoke Testing & Acceptance Tests",
description: "For confidence in your deployments.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0118 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3l1.5 1.5 3-3.75"
/>
</svg>
),
},
{
title: "Conventional commits git hook",
description: "Keep your commit history neat and tidy.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
/>
</svg>
),
},
{
title: "Observability",
description: "Open Telemetry integration for seamless monitoring.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
{
title: "Absolute imports",
description: "No more spaghetti imports.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
),
},
{
title: "Health checks",
description: "Kubernetes-compatible for robust deployments.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
/>
</svg>
),
},
{
title: "Radix UI",
description: "Headless UI components for endless customization.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 00.658-.663 48.422 48.422 0 00-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 01-.61-.58v0z"
/>
</svg>
),
},
{
title: "CVA",
description: "Create a consistent, reusable, and atomic design system.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
/>
</svg>
),
},
{
title: "Renovate BOT",
description: "Auto-updating dependencies, so you can focus on coding.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
/>
</svg>
),
},
{
title: "Patch-package",
description: "Fix external dependencies without losing your mind.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
),
},
{
title: "Components coupling & cohesion graph",
description: "A tool for managing component relationships.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6a7.5 7.5 0 107.5 7.5h-7.5V6z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 10.5H21A7.5 7.5 0 0013.5 3v7.5z" />
</svg>
),
},
{
title: "GitHub Actions",
description: "Pre-configured actions for smooth workflows, including Bundle Size and performance stats.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
),
},
{
title: "Automated ChatGPT Code Reviews",
description: "Stay on the cutting edge with AI-powered code reviews!",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.068.157 2.148.279 3.238.364.466.037.893.281 1.153.671L12 21l2.652-3.978c.26-.39.687-.634 1.153-.67 1.09-.086 2.17-.208 3.238-.365 1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
),
},
{
title: "Semantic Release",
description: "For automatic changelog generation.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 11.25l-3-3m0 0l-3 3m3-3v7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
},
]

View file

@ -1,12 +1,11 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import type { NextRequest } from "next/server" import type { NextRequest } from "next/server"
import NextAuth from "next-auth"
import { authConfig } from "./auth.config"
export function middleware(request: NextRequest) { export default NextAuth(authConfig).auth
// TODO: Feel free to remove this block
if (request.headers?.get("host")?.includes("next-enterprise.vercel.app")) { export function middleware(request: NextRequest) {}
return NextResponse.redirect("https://blazity.com/open-source/nextjs-enterprise-boilerplate", { status: 301 })
}
}
export const config = { export const config = {
matcher: [ matcher: [

View file

@ -16,6 +16,15 @@ const config = withPlugins([[withBundleAnalyzer({ enabled: env.ANALYZE })]], {
{ source: "/ping", destination: "/api/health" }, { source: "/ping", destination: "/api/health" },
] ]
}, },
async redirects() {
return [
{
source: '/',
destination: '/login',
permanent: true,
},
]
},
}) })
export default config export default config

View file

@ -11,18 +11,20 @@
"prettier": "prettier --check \"**/*.{js,jsx,ts,tsx}\"", "prettier": "prettier --check \"**/*.{js,jsx,ts,tsx}\"",
"prettier:fix": "prettier --write \"**/*.{js,jsx,ts,tsx}\"", "prettier:fix": "prettier --write \"**/*.{js,jsx,ts,tsx}\"",
"analyze": "cross-env ANALYZE=true yarn build", "analyze": "cross-env ANALYZE=true yarn build",
"storybook": "cross-env FORCE_COLOR=1 storybook dev -p 6006", "test": "cross-env FORCE_COLOR=1 jest ./test",
"test-storybook": "cross-env FORCE_COLOR=1 test-storybook", "test:watch": "cross-env NODE_ENV=test jest -i --watch",
"build-storybook": "cross-env FORCE_COLOR=1 storybook build",
"test": "cross-env FORCE_COLOR=1 jest --passWithNoTests",
"e2e:headless": "playwright test",
"e2e:ui": "playwright test --ui",
"format": "prettier --write \"**/*.{ts,tsx,md}\"", "format": "prettier --write \"**/*.{ts,tsx,md}\"",
"preinstall": "npx npm-only-allow@latest --PM yarn", "preinstall": "npx npm-only-allow@latest --PM yarn",
"postinstall": "npx patch-package -y", "postinstall": "npx patch-package -y",
"coupling-graph": "npx madge --extensions js,jsx,ts,tsx,css,md,mdx ./ --exclude '.next|tailwind.config.js|reset.d.ts|prettier.config.js|postcss.config.js|playwright.config.ts|next.config.js|next-env.d.ts|instrumentation.ts|e2e/|README.md|.storybook/|.eslintrc.js' --image graph.svg" "coupling-graph": "npx madge --extensions js,jsx,ts,tsx,css,md,mdx ./ --exclude '.next|tailwind.config.js|reset.d.ts|prettier.config.js|postcss.config.js|playwright.config.ts|next.config.js|next-env.d.ts|instrumentation.ts|e2e/|README.md|.storybook/|.eslintrc.js' --image graph.svg"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.8",
"@material-ui/core": "^4.12.4",
"@mui/icons-material": "^5.15.5",
"@mui/material": "^5.15.5",
"@next/bundle-analyzer": "^14.0.3", "@next/bundle-analyzer": "^14.0.3",
"@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
@ -39,6 +41,7 @@
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@reduxjs/toolkit": "^2.0.1",
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^11.1.0", "@semantic-release/commit-analyzer": "^11.1.0",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
@ -50,9 +53,12 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"next": "^14.0.3", "next": "^14.0.3",
"next-auth": "^5.0.0-beta.5",
"next-compose-plugins": "^2.2.1", "next-compose-plugins": "^2.2.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.49.3",
"react-redux": "^9.1.0",
"tailwind-merge": "^2.0.0", "tailwind-merge": "^2.0.0",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },

View file

@ -1,77 +0,0 @@
import { defineConfig, devices } from "@playwright/test"
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./e2e",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: "yarn dev",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
},
})

View file

@ -0,0 +1,119 @@
"use client"
import Backdrop from "@mui/material/Backdrop"
import Box from "@mui/material/Box"
import Button from "@mui/material/Button"
import CircularProgress from "@mui/material/CircularProgress"
import TextField from "@mui/material/TextField"
import Typography from "@mui/material/Typography"
import { FieldValues, useForm } from "react-hook-form"
import { addUser } from "src/lib/redux/features/userSlice"
import { useAppDispatch } from "src/lib/redux/hooks"
export default function AddUserForm() {
const dispatch = useAppDispatch()
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm()
const handleFormSubmit = async (data: FieldValues) => {
reset()
dispatch(addUser(data))
}
return (
<>
<Box sx={{ backgroundColor: "beige", padding: 2 }}>
<Typography component="h1" variant="h5" align="center">
Add User
</Typography>
<Box component="form" onSubmit={handleSubmit(handleFormSubmit)} noValidate sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="branchId"
label="Branch ID"
autoFocus
error={Boolean(errors.branchId)}
helperText={errors.branchId && "Branch ID is required and should only be a number"}
{...register("branchId", { pattern: /^\d+$/, required: true })}
/>
<TextField
margin="normal"
required
fullWidth
id="username"
label="Username"
error={Boolean(errors.username)}
helperText={errors.username && "Username is required"}
{...register("username", { required: true })}
/>
<TextField
margin="normal"
required
fullWidth
id="firstName"
label="First Name"
error={Boolean(errors.firstName)}
helperText={errors.firstName && "First Name is required"}
{...register("firstName", { required: true })}
/>
<TextField margin="normal" fullWidth id="middleName" label="Middle Name" {...register("middleName")} />
<TextField
margin="normal"
required
fullWidth
id="lastName"
label="Last Name"
error={Boolean(errors.lastName)}
helperText={errors.lastName && "Last Name is required"}
{...register("lastName", { required: true })}
/>
<TextField
margin="normal"
required
fullWidth
id="position"
label="Position"
error={Boolean(errors.position)}
helperText={errors.position && "Position is required"}
{...register("position", { required: true })}
/>
<TextField
margin="normal"
required
fullWidth
label="Password"
type="password"
id="password"
error={Boolean(errors.password)}
helperText={errors.password && "Password is required with a minimum of 6 characters"}
{...register("password", { required: true, minLength: 6 })}
/>
<Box sx={{ display: "flex", justifyContent: "end" }}>
<Button
variant="contained"
sx={{ mt: 3, mb: 2, mr: 3 }}
onClick={() => {
reset()
}}
color="secondary"
>
Reset
</Button>
<Button type="submit" variant="contained" sx={{ mt: 3, mb: 2 }}>
Add
</Button>
</Box>
</Box>
</Box>
<Backdrop sx={{ color: "#fff", zIndex: (theme) => theme.zIndex.drawer + 1 }} open={isSubmitting}>
<CircularProgress color="inherit" />
</Backdrop>
</>
)
}

View file

@ -0,0 +1,54 @@
"use client"
import { Typography } from "@mui/material"
import Backdrop from "@mui/material/Backdrop"
import Box from "@mui/material/Box"
import Button from "@mui/material/Button"
import CircularProgress from "@mui/material/CircularProgress"
import Container from "@mui/material/Container"
import Grid from "@mui/material/Grid"
import { useRouter } from "next/navigation"
import { useSession } from "src/components/SessionProvider"
import AddUserForm from "./add-user-form"
import UserTable from "./user-table"
export default function Dashboard() {
const { session, setSession } = useSession()
const router = useRouter()
if (!session) {
router.push("/login")
}
return (
<>
{session?.username ? (
<Container maxWidth="xl">
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 10, mt: 4 }}>
<Typography variant="h3">{session?.username}</Typography>
<Button
variant="contained"
sx={{ mt: 3, mb: 2 }}
onClick={() => {
setSession(undefined)
}}
>
Logout
</Button>
</Box>
<Grid container spacing={2}>
<Grid item xs={4}>
<AddUserForm />
</Grid>
<Grid item xs={8}>
<UserTable />
</Grid>
</Grid>
</Container>
) : (
<Backdrop sx={{ color: "#fff", zIndex: (theme) => theme.zIndex.drawer + 1 }} open={true}>
<CircularProgress color="inherit" />
</Backdrop>
)}
</>
)
}

View file

@ -0,0 +1,10 @@
import { Metadata } from "next"
import Dashboard from "./dashboard"
export const metadata: Metadata = {
title: "XAM Dashboard",
}
export default function Web() {
return <Dashboard />
}

View file

@ -0,0 +1,59 @@
"use client"
import Button from "@mui/material/Button"
import Paper from "@mui/material/Paper"
import Table from "@mui/material/Table"
import TableBody from "@mui/material/TableBody"
import TableCell from "@mui/material/TableCell"
import TableContainer from "@mui/material/TableContainer"
import TableHead from "@mui/material/TableHead"
import TableRow from "@mui/material/TableRow"
import { useSelector } from "react-redux"
import { removeUser } from "src/lib/redux/features/userSlice"
import { useAppDispatch } from "src/lib/redux/hooks"
import type { RootState } from "src/lib/redux/store"
import { TUser } from "../types/user"
export default function UserTable() {
const dispatch = useAppDispatch()
const users = useSelector((state: RootState) => state.users.userList || [])
const getFullName = (user: TUser) => {
return user.firstName + (user.middleName && " " + user.middleName) + " " + user.lastName
}
const handleRemoveUser = async (user: TUser) => {
dispatch(removeUser(user))
}
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>Branch ID</TableCell>
<TableCell>Username</TableCell>
<TableCell>Name</TableCell>
<TableCell>Position</TableCell>
<TableCell>Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user, idx) => (
<TableRow key={user.branchId} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
<TableCell>{idx + 1}</TableCell>
<TableCell>{user.branchId}</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{getFullName(user)}</TableCell>
<TableCell>{user.position}</TableCell>
<TableCell>
<Button onClick={() => handleRemoveUser(user)}>Remove</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}

25
src/app/layout.tsx Normal file
View file

@ -0,0 +1,25 @@
import "@fontsource/roboto/300.css"
import "@fontsource/roboto/400.css"
import "@fontsource/roboto/500.css"
import "@fontsource/roboto/700.css"
import { CssBaseline } from "@mui/material"
import SessonProvider from "src/components/SessionProvider"
import StoreProvider from "src/components/StoreProvider"
import ThemeProvider from "src/components/ThemeProvider"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<StoreProvider>
<SessonProvider>
<ThemeProvider>
<CssBaseline />
{children}
</ThemeProvider>
</SessonProvider>
</StoreProvider>
</body>
</html>
)
}

View file

@ -0,0 +1,120 @@
"use client"
import LockOutlinedIcon from "@mui/icons-material/LockOutlined"
import Avatar from "@mui/material/Avatar"
import Backdrop from "@mui/material/Backdrop"
import Box from "@mui/material/Box"
import Button from "@mui/material/Button"
import CircularProgress from "@mui/material/CircularProgress"
import Container from "@mui/material/Container"
import TextField from "@mui/material/TextField"
import Typography from "@mui/material/Typography"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { FieldValues, useForm } from "react-hook-form"
import { useSession } from "src/components/SessionProvider"
import { authenticate } from "src/lib/authentication/authenticate"
export default function LoginPage() {
const { session, setSession } = useSession()
const router = useRouter()
const [apiError, setApiError] = useState<string>("")
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm()
if (session) {
router.push("/dashboard")
}
const handleFormSubmit = async (data: FieldValues) => {
try {
const user = await authenticate(data)
if (user) {
setSession(user)
router.push("/dashboard")
return
}
setApiError("Invalid credentials. Please Try again.")
} catch (err) {
setApiError("Unexpected error. Please Try again.")
}
}
return (
<>
{!session?.username && (
<Container component="main" maxWidth="xs" sx={{ backgroundColor: "beige" }}>
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Login
</Typography>
<Box component="form" onSubmit={handleSubmit(handleFormSubmit)} noValidate sx={{ mt: 1 }} data-test>
<TextField
margin="normal"
required
fullWidth
id="branchId"
label="Branch ID"
autoFocus
inputProps={{ "data-testid": "branchId" }}
error={Boolean(errors.branchId)}
helperText={errors.branchId && "Branch ID is required and should only be a number"}
{...register("branchId", { required: true, pattern: /^\d+$/ })}
/>
<TextField
margin="normal"
required
fullWidth
id="username"
label="Username"
inputProps={{ "data-testid": "username" }}
error={Boolean(errors.username)}
helperText={errors.username && "Username is required"}
{...register("username", { required: true })}
/>
<TextField
margin="normal"
required
fullWidth
label="Password"
type="password"
id="password"
inputProps={{ "data-testid": "password" }}
error={Boolean(errors.password)}
helperText={errors.password && "Password is required"}
{...register("password", { required: true })}
/>
<Typography sx={{ mt: 2, ml: 0.5, color: "red" }}>{apiError}</Typography>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
onClick={() => setApiError("")}
data-testid="loginButton"
>
Login
</Button>
<Backdrop sx={{ color: "#fff", zIndex: (theme) => theme.zIndex.drawer + 1 }} open={isSubmitting}>
<CircularProgress color="inherit" />
</Backdrop>
</Box>
</Box>
</Container>
)}
</>
)
}

10
src/app/login/page.tsx Normal file
View file

@ -0,0 +1,10 @@
import { Metadata } from "next"
import LoginForm from "./login-form"
export const metadata: Metadata = {
title: "XAM Login Form",
}
export default function LoginPage() {
return <LoginForm />
}

9
src/app/types/user.ts Normal file
View file

@ -0,0 +1,9 @@
export interface TUser {
branchId: number
username: string
password: string
firstName: string
middleName?: string
lastName: string
position: string
}

View file

@ -0,0 +1,30 @@
"use client"
import { createContext, Dispatch, ReactNode, SetStateAction, useContext, useState } from "react"
import { TUser } from "src/app/types/user"
interface TSessionContext {
session?: TUser
setSession: Dispatch<SetStateAction<TUser | undefined>>
}
const SessionContext = createContext<TSessionContext>({
session: undefined,
setSession: () => {},
})
export default function SessionProvider({ children, value }: { children: ReactNode; value?: TSessionContext }) {
const [user, setUser] = useState<TUser | undefined>()
const contextValue = value || { session: user, setSession: setUser }
return <SessionContext.Provider value={contextValue}>{children}</SessionContext.Provider>
}
export const useSession = () => {
const context = useContext(SessionContext)
if (context === undefined) {
throw new Error("useSession must be used within a SessionProvider")
}
return context
}

View file

@ -0,0 +1,14 @@
"use client"
import { useRef } from "react"
import { Provider } from "react-redux"
import { AppStore, makeStore } from "src/lib/redux/store"
export default function StoreProvider({ children }: { children: React.ReactNode }) {
const storeRef = useRef<AppStore>()
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = makeStore()
}
return <Provider store={storeRef.current}>{children}</Provider>
}

View file

@ -0,0 +1,10 @@
"use client"
import { createTheme, ThemeProvider as MuiThemeProvider } from "@mui/material/styles"
import { ReactNode } from "react"
const defaultTheme = createTheme()
export default function ThemeProvider({ children }: { children: ReactNode }) {
return <MuiThemeProvider theme={defaultTheme}>{children}</MuiThemeProvider>
}

View file

@ -0,0 +1,44 @@
import { FieldValues } from "react-hook-form"
import { delay } from "../delay"
export const users = [
{
branchId: 10001,
username: "testuser01",
password: "pa55w0rd001",
firstName: "John",
middleName: "Sanchez",
lastName: "Doe",
position: "Developer",
},
{
branchId: 10002,
username: "testuser02",
password: "pa55w0rd002",
firstName: "Ricardo",
middleName: "Dubov",
lastName: "Martinez",
position: "Lead Developer",
},
{
branchId: 10003,
username: "testuser03",
password: "pa55w0rd003",
firstName: "Gol",
middleName: "Denver",
lastName: "Roger",
position: "Project Manager",
},
]
export async function authenticate(fieldValues: FieldValues) {
await delay(2000)
const { branchId, username, password } = fieldValues
const user = users.find(
(account) =>
String(account.username) === username &&
String(account.password) === password &&
String(account.branchId) === branchId
)
return user
}

1
src/lib/delay.ts Normal file
View file

@ -0,0 +1 @@
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms))

View file

@ -0,0 +1,51 @@
import { createSlice } from "@reduxjs/toolkit"
export const userSlice = createSlice({
name: "user",
initialState: {
userList: [
{
branchId: 10001,
username: "testuser01",
password: "pa55w0rd001",
firstName: "John",
middleName: "Sanchez",
lastName: "Doe",
position: "Developer",
},
{
branchId: 10002,
username: "testuser02",
password: "pa55w0rd002",
firstName: "Ricardo",
middleName: "Dubov",
lastName: "Martinez",
position: "Lead Developer",
},
{
branchId: 10003,
username: "testuser03",
password: "pa55w0rd003",
firstName: "Gol",
middleName: "Denver",
lastName: "Roger",
position: "Project Manager",
},
],
},
reducers: {
addUser: (state, action) => {
state.userList.push(action.payload)
},
removeUser: (state, action) => {
const { branchId, username } = action.payload
state.userList = state.userList.filter(
(account) => account.username !== username && account.branchId !== branchId
)
},
},
})
export const { addUser, removeUser } = userSlice.actions
export default userSlice.reducer

8
src/lib/redux/hooks.ts Normal file
View file

@ -0,0 +1,8 @@
import { useDispatch, useSelector, useStore } from "react-redux"
import type { TypedUseSelectorHook } from "react-redux"
import type { AppDispatch, AppStore, RootState } from "./store"
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
export const useAppStore: () => AppStore = useStore

16
src/lib/redux/store.ts Normal file
View file

@ -0,0 +1,16 @@
import { configureStore } from "@reduxjs/toolkit"
import userReducer from "./features/userSlice"
export const makeStore = () => {
return configureStore({
reducer: {
users: userReducer,
},
})
}
// Infer the type of makeStore
export type AppStore = ReturnType<typeof makeStore>
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<AppStore["getState"]>
export type AppDispatch = AppStore["dispatch"]

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -1,90 +0,0 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { pick, omit } = require("lodash")
const colors = require("tailwindcss/colors")
const defaultTheme = require("tailwindcss/defaultTheme")
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
primary: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
},
fontFamily: {
body: [
"Inter",
"ui-sans-serif",
"system-ui",
"-apple-system",
"system-ui",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"Noto Sans",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
],
sans: [
"Inter",
"ui-sans-serif",
"system-ui",
"-apple-system",
"system-ui",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"Noto Sans",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
],
},
borderWidth: {
DEFAULT: "1px",
0: "0",
2: "2px",
3: "3px",
4: "4px",
6: "6px",
8: "8px",
},
minHeight: {
...defaultTheme.height,
},
minWidth: {
...defaultTheme.width,
},
},
},
plugins: [],
future: {
hoverOnlyWhenSupported: true,
},
}

View file

@ -0,0 +1,185 @@
import { render, cleanup, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import "@testing-library/jest-dom"
import SessionProvider from "src/components/SessionProvider"
import LoginForm from "src/app/login/login-form"
import { TUser } from "src/app/types/user"
import { authenticate } from "src/lib/authentication/authenticate"
const mockUser: TUser = {
branchId: 10001,
username: "testuser01",
password: "pa55w0rd001",
firstName: "John",
middleName: "Sanchez",
lastName: "Doe",
position: "Developer",
}
const mockedPushFn = jest.fn()
jest.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({
push: mockedPushFn,
}),
}
})
jest.mock("../../src/lib/authentication/authenticate", () => ({
authenticate: jest.fn(),
}))
describe("Login Form", () => {
afterEach(() => {
cleanup()
jest.clearAllMocks()
})
describe("When user is logged in", () => {
beforeEach(() => {
render(
<SessionProvider value={{ session: mockUser, setSession: jest.fn() }}>
<LoginForm />
</SessionProvider>
)
})
afterEach(() => {})
it("should redirect to /dashboard when the user is logged in", async () => {
expect(mockedPushFn).toHaveBeenCalledWith("/dashboard")
})
})
describe("When user is not logged in", () => {
beforeEach(() => {
render(
<SessionProvider>
<LoginForm />
</SessionProvider>
)
})
// ARRANGE
it("should not redirect to /dashboard when the user is not logged in", async () => {
expect(mockedPushFn).not.toHaveBeenCalled()
})
it("should render all of the necessary fields in the form", async () => {
const branchIdField = await screen.findByTestId("branchId")
const usernameField = await screen.findByTestId("username")
const passwordField = await screen.findByTestId("password")
expect(branchIdField).not.toBeNull()
expect(usernameField).not.toBeNull()
expect(passwordField).not.toBeNull()
})
it("should render all of the necessary text in the form", async () => {
const branchIdField = await screen.findByText("Branch ID")
const usernameField = await screen.findByText("Username")
const passwordField = await screen.findByText("Password")
const loginButton = await screen.getByRole("button", {
name: /Login/i,
})
expect(branchIdField).not.toBeNull()
expect(usernameField).not.toBeNull()
expect(passwordField).not.toBeNull()
expect(loginButton).not.toBeNull()
})
it.each([
[123, "asd", "fgh", 2],
["asd", undefined, undefined, 0],
[undefined, "asd", "fgh", 0],
])(
"given branch id = %p, username = %p, password = %p, routing should be pushed to /dashboard %p times",
async (branchId?: number | string, username?: string, password?: string, result?: number) => {
;(authenticate as jest.Mock).mockResolvedValue(mockUser)
const user = userEvent.setup()
if (branchId) {
const branchIdField = await screen.findByTestId("branchId")
await user.type(branchIdField, String(branchId))
}
if (username) {
const usernameField = await screen.findByTestId("username")
await user.type(usernameField, username)
}
if (password) {
const passwordField = await screen.findByTestId("password")
await user.type(passwordField, password)
}
const loginButton = await screen.findByTestId("loginButton")
await user.click(loginButton)
expect(mockedPushFn).toHaveBeenCalledTimes(result || 0)
}
)
it.each([
[123, "asd", "fgh", false, false, false],
["asd", undefined, undefined, true, true, true],
[undefined, "asd", "fgh", true, false, false],
])(
"given branch id = %p, username = %p, password = %p, error for branch id should be %p, username should be %p, and password should be %p",
async (
branchId?: number | string,
username?: string,
password?: string,
branchIdResult?: boolean,
usernameResult?: boolean,
passwordResult?: boolean
) => {
;(authenticate as jest.Mock).mockResolvedValue(mockUser)
const user = userEvent.setup()
if (branchId) {
const branchIdField = await screen.findByTestId("branchId")
await user.type(branchIdField, String(branchId))
}
if (username) {
const usernameField = await screen.findByTestId("username")
await user.type(usernameField, username)
}
if (password) {
const passwordField = await screen.findByTestId("password")
await user.type(passwordField, password)
}
const loginButton = await screen.findByTestId("loginButton")
await user.click(loginButton)
expect(Boolean(screen.queryByText("Branch ID is required and should only be a number"))).toBe(branchIdResult)
expect(Boolean(screen.queryByText("Username is required"))).toBe(usernameResult)
expect(Boolean(screen.queryByText("Password is required"))).toBe(passwordResult)
}
)
it("should show error for invalid credential when authenticate function does not get any user", async () => {
;(authenticate as jest.Mock).mockResolvedValue(null)
const user = userEvent.setup()
const branchIdField = await screen.findByTestId("branchId")
await user.type(branchIdField, "123")
const usernameField = await screen.findByTestId("username")
await user.type(usernameField, "asd")
const passwordField = await screen.findByTestId("password")
await user.type(passwordField, "asd")
const loginButton = await screen.findByTestId("loginButton")
await user.click(loginButton)
expect(Boolean(screen.queryByText("Invalid credentials. Please Try again."))).toBeTruthy()
})
it("should show error when authenticate function fails", async () => {
;(authenticate as jest.Mock).mockRejectedValue(new Error())
const user = userEvent.setup()
const branchIdField = await screen.findByTestId("branchId")
await user.type(branchIdField, "123")
const usernameField = await screen.findByTestId("username")
await user.type(usernameField, "asd")
const passwordField = await screen.findByTestId("password")
await user.type(passwordField, "asd")
const loginButton = await screen.findByTestId("loginButton")
await user.click(loginButton)
expect(Boolean(screen.queryByText("Unexpected error. Please Try again."))).toBeTruthy()
})
})
})

5174
yarn.lock

File diff suppressed because it is too large Load diff