diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f83d477 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies and Build +node_modules +dist +dist-ssr +*.local +coverage +build + +# Editor directories and IDEs +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.vscode + +# dotenv environment variable files +.env +.env.* +.env.local + +# CI & Testing +.github +src/tests +**/*.test.tsx +**/*.test.ts + +# Temp +.temp +.tmp \ No newline at end of file diff --git a/.gitignore b/.gitignore index 04bae08..b126fae 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ node_modules dist dist-ssr *.local +coverage +build # Editor directories and files .idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc54cb2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# --- STAGE 1: Build --- +# We use a lightweight Node image to build the app +FROM node:25-alpine3.19 AS builder + +WORKDIR /app + +# 1. We copy the dependency files to leverage Docker's caching +COPY package*.json ./ +RUN npm install + +# 2. We copy the rest of the project files +COPY . . + +# 3. We run the build (this uses the tsconfig.json we already fixed) +RUN npm run build + +# --- STAGE 2: Production --- +FROM nginx:1.27.11-alpine3.21 + +# Update OS packages to reduce known vulnerabilities at build time +RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/* + +# 4. We copy only the built files (dist) from the previous stage +# This makes the final image extremely small (approx 20-40MB) +COPY --from=builder /app/dist /usr/share/nginx/html + +# 5. We expose port 80 +EXPOSE 80 + +# 6. We start Nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 5e6b472..646bee7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'node_modules', 'build', 'coverage']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/package-lock.json b/package-lock.json index c660d53..7f62877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@types/react-dom": "^19.2.3", "@types/testing-library__jest-dom": "^5.14.9", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.5", "autoprefixer": "^10.4.27", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", @@ -234,7 +235,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -277,7 +278,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -336,7 +337,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -346,6 +347,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -2059,6 +2070,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", @@ -2256,6 +2298,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "devOptional": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3258,7 +3319,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -3333,6 +3394,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3417,6 +3485,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-diff": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", @@ -4090,6 +4197,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4715,7 +4863,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" diff --git a/package.json b/package.json index 1d32af9..7c7f735 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@types/react-dom": "^19.2.3", "@types/testing-library__jest-dom": "^5.14.9", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.5", "autoprefixer": "^10.4.27", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/src/app/providers/AuthProvider.tsx b/src/app/providers/AuthProvider.tsx index 0583ac7..83a2667 100644 --- a/src/app/providers/AuthProvider.tsx +++ b/src/app/providers/AuthProvider.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useAuthStore } from '@/features/auth/store/authStore'; +import { useAuthStore } from '@/features/auth'; export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const checkAuth = useAuthStore((s) => s.actions.checkAuth); // A function that checks the token in localStorage/cookies diff --git a/src/app/providers/index.ts b/src/app/providers/index.ts new file mode 100644 index 0000000..6f43318 --- /dev/null +++ b/src/app/providers/index.ts @@ -0,0 +1,2 @@ +export * from './AuthProvider'; +export * from './MainProvider'; \ No newline at end of file diff --git a/src/app/routes/AppRouter.tsx b/src/app/routes/AppRouter.tsx index 8d9cc93..6a3ac5f 100644 --- a/src/app/routes/AppRouter.tsx +++ b/src/app/routes/AppRouter.tsx @@ -1,11 +1,11 @@ import { createBrowserRouter } from 'react-router-dom'; import { ProtectedGuard, PublicGuard } from './RouteGuards'; -import { lazyImport } from '@/shared/utils/LazyImport'; +import { lazyImport } from '@/shared/utils'; // Pages -const LoginPage = lazyImport(() => import('@/features/auth/pages/LoginPage'), 'LoginPage'); -const RegisterPage = lazyImport(() => import('@/features/auth/pages/RegisterPage'), 'RegisterPage'); -const DashboardPage = lazyImport(() => import('@/features/tasks/pages/DashboardPage'), 'DashboardPage'); +const LoginPage = lazyImport(() => import('@/features/auth'), 'LoginPage'); +const RegisterPage = lazyImport(() => import('@/features/auth'), 'RegisterPage'); +const DashboardPage = lazyImport(() => import('@/features/tasks'), 'DashboardPage'); export const router = createBrowserRouter([ { diff --git a/src/app/routes/RouteGuards.tsx b/src/app/routes/RouteGuards.tsx index c2bedd3..0097a5f 100644 --- a/src/app/routes/RouteGuards.tsx +++ b/src/app/routes/RouteGuards.tsx @@ -1,5 +1,5 @@ import { Navigate, Outlet } from 'react-router-dom'; -import { useAuthStore } from '@/features/auth/store/authStore'; +import { useAuthStore } from '@/features/auth'; export const ProtectedGuard = () => { const isAuth = useAuthStore(s => s.isAuthenticated); diff --git a/src/features/tasks/components/CreateTaskModal/index.tsx b/src/features/tasks/components/CreateTaskModal/index.tsx index 06ba74c..22d61b3 100644 --- a/src/features/tasks/components/CreateTaskModal/index.tsx +++ b/src/features/tasks/components/CreateTaskModal/index.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; import { useTaskStore } from '../../store/useTaskStore'; -import { Slider } from '@/shared/components/ui/slider/Slider'; -import { Modal } from '@/shared/components/ui/Modal'; +import { Modal, Slider, Input, Button } from '@/shared/components/ui'; import type { Priority } from '@/shared/types'; export const CreateTaskModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => { @@ -24,64 +23,73 @@ export const CreateTaskModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: status: 'PENDING', dueDate: null, }); - onClose(); // Close after saving + onClose(); }; + const priorityScore = (formData.impact / formData.effort).toFixed(2); + return ( -
- {/* Title */} -
- - setFormData({...formData, title: e.target.value})} - /> -
+ + setFormData({ ...formData, title: e.target.value })} + /> - {/* Sliders of Impact vs Effort */} -
- setFormData({...formData, impact: val})} - /> - setFormData({...formData, effort: val})} - /> -
+ setFormData({ ...formData, description: e.target.value })} + /> - {/* Score Visual (Staff level detail) */} -
- Calculated Priority: - - {(formData.impact / formData.effort).toFixed(2)} - -
+
+ setFormData({ ...formData, impact: val })} + /> + setFormData({ ...formData, effort: val })} + /> +
- {/* Buttons */} -
- - + {/* Visual Priority Score */} +
+
+ Priority Score + Impact / Effort
- + {priorityScore} +
+ + {/* Buttons */} +
+ + + +
+ ); }; \ No newline at end of file diff --git a/src/features/tasks/components/DashboardHeader/WelcomeMessage.tsx b/src/features/tasks/components/DashboardHeader/WelcomeMessage.tsx index f7febf4..a766ac4 100644 --- a/src/features/tasks/components/DashboardHeader/WelcomeMessage.tsx +++ b/src/features/tasks/components/DashboardHeader/WelcomeMessage.tsx @@ -1,4 +1,4 @@ -import { useAuthStore } from '@/features/auth/store/authStore'; +import { useAuthStore } from '@/features/auth'; export const WelcomeMessage = () => { const user = useAuthStore((s) => s.user); diff --git a/src/features/tasks/components/DashboardHeader/index.tsx b/src/features/tasks/components/DashboardHeader/index.tsx index fbd52bd..596954f 100644 --- a/src/features/tasks/components/DashboardHeader/index.tsx +++ b/src/features/tasks/components/DashboardHeader/index.tsx @@ -1,4 +1,4 @@ -import { useAuthStore } from '@/features/auth/store/authStore'; +import { useAuthStore } from '@/features/auth'; import { WelcomeMessage } from './WelcomeMessage'; export const DashboardHeader = () => { diff --git a/src/features/tasks/index.ts b/src/features/tasks/index.ts new file mode 100644 index 0000000..f80a3f2 --- /dev/null +++ b/src/features/tasks/index.ts @@ -0,0 +1,5 @@ +export * from './hooks/useTasksHook'; +export * from './store/useTaskStore'; + +// Pages +export * from './pages/DashboardPage'; \ No newline at end of file diff --git a/src/features/tasks/pages/DashboardPage.tsx b/src/features/tasks/pages/DashboardPage.tsx index 391badd..c699aac 100644 --- a/src/features/tasks/pages/DashboardPage.tsx +++ b/src/features/tasks/pages/DashboardPage.tsx @@ -2,7 +2,7 @@ import { useTasks } from '../hooks/useTasksHook'; import { DashboardHeader } from '../components/DashboardHeader'; import { TaskSummaryCard } from '../components/ProgressStats/TaskSummaryCard'; import { TaskList } from '../components/TaskList'; -import { MainLayout } from '@/shared/components/layout/MainLayout'; +import { MainLayout } from '@/shared/components/layout'; export const DashboardPage = () => { // We consume the hook. React Query handles the loading state for us. diff --git a/src/shared/components/layout/MainLayout.tsx b/src/shared/components/layout/MainLayout.tsx index e7dae7a..4475d5a 100644 --- a/src/shared/components/layout/MainLayout.tsx +++ b/src/shared/components/layout/MainLayout.tsx @@ -1,5 +1,5 @@ import { MobileNav } from './MobileNav'; -import { Sidebar } from './Sidebar'; +import { Sidebar } from './Sidebar/Sidebar'; export const MainLayout = ({ children }: { children: React.ReactNode }) => { return ( diff --git a/src/shared/components/layout/Sidebar/index.tsx b/src/shared/components/layout/Sidebar/Sidebar.tsx similarity index 100% rename from src/shared/components/layout/Sidebar/index.tsx rename to src/shared/components/layout/Sidebar/Sidebar.tsx diff --git a/src/shared/components/layout/Sidebar/index.ts b/src/shared/components/layout/Sidebar/index.ts new file mode 100644 index 0000000..0b42f98 --- /dev/null +++ b/src/shared/components/layout/Sidebar/index.ts @@ -0,0 +1 @@ +export * from './Sidebar'; \ No newline at end of file diff --git a/src/shared/components/layout/index.ts b/src/shared/components/layout/index.ts new file mode 100644 index 0000000..737f505 --- /dev/null +++ b/src/shared/components/layout/index.ts @@ -0,0 +1,3 @@ +export * from './MainLayout'; +export * from './MobileNav'; +export * from './Sidebar/Sidebar'; \ No newline at end of file diff --git a/src/shared/components/ui/Button.tsx b/src/shared/components/ui/Button.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/shared/components/ui/Button/Button.test.tsx b/src/shared/components/ui/Button/Button.test.tsx new file mode 100644 index 0000000..3f944c9 --- /dev/null +++ b/src/shared/components/ui/Button/Button.test.tsx @@ -0,0 +1,39 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { Button } from './Button'; + +describe('Button Component', () => { + it('You must render the content correctly', () => { + render(); + expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument(); + }); + + it('You must call onClick when clicked', () => { + const handleClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('You must not call onClick if the button is disabled', () => { + const handleClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('You must display the loading state and disable the button', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + // If you use an emoji or text for the spinner, look for it here + expect(screen.getByText(/🌀/i)).toBeInTheDocument(); + }); + + it('should render the left icon when provided', () => { + render(); + + // This covers the left icon logic branch + expect(screen.getByTestId('btn-icon')).toBeInTheDocument(); + expect(screen.getByText(/launch/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/shared/components/ui/Button/Button.tsx b/src/shared/components/ui/Button/Button.tsx new file mode 100644 index 0000000..f815ecc --- /dev/null +++ b/src/shared/components/ui/Button/Button.tsx @@ -0,0 +1,48 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + isLoading?: boolean; + leftIcon?: ReactNode; +} + +export const Button = ({ + children, + variant = 'primary', + size = 'md', + isLoading, + leftIcon, + className, + disabled, + ...props +}: ButtonProps) => { + + // Definition of base styles and variants + const baseStyles = "inline-flex items-center justify-center rounded font-medium transition-colors focus:outline-none disabled:opacity-50"; + + const variants = { + primary: "bg-blue-600 text-white hover:bg-blue-700", + secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300", + danger: "bg-red-600 text-white hover:bg-red-700", + ghost: "bg-transparent hover:bg-gray-100 text-gray-600", + }; + + const sizes = { + sm: "px-3 py-1.5 text-xs", + md: "px-4 py-2 text-sm", + lg: "px-6 py-3 text-base", + }; + + return ( + + ); +}; \ No newline at end of file diff --git a/src/shared/components/ui/Input/Input.test.tsx b/src/shared/components/ui/Input/Input.test.tsx new file mode 100644 index 0000000..44654be --- /dev/null +++ b/src/shared/components/ui/Input/Input.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Input } from './Input'; + +describe('Input Component', () => { + it('You must render the label if provided', () => { + render(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + it('You must update the value when typing', () => { + render(); + const input = screen.getByPlaceholderText(/name/i) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'Name' } }); + expect(input.value).toBe('Name'); + }); + + it('You must display the error message', () => { + render(); + expect(screen.getByText(/required field/i)).toBeInTheDocument(); + // Verify that it has error class (e.g. border-red-500) + expect(screen.getByRole('textbox')).toHaveClass('border-red-500'); + }); + + it('should render the icon when provided', () => { + render(🔍} />); + + // This covers the conditional rendering of the icon + expect(screen.getByTestId('search-icon')).toBeInTheDocument(); + + // This ensures that the class logic (pl-10 vs pl-3) is also tested + expect(screen.getByPlaceholderText(/search/i)).toHaveClass('pl-10'); + }); + + it('should use the provided id instead of the auto-generated one', () => { + // This covers the ID initialization logic + render(); + const input = screen.getByLabelText(/username/i); + expect(input).toHaveAttribute('id', 'custom-id'); + }); +}); \ No newline at end of file diff --git a/src/shared/components/ui/Input/Input.tsx b/src/shared/components/ui/Input/Input.tsx new file mode 100644 index 0000000..64e6fc8 --- /dev/null +++ b/src/shared/components/ui/Input/Input.tsx @@ -0,0 +1,45 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + icon?: React.ReactNode; +} + +export const Input = forwardRef( + ({ label, error, icon, className, ...props }, ref) => { + const inputId = props.id || label?.replace(/\s+/g, '-').toLowerCase(); + + return ( +
+ {label && ( + + )} + +
+ {icon && ( +
+ {icon} +
+ )} + +
+ + {error && {error}} +
+ ); + } +); + +Input.displayName = 'Input'; \ No newline at end of file diff --git a/src/shared/components/ui/Modal/Modal.test.tsx b/src/shared/components/ui/Modal/Modal.test.tsx new file mode 100644 index 0000000..e9ceada --- /dev/null +++ b/src/shared/components/ui/Modal/Modal.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { Modal } from './Modal'; + +describe('Modal Component', () => { + it('You must not render anything if isOpen is false', () => { + render( {}}>Content); + expect(screen.queryByText(/content/i)).not.toBeInTheDocument(); + }); + + it('You must render the content and the close button if isOpen is true', () => { + render( {}}>Modal Content); + expect(screen.getByText(/modal content/i)).toBeInTheDocument(); + }); + + it('You must call onClose when clicking the close button', () => { + const handleClose = vi.fn(); + render(Content); + + // Assuming you have a button with an "X" or "Close modal" text + const closeButton = screen.getByRole('button', { name: /close modal/i }); + fireEvent.click(closeButton); + + expect(handleClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when Escape key is pressed', () => { + const handleClose = vi.fn(); + render(Content); + + // This triggers the event handler you have in lines 25-27 + fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); + + expect(handleClose).toHaveBeenCalledTimes(1); + }); + + it('should not render anything if isOpen is false', () => { + const { container } = render( + {}}> +
Hidden
+
+ ); + + // This covers the "return null" line (early exit branch) + expect(container.firstChild).toBeNull(); + }); + + it('should close when Escape key is pressed', () => { + const handleClose = vi.fn(); + render(Content); + + // This covers the global event logic (if implemented) + fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); + expect(handleClose).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/shared/components/ui/Modal/index.tsx b/src/shared/components/ui/Modal/Modal.tsx similarity index 98% rename from src/shared/components/ui/Modal/index.tsx rename to src/shared/components/ui/Modal/Modal.tsx index de0b0ce..573c28e 100644 --- a/src/shared/components/ui/Modal/index.tsx +++ b/src/shared/components/ui/Modal/Modal.tsx @@ -55,7 +55,7 @@ export const Modal = ({ isOpen, onClose, title, children }: ModalProps) => { diff --git a/src/shared/components/ui/slider/Slider.test.tsx b/src/shared/components/ui/Slider/Slider.test.tsx similarity index 71% rename from src/shared/components/ui/slider/Slider.test.tsx rename to src/shared/components/ui/Slider/Slider.test.tsx index e780357..41cc1ab 100644 --- a/src/shared/components/ui/slider/Slider.test.tsx +++ b/src/shared/components/ui/Slider/Slider.test.tsx @@ -3,27 +3,27 @@ import { describe, it, expect, vi } from 'vitest'; import { Slider } from './Slider'; describe('Slider Component', () => { - it('debe renderizar el label correctamente', () => { + it('You must render the label correctly', () => { render( {}} />); - // Usamos una string simple para evitar problemas con Overloads de tipos + // We use a simple string to avoid issues with type overloads const labelElement = screen.getByText('Impact'); expect(labelElement).toBeInTheDocument(); }); - it('debe mostrar el valor actual', () => { + it('You must display the current value', () => { render( {}} />); const valueElement = screen.getByText('7'); expect(valueElement).toBeInTheDocument(); }); - it('debe llamar a onChange cuando el valor cambia', () => { + it('You must call onChange when the value changes', () => { const handleChange = vi.fn(); render(); - // Buscamos por el rol de accesibilidad del input range + // We look for the accessibility role of the range input const sliderInput = screen.getByRole('slider'); - // Simulamos el cambio + // We simulate the change fireEvent.change(sliderInput, { target: { value: '8' } }); expect(handleChange).toHaveBeenCalledWith(8); diff --git a/src/shared/components/ui/slider/Slider.tsx b/src/shared/components/ui/Slider/Slider.tsx similarity index 100% rename from src/shared/components/ui/slider/Slider.tsx rename to src/shared/components/ui/Slider/Slider.tsx diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts new file mode 100644 index 0000000..8e8119c --- /dev/null +++ b/src/shared/components/ui/index.ts @@ -0,0 +1,4 @@ +export * from './Modal/Modal'; +export * from './Slider/Slider'; +export * from './Button/Button'; +export * from './Input/Input'; \ No newline at end of file diff --git a/src/shared/components/ui/input.tsx b/src/shared/components/ui/input.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 0000000..d6af17e --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1 @@ +export * from './LazyImport'; \ No newline at end of file