First draft
This commit is contained in:
parent
18be2ec837
commit
df0739bb8b
36 changed files with 3342 additions and 3501 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cva(?:<[^>]*>)?(([^)]*))", "[\"'`]([^\"'`]*).*?[\"'`]", "(?:twMerge|twJoin)\\(([^\\);]*)[\\);]"]
|
||||
]
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export async function GET() {
|
||||
return Response.json({ status: "ok" })
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import "styles/tailwind.css"
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
65
app/page.tsx
65
app/page.tsx
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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/)
|
||||
})
|
||||
|
|
@ -9,6 +9,7 @@ const customJestConfig = {
|
|||
testEnvironment: "jest-environment-jsdom",
|
||||
modulePathIgnorePatterns: ["<rootDir>/dist/"],
|
||||
testPathIgnorePatterns: ["<rootDir>/e2e"],
|
||||
moduleDirectories: ["node_modules", "<rootDir>/"],
|
||||
}
|
||||
|
||||
module.exports = createJestConfig(customJestConfig)
|
||||
|
|
|
|||
416
lp-items.tsx
416
lp-items.tsx
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import NextAuth from "next-auth"
|
||||
import { authConfig } from "./auth.config"
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// TODO: Feel free to remove this block
|
||||
if (request.headers?.get("host")?.includes("next-enterprise.vercel.app")) {
|
||||
return NextResponse.redirect("https://blazity.com/open-source/nextjs-enterprise-boilerplate", { status: 301 })
|
||||
}
|
||||
}
|
||||
export default NextAuth(authConfig).auth
|
||||
|
||||
export function middleware(request: NextRequest) {}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ const config = withPlugins([[withBundleAnalyzer({ enabled: env.ANALYZE })]], {
|
|||
{ source: "/ping", destination: "/api/health" },
|
||||
]
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/login',
|
||||
permanent: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export default config
|
||||
|
|
|
|||
18
package.json
18
package.json
|
|
@ -11,18 +11,20 @@
|
|||
"prettier": "prettier --check \"**/*.{js,jsx,ts,tsx}\"",
|
||||
"prettier:fix": "prettier --write \"**/*.{js,jsx,ts,tsx}\"",
|
||||
"analyze": "cross-env ANALYZE=true yarn build",
|
||||
"storybook": "cross-env FORCE_COLOR=1 storybook dev -p 6006",
|
||||
"test-storybook": "cross-env FORCE_COLOR=1 test-storybook",
|
||||
"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",
|
||||
"test": "cross-env FORCE_COLOR=1 jest ./test",
|
||||
"test:watch": "cross-env NODE_ENV=test jest -i --watch",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"preinstall": "npx npm-only-allow@latest --PM yarn",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
|
|
@ -39,6 +41,7 @@
|
|||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^11.1.0",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
|
|
@ -50,9 +53,12 @@
|
|||
"class-variance-authority": "^0.7.0",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "^14.0.3",
|
||||
"next-auth": "^5.0.0-beta.5",
|
||||
"next-compose-plugins": "^2.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-redux": "^9.1.0",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
119
src/app/dashboard/add-user-form.tsx
Normal file
119
src/app/dashboard/add-user-form.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
54
src/app/dashboard/dashboard.tsx
Normal file
54
src/app/dashboard/dashboard.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
10
src/app/dashboard/page.tsx
Normal file
10
src/app/dashboard/page.tsx
Normal 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 />
|
||||
}
|
||||
59
src/app/dashboard/user-table.tsx
Normal file
59
src/app/dashboard/user-table.tsx
Normal 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
25
src/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
src/app/login/login-form.tsx
Normal file
120
src/app/login/login-form.tsx
Normal 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
10
src/app/login/page.tsx
Normal 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
9
src/app/types/user.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export interface TUser {
|
||||
branchId: number
|
||||
username: string
|
||||
password: string
|
||||
firstName: string
|
||||
middleName?: string
|
||||
lastName: string
|
||||
position: string
|
||||
}
|
||||
30
src/components/SessionProvider/index.tsx
Normal file
30
src/components/SessionProvider/index.tsx
Normal 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
|
||||
}
|
||||
14
src/components/StoreProvider/index.tsx
Normal file
14
src/components/StoreProvider/index.tsx
Normal 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>
|
||||
}
|
||||
10
src/components/ThemeProvider/index.tsx
Normal file
10
src/components/ThemeProvider/index.tsx
Normal 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>
|
||||
}
|
||||
44
src/lib/authentication/authenticate.ts
Normal file
44
src/lib/authentication/authenticate.ts
Normal 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
1
src/lib/delay.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms))
|
||||
51
src/lib/redux/features/userSlice.ts
Normal file
51
src/lib/redux/features/userSlice.ts
Normal 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
8
src/lib/redux/hooks.ts
Normal 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
16
src/lib/redux/store.ts
Normal 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"]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
185
test/login/login-form.test.tsx
Normal file
185
test/login/login-form.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue