diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..9e6de2f --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +tailwind.config.ts \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 6307415..1c88a7d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,14 +5,19 @@ "src/**/*-vm.ts" ], "rules": { - "react-hooks/rules-of-hooks": "off" + "react-hooks/rules-of-hooks": "off" } + }, + { + "files": [ + "src/app/**/*.stories.tsx" + ], + "rules": { + "react/jsx-props-no-spreading": "off" + } } ], - "plugins": [ - "prettier" - ], - "settings": { + "settings": { "react": { "version": "detect" }, @@ -35,6 +40,16 @@ } } }, + "plugins": [ + "prettier" + ], + "extends": [ + "airbnb", + "next/core-web-vitals", + "next/typescript", + "prettier", + "plugin:storybook/recommended" + ], "rules": { "no-use-before-define": "off", "class-methods-use-this": "off", @@ -43,13 +58,24 @@ "no-promise-executor-return": "off", "@typescript-eslint/no-shadow": "off", "react/require-default-props": "off", + "import/order": [ + "error", + { + "pathGroups": [ + { + "pattern": "@/**", + "group": "external" + } + ] + } + ], "no-shadow": "off", "prettier/prettier": [ "warn", { "printWidth": 80, "tabWidth": 2, - "endOfLine":"auto", + "endOfLine": "auto", "useTabs": false, "semi": true, "singleQuote": false, @@ -85,11 +111,5 @@ "devDependencies": true } ] - }, - "extends": [ - "airbnb", - "next/core-web-vitals", - "next/typescript", - "prettier" - ] -} + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 39c19d2..a39f2a5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +*storybook.log diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..6efa0cd --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,18 @@ +import type { StorybookConfig } from "@storybook/nextjs"; + +const config: StorybookConfig = { + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [ + "@storybook/addon-onboarding", + "@storybook/addon-essentials", + "@chromatic-com/storybook", + "@storybook/addon-interactions", + "storybook-dark-mode" + ], + framework: { + name: "@storybook/nextjs", + options: {}, + }, + staticDirs: ["../public"], +}; +export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..dc1dbaa --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,134 @@ +import React, { useRef } from "react"; +import { themes } from '@storybook/theming'; +import { ThemeProvider } from "../src/app/[lang]/dashboard/components/client/theme-provider/theme-provider"; +import { DARK_MODE_EVENT_NAME, UPDATE_DARK_MODE_EVENT_NAME } from 'storybook-dark-mode'; +import { initI18next, LANGS } from "../src/bootstrap/i18n/i18n" +import { addons } from '@storybook/preview-api'; +import { i18n } from "i18next"; +import { I18nextProvider } from "react-i18next"; +const channel = addons.getChannel(); +import "../src/app/globals.css" +/** + * + * This function will expand the object with nested properties + * @param obj refrence Object to that + * @param leftKeys keys to be nested object keys + * @param value value to be nested + * + */ +export const recursiveNestedProps = ( + obj: Record, + leftKeys: string[], + value: unknown, +): Record => { + if (leftKeys.length <= 0) return obj; + if (leftKeys.length === 1) { + // eslint-disable-next-line no-param-reassign + obj[leftKeys[0]] = value; + return obj; + } + const key = leftKeys.shift(); + if (!key) return obj; + + if (!obj[key]) { + // eslint-disable-next-line no-param-reassign + obj[key] = {}; + } + + return recursiveNestedProps( + obj[key] as Record, + leftKeys, + value, + ); +}; + +const preview = { + decorators: [ + (Story, data) => { + const [isDark, setDark] = React.useState(true); + const [i18n, setI18n] = React.useState() + const parsedProps = {} as Record; + const { locale } = data.globals + const props = data.allArgs; + Object.entries(props).forEach((prop) => { + const [key, value] = prop; + if (!key.includes("vm")) { + parsedProps[key] = value; + return; + } + const splitedKey = key.split("."); + + recursiveNestedProps(parsedProps, splitedKey, value); + }); + + + React.useEffect(() => { + channel.on(DARK_MODE_EVENT_NAME, setDark); + return () => channel.removeListener(DARK_MODE_EVENT_NAME, setDark); + }, [channel, setDark]); + + React.useEffect(() => { + (async () => { + setI18n((await initI18next({ lng: locale })).i18n); + })() + }, []) + + React.useEffect(() => { + i18n?.changeLanguage(locale); + }, [locale]); + + return ( + + { + i18n && ( + + + + ) + } + + ); + }, + ], + darkMode: { + // Override the default dark theme + dark: { ...themes.dark, appBg: 'black' }, + // Override the default light theme + classTarget: 'html', + light: { ...themes.normal, appBg: 'red' }, + }, + parameters: { + nextjs: { + appDirectory: true, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + globalTypes: { + locale: { + name: 'Locale', + description: 'Internationalization locale', + toolbar: { + icon: 'globe', + items: [ + { value: LANGS.EN, title: 'English' }, + { value: LANGS.RU, title: 'Russian' }, + ], + showName: true, + }, + }, + } +}; + +export default preview; diff --git a/docker-compose.yml b/docker-compose.yml index 9e2981f..189a656 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,6 @@ services: POSTGRES_USER: admin POSTGRES_DB: nextbp - app: build: . ports: diff --git a/package.json b/package.json index ad7a6a0..59fef4e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "start": "next start --port 4000", "lint": "next lint --fix", "test": "vitest", - "seed": "node -r dotenv/config ./src/bootstrap/boundaries/db/seed.js" + "seed": "node -r dotenv/config ./src/bootstrap/boundaries/db/seed.js", + "storybook": "storybook dev -p 6006 --no-open", + "build-storybook": "storybook build" }, "dependencies": { "@heroicons/react": "^2.1.5", @@ -24,6 +26,7 @@ "lucide-react": "^0.454.0", "next": "15.0.2", "next-i18n-router": "^5.5.1", + "next-themes": "^0.4.3", "postgres": "^3.4.5", "react": "19.0.0-rc-69d4b800-20241021", "react-cookie": "^7.2.2", @@ -37,7 +40,15 @@ "zod": "^3.23.8" }, "devDependencies": { + "@chromatic-com/storybook": "^3.2.2", "@faker-js/faker": "^9.1.0", + "@storybook/addon-essentials": "^8.4.5", + "@storybook/addon-interactions": "^8.4.5", + "@storybook/addon-onboarding": "^8.4.5", + "@storybook/blocks": "^8.4.5", + "@storybook/nextjs": "^8.4.5", + "@storybook/react": "^8.4.5", + "@storybook/test": "^8.4.5", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.0.1", "@types/node": "^20", @@ -53,10 +64,13 @@ "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-storybook": "^0.11.1", "jsdom": "^25.0.1", "moq.ts": "^10.0.8", "postcss": "^8", "prettier": "^3.3.3", + "storybook": "^8.4.5", + "storybook-dark-mode": "^4.0.2", "tailwindcss": "^3.4.1", "typescript": "^5", "vitest": "^2.1.4" diff --git a/src/app/[lang]/dashboard/components/client/theme-provider/theme-provider.tsx b/src/app/[lang]/dashboard/components/client/theme-provider/theme-provider.tsx new file mode 100644 index 0000000..6a1ffe4 --- /dev/null +++ b/src/app/[lang]/dashboard/components/client/theme-provider/theme-provider.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as React from "react" +import { ThemeProvider as NextThemesProvider } from "next-themes" + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children} +} diff --git a/src/app/[lang]/dashboard/page.tsx b/src/app/[lang]/dashboard/page.tsx index f9f980f..ea09ada 100644 --- a/src/app/[lang]/dashboard/page.tsx +++ b/src/app/[lang]/dashboard/page.tsx @@ -6,11 +6,11 @@ import CardWrapper from "@/app/[lang]/dashboard/components/server/cards/cards"; import LatestInvoices from "@/app/[lang]/dashboard/components/server/latest-invoices/latest-invoices"; import RevenueChart from "@/app/[lang]/dashboard/components/server/revenue-chart/revenue-chart"; import { Suspense } from "react"; -import { getServerTranslation } from "@/bootstrap/i18n/i18n"; +import { getServerTranslation, LANGS } from "@/bootstrap/i18n/i18n"; import langKey from "@/bootstrap/i18n/dictionaries/lang-key"; export default async function Dashboard(props: { - params: Promise<{ lang: string }>; + params: Promise<{ lang: LANGS }>; }) { const { params } = props; const { lang } = await params; diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index b142672..89037c1 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,4 +1,5 @@ -import { initI18next } from "@/bootstrap/i18n/i18n"; +import { ThemeProvider } from "@/app/[lang]/dashboard/components/client/theme-provider/theme-provider"; +import { initI18next, LANGS } from "@/bootstrap/i18n/i18n"; import TranslationsProvider from "@/bootstrap/i18n/i18n-provider"; import localFont from "next/font/local"; import { PropsWithChildren } from "react"; @@ -15,19 +16,26 @@ const geistMono = localFont({ }); export default async function layout( - props: PropsWithChildren & { params: Promise<{ lang: string }> }, + props: PropsWithChildren & { params: Promise<{ lang: LANGS }> }, ) { const { params, children } = props; const { lang } = await params; const { resources } = await initI18next({ lng: lang }); return ( - + - - {children} - + + + {children} + + ); diff --git a/src/app/components/button/button.tsx b/src/app/components/button/button.tsx index 5520728..2c72f27 100644 --- a/src/app/components/button/button.tsx +++ b/src/app/components/button/button.tsx @@ -11,7 +11,6 @@ import { cn } from "@/bootstrap/helpers/lib/ui-utils"; export default class Button extends BaseView { protected Build(props: BuildProps): ReactNode { const { vm } = props; - return ( {vm.props.title} diff --git a/src/app/components/button/stories/Button.stories.tsx b/src/app/components/button/stories/Button.stories.tsx new file mode 100644 index 0000000..d7739ae --- /dev/null +++ b/src/app/components/button/stories/Button.stories.tsx @@ -0,0 +1,65 @@ +import CreateRandomInvoiceButtonVM from "@/app/[lang]/dashboard/vm/create-random-invoice-button-vm"; +import Button from "@/app/components/button/button"; +import { DiContext, useDI } from "@/bootstrap/di/di-context"; +import mockedModuleDi from "@/bootstrap/di/mocked-module-di"; +import Story from "@/bootstrap/helpers/view/storybook-base-template-type"; +import getArgVM from "@/bootstrap/helpers/view/storybook-with-arg-vm"; +import createInvoiceUsecase from "@/feature/core/invoice/domain/usecase/create-invoice-usecase"; +import type { Meta } from "@storybook/react"; +import { useRef } from "react"; + +const meta: Meta = { + title: "general/Button", +}; + +export default meta; + +export const Primary: Story = { + argTypes: { + "vm.props.isDisable": { + control: "boolean", + }, + "vm.props.title": { + control: "text", + }, + }, + args: { + "vm.props.title": "Button", + "vm.props.isDisable": false, + }, + render: (_props, globalData) => { + const vm = getArgVM(globalData.parsedProps.vm); // You can use parsed props to access your vm properties. + return