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",
|
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)
|
||||||
|
|
|
||||||
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 { 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: [
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
18
package.json
18
package.json
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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