├── .env ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── index.html ├── json-server.json ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── json-server │ └── index.html └── vite.svg ├── src ├── App.css ├── App.tsx ├── assets │ └── react.svg ├── components │ ├── AuthStatus.tsx │ ├── CancelOrderButton.tsx │ ├── CategoryList.tsx │ ├── CategorySelect.tsx │ ├── ErrorMessage.tsx │ ├── ExpandableText.tsx │ ├── Greet.tsx │ ├── Label.tsx │ ├── LanguageSelector.tsx │ ├── LoginButton.tsx │ ├── LogoutButton.tsx │ ├── NavBar.tsx │ ├── Onboarding.tsx │ ├── OrderStatusSelector.tsx │ ├── PrivateRoutes.tsx │ ├── ProductDetail.tsx │ ├── ProductForm.tsx │ ├── ProductImageGallery.tsx │ ├── ProductList.tsx │ ├── ProductTable.tsx │ ├── QuantitySelector.tsx │ ├── SearchBox.tsx │ ├── TagList.tsx │ ├── TermsAndConditions.tsx │ ├── ToastDemo.tsx │ ├── UserAccount.tsx │ ├── UserList.tsx │ └── UserTable.tsx ├── data │ └── db.json ├── entities.ts ├── environment.d.ts ├── hooks │ ├── useCart.ts │ ├── useCategories.ts │ ├── useLanguage.ts │ └── useProduct.ts ├── index.css ├── main.tsx ├── pages │ ├── BrowseProductsPage.tsx │ ├── ErrorPage.tsx │ ├── HomePage.tsx │ ├── Layout.tsx │ ├── PlaygroundPage.tsx │ ├── ProductDetailPage.tsx │ ├── ProductListPage.tsx │ └── admin │ │ ├── AdminHomePage.tsx │ │ ├── AdminLayout.tsx │ │ ├── EditProductPage.tsx │ │ ├── NewProductPage.tsx │ │ └── ProductListPage.tsx ├── providers │ ├── AuthProvider.tsx │ ├── CartProvider.tsx │ ├── ReactQueryProvider.tsx │ ├── index.tsx │ └── language │ │ ├── LanguageContext.ts │ │ ├── LanguageProvider.tsx │ │ ├── data │ │ ├── en.json │ │ └── es.json │ │ └── type.ts ├── routes.tsx ├── validationSchemas │ └── productSchema.ts └── vite-env.d.ts ├── tailwind.config.ts ├── tests ├── AllProviders.tsx ├── Router.test.tsx ├── components │ ├── AuthStatus.test.tsx │ ├── CategoryList.test.tsx │ ├── ExpandableText.test.tsx │ ├── Greet.test.tsx │ ├── Label.test.tsx │ ├── OrderStatusSelector.test.tsx │ ├── ProductDetail.test.tsx │ ├── ProductForm.test.tsx │ ├── ProductImageGallery.test.tsx │ ├── ProductList.test.tsx │ ├── QuantitySelector.test.tsx │ ├── SearchBox.test.tsx │ ├── TagList.test.tsx │ ├── TermsAndConditions.test.tsx │ ├── ToastDemo.test.tsx │ ├── UserAccount.test.tsx │ └── UserList.test.tsx ├── main.test.ts ├── mocks │ ├── db.ts │ ├── handlers.ts │ └── server.ts ├── pages │ ├── BrowseProductsPage.test.tsx │ └── ProductDetailPage.test.tsx ├── setup.ts └── utils.tsx ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts /.env: -------------------------------------------------------------------------------- 1 | VITE_AUTH0_DOMAIN= 2 | VITE_AUTH0_CLIENTID= -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended-type-checked", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | parserOptions: { 12 | project: true, 13 | tsconfigRootDir: __dirname, 14 | }, 15 | plugins: ["react-refresh"], 16 | rules: { 17 | "react-refresh/only-export-components": [ 18 | "warn", 19 | { allowConstantExport: true }, 20 | ], 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | 27 | coverage/ 28 | /test-results/ 29 | /playwright-report/ 30 | /blob-report/ 31 | /playwright/.cache/ 32 | /test-results/ 33 | /playwright-report/ 34 | /blob-report/ 35 | /playwright/.cache/ 36 | /e2e/playwright/.auth/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Testing React Apps 2 | 3 | This is the starter project for my Reacting testing course where you'll learn everything you need to know to effectively test React apps. You can find the full course at: 4 | 5 | https://codewithmosh.com 6 | 7 | ## About this Project 8 | 9 | This is a React app built with the following technologies and libraries: 10 | 11 | - Auth0 12 | - Tailwind 13 | - RadixUI 14 | - React Router 15 | - React Query 16 | - Redux Toolkit 17 | 18 | Please follow these instructions carefully to setup this project on your machine. 19 | 20 | ## Setting up Auth0 for Authentication 21 | 22 | 1. **Sign up for an Auth0 Account:** 23 | 24 | If you don't already have an Auth0 account, you can sign up for one at [https://auth0.com/](https://auth0.com/). Auth0 offers a free tier that you can use for your project. 25 | 26 | 2. **Create a New Application:** 27 | 28 | - Log in to your Auth0 account. 29 | - Go to the Auth0 Dashboard. 30 | - Click on "Applications" in the left sidebar. 31 | - Click the "Create Application" button. 32 | - Give your application a name (e.g., "My React App"). 33 | - Select "Single Page Web Applications" as the application type. 34 | 35 | 3. **Configure Application Settings:** 36 | 37 | - On the application settings page, configure the following settings: 38 | - Allowed Callback URLs: `http://localhost:5173` 39 | - Allowed Logout URLs: `http://localhost:5173` 40 | - Allowed Web Origins: `http://localhost:5173` 41 | - Save the changes. 42 | 43 | 4. **Obtain Auth0 Domain and ClientID:** 44 | 45 | - On the application settings page, you will find your Auth0 Domain and Client ID near the top of the page. 46 | - Copy the Auth0 Domain (e.g., `your-auth0-domain.auth0.com`) and Client ID (e.g., `your-client-id`). 47 | 48 | 5. **Create a `.env.local` File:** 49 | 50 | - In the root directory of the project, you'll find a sample `.env` file. Make a copy and save it as `.env.local`. 51 | - Replace the Auth0 Domain and Client ID with the actual values you obtained from Auth0. 52 | 53 | 54 | ## Running the App 55 | 56 | Now that you have set up Auth0 and configured your environment variables, you can run the React app using the following commands: 57 | 58 | ```bash 59 | # Install dependencies 60 | npm install 61 | 62 | # Start the development server 63 | npm start 64 | ``` 65 | 66 | This will start the back-end process at `http://localhost:3000`. If port 3000 is in use on your machine, update the port number in the following files and run `npm start` again: 67 | 68 | - json-server.json 69 | - src/main.tsx 70 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Testing React Apps 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /json-server.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3000, 3 | "static": "./public/json-server" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-testing-vite", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "server": "json-server --watch src/data/db.json --delay 500", 12 | "start": "concurrently \"npm run server\" \"npm run dev\"", 13 | "test": "vitest", 14 | "test:ui": "vitest --ui", 15 | "coverage": "vitest run --coverage" 16 | }, 17 | "dependencies": { 18 | "@auth0/auth0-react": "^2.2.3", 19 | "@hookform/resolvers": "^3.3.2", 20 | "@radix-ui/themes": "^2.0.0", 21 | "axios": "^1.6.0", 22 | "delay": "^6.0.0", 23 | "json-server": "^0.17.4", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0", 26 | "react-hook-form": "^7.47.0", 27 | "react-hot-toast": "^2.4.1", 28 | "react-icons": "^4.12.0", 29 | "react-loading-skeleton": "^3.3.1", 30 | "react-query": "^3.39.3", 31 | "react-router-dom": "^6.17.0", 32 | "zod": "^3.22.4" 33 | }, 34 | "devDependencies": { 35 | "@faker-js/faker": "^8.4.0", 36 | "@mswjs/data": "^0.16.1", 37 | "@tailwindcss/typography": "^0.5.10", 38 | "@testing-library/jest-dom": "^6.4.0", 39 | "@testing-library/react": "^14.2.0", 40 | "@testing-library/user-event": "^14.5.2", 41 | "@types/node": "^20.10.0", 42 | "@types/react": "^18.2.43", 43 | "@types/react-dom": "^18.2.7", 44 | "@typescript-eslint/eslint-plugin": "^6.14.0", 45 | "@typescript-eslint/parser": "^6.14.0", 46 | "@vitejs/plugin-react": "^4.2.1", 47 | "@vitest/ui": "^1.2.2", 48 | "autoprefixer": "^10.4.16", 49 | "concurrently": "^8.2.2", 50 | "eslint": "^8.55.0", 51 | "eslint-plugin-react-hooks": "^4.6.0", 52 | "eslint-plugin-react-refresh": "^0.4.5", 53 | "jsdom": "^24.0.0", 54 | "msw": "^2.1.6", 55 | "postcss": "^8.4.31", 56 | "resize-observer-polyfill": "^1.5.1", 57 | "tailwindcss": "^3.3.5", 58 | "typescript": "^5.2.2", 59 | "vite": "^5.0.8", 60 | "vitest": "^1.2.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/json-server/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | JSON Server 7 | 8 | 9 |

JSON Server is running!

10 | 11 | 12 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mosh-hamedani/react-testing-finish/e4efde1b83ed71745c82be21d735b2e3ca8e9ad7/src/App.css -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import Layout from './pages/Layout'; 2 | import Providers from './providers'; 3 | 4 | function App() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | 12 | export default App; 13 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/AuthStatus.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from "@auth0/auth0-react"; 2 | import { Text } from "@radix-ui/themes"; 3 | import LoginButton from "./LoginButton"; 4 | import LogoutButton from "./LogoutButton"; 5 | 6 | const AuthStatus = () => { 7 | const { user, isAuthenticated, isLoading } = useAuth0(); 8 | 9 | if (isLoading) return
Loading...
; 10 | 11 | if (isAuthenticated) 12 | return ( 13 |
14 | {user!.name} 15 | 16 |
17 | ); 18 | 19 | return ; 20 | }; 21 | 22 | export default AuthStatus; 23 | -------------------------------------------------------------------------------- /src/components/CancelOrderButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dialog, Flex } from "@radix-ui/themes"; 2 | 3 | const CancelOrderButton = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | Confirm 12 | 13 | Are you sure you want to cancel this order? 14 | 15 | 16 | 17 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default CancelOrderButton; 31 | -------------------------------------------------------------------------------- /src/components/CategoryList.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useQuery } from "react-query"; 3 | import { Category } from "../entities"; 4 | 5 | function CategoryList() { 6 | const { 7 | isLoading, 8 | error, 9 | data: categories, 10 | } = useQuery({ 11 | queryKey: ["categories"], 12 | queryFn: () => 13 | axios 14 | .get("/categories") 15 | .then((res) => res.data), 16 | }); 17 | 18 | if (error) return
Error: {error.message}
; 19 | 20 | return ( 21 |
22 |

Category List

23 | {isLoading ? ( 24 |
Loading...
25 | ) : ( 26 |
    27 | {categories!.map((category) => ( 28 |
  • {category.name}
  • 29 | ))} 30 |
31 | )} 32 |
33 | ); 34 | } 35 | 36 | export default CategoryList; 37 | -------------------------------------------------------------------------------- /src/components/CategorySelect.tsx: -------------------------------------------------------------------------------- 1 | import { Select } from "@radix-ui/themes"; 2 | import axios from "axios"; 3 | import Skeleton from "react-loading-skeleton"; 4 | import { useQuery } from "react-query"; 5 | import { Category } from "../entities"; 6 | 7 | interface Props { 8 | onChange: (categoryId: number) => void; 9 | } 10 | 11 | const CategorySelect = ({ onChange }: Props) => { 12 | const { 13 | isLoading, 14 | error, 15 | data: categories, 16 | } = useQuery({ 17 | queryKey: ["categories"], 18 | queryFn: () => 19 | axios 20 | .get("/categories") 21 | .then((res) => res.data), 22 | }); 23 | 24 | if (isLoading) 25 | return ( 26 |
27 | 28 |
29 | ); 30 | 31 | if (error) return null; 32 | 33 | return ( 34 | 36 | onChange(parseInt(categoryId)) 37 | } 38 | > 39 | 40 | 41 | 42 | Category 43 | All 44 | {categories?.map((category) => ( 45 | 49 | {category.name} 50 | 51 | ))} 52 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default CategorySelect; 59 | -------------------------------------------------------------------------------- /src/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "@radix-ui/themes"; 2 | import { FieldError } from "react-hook-form"; 3 | 4 | const ErrorMessage = ({ error }: { error: FieldError | undefined }) => { 5 | if (!error) return null; 6 | 7 | return ( 8 | 9 | {error.message} 10 | 11 | ); 12 | }; 13 | 14 | export default ErrorMessage; 15 | -------------------------------------------------------------------------------- /src/components/ExpandableText.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | const ExpandableText = ({ text }: { text: string }) => { 4 | const limit = 255; 5 | const [isExpanded, setExpanded] = useState(false); 6 | 7 | if (text.length <= limit) return
{text}
; 8 | 9 | return ( 10 |
11 | {isExpanded ? ( 12 |
{text}
13 | ) : ( 14 |
{text.substring(0, limit)}...
15 | )} 16 | 19 |
20 | ); 21 | }; 22 | 23 | export default ExpandableText; 24 | -------------------------------------------------------------------------------- /src/components/Greet.tsx: -------------------------------------------------------------------------------- 1 | const Greet = ({ name }: { name?: string }) => { 2 | if (name) return

Hello {name}

; 3 | 4 | return ; 5 | }; 6 | 7 | export default Greet; 8 | -------------------------------------------------------------------------------- /src/components/Label.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "@radix-ui/themes"; 2 | import useLanguage from "../hooks/useLanguage"; 3 | 4 | const Label = ({ labelId }: { labelId: string }) => { 5 | const { getLabel } = useLanguage(); 6 | 7 | return {getLabel(labelId)}; 8 | }; 9 | 10 | export default Label; 11 | -------------------------------------------------------------------------------- /src/components/LanguageSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Select } from "@radix-ui/themes"; 2 | import useLanguage from "../hooks/useLanguage"; 3 | import { Language } from "../providers/language/type"; 4 | 5 | const LanguageSelector = () => { 6 | const { currentLanguage, changeLanguage } = useLanguage(); 7 | 8 | return ( 9 | changeLanguage(lang as Language)} 12 | > 13 | 14 | 15 | EN 16 | ES 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default LanguageSelector; 23 | -------------------------------------------------------------------------------- /src/components/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from "@auth0/auth0-react"; 2 | import { Button } from "@radix-ui/themes"; 3 | 4 | const LoginButton = () => { 5 | const { loginWithRedirect } = useAuth0(); 6 | 7 | return ; 8 | }; 9 | 10 | export default LoginButton; 11 | -------------------------------------------------------------------------------- /src/components/LogoutButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from "@auth0/auth0-react"; 2 | import { Button } from "@radix-ui/themes"; 3 | 4 | const LogoutButton = () => { 5 | const { logout } = useAuth0(); 6 | 7 | return ( 8 | 17 | ); 18 | }; 19 | 20 | export default LogoutButton; 21 | -------------------------------------------------------------------------------- /src/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, Flex, Text } from "@radix-ui/themes"; 2 | import { AiOutlineShoppingCart } from "react-icons/ai"; 3 | import { MdHome } from "react-icons/md"; 4 | import { Link, NavLink } from "react-router-dom"; 5 | import { useCart } from "../hooks/useCart"; 6 | import AuthStatus from "./AuthStatus"; 7 | import LanguageSelector from "./LanguageSelector"; 8 | 9 | const NavBar = () => { 10 | const { getItemCount } = useCart(); 11 | 12 | const links = [ 13 | { label: "Products", href: "/products" }, 14 | { label: "Playground", href: "/playground" }, 15 | { label: "Admin", href: "/admin" }, 16 | ]; 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
    27 | {links.map((link) => ( 28 |
  • 29 | 32 | isActive 33 | ? "text-zinc-800" 34 | : "text-zinc-700 hover:text-blue-500" 35 | } 36 | > 37 | {link.label} 38 | 39 |
  • 40 | ))} 41 |
42 |
43 | 44 | 45 | 46 | {getItemCount()} 47 | 48 | 49 | 50 | 51 |
52 | ); 53 | }; 54 | 55 | export default NavBar; 56 | -------------------------------------------------------------------------------- /src/components/Onboarding.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@radix-ui/themes"; 2 | import { useState } from "react"; 3 | 4 | function Onboarding() { 5 | const [isTutorialCompleted, setTutorialCompleted] = useState( 6 | localStorage.getItem("tutorialCompleted") === "true" 7 | ); 8 | 9 | const markTutorialCompleted = () => { 10 | localStorage.setItem("tutorialCompleted", "true"); 11 | setTutorialCompleted(true); 12 | }; 13 | 14 | return ( 15 |
16 | {isTutorialCompleted ? ( 17 |
18 |

Welcome back!

19 |

You've already completed the tutorial.

20 |
21 | ) : ( 22 |
23 |

Welcome to our app!

24 |

Complete the tutorial to get started.

25 | 28 |
29 | )} 30 |
31 | ); 32 | } 33 | 34 | export default Onboarding; 35 | -------------------------------------------------------------------------------- /src/components/OrderStatusSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Select } from "@radix-ui/themes"; 2 | 3 | interface Props { 4 | onChange: (status: string) => void; 5 | } 6 | 7 | const OrderStatusSelector = ({ onChange }: Props) => { 8 | return ( 9 | 10 | 11 | 12 | 13 | Status 14 | New 15 | Processed 16 | Fulfilled 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default OrderStatusSelector; 24 | -------------------------------------------------------------------------------- /src/components/PrivateRoutes.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from "@auth0/auth0-react"; 2 | import { Navigate, Outlet, useLocation } from "react-router-dom"; 3 | 4 | const PrivateRoutes = () => { 5 | const { pathname } = useLocation(); 6 | const { isLoading, isAuthenticated } = useAuth0(); 7 | // const isAuthenticated = true; 8 | // const isLoading = false; 9 | 10 | if (isLoading) return null; 11 | 12 | if (!isAuthenticated) return ; 13 | 14 | return ; 15 | }; 16 | 17 | export default PrivateRoutes; 18 | -------------------------------------------------------------------------------- /src/components/ProductDetail.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from "../entities"; 2 | import { useQuery } from "react-query"; 3 | import axios from "axios"; 4 | 5 | const ProductDetail = ({ productId }: { productId: number }) => { 6 | const { data: product, isLoading, error } = useQuery({ 7 | queryKey: ['products', productId], 8 | queryFn: () => axios.get('/products/' + productId).then(res => res.data) 9 | }) 10 | 11 | if (!productId) return
Invalid productId
; 12 | 13 | if (isLoading) return
Loading...
; 14 | 15 | if (error) return
Error: {error.message}
; 16 | 17 | if (!product) return
The given product was not found.
; 18 | 19 | return ( 20 |
21 |

Product Detail

22 |
Name: {product.name}
23 |
Price: ${product.price}
24 |
25 | ); 26 | }; 27 | 28 | export default ProductDetail; 29 | -------------------------------------------------------------------------------- /src/components/ProductForm.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import { Box, Button, Select, TextField } from "@radix-ui/themes"; 3 | import { useState } from "react"; 4 | import { Controller, useForm } from "react-hook-form"; 5 | import toast from "react-hot-toast"; 6 | import { Product } from "../entities"; 7 | import useCategories from "../hooks/useCategories"; 8 | import { 9 | ProductFormData, 10 | productFormSchema, 11 | } from "../validationSchemas/productSchema"; 12 | import ErrorMessage from "./ErrorMessage"; 13 | 14 | interface Props { 15 | product?: Product; 16 | onSubmit: (product: ProductFormData) => Promise; 17 | } 18 | 19 | const ProductForm = ({ product, onSubmit }: Props) => { 20 | const { data: categories, isLoading } = useCategories(); 21 | const [isSubmitting, setSubmitting] = useState(false); 22 | 23 | const { 24 | register, 25 | handleSubmit, 26 | control, 27 | formState: { errors }, 28 | } = useForm({ 29 | defaultValues: product, 30 | resolver: zodResolver(productFormSchema), 31 | }); 32 | 33 | if (isLoading) return
Loading...
; 34 | 35 | return ( 36 |
{ 39 | try { 40 | setSubmitting(true); 41 | await onSubmit(formData); 42 | } catch (error) { 43 | toast.error("An unexpected error occurred"); 44 | } finally { 45 | setSubmitting(false); 46 | } 47 | })} 48 | className="space-y-3" 49 | > 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | $ 59 | 65 | 66 | 67 | 68 | 69 | ( 73 | field.onChange(+value)} 77 | > 78 | 79 | 80 | 81 | {categories?.map((category) => ( 82 | 86 | {category.name} 87 | 88 | ))} 89 | 90 | 91 | 92 | )} 93 | /> 94 | 95 | 96 | 99 |
100 | ); 101 | }; 102 | 103 | export default ProductForm; 104 | -------------------------------------------------------------------------------- /src/components/ProductImageGallery.tsx: -------------------------------------------------------------------------------- 1 | const ProductImageGallery = ({ imageUrls }: { imageUrls: string[] }) => { 2 | if (imageUrls.length === 0) return null; 3 | 4 | return ( 5 |
    6 | {imageUrls.map((url) => ( 7 |
  • 8 | 9 |
  • 10 | ))} 11 |
12 | ); 13 | }; 14 | 15 | export default ProductImageGallery; 16 | -------------------------------------------------------------------------------- /src/components/ProductList.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useQuery } from "react-query"; 3 | import { Product } from "../entities"; 4 | 5 | const ProductList = () => { 6 | const { data: products, error, isLoading } = useQuery({ 7 | queryKey: ['products'], 8 | queryFn: () => axios.get('/products').then(res => res.data) 9 | }); 10 | 11 | if (isLoading) return
Loading...
; 12 | 13 | if (error) return
Error: {error.message}
; 14 | 15 | if (products!.length === 0) return

No products available.

; 16 | 17 | return ( 18 |
    19 | {products!.map((product) => ( 20 |
  • {product.name}
  • 21 | ))} 22 |
23 | ); 24 | }; 25 | 26 | export default ProductList; 27 | -------------------------------------------------------------------------------- /src/components/ProductTable.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from "@radix-ui/themes"; 2 | import axios from "axios"; 3 | import Skeleton from "react-loading-skeleton"; 4 | import { useQuery } from "react-query"; 5 | import { Product } from "../entities"; 6 | import QuantitySelector from "./QuantitySelector"; 7 | 8 | interface Props { 9 | selectedCategoryId?: number; 10 | } 11 | 12 | const ProductTable = ({ selectedCategoryId }: Props) => { 13 | const productsQuery = useQuery({ 14 | queryKey: ["products"], 15 | queryFn: () => 16 | axios.get("/products").then((res) => res.data), 17 | }); 18 | 19 | const skeletons = [1, 2, 3, 4, 5]; 20 | const { error, data: products, isLoading } = productsQuery; 21 | 22 | if (error) return
Error: {error.message}
; 23 | 24 | const visibleProducts = selectedCategoryId 25 | ? products!.filter( 26 | (p) => p.categoryId === selectedCategoryId 27 | ) 28 | : products; 29 | 30 | return ( 31 | 32 | 33 | 34 | Name 35 | Price 36 | 37 | 38 | 39 | 43 | {isLoading && 44 | skeletons.map((skeleton) => ( 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ))} 57 | {!isLoading && 58 | visibleProducts!.map((product) => ( 59 | 60 | {product.name} 61 | ${product.price} 62 | 63 | 64 | 65 | 66 | ))} 67 | 68 | 69 | ); 70 | }; 71 | 72 | export default ProductTable; 73 | -------------------------------------------------------------------------------- /src/components/QuantitySelector.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Text } from "@radix-ui/themes"; 2 | import { useCart } from "../hooks/useCart"; 3 | import { Product } from "../entities"; 4 | 5 | const QuantitySelector = ({ product }: { product: Product }) => { 6 | const { getItem, addToCart, removeFromCart } = useCart(); 7 | 8 | const cartItem = getItem(product); 9 | if (!cartItem) 10 | return ; 11 | 12 | return ( 13 | 14 | 15 | {cartItem.quantity} 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default QuantitySelector; 22 | -------------------------------------------------------------------------------- /src/components/SearchBox.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | interface Props { 4 | onChange: (text: string) => void; 5 | } 6 | 7 | const SearchBox = ({ onChange }: Props) => { 8 | const [searchTerm, setSearchTerm] = useState(""); 9 | 10 | return ( 11 |
12 | setSearchTerm(e.target.value)} 17 | onKeyDown={(e) => { 18 | if (e.key === "Enter" && searchTerm) onChange(searchTerm); 19 | }} 20 | /> 21 |
22 | ); 23 | }; 24 | 25 | export default SearchBox; 26 | -------------------------------------------------------------------------------- /src/components/TagList.tsx: -------------------------------------------------------------------------------- 1 | import delay from "delay"; 2 | import { useEffect, useState } from "react"; 3 | 4 | const TagList = () => { 5 | const [tags, setTags] = useState([]); 6 | 7 | useEffect(() => { 8 | const fetchTags = async () => { 9 | await delay(500); 10 | setTags(["tag1", "tag2", "tag3"]); 11 | }; 12 | fetchTags(); 13 | }); 14 | 15 | return ( 16 |
    17 | {tags.map((tag) => ( 18 |
  • {tag}
  • 19 | ))} 20 |
21 | ); 22 | }; 23 | 24 | export default TagList; 25 | -------------------------------------------------------------------------------- /src/components/TermsAndConditions.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | const TermsAndConditions = () => { 4 | const [isChecked, setIsChecked] = useState(false); 5 | 6 | return ( 7 |
8 |

Terms & Conditions

9 |

10 | Lorem ipsum dolor, sit amet consectetur adipisicing elit. Autem, 11 | delectus. 12 |

13 |
14 | 24 |
25 | 28 |
29 | ); 30 | }; 31 | 32 | export default TermsAndConditions; 33 | -------------------------------------------------------------------------------- /src/components/ToastDemo.tsx: -------------------------------------------------------------------------------- 1 | import toast from "react-hot-toast"; 2 | 3 | const ToastDemo = () => { 4 | return ( 5 | 8 | ); 9 | }; 10 | 11 | export default ToastDemo; 12 | -------------------------------------------------------------------------------- /src/components/UserAccount.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "../entities"; 2 | 3 | const UserAccount = ({ user }: { user: User }) => { 4 | return ( 5 | <> 6 |

User Profile

7 | {user.isAdmin && } 8 |
9 | Name: {user.name} 10 |
11 | 12 | ); 13 | }; 14 | 15 | export default UserAccount; 16 | -------------------------------------------------------------------------------- /src/components/UserList.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "../entities"; 2 | 3 | const UserList = ({ users }: { users: User[] }) => { 4 | if (users.length === 0) return

No users available.

; 5 | 6 | return ( 7 |
    8 | {users.map((user) => ( 9 |
  • 10 | {user.name} 11 |
  • 12 | ))} 13 |
14 | ); 15 | }; 16 | 17 | export default UserList; 18 | -------------------------------------------------------------------------------- /src/components/UserTable.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "../entities"; 2 | 3 | const UserTable = ({ users }: { users: User[] }) => { 4 | if (users.length === 0) return

No users available.

; 5 | 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {users.map((user) => ( 17 | 18 | 19 | 20 | 23 | 24 | ))} 25 | 26 |
IDName
{user.id}{user.name} 21 | Edit 22 |
27 | ); 28 | }; 29 | 30 | export default UserTable; 31 | -------------------------------------------------------------------------------- /src/data/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "categories": [ 3 | { 4 | "id": 1, 5 | "name": "Electronics" 6 | }, 7 | { 8 | "id": 2, 9 | "name": "Appliances" 10 | }, 11 | { 12 | "id": 3, 13 | "name": "Accessories" 14 | } 15 | ], 16 | "products": [ 17 | { 18 | "id": 1, 19 | "name": "Refined Concrete Soap", 20 | "price": 799, 21 | "categoryId": 1 22 | }, 23 | { 24 | "id": 2, 25 | "name": "Smartphone", 26 | "price": 499, 27 | "categoryId": 1 28 | }, 29 | { 30 | "id": 3, 31 | "name": "Headphones", 32 | "price": 149, 33 | "categoryId": 1 34 | }, 35 | { 36 | "id": 4, 37 | "name": "Tablet", 38 | "price": 349, 39 | "categoryId": 1 40 | }, 41 | { 42 | "id": 5, 43 | "name": "Smartwatch", 44 | "price": 199, 45 | "categoryId": 1 46 | }, 47 | { 48 | "id": 6, 49 | "name": "Camera", 50 | "price": 699, 51 | "categoryId": 1 52 | }, 53 | { 54 | "id": 7, 55 | "name": "Gaming Console", 56 | "price": 299, 57 | "categoryId": 1 58 | }, 59 | { 60 | "id": 8, 61 | "name": "Bluetooth Speaker", 62 | "price": 79, 63 | "categoryId": 1 64 | }, 65 | { 66 | "id": 9, 67 | "name": "Monitor", 68 | "price": 249, 69 | "categoryId": 1 70 | }, 71 | { 72 | "id": 10, 73 | "name": "Keyboard", 74 | "price": 49, 75 | "categoryId": 1 76 | }, 77 | { 78 | "id": 11, 79 | "name": "Wireless Earbuds", 80 | "price": 129, 81 | "categoryId": 1 82 | }, 83 | { 84 | "id": 12, 85 | "name": "Fitness Tracker", 86 | "price": 79, 87 | "categoryId": 1 88 | }, 89 | { 90 | "id": 13, 91 | "name": "External Hard Drive", 92 | "price": 149, 93 | "categoryId": 1 94 | }, 95 | { 96 | "id": 14, 97 | "name": "Coffee Maker", 98 | "price": 59, 99 | "categoryId": 2 100 | }, 101 | { 102 | "id": 15, 103 | "name": "Robot Vacuum Cleaner", 104 | "price": 299, 105 | "categoryId": 2 106 | }, 107 | { 108 | "id": 16, 109 | "name": "Electric Toothbrush", 110 | "price": 39, 111 | "categoryId": 2 112 | }, 113 | { 114 | "id": 17, 115 | "name": "Wireless Router", 116 | "price": 89, 117 | "categoryId": 1 118 | }, 119 | { 120 | "id": 18, 121 | "name": "Bluetooth Headset", 122 | "price": 59, 123 | "categoryId": 1 124 | }, 125 | { 126 | "id": 19, 127 | "name": "Printer", 128 | "price": 199, 129 | "categoryId": 1 130 | }, 131 | { 132 | "id": 20, 133 | "name": "Desk Chair", 134 | "price": 149, 135 | "categoryId": 2 136 | }, 137 | { 138 | "id": 21, 139 | "name": "Wireless Mouse", 140 | "price": 29, 141 | "categoryId": 1 142 | }, 143 | { 144 | "id": 22, 145 | "name": "Bluetooth Keyboard", 146 | "price": 49, 147 | "categoryId": 1 148 | }, 149 | { 150 | "id": 23, 151 | "name": "External SSD Drive", 152 | "price": 179, 153 | "categoryId": 1 154 | }, 155 | { 156 | "id": 24, 157 | "name": "Smart TV", 158 | "price": 499, 159 | "categoryId": 1 160 | }, 161 | { 162 | "id": 25, 163 | "name": "Air Purifier", 164 | "price": 149, 165 | "categoryId": 2 166 | }, 167 | { 168 | "id": 26, 169 | "name": "Gaming Mouse", 170 | "price": 69, 171 | "categoryId": 1 172 | }, 173 | { 174 | "id": 27, 175 | "name": "Wireless Charger", 176 | "price": 39, 177 | "categoryId": 1 178 | }, 179 | { 180 | "id": 28, 181 | "name": "Desk Lamp", 182 | "price": 29, 183 | "categoryId": 2 184 | }, 185 | { 186 | "id": 29, 187 | "name": "Bluetooth Speaker", 188 | "price": 79, 189 | "categoryId": 1 190 | }, 191 | { 192 | "id": 30, 193 | "name": "Coffee Grinder", 194 | "price": 39, 195 | "categoryId": 2 196 | }, 197 | { 198 | "id": 31, 199 | "name": "Product 1", 200 | "price": 11, 201 | "categoryId": 3 202 | }, 203 | { 204 | "name": "Product 1", 205 | "price": 10, 206 | "categoryId": 1, 207 | "id": 32 208 | }, 209 | { 210 | "name": "New Product", 211 | "price": 10, 212 | "categoryId": 1, 213 | "id": 33 214 | }, 215 | { 216 | "name": "Product 1", 217 | "price": 10, 218 | "categoryId": 1, 219 | "id": 34 220 | }, 221 | { 222 | "name": "Product 1", 223 | "price": 10, 224 | "categoryId": 1, 225 | "id": 35 226 | }, 227 | { 228 | "name": "Rustic Bronze Tuna", 229 | "price": 10, 230 | "categoryId": 1, 231 | "id": 36 232 | }, 233 | { 234 | "name": "Sleek Concrete Gloves", 235 | "price": 10, 236 | "categoryId": 1, 237 | "id": 37 238 | }, 239 | { 240 | "name": "Ergonomic Bronze Car", 241 | "price": 10, 242 | "categoryId": 1, 243 | "id": 38 244 | }, 245 | { 246 | "name": "Handmade Soft Gloves", 247 | "price": 10, 248 | "categoryId": 1, 249 | "id": 39 250 | }, 251 | { 252 | "name": "Licensed Frozen Computer", 253 | "price": 10, 254 | "categoryId": 1, 255 | "id": 40 256 | }, 257 | { 258 | "name": "Elegant Rubber Bike", 259 | "price": 10, 260 | "categoryId": 1, 261 | "id": 41 262 | }, 263 | { 264 | "name": "Handcrafted Wooden Cheese", 265 | "price": 10, 266 | "categoryId": 1, 267 | "id": 42 268 | }, 269 | { 270 | "name": "Handcrafted Wooden Hat", 271 | "price": 10, 272 | "categoryId": 1, 273 | "id": 43 274 | }, 275 | { 276 | "name": "Handcrafted Plastic Pants", 277 | "price": 10, 278 | "categoryId": 1, 279 | "id": 44 280 | }, 281 | { 282 | "name": "Intelligent Soft Sausages", 283 | "price": 10, 284 | "categoryId": 1, 285 | "id": 45 286 | }, 287 | { 288 | "name": "Modern Metal Ball", 289 | "price": 10, 290 | "categoryId": 1, 291 | "id": 46 292 | }, 293 | { 294 | "name": "Practical Rubber Sausages", 295 | "price": 10, 296 | "categoryId": 1, 297 | "id": 47 298 | }, 299 | { 300 | "name": "Recycled Steel Pants", 301 | "price": 10, 302 | "categoryId": 1, 303 | "id": 48 304 | }, 305 | { 306 | "name": "Ergonomic Cotton Soap", 307 | "price": 10, 308 | "categoryId": 1, 309 | "id": 49 310 | }, 311 | { 312 | "name": "Handcrafted Frozen Hat", 313 | "price": 10, 314 | "categoryId": 1, 315 | "id": 50 316 | }, 317 | { 318 | "name": "Generic Wooden Car", 319 | "price": 10, 320 | "categoryId": 1, 321 | "id": 51 322 | }, 323 | { 324 | "name": "Ergonomic Fresh Bike", 325 | "price": 10, 326 | "categoryId": 1, 327 | "id": 52 328 | }, 329 | { 330 | "name": "Elegant Cotton Pizza", 331 | "price": 10, 332 | "categoryId": 1, 333 | "id": 53 334 | }, 335 | { 336 | "name": "Bespoke Rubber Mouse", 337 | "price": 10, 338 | "categoryId": 1, 339 | "id": 54 340 | }, 341 | { 342 | "name": "Intelligent Fresh Shoes", 343 | "price": 10, 344 | "categoryId": 1, 345 | "id": 55 346 | }, 347 | { 348 | "name": "Oriental Metal Chips", 349 | "price": 10, 350 | "categoryId": 1, 351 | "id": 56 352 | }, 353 | { 354 | "name": "Generic Steel Chicken", 355 | "price": 10, 356 | "categoryId": 1, 357 | "id": 57 358 | }, 359 | { 360 | "name": "Luxurious Frozen Towels", 361 | "price": 10, 362 | "categoryId": 1, 363 | "id": 58 364 | }, 365 | { 366 | "name": "Generic Metal Shoes", 367 | "price": 10, 368 | "categoryId": 1, 369 | "id": 59 370 | }, 371 | { 372 | "name": "Practical Fresh Gloves", 373 | "price": 10, 374 | "categoryId": 1, 375 | "id": 60 376 | }, 377 | { 378 | "name": "Licensed Bronze Chair", 379 | "price": 10, 380 | "categoryId": 1, 381 | "id": 61 382 | }, 383 | { 384 | "name": "Licensed Bronze Keyboard", 385 | "price": 10, 386 | "categoryId": 1, 387 | "id": 62 388 | }, 389 | { 390 | "name": "Electronic Frozen Keyboard", 391 | "price": 10, 392 | "categoryId": 1, 393 | "id": 63 394 | } 395 | ] 396 | } -------------------------------------------------------------------------------- /src/entities.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: number; 3 | name: string; 4 | isAdmin?: boolean; 5 | }; 6 | 7 | export type Category = { 8 | id: number; 9 | name: string; 10 | }; 11 | 12 | export type Product = { 13 | id: number; 14 | name: string; 15 | price: number; 16 | categoryId: number; 17 | }; -------------------------------------------------------------------------------- /src/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | VITE_AUTH0_DOMAIN: string; 5 | VITE_AUTH0_CLIENTID: string; 6 | AUTH0_USERNAME: string; 7 | AUTH0_PASSWORD: string; 8 | } 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /src/hooks/useCart.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { CartContext } from "../providers/CartProvider"; 3 | 4 | export const useCart = () => { 5 | const context = useContext(CartContext); 6 | if (!context) throw new Error("useCart must be used within a CartProvider"); 7 | 8 | return context; 9 | }; 10 | -------------------------------------------------------------------------------- /src/hooks/useCategories.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useQuery } from "react-query"; 3 | import { Category } from "../entities"; 4 | 5 | const useCategories = () => { 6 | return useQuery({ 7 | queryKey: ["categories"], 8 | queryFn: () => axios.get("/categories").then((res) => res.data), 9 | }); 10 | }; 11 | 12 | export default useCategories; 13 | -------------------------------------------------------------------------------- /src/hooks/useLanguage.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import LanguageContext from "../providers/language/LanguageContext"; 3 | 4 | const useLanguage = () => { 5 | const context = useContext(LanguageContext); 6 | if (!context) 7 | throw new Error("useLanguage must be used within a LanguageProvider"); 8 | 9 | return context; 10 | }; 11 | 12 | export default useLanguage; 13 | -------------------------------------------------------------------------------- /src/hooks/useProduct.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from "axios"; 2 | import { useQuery } from "react-query"; 3 | import { Product } from "../entities"; 4 | 5 | const useProduct = (productId: number) => { 6 | return useQuery({ 7 | queryKey: ["products", productId], 8 | queryFn: () => fetchProduct(productId), 9 | cacheTime: 0, 10 | }); 11 | }; 12 | 13 | const fetchProduct = async (id: number) => { 14 | try { 15 | if (isNaN(id)) return null; 16 | const { data } = await axios.get(`/products/${id}`); 17 | return data; 18 | } catch (error) { 19 | if ( 20 | error instanceof AxiosError && 21 | error.response && 22 | error.response.status === 404 23 | ) 24 | return null; 25 | throw error; 26 | } 27 | }; 28 | 29 | export default useProduct; 30 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 7 | line-height: 1.5; 8 | font-weight: 400; 9 | 10 | color-scheme: light dark; 11 | color: rgba(255, 255, 255, 0.87); 12 | background-color: #242424; 13 | 14 | font-synthesis: none; 15 | text-rendering: optimizeLegibility; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | -webkit-text-size-adjust: 100%; 19 | } 20 | 21 | a { 22 | font-weight: 500; 23 | color: #646cff; 24 | text-decoration: inherit; 25 | } 26 | 27 | a:hover { 28 | color: #535bf2; 29 | } 30 | 31 | .btn { 32 | background: #646cff; 33 | color: #fff; 34 | padding: 0.2rem 1rem; 35 | border-radius: 5px; 36 | } 37 | 38 | .btn:disabled { 39 | background: #ccc; 40 | } 41 | 42 | .input { 43 | border: 1px solid #ccc; 44 | border-radius: 5px; 45 | padding: 0.3rem 1rem; 46 | } 47 | 48 | .input:focus { 49 | outline-color: #646cff; 50 | } 51 | 52 | @media (prefers-color-scheme: light) { 53 | :root { 54 | color: #213547; 55 | background-color: #ffffff; 56 | } 57 | a:hover { 58 | color: #747bff; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom/client"; 4 | import { RouterProvider, createBrowserRouter } from "react-router-dom"; 5 | import "./index.css"; 6 | import routes from "./routes"; 7 | 8 | axios.defaults.baseURL = "http://localhost:3000"; 9 | 10 | const router = createBrowserRouter(routes); 11 | 12 | ReactDOM.createRoot(document.getElementById("root")!).render( 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /src/pages/BrowseProductsPage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import "react-loading-skeleton/dist/skeleton.css"; 3 | import CategorySelect from "../components/CategorySelect"; 4 | import ProductTable from "../components/ProductTable"; 5 | 6 | function BrowseProducts() { 7 | const [selectedCategoryId, setSelectedCategoryId] = useState< 8 | number | undefined 9 | >(); 10 | 11 | return ( 12 |
13 |

Products

14 |
15 | 17 | setSelectedCategoryId(categoryId) 18 | } 19 | /> 20 |
21 | 22 |
23 | ); 24 | } 25 | 26 | export default BrowseProducts; 27 | -------------------------------------------------------------------------------- /src/pages/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import { isRouteErrorResponse, useRouteError } from "react-router-dom"; 2 | 3 | const ErrorPage = () => { 4 | const error = useRouteError(); 5 | const prod = import.meta.env.PROD; 6 | 7 | return ( 8 |
9 |
10 |

Oops...

11 | {isRouteErrorResponse(error) 12 | ? "The requested page was not found." 13 | : prod 14 | ? "An unexpected error occurred." 15 | : (error as Error).message} 16 |
17 |
18 | ); 19 | }; 20 | 21 | export default ErrorPage; 22 | -------------------------------------------------------------------------------- /src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import Label from "../components/Label"; 2 | 3 | const HomePage = () => { 4 | return ( 5 |
6 |

Home Page

7 |
9 | ); 10 | }; 11 | 12 | export default HomePage; 13 | -------------------------------------------------------------------------------- /src/pages/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from "react-hot-toast"; 2 | import { Outlet } from "react-router-dom"; 3 | import NavBar from "../components/NavBar"; 4 | 5 | const Layout = () => { 6 | return ( 7 | <> 8 | 9 |
10 | 11 | 12 |
13 | 14 | ); 15 | }; 16 | 17 | export default Layout; 18 | -------------------------------------------------------------------------------- /src/pages/PlaygroundPage.tsx: -------------------------------------------------------------------------------- 1 | import OrderStatusSelector from "../components/OrderStatusSelector"; 2 | import ProductForm from "../components/ProductForm"; 3 | import BrowseProducts from "./BrowseProductsPage"; 4 | 5 | const PlaygroundPage = () => { 6 | return ( 7 | 8 | ); 9 | }; 10 | 11 | export default PlaygroundPage; 12 | -------------------------------------------------------------------------------- /src/pages/ProductDetailPage.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | import useProduct from "../hooks/useProduct"; 3 | 4 | const ProductDetailPage = () => { 5 | const params = useParams(); 6 | const productId = parseInt(params.id!); 7 | const { data: product, isLoading, error } = useProduct(productId); 8 | 9 | if (isLoading) return
Loading...
; 10 | 11 | if (error) return
Error: {error.message}
; 12 | 13 | if (!product) return
The given product was not found.
; 14 | 15 | return ( 16 |
17 |

{product.name}

18 |

{"$" + product.price}

19 |
20 | ); 21 | }; 22 | 23 | export default ProductDetailPage; 24 | -------------------------------------------------------------------------------- /src/pages/ProductListPage.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from "@radix-ui/themes"; 2 | import axios from "axios"; 3 | import { useQuery } from "react-query"; 4 | import { Link } from "react-router-dom"; 5 | import QuantitySelector from "../components/QuantitySelector"; 6 | import { Product } from "../entities"; 7 | 8 | function ProductListPage() { 9 | const { data: products, isLoading, error } = useProducts(); 10 | 11 | const renderProducts = () => { 12 | if (isLoading) return
Loading...
; 13 | 14 | if (error) return
Error: {error.message}
; 15 | 16 | if (products!.length === 0) return

No product was found!

; 17 | 18 | return ( 19 | 20 | 21 | 22 | Name 23 | Price 24 | 25 | 26 | 27 | 28 | {products!.map((product) => ( 29 | 30 | 31 | {product.name} 32 | 33 | ${product.price} 34 | 35 | 36 | 37 | 38 | ))} 39 | 40 | 41 | ); 42 | }; 43 | 44 | return ( 45 |
46 |

Products

47 | {renderProducts()} 48 |
49 | ); 50 | } 51 | 52 | const useProducts = () => 53 | useQuery({ 54 | queryKey: ["products"], 55 | queryFn: () => axios.get("/products").then((res) => res.data), 56 | }); 57 | 58 | export default ProductListPage; 59 | -------------------------------------------------------------------------------- /src/pages/admin/AdminHomePage.tsx: -------------------------------------------------------------------------------- 1 | import { withAuthenticationRequired } from "@auth0/auth0-react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | const AdminHomePage = withAuthenticationRequired(() => { 5 | return ( 6 |
7 |

Admin Area

8 | Products 9 |
10 | ); 11 | }); 12 | 13 | export default AdminHomePage; 14 | -------------------------------------------------------------------------------- /src/pages/admin/AdminLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router"; 2 | 3 | const AdminLayout = () => { 4 | return ; 5 | }; 6 | 7 | export default AdminLayout; 8 | -------------------------------------------------------------------------------- /src/pages/admin/EditProductPage.tsx: -------------------------------------------------------------------------------- 1 | import { withAuthenticationRequired } from "@auth0/auth0-react"; 2 | import { Heading } from "@radix-ui/themes"; 3 | import axios from "axios"; 4 | import toast from "react-hot-toast"; 5 | import { useNavigate, useParams } from "react-router-dom"; 6 | import ProductForm from "../../components/ProductForm"; 7 | import useProduct from "../../hooks/useProduct"; 8 | 9 | const EditProductPage = withAuthenticationRequired(() => { 10 | const navigate = useNavigate(); 11 | const params = useParams(); 12 | const productId = parseInt(params.id!); 13 | const { data: product, isLoading, error } = useProduct(productId); 14 | 15 | if (isLoading) return
Loading...
; 16 | 17 | if (error) return
Error: {error.message}
; 18 | 19 | if (!product) return
The given product was not found.
; 20 | 21 | return ( 22 |
23 | Edit Product 24 | { 27 | await axios.put("/products/" + productId, product); 28 | toast.success("Changes were successfully saved."); 29 | navigate("/admin/products"); 30 | }} 31 | /> 32 |
33 | ); 34 | }); 35 | 36 | export default EditProductPage; 37 | -------------------------------------------------------------------------------- /src/pages/admin/NewProductPage.tsx: -------------------------------------------------------------------------------- 1 | import { withAuthenticationRequired } from "@auth0/auth0-react"; 2 | import { Heading } from "@radix-ui/themes"; 3 | import axios from "axios"; 4 | import { useNavigate } from "react-router-dom"; 5 | import ProductForm from "../../components/ProductForm"; 6 | 7 | const NewProductPage = withAuthenticationRequired( 8 | () => { 9 | const navigate = useNavigate(); 10 | 11 | return ( 12 |
13 | New Product 14 | { 16 | await axios.post("/products", product); 17 | navigate("/admin/products"); 18 | }} 19 | /> 20 |
21 | ); 22 | }, 23 | { 24 | onRedirecting: () =>

Loading auth...

, 25 | } 26 | ); 27 | 28 | export default NewProductPage; 29 | -------------------------------------------------------------------------------- /src/pages/admin/ProductListPage.tsx: -------------------------------------------------------------------------------- 1 | import { withAuthenticationRequired } from "@auth0/auth0-react"; 2 | import { Button, Table } from "@radix-ui/themes"; 3 | import axios from "axios"; 4 | import { useQuery } from "react-query"; 5 | import { Link } from "react-router-dom"; 6 | import { Product } from "../../entities"; 7 | 8 | const ProductListPage = withAuthenticationRequired(() => { 9 | const { data: products, isLoading, error } = useProducts(); 10 | 11 | const renderProducts = () => { 12 | if (isLoading) return
Loading...
; 13 | 14 | if (error) return
Error: {error.message}
; 15 | 16 | if (products!.length === 0) return

No product was found!

; 17 | 18 | return ( 19 | 20 | 21 | 22 | Name 23 | 24 | 25 | 26 | 27 | {products!.map((product) => ( 28 | 29 | {product.name} 30 | ${product.price} 31 | 32 | Edit 33 | 34 | 35 | ))} 36 | 37 | 38 | ); 39 | }; 40 | 41 | return ( 42 |
43 |

Products

44 | 45 | 46 | 47 | {renderProducts()} 48 |
49 | ); 50 | }); 51 | 52 | const useProducts = () => 53 | useQuery({ 54 | queryKey: ["products"], 55 | queryFn: () => axios.get("/products").then((res) => res.data), 56 | }); 57 | 58 | export default ProductListPage; 59 | -------------------------------------------------------------------------------- /src/providers/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Auth0Provider } from "@auth0/auth0-react"; 2 | import { PropsWithChildren } from "react"; 3 | import { useNavigate } from "react-router-dom"; 4 | 5 | const AuthProvider = ({ children }: PropsWithChildren) => { 6 | const navigate = useNavigate(); 7 | 8 | const domain = import.meta.env.VITE_AUTH0_DOMAIN; 9 | const clientId = import.meta.env.VITE_AUTH0_CLIENTID; 10 | 11 | if (!domain || !clientId) { 12 | throw new Error( 13 | "Auth0 is not configured. Follow the instruction on README.md." 14 | ); 15 | } 16 | 17 | return ( 18 | { 25 | navigate(appState?.returnTo || window.location.pathname); 26 | }} 27 | > 28 | {children} 29 | 30 | ); 31 | }; 32 | 33 | export default AuthProvider; 34 | -------------------------------------------------------------------------------- /src/providers/CartProvider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, createContext, useState } from "react"; 2 | import { Product } from "../entities"; 3 | 4 | type CartItem = { 5 | product: Product; 6 | quantity: number; 7 | }; 8 | 9 | type CartContextType = { 10 | getItem: (product: Product) => CartItem | null; 11 | addToCart: (product: Product) => void; 12 | removeFromCart: (product: Product) => void; 13 | getItemCount: () => number; 14 | }; 15 | 16 | export const CartContext = createContext( 17 | {} as CartContextType 18 | ); 19 | 20 | export function CartProvider({ children }: PropsWithChildren) { 21 | const [cartItems, setCartItems] = useState([]); 22 | 23 | const getItem = (product: Product) => { 24 | const index = cartItems.findIndex((item) => item.product.id === product.id); 25 | return index !== -1 ? cartItems[index] : null; 26 | }; 27 | 28 | const addToCart = (product: Product) => { 29 | const item = getItem(product); 30 | 31 | if (item) { 32 | // If the product is already in the cart, update its quantity 33 | setCartItems( 34 | cartItems.map((item) => 35 | item.product.id === product.id 36 | ? { ...item, quantity: item.quantity + 1 } 37 | : item 38 | ) 39 | ); 40 | } else { 41 | // If the product is not in the cart, add it with a quantity of 1 42 | setCartItems([...cartItems, { product, quantity: 1 }]); 43 | } 44 | }; 45 | 46 | const removeFromCart = (product: Product) => { 47 | const item = getItem(product); 48 | if (!item) return; 49 | 50 | if (item.quantity > 1) { 51 | setCartItems( 52 | cartItems.map((item) => 53 | item.product.id === product.id 54 | ? { ...item, quantity: item.quantity - 1 } 55 | : item 56 | ) 57 | ); 58 | } else 59 | setCartItems(cartItems.filter((item) => item.product.id !== product.id)); 60 | }; 61 | 62 | const getItemCount = () => 63 | cartItems.reduce((total, product) => total + product.quantity, 0); 64 | 65 | return ( 66 | 69 | {children} 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/providers/ReactQueryProvider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useState } from "react"; 2 | import { QueryClient, QueryClientProvider } from "react-query"; 3 | 4 | const ReactQueryProvider = ({ children }: PropsWithChildren) => { 5 | const [queryClient] = useState(() => new QueryClient()); 6 | 7 | return ( 8 | {children} 9 | ); 10 | }; 11 | 12 | export default ReactQueryProvider; 13 | -------------------------------------------------------------------------------- /src/providers/index.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from "@radix-ui/themes"; 2 | import "@radix-ui/themes/styles.css"; 3 | import { PropsWithChildren } from "react"; 4 | import AuthProvider from "./AuthProvider"; 5 | import { CartProvider } from "./CartProvider"; 6 | import ReactQueryProvider from "./ReactQueryProvider"; 7 | import { LanguageProvider } from "./language/LanguageProvider"; 8 | 9 | const Providers = ({ children }: PropsWithChildren) => { 10 | return ( 11 | 12 | 13 | 14 | 15 | {children} 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default Providers; 24 | -------------------------------------------------------------------------------- /src/providers/language/LanguageContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { Language } from "./type"; 3 | 4 | type LanguageContextType = { 5 | currentLanguage: Language; 6 | changeLanguage: (language: Language) => void; 7 | getLabel: (labelId: string) => string; 8 | }; 9 | 10 | const LanguageContext = createContext( 11 | {} as LanguageContextType 12 | ); 13 | 14 | export default LanguageContext; 15 | -------------------------------------------------------------------------------- /src/providers/language/LanguageProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from "react"; 2 | 3 | import LanguageContext from "./LanguageContext"; 4 | import en from "./data/en.json"; 5 | import es from "./data/es.json"; 6 | import { Language } from "./type"; 7 | 8 | interface Props { 9 | language: Language; 10 | children: ReactNode; 11 | } 12 | 13 | export function LanguageProvider({ language, children }: Props) { 14 | const [currentLanguage, setCurrentLanguage] = useState(language); 15 | 16 | const changeLanguage = (language: Language) => setCurrentLanguage(language); 17 | 18 | const labelsDictionary: { [key: string]: { [key: string]: string } } = { 19 | en, 20 | es, 21 | }; 22 | 23 | const getLabel = (labelId: string) => { 24 | const label = labelsDictionary[currentLanguage][labelId]; 25 | if (!label) 26 | throw new Error( 27 | `LabelID ${labelId} not found in ${currentLanguage}.json` 28 | ); 29 | return label; 30 | }; 31 | 32 | return ( 33 | 36 | {children} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/providers/language/data/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "welcome": "Welcome", 3 | "new_product": "New Product", 4 | "edit_product": "Edit Product" 5 | } -------------------------------------------------------------------------------- /src/providers/language/data/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "welcome": "Bienvenidos", 3 | "new_product": "Nuevo Producto", 4 | "edit_product": "Editar Producto" 5 | } 6 | -------------------------------------------------------------------------------- /src/providers/language/type.ts: -------------------------------------------------------------------------------- 1 | export type Language = "en" | "es"; 2 | -------------------------------------------------------------------------------- /src/routes.tsx: -------------------------------------------------------------------------------- 1 | import { RouteObject } from "react-router-dom"; 2 | import App from "./App.tsx"; 3 | import ErrorPage from "./pages/ErrorPage.tsx"; 4 | import HomePage from "./pages/HomePage.tsx"; 5 | import PlaygroundPage from "./pages/PlaygroundPage.tsx"; 6 | import ProductDetailPage from "./pages/ProductDetailPage.tsx"; 7 | import ProductListPage from "./pages/ProductListPage.tsx"; 8 | import AdminHomePage from "./pages/admin/AdminHomePage.tsx"; 9 | import AdminLayout from "./pages/admin/AdminLayout.tsx"; 10 | import EditProductPage from "./pages/admin/EditProductPage.tsx"; 11 | import NewProductPage from "./pages/admin/NewProductPage.tsx"; 12 | import AdminProductListPage from "./pages/admin/ProductListPage.tsx"; 13 | 14 | const routes: RouteObject[] = [ 15 | { 16 | path: "/", 17 | element: , 18 | errorElement: , 19 | children: [ 20 | { index: true, element: }, 21 | { path: "playground", element: }, 22 | { path: "products", element: }, 23 | { path: "products/:id", element: }, 24 | { 25 | path: "admin", 26 | element: , 27 | children: [ 28 | { index: true, element: }, 29 | { path: "products", element: }, 30 | { path: "products/new", element: }, 31 | { path: "products/:id/edit", element: }, 32 | ], 33 | }, 34 | ], 35 | }, 36 | ]; 37 | 38 | export default routes; 39 | -------------------------------------------------------------------------------- /src/validationSchemas/productSchema.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | 3 | export const productFormSchema = z.object({ 4 | id: z.number().optional(), 5 | name: z.string().min(1, "Name is required").max(255), 6 | price: z.coerce 7 | .number({ 8 | required_error: "Price is required", 9 | invalid_type_error: "Price is required", 10 | }) 11 | .min(1) 12 | .max(1000), 13 | categoryId: z.number({ 14 | required_error: "Category is required", 15 | }), 16 | }); 17 | 18 | export type ProductFormData = z.infer; 19 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [ 8 | require('@tailwindcss/typography'), 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /tests/AllProviders.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react"; 2 | import { QueryClient, QueryClientProvider } from "react-query"; 3 | import { CartProvider } from "../src/providers/CartProvider"; 4 | import { Theme } from "@radix-ui/themes"; 5 | 6 | const AllProviders = ({ children }: PropsWithChildren) => { 7 | const client = new QueryClient({ 8 | defaultOptions: { 9 | queries: { 10 | retry: false 11 | } 12 | } 13 | }); 14 | 15 | return ( 16 | 17 | 18 | 19 | {children} 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export default AllProviders; -------------------------------------------------------------------------------- /tests/Router.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen } from '@testing-library/react' 2 | import { navigateTo } from './utils' 3 | import { db } from './mocks/db'; 4 | 5 | describe('Router', () => { 6 | it('should render the home page for /', () => { 7 | navigateTo('/'); 8 | 9 | expect(screen.getByRole('heading', { name: /home/i })).toBeInTheDocument(); 10 | }) 11 | 12 | it('should render the products page for /products', () => { 13 | navigateTo('/products'); 14 | 15 | expect(screen.getByRole('heading', { name: /products/i })).toBeInTheDocument(); 16 | }) 17 | 18 | it('should render the product details page for /products/:id', async () => { 19 | const product = db.product.create(); 20 | 21 | navigateTo('/products/' + product.id); 22 | 23 | expect(await screen.findByRole('heading', { name: product.name })).toBeInTheDocument(); 24 | 25 | db.product.delete({ where: { id: { equals: product.id }}}); 26 | }); 27 | 28 | it('should render the not found page for invalid routes', () => { 29 | navigateTo('/invalid-route'); 30 | 31 | expect(screen.getByText(/not found/i)).toBeInTheDocument(); 32 | }) 33 | 34 | it('should render the admin home page for /admin', () => { 35 | navigateTo('/admin'); 36 | 37 | expect(screen.getByRole('heading', { name: /admin/i })).toBeInTheDocument(); 38 | }) 39 | }) -------------------------------------------------------------------------------- /tests/components/AuthStatus.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import AuthStatus from "../../src/components/AuthStatus"; 3 | import { mockAuthState } from "../utils"; 4 | 5 | describe("AuthStatus", () => { 6 | it("should render the loading message while fetching the auth status", () => { 7 | mockAuthState({ 8 | isLoading: true, 9 | isAuthenticated: false, 10 | user: undefined, 11 | }); 12 | 13 | render(); 14 | 15 | expect(screen.getByText(/loading/i)).toBeInTheDocument(); 16 | }); 17 | 18 | it("should render the login button if the user is not authenticated", () => { 19 | mockAuthState({ 20 | isLoading: false, 21 | isAuthenticated: false, 22 | user: undefined, 23 | }); 24 | 25 | render(); 26 | 27 | expect( 28 | screen.getByRole("button", { name: /log in/i }) 29 | ).toBeInTheDocument(); 30 | expect( 31 | screen.queryByRole("button", { name: /log out/i }) 32 | ).not.toBeInTheDocument(); 33 | }); 34 | 35 | it("should render the user name if authenticated", () => { 36 | mockAuthState({ 37 | isLoading: false, 38 | isAuthenticated: true, 39 | user: { name: "Mosh" }, 40 | }); 41 | 42 | render(); 43 | 44 | expect(screen.getByText(/mosh/i)).toBeInTheDocument(); 45 | expect( 46 | screen.getByRole("button", { name: /log out/i }) 47 | ).toBeInTheDocument(); 48 | expect( 49 | screen.queryByRole("button", { name: /log in/i }) 50 | ).not.toBeInTheDocument(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/components/CategoryList.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | render, 3 | screen, 4 | waitForElementToBeRemoved, 5 | } from "@testing-library/react"; 6 | import CategoryList from "../../src/components/CategoryList"; 7 | import { Category } from "../../src/entities"; 8 | import AllProviders from "../AllProviders"; 9 | import { db } from "../mocks/db"; 10 | import { simulateDelay, simulateError } from "../utils"; 11 | 12 | describe("CategoryList", () => { 13 | const categories: Category[] = []; 14 | 15 | beforeAll(() => { 16 | [1, 2].forEach((item) => { 17 | const category = db.category.create({ 18 | name: "Category " + item, 19 | }); 20 | categories.push(category); 21 | }); 22 | }); 23 | 24 | afterAll(() => { 25 | const categoryIds = categories.map((c) => c.id); 26 | db.category.deleteMany({ 27 | where: { id: { in: categoryIds } }, 28 | }); 29 | }); 30 | 31 | const renderComponent = () => { 32 | render(, { wrapper: AllProviders }); 33 | }; 34 | 35 | it("should render a list of categories", async () => { 36 | renderComponent(); 37 | 38 | await waitForElementToBeRemoved(() => 39 | screen.queryByText(/loading/i) 40 | ); 41 | 42 | categories.forEach((category) => { 43 | expect( 44 | screen.getByText(category.name) 45 | ).toBeInTheDocument(); 46 | }); 47 | }); 48 | 49 | it("should render a loading message when fetching categories", () => { 50 | simulateDelay("/categories"); 51 | 52 | renderComponent(); 53 | 54 | expect(screen.getByText(/loading/i)).toBeInTheDocument(); 55 | }); 56 | 57 | it("should render an error message if fetching categories fails", async () => { 58 | simulateError("/categories"); 59 | 60 | renderComponent(); 61 | 62 | expect( 63 | await screen.findByText(/error/i) 64 | ).toBeInTheDocument(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/components/ExpandableText.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import ExpandableText from '../../src/components/ExpandableText'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | describe('ExpandableText', () => { 6 | const limit = 255; 7 | const longText = 'a'.repeat(limit + 1); 8 | const truncatedText = longText.substring(0, limit) + '...'; 9 | 10 | it('should render the full text if less than 255 characters', () => { 11 | const text = "Short text"; 12 | 13 | render(); 14 | 15 | expect(screen.getByText(text)).toBeInTheDocument(); 16 | }); 17 | 18 | it('should truncate text if longer than 255 characters', () => { 19 | render(); 20 | 21 | expect(screen.getByText(truncatedText)).toBeInTheDocument(); 22 | const button = screen.getByRole('button'); 23 | expect(button).toHaveTextContent(/more/i); 24 | }); 25 | 26 | it('should expand text when Show More button is clicked', async () => { 27 | render(); 28 | 29 | const button = screen.getByRole('button'); 30 | const user = userEvent.setup(); 31 | await user.click(button); 32 | 33 | expect(screen.getByText(longText)).toBeInTheDocument(); 34 | expect(button).toHaveTextContent(/less/i); 35 | }); 36 | 37 | it('should collapse text when Show Less button is clicked', async () => { 38 | render(); 39 | const showMoreButton = screen.getByRole('button', { name: /more/i }); 40 | const user = userEvent.setup(); 41 | await user.click(showMoreButton); 42 | 43 | const showLessButton = screen.getByRole('button', { name: /less/i }); 44 | await user.click(showLessButton); 45 | 46 | expect(screen.getByText(truncatedText)).toBeInTheDocument(); 47 | expect(showMoreButton).toHaveTextContent(/more/i); 48 | }); 49 | }) -------------------------------------------------------------------------------- /tests/components/Greet.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import Greet from "../../src/components/Greet"; 3 | 4 | describe("Greet", () => { 5 | it("should render Hello with the name when name is provided", () => { 6 | render(); 7 | 8 | const heading = screen.getByRole("heading"); 9 | expect(heading).toBeInTheDocument(); 10 | expect(heading).toHaveTextContent(/mosh/i); 11 | }); 12 | 13 | it("should render login button when name is not provided", () => { 14 | render(); 15 | 16 | const button = screen.getByRole("button"); 17 | expect(button).toBeInTheDocument(); 18 | expect(button).toHaveTextContent(/login/i); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /tests/components/Label.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import Label from '../../src/components/Label' 3 | import { LanguageProvider } from '../../src/providers/language/LanguageProvider' 4 | import { Language } from '../../src/providers/language/type' 5 | 6 | describe('Label', () => { 7 | const renderComponent = (labelId: string, language: Language) => { 8 | render( 9 | 10 | 12 | ); 13 | } 14 | 15 | describe('Given the current language is EN', () => { 16 | it.each([ 17 | { labelId: 'welcome', text: 'Welcome' }, 18 | { labelId: 'new_product', text: 'New Product' }, 19 | { labelId: 'edit_product', text: 'Edit Product' }, 20 | ])('should render $text for $labelId', ({ labelId, text }) => { 21 | renderComponent(labelId, 'en'); 22 | 23 | expect(screen.getByText(text)).toBeInTheDocument(); 24 | }) 25 | }) 26 | 27 | describe('Given the current language is ES', () => { 28 | it.each([ 29 | { labelId: 'welcome', text: 'Bienvenidos' }, 30 | { labelId: 'new_product', text: 'Nuevo Producto' }, 31 | { labelId: 'edit_product', text: 'Editar Producto' }, 32 | ])('should render $text for $labelId', ({ labelId, text }) => { 33 | renderComponent(labelId, 'es'); 34 | 35 | expect(screen.getByText(text)).toBeInTheDocument(); 36 | }) 37 | }) 38 | 39 | it('should throw an error if given an invalid labelId', () => { 40 | expect(() => renderComponent('!', 'en')).toThrowError(); 41 | }) 42 | 43 | }) -------------------------------------------------------------------------------- /tests/components/OrderStatusSelector.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import OrderStatusSelector from "../../src/components/OrderStatusSelector"; 3 | import { Theme } from "@radix-ui/themes"; 4 | import userEvent from "@testing-library/user-event"; 5 | 6 | describe("OrderStatusSelector", () => { 7 | const renderComponent = () => { 8 | const onChange = vi.fn(); 9 | 10 | render( 11 | 12 | 13 | 14 | ); 15 | 16 | return { 17 | trigger: screen.getByRole("combobox"), 18 | getOptions: () => screen.findAllByRole("option"), 19 | getOption: (label: RegExp) => screen.findByRole('option', { name: label }), 20 | user: userEvent.setup(), 21 | onChange 22 | } 23 | } 24 | 25 | it("should render New as the default value", () => { 26 | const {trigger} = renderComponent(); 27 | 28 | expect(trigger).toHaveTextContent(/new/i); 29 | }); 30 | 31 | it("should render correct statuses", async () => { 32 | const {trigger, getOptions, user} = renderComponent(); 33 | 34 | await user.click(trigger); 35 | 36 | const options = await getOptions(); 37 | expect(options).toHaveLength(3); 38 | const labels = options.map((option) => option.textContent); 39 | expect(labels).toEqual(["New", "Processed", "Fulfilled"]); 40 | }); 41 | 42 | it.each([ 43 | { label: /processed/i, value: 'processed' }, 44 | { label: /fulfilled/i, value: 'fulfilled' }, 45 | ])('should call onChange with $value when the $label option is selected', async ({ label, value }) => { 46 | const {trigger, user, onChange, getOption} = renderComponent(); 47 | await user.click(trigger); 48 | 49 | const option = await getOption(label); 50 | await user.click(option); 51 | 52 | expect(onChange).toHaveBeenCalledWith(value); 53 | }); 54 | 55 | it("should call onChange with 'new' when the New option is selected", async () => { 56 | const {trigger, user, onChange, getOption} = renderComponent(); 57 | await user.click(trigger); 58 | 59 | const processedOption = await getOption(/processed/i); 60 | await user.click(processedOption); 61 | 62 | await user.click(trigger); 63 | const newOption = await getOption(/new/i); 64 | await user.click(newOption); 65 | 66 | expect(onChange).toHaveBeenCalledWith('new'); 67 | }) 68 | }); 69 | -------------------------------------------------------------------------------- /tests/components/ProductDetail.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitForElementToBeRemoved } from '@testing-library/react' 2 | import ProductDetail from '../../src/components/ProductDetail' 3 | import { server } from '../mocks/server'; 4 | import { http, HttpResponse, delay } from 'msw'; 5 | import { db } from '../mocks/db'; 6 | import AllProviders from '../AllProviders'; 7 | 8 | describe('ProductDetail', () => { 9 | let productId: number; 10 | 11 | beforeAll(() => { 12 | const product = db.product.create(); 13 | productId = product.id; 14 | }) 15 | 16 | afterAll(() => { 17 | db.product.delete({ where: { id: { equals: productId }}}) 18 | }) 19 | 20 | it('should render product details', async () => { 21 | const product = db.product.findFirst({ where: { id: { equals: productId }}}) 22 | 23 | render(, { wrapper: AllProviders }); 24 | 25 | expect(await screen.findByText(new RegExp(product!.name))).toBeInTheDocument(); 26 | expect(await screen.findByText(new RegExp(product!.price.toString()))).toBeInTheDocument(); 27 | }); 28 | 29 | it('should render message if product not found', async () => { 30 | server.use(http.get('/products/1', () => HttpResponse.json(null))); 31 | 32 | render(, { wrapper: AllProviders }); 33 | 34 | const message = await screen.findByText(/not found/i); 35 | expect(message).toBeInTheDocument(); 36 | }) 37 | 38 | it('should render an error for invalid productId', async () => { 39 | render(, { wrapper: AllProviders }); 40 | 41 | const message = await screen.findByText(/invalid/i); 42 | expect(message).toBeInTheDocument(); 43 | }) 44 | 45 | it('should render an error if data fetching fails', async () => { 46 | server.use(http.get('/products/1', () => HttpResponse.error())); 47 | 48 | render(, { wrapper: AllProviders }); 49 | 50 | expect(await screen.findByText(/error/i)).toBeInTheDocument(); 51 | }); 52 | 53 | it('should render a loading indicator when fetching data', async () => { 54 | server.use(http.get('/products/1', async () => { 55 | await delay(); 56 | return HttpResponse.json([]) 57 | })); 58 | 59 | render(, { wrapper: AllProviders }); 60 | 61 | expect(await screen.findByText(/loading/i)).toBeInTheDocument(); 62 | }); 63 | 64 | it('should remove the loading indicator after data is fetched', async () => { 65 | render(, { wrapper: AllProviders }); 66 | 67 | await waitForElementToBeRemoved(() => screen.queryByText(/loading/i)); 68 | }) 69 | 70 | it('should remove the loading indicator if data fetching fails', async () => { 71 | server.use(http.get('/products', () => HttpResponse.error())); 72 | 73 | render(, { wrapper: AllProviders }); 74 | 75 | await waitForElementToBeRemoved(() => screen.queryByText(/loading/i)); 76 | }) 77 | }) -------------------------------------------------------------------------------- /tests/components/ProductForm.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 4 | import { render, screen } from "@testing-library/react"; 5 | import userEvent from "@testing-library/user-event"; 6 | import ProductForm from "../../src/components/ProductForm"; 7 | import { Category, Product } from "../../src/entities"; 8 | import AllProviders from "../AllProviders"; 9 | import { db } from "../mocks/db"; 10 | import { Toaster } from "react-hot-toast"; 11 | 12 | describe("ProductForm", () => { 13 | let category: Category; 14 | 15 | beforeAll(() => { 16 | category = db.category.create(); 17 | }); 18 | 19 | afterAll(() => { 20 | db.category.delete({ 21 | where: { id: { equals: category.id } }, 22 | }); 23 | }); 24 | 25 | const renderComponent = (product?: Product) => { 26 | const onSubmit = vi.fn(); 27 | 28 | render( 29 | <> 30 | 31 | 32 | , 33 | { 34 | wrapper: AllProviders, 35 | } 36 | ); 37 | 38 | return { 39 | onSubmit, 40 | 41 | expectErrorToBeInTheDocument: (errorMessage: RegExp) => { 42 | const error = screen.getByRole("alert"); 43 | expect(error).toBeInTheDocument(); 44 | expect(error).toHaveTextContent(errorMessage); 45 | }, 46 | 47 | waitForFormToLoad: async () => { 48 | await screen.findByRole("form"); 49 | 50 | const nameInput = screen.getByPlaceholderText(/name/i); 51 | const priceInput = screen.getByPlaceholderText(/price/i); 52 | const categoryInput = screen.getByRole("combobox", { 53 | name: /category/i, 54 | }); 55 | const submitButton = screen.getByRole("button"); 56 | 57 | type FormData = { 58 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 59 | [K in keyof Product]: any; 60 | }; 61 | 62 | const validData: FormData = { 63 | id: 1, 64 | name: "a", 65 | price: 1, 66 | categoryId: category.id, 67 | }; 68 | 69 | const fill = async (product: FormData) => { 70 | const user = userEvent.setup(); 71 | 72 | if (product.name !== undefined) 73 | await user.type(nameInput, product.name); 74 | 75 | if (product.price !== undefined) 76 | await user.type( 77 | priceInput, 78 | product.price.toString() 79 | ); 80 | 81 | await user.tab(); 82 | await user.click(categoryInput); 83 | const options = screen.getAllByRole("option"); 84 | await user.click(options[0]); 85 | await user.click(submitButton); 86 | }; 87 | 88 | return { 89 | nameInput, 90 | priceInput, 91 | categoryInput, 92 | submitButton, 93 | fill, 94 | validData, 95 | }; 96 | }, 97 | }; 98 | }; 99 | 100 | it("should render form fields", async () => { 101 | const { waitForFormToLoad } = renderComponent(); 102 | 103 | const { nameInput, priceInput, categoryInput } = 104 | await waitForFormToLoad(); 105 | 106 | expect(nameInput).toBeInTheDocument(); 107 | expect(priceInput).toBeInTheDocument(); 108 | expect(categoryInput).toBeInTheDocument(); 109 | }); 110 | 111 | it("should populate form fields when editing a product", async () => { 112 | const product: Product = { 113 | id: 1, 114 | name: "Bread", 115 | price: 10, 116 | categoryId: category.id, 117 | }; 118 | 119 | const { waitForFormToLoad } = renderComponent(product); 120 | 121 | const inputs = await waitForFormToLoad(); 122 | 123 | expect(inputs.nameInput).toHaveValue(product.name); 124 | expect(inputs.priceInput).toHaveValue( 125 | product.price.toString() 126 | ); 127 | expect(inputs.categoryInput).toHaveTextContent( 128 | category.name 129 | ); 130 | }); 131 | 132 | it("should put focus on the name field", async () => { 133 | const { waitForFormToLoad } = renderComponent(); 134 | 135 | const { nameInput } = await waitForFormToLoad(); 136 | expect(nameInput).toHaveFocus(); 137 | }); 138 | 139 | it.each([ 140 | { 141 | scenario: "missing", 142 | errorMessage: /required/i, 143 | }, 144 | { 145 | scenario: "longer than 255 characters", 146 | name: "a".repeat(256), 147 | errorMessage: /255/, 148 | }, 149 | ])( 150 | "should display an error if name is $scenario", 151 | async ({ name, errorMessage }) => { 152 | const { waitForFormToLoad, expectErrorToBeInTheDocument } = 153 | renderComponent(); 154 | 155 | const form = await waitForFormToLoad(); 156 | await form.fill({ ...form.validData, name }); 157 | 158 | expectErrorToBeInTheDocument(errorMessage); 159 | } 160 | ); 161 | 162 | it.each([ 163 | { 164 | scenario: "missing", 165 | errorMessage: /required/i, 166 | }, 167 | { 168 | scenario: "0", 169 | price: 0, 170 | errorMessage: /1/, 171 | }, 172 | { 173 | scenario: "negative", 174 | price: -1, 175 | errorMessage: /1/, 176 | }, 177 | { 178 | scenario: "greater than 1000", 179 | price: 1001, 180 | errorMessage: /1000/, 181 | }, 182 | { 183 | scenario: "not a number", 184 | price: "a", 185 | errorMessage: /required/, 186 | }, 187 | ])( 188 | "should display an error if price is $scenario", 189 | async ({ price, errorMessage }) => { 190 | const { waitForFormToLoad, expectErrorToBeInTheDocument } = 191 | renderComponent(); 192 | 193 | const form = await waitForFormToLoad(); 194 | await form.fill({ ...form.validData, price }); 195 | 196 | expectErrorToBeInTheDocument(errorMessage); 197 | } 198 | ); 199 | 200 | it("should call onSubmit with the correct data", async () => { 201 | const { waitForFormToLoad, onSubmit } = renderComponent(); 202 | 203 | const form = await waitForFormToLoad(); 204 | await form.fill(form.validData); 205 | 206 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unused-vars 207 | const { id, ...formData } = form.validData; 208 | expect(onSubmit).toHaveBeenCalledWith(formData); 209 | }); 210 | 211 | it('should display a toast if submission fails', async () => { 212 | const { waitForFormToLoad, onSubmit } = renderComponent(); 213 | onSubmit.mockRejectedValue({}); 214 | 215 | const form = await waitForFormToLoad(); 216 | await form.fill(form.validData); 217 | 218 | const toast = await screen.findByRole('status'); 219 | expect(toast).toBeInTheDocument(); 220 | expect(toast).toHaveTextContent(/error/i); 221 | }); 222 | 223 | it('should disable the submit button upon submission', async () => { 224 | const { waitForFormToLoad, onSubmit } = renderComponent(); 225 | onSubmit.mockReturnValue(new Promise(() => {})); 226 | 227 | const form = await waitForFormToLoad(); 228 | await form.fill(form.validData); 229 | 230 | expect(form.submitButton).toBeDisabled(); 231 | }) 232 | 233 | it('should re-enable the submit button after submission', async () => { 234 | const { waitForFormToLoad, onSubmit } = renderComponent(); 235 | onSubmit.mockResolvedValue({}); 236 | 237 | const form = await waitForFormToLoad(); 238 | await form.fill(form.validData); 239 | 240 | expect(form.submitButton).not.toBeDisabled(); 241 | }) 242 | 243 | it('should re-enable the submit button after submission', async () => { 244 | const { waitForFormToLoad, onSubmit } = renderComponent(); 245 | onSubmit.mockRejectedValue('error'); 246 | 247 | const form = await waitForFormToLoad(); 248 | await form.fill(form.validData); 249 | 250 | expect(form.submitButton).not.toBeDisabled(); 251 | }) 252 | }); 253 | -------------------------------------------------------------------------------- /tests/components/ProductImageGallery.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import ProductImageGallery from '../../src/components/ProductImageGallery'; 3 | 4 | describe('ProductImageGallery', () => { 5 | it('should render nothing if given an empty array', () => { 6 | const { container } = render(); 7 | expect(container).toBeEmptyDOMElement(); 8 | }); 9 | 10 | it('should render a list of images', () => { 11 | const imageUrls = ['url1', 'url2']; 12 | 13 | render(); 14 | 15 | const images = screen.getAllByRole('img'); 16 | expect(images).toHaveLength(2); 17 | imageUrls.forEach((url, index) => { 18 | expect(images[index]).toHaveAttribute('src', url); 19 | }) 20 | }) 21 | }) -------------------------------------------------------------------------------- /tests/components/ProductList.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'; 2 | import { HttpResponse, delay, http } from 'msw'; 3 | import ProductList from '../../src/components/ProductList'; 4 | import AllProviders from '../AllProviders'; 5 | import { db } from '../mocks/db'; 6 | import { server } from '../mocks/server'; 7 | 8 | describe('ProductList', () => { 9 | const productIds: number[] = []; 10 | 11 | beforeAll(() => { 12 | [1, 2, 3].forEach(() => { 13 | const product = db.product.create(); 14 | productIds.push(product.id); 15 | }); 16 | }); 17 | 18 | afterAll(() => { 19 | db.product.deleteMany({ where: { id: { in: productIds }}}) 20 | }) 21 | 22 | it('should render the list of products', async () => { 23 | render(, { wrapper: AllProviders }); 24 | 25 | const items = await screen.findAllByRole('listitem'); 26 | expect(items.length).toBeGreaterThan(0); 27 | }); 28 | 29 | it('should render no products available if no product is found', async () => { 30 | server.use(http.get('/products', () => HttpResponse.json([]))); 31 | 32 | render(, { wrapper: AllProviders }); 33 | 34 | const message = await screen.findByText(/no products/i); 35 | expect(message).toBeInTheDocument(); 36 | }); 37 | 38 | it('should render an error message when there is an error', async () => { 39 | server.use(http.get('/products', () => HttpResponse.error())); 40 | 41 | render(, { wrapper: AllProviders }); 42 | 43 | expect(await screen.findByText(/error/i)).toBeInTheDocument(); 44 | }); 45 | 46 | it('should render a loading indicator when fetching data', async () => { 47 | server.use(http.get('/products', async () => { 48 | await delay(); 49 | return HttpResponse.json([]); 50 | })); 51 | 52 | render(, { wrapper: AllProviders }); 53 | 54 | expect(await screen.findByText(/loading/i)).toBeInTheDocument(); 55 | }); 56 | 57 | it('should remove the loading indicator after data is fetched', async () => { 58 | render(, { wrapper: AllProviders }); 59 | 60 | await waitForElementToBeRemoved(() => screen.queryByText(/loading/i)); 61 | }) 62 | 63 | it('should remove the loading indicator if data fetching fails', async () => { 64 | server.use(http.get('/products', () => HttpResponse.error())); 65 | 66 | render(, { wrapper: AllProviders }); 67 | 68 | await waitForElementToBeRemoved(() => screen.queryByText(/loading/i)); 69 | }) 70 | }) -------------------------------------------------------------------------------- /tests/components/QuantitySelector.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import QuantitySelector from "../../src/components/QuantitySelector"; 4 | import { Product } from "../../src/entities"; 5 | import { CartProvider } from "../../src/providers/CartProvider"; 6 | 7 | describe("QuantitySelector", () => { 8 | const renderComponent = () => { 9 | const product: Product = { 10 | id: 1, 11 | name: "Milk", 12 | price: 5, 13 | categoryId: 1, 14 | }; 15 | 16 | render( 17 | 18 | 19 | 20 | ); 21 | 22 | const getAddToCartButton = () => 23 | screen.queryByRole("button", { 24 | name: /add to cart/i, 25 | }); 26 | 27 | const getQuantityControls = () => ({ 28 | quantity: screen.queryByRole("status"), 29 | decrementButton: screen.queryByRole("button", { 30 | name: "-", 31 | }), 32 | incrementButton: screen.queryByRole("button", { 33 | name: "+", 34 | }), 35 | }); 36 | 37 | const user = userEvent.setup(); 38 | 39 | const addToCart = async () => { 40 | const button = getAddToCartButton(); 41 | await user.click(button!); 42 | }; 43 | 44 | const incrementQuantity = async () => { 45 | const { incrementButton } = getQuantityControls(); 46 | await user.click(incrementButton!); 47 | }; 48 | 49 | const decrementQuantity = async () => { 50 | const { decrementButton } = getQuantityControls(); 51 | await user.click(decrementButton!); 52 | }; 53 | 54 | return { 55 | getAddToCartButton, 56 | getQuantityControls, 57 | addToCart, 58 | incrementQuantity, 59 | decrementQuantity, 60 | }; 61 | }; 62 | 63 | it("should render the Add to Cart button", () => { 64 | const { getAddToCartButton } = renderComponent(); 65 | 66 | expect(getAddToCartButton()).toBeInTheDocument(); 67 | }); 68 | 69 | it("should add the product to the cart", async () => { 70 | const { 71 | getAddToCartButton, 72 | addToCart, 73 | getQuantityControls, 74 | } = renderComponent(); 75 | 76 | await addToCart(); 77 | 78 | const { quantity, incrementButton, decrementButton } = 79 | getQuantityControls(); 80 | expect(quantity).toHaveTextContent("1"); 81 | expect(decrementButton).toBeInTheDocument(); 82 | expect(incrementButton).toBeInTheDocument(); 83 | expect(getAddToCartButton()).not.toBeInTheDocument(); 84 | }); 85 | 86 | it("should increment the quantity", async () => { 87 | const { incrementQuantity, addToCart, getQuantityControls } = 88 | renderComponent(); 89 | await addToCart(); 90 | 91 | await incrementQuantity(); 92 | 93 | const { quantity } = getQuantityControls(); 94 | expect(quantity).toHaveTextContent("2"); 95 | }); 96 | 97 | it("should decrement the quantity", async () => { 98 | const { 99 | incrementQuantity, 100 | decrementQuantity, 101 | addToCart, 102 | getQuantityControls, 103 | } = renderComponent(); 104 | await addToCart(); 105 | await incrementQuantity(); 106 | 107 | await decrementQuantity(); 108 | 109 | const { quantity } = getQuantityControls(); 110 | expect(quantity).toHaveTextContent("1"); 111 | }); 112 | 113 | it("should remove the product from the cart", async () => { 114 | const { 115 | getAddToCartButton, 116 | decrementQuantity, 117 | addToCart, 118 | getQuantityControls, 119 | } = renderComponent(); 120 | await addToCart(); 121 | 122 | await decrementQuantity(); 123 | 124 | const { incrementButton, decrementButton, quantity } = 125 | getQuantityControls(); 126 | expect(quantity).not.toBeInTheDocument(); 127 | expect(decrementButton).not.toBeInTheDocument(); 128 | expect(incrementButton).not.toBeInTheDocument(); 129 | expect(getAddToCartButton()).toBeInTheDocument(); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /tests/components/SearchBox.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import SearchBox from "../../src/components/SearchBox"; 3 | import userEvent from "@testing-library/user-event"; 4 | 5 | describe("SearchBox", () => { 6 | const renderSearchBox = () => { 7 | const onChange = vi.fn(); 8 | render(); 9 | 10 | return { 11 | input: screen.getByPlaceholderText(/search/i), 12 | user: userEvent.setup(), 13 | onChange, 14 | }; 15 | }; 16 | 17 | it("should render an input field for searching", () => { 18 | const { input } = renderSearchBox(); 19 | 20 | expect(input).toBeInTheDocument(); 21 | }); 22 | 23 | it("should call onChange when Enter is pressed", async () => { 24 | const { input, onChange, user } = renderSearchBox(); 25 | 26 | const searchTerm = "SearchTerm"; 27 | await user.type(input, searchTerm + "{enter}"); 28 | 29 | expect(onChange).toHaveBeenCalledWith(searchTerm); 30 | }); 31 | 32 | it("should not call onChange if input field is empty", async () => { 33 | const { input, onChange, user } = renderSearchBox(); 34 | 35 | await user.type(input, "{enter}"); 36 | 37 | expect(onChange).not.toHaveBeenCalled(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /tests/components/TagList.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import TagList from "../../src/components/TagList"; 3 | 4 | describe("TagList", () => { 5 | it("should render tags", async () => { 6 | render(); 7 | 8 | const listItems = await screen.findAllByRole("listitem"); 9 | expect(listItems.length).toBeGreaterThan(0); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tests/components/TermsAndConditions.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import TermsAndConditions from "../../src/components/TermsAndConditions"; 3 | import userEvent from "@testing-library/user-event"; 4 | 5 | describe("TermsAndConditions", () => { 6 | const renderComponent = () => { 7 | render(); 8 | 9 | return { 10 | heading: screen.getByRole("heading"), 11 | checkbox: screen.getByRole("checkbox"), 12 | button: screen.getByRole("button"), 13 | }; 14 | }; 15 | 16 | it("should render with correct text and initial state", () => { 17 | const { heading, checkbox, button } = renderComponent(); 18 | 19 | expect(heading).toHaveTextContent("Terms & Conditions"); 20 | expect(checkbox).not.toBeChecked(); 21 | expect(button).toBeDisabled(); 22 | }); 23 | 24 | it("should enable the button when the checkbox is checked", async () => { 25 | const { checkbox, button } = renderComponent(); 26 | 27 | const user = userEvent.setup(); 28 | await user.click(checkbox); 29 | 30 | expect(button).toBeEnabled(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/components/ToastDemo.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import ToastDemo from "../../src/components/ToastDemo"; 3 | import { Toaster } from "react-hot-toast"; 4 | import userEvent from "@testing-library/user-event"; 5 | 6 | describe("ToastDemo", () => { 7 | it('should render a toast', async () => { 8 | render( 9 | <> 10 | 11 | 12 | 13 | ); 14 | 15 | const button = screen.getByRole('button'); 16 | const user = userEvent.setup(); 17 | await user.click(button); 18 | 19 | const toast = await screen.findByText(/success/i); 20 | expect(toast).toBeInTheDocument(); 21 | }) 22 | }); 23 | -------------------------------------------------------------------------------- /tests/components/UserAccount.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { User } from "../../src/entities"; 3 | import UserAccount from "../../src/components/UserAccount"; 4 | 5 | describe("UserAccount", () => { 6 | it("should render user name", () => { 7 | const user: User = { id: 1, name: "Mosh" }; 8 | 9 | render(); 10 | 11 | expect(screen.getByText(user.name)).toBeInTheDocument(); 12 | }); 13 | 14 | it("should render edit button if user is admin", () => { 15 | const user: User = { id: 1, name: "Mosh", isAdmin: true }; 16 | 17 | render(); 18 | 19 | const button = screen.getByRole("button"); 20 | expect(button).toBeInTheDocument(); 21 | expect(button).toHaveTextContent(/edit/i); 22 | }); 23 | 24 | it("should not render edit button if user is not admin", () => { 25 | const user: User = { id: 1, name: "Mosh" }; 26 | 27 | render(); 28 | 29 | const button = screen.queryByRole("button"); 30 | expect(button).not.toBeInTheDocument(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/components/UserList.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import UserList from '../../src/components/UserList'; 3 | import { User } from '../../src/entities'; 4 | 5 | describe('UserList', () => { 6 | it('should render no users when the users array is empty', () => { 7 | render(); 8 | 9 | expect(screen.getByText(/no users/i)).toBeInTheDocument(); 10 | }); 11 | 12 | it('should render a list of users', () => { 13 | const users: User[] = [ 14 | { id: 1, name: 'Mosh' }, 15 | { id: 2, name: 'John' }, 16 | ]; 17 | 18 | render(); 19 | 20 | users.forEach(user => { 21 | const link = screen.getByRole('link', { name: user.name }); 22 | expect(link).toBeInTheDocument(); 23 | expect(link).toHaveAttribute('href', `/users/${user.id}`); 24 | }) 25 | }); 26 | }) -------------------------------------------------------------------------------- /tests/main.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect, describe } from 'vitest'; 2 | import { faker } from '@faker-js/faker'; 3 | import { db } from './mocks/db'; 4 | 5 | describe('group', () => { 6 | it('should', () => { 7 | const product = db.product.create({ name: 'Apple' }); 8 | console.log(db.product.delete({ where: { id: { equals: product.id } }})) 9 | }) 10 | }) -------------------------------------------------------------------------------- /tests/mocks/db.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/unbound-method */ 2 | import { factory, manyOf, oneOf, primaryKey } from '@mswjs/data'; 3 | import { faker } from '@faker-js/faker'; 4 | 5 | export const db = factory({ 6 | category: { 7 | id: primaryKey(faker.number.int), 8 | name: faker.commerce.department, 9 | products: manyOf('product') 10 | }, 11 | product: { 12 | id: primaryKey(faker.number.int), 13 | name: faker.commerce.productName, 14 | price: () => faker.number.int({ min: 1, max: 100 }), 15 | categoryId: faker.number.int, 16 | category: oneOf('category') 17 | } 18 | }); 19 | 20 | export const getProductsByCategory = (categoryId: number) => 21 | db.product.findMany({ 22 | where: { 23 | categoryId: { equals: categoryId } 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /tests/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { db } from './db'; 2 | 3 | export const handlers = [ 4 | ...db.product.toHandlers('rest'), 5 | ...db.category.toHandlers('rest') 6 | ] -------------------------------------------------------------------------------- /tests/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | import { handlers } from './handlers'; 3 | 4 | export const server = setupServer(...handlers); -------------------------------------------------------------------------------- /tests/pages/BrowseProductsPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from "@radix-ui/themes"; 2 | import { 3 | render, 4 | screen, 5 | waitForElementToBeRemoved, 6 | } from "@testing-library/react"; 7 | import userEvent from "@testing-library/user-event"; 8 | import { Category, Product } from "../../src/entities"; 9 | import BrowseProducts from "../../src/pages/BrowseProductsPage"; 10 | import { CartProvider } from "../../src/providers/CartProvider"; 11 | import { db, getProductsByCategory } from "../mocks/db"; 12 | import { simulateDelay, simulateError } from "../utils"; 13 | import AllProviders from "../AllProviders"; 14 | 15 | describe("BrowseProductsPage", () => { 16 | const categories: Category[] = []; 17 | const products: Product[] = []; 18 | 19 | beforeAll(() => { 20 | [1, 2].forEach((item) => { 21 | const category = db.category.create({ name: 'Category ' + item }); 22 | categories.push(category); 23 | [1, 2].forEach(() => { 24 | products.push( 25 | db.product.create({ categoryId: category.id }) 26 | ); 27 | }); 28 | }); 29 | }); 30 | 31 | afterAll(() => { 32 | const categoryIds = categories.map((c) => c.id); 33 | db.category.deleteMany({ 34 | where: { id: { in: categoryIds } }, 35 | }); 36 | 37 | const productIds = products.map((p) => p.id); 38 | db.product.deleteMany({ 39 | where: { id: { in: productIds } }, 40 | }); 41 | }); 42 | 43 | it("should show a loading skeleton when fetching categories", () => { 44 | simulateDelay("/categories"); 45 | 46 | const { getCategoriesSkeleton } = renderComponent(); 47 | 48 | expect(getCategoriesSkeleton()).toBeInTheDocument(); 49 | }); 50 | 51 | it("should hide the loading skeleton after categories are fetched", async () => { 52 | const { getCategoriesSkeleton } = renderComponent(); 53 | 54 | await waitForElementToBeRemoved(getCategoriesSkeleton); 55 | }); 56 | 57 | it("should show a loading skeleton when fetching products", () => { 58 | simulateDelay("/products"); 59 | 60 | const { getProductsSkeleton } = renderComponent(); 61 | 62 | expect(getProductsSkeleton()).toBeInTheDocument(); 63 | }); 64 | 65 | it("should hide the loading skeleton after products are fetched", async () => { 66 | const { getProductsSkeleton } = renderComponent(); 67 | 68 | await waitForElementToBeRemoved(getProductsSkeleton); 69 | }); 70 | 71 | it("should not render an error if categories cannot be fetched", async () => { 72 | simulateError("/categories"); 73 | 74 | const { getCategoriesSkeleton, getCategoriesComboBox } = 75 | renderComponent(); 76 | 77 | await waitForElementToBeRemoved(getCategoriesSkeleton); 78 | 79 | expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); 80 | expect(getCategoriesComboBox()).not.toBeInTheDocument(); 81 | }); 82 | 83 | it("should render an error if products cannot be fetched", async () => { 84 | simulateError("/products"); 85 | 86 | renderComponent(); 87 | 88 | expect( 89 | await screen.findByText(/error/i) 90 | ).toBeInTheDocument(); 91 | }); 92 | 93 | it("should render categories", async () => { 94 | const { getCategoriesSkeleton, getCategoriesComboBox } = 95 | renderComponent(); 96 | 97 | await waitForElementToBeRemoved(getCategoriesSkeleton); 98 | 99 | const combobox = getCategoriesComboBox(); 100 | expect(combobox).toBeInTheDocument(); 101 | 102 | const user = userEvent.setup(); 103 | await user.click(combobox!); 104 | 105 | expect( 106 | screen.getByRole("option", { name: /all/i }) 107 | ).toBeInTheDocument(); 108 | categories.forEach((category) => { 109 | expect( 110 | screen.getByRole("option", { name: category.name }) 111 | ).toBeInTheDocument(); 112 | }); 113 | }); 114 | 115 | it("should render products", async () => { 116 | const { getProductsSkeleton } = renderComponent(); 117 | 118 | await waitForElementToBeRemoved(getProductsSkeleton); 119 | 120 | products.forEach((product) => { 121 | expect(screen.getByText(product.name)).toBeInTheDocument(); 122 | }); 123 | }); 124 | 125 | it("should filter products by category", async () => { 126 | const { selectCategory, expectProductsToBeInTheDocument } = 127 | renderComponent(); 128 | 129 | const selectedCategory = categories[0]; 130 | await selectCategory(selectedCategory.name); 131 | 132 | const products = getProductsByCategory(selectedCategory.id); 133 | expectProductsToBeInTheDocument(products); 134 | }); 135 | 136 | it("should render all products if All category is selected", async () => { 137 | const { selectCategory, expectProductsToBeInTheDocument } = 138 | renderComponent(); 139 | 140 | await selectCategory(/all/i); 141 | 142 | const products = db.product.getAll(); 143 | expectProductsToBeInTheDocument(products); 144 | }); 145 | }); 146 | 147 | const renderComponent = () => { 148 | render(, { wrapper: AllProviders }); 149 | 150 | const getCategoriesSkeleton = () => 151 | screen.queryByRole("progressbar", { 152 | name: /categories/i, 153 | }); 154 | 155 | const getProductsSkeleton = () => 156 | screen.queryByRole("progressbar", { name: /products/i }); 157 | 158 | const getCategoriesComboBox = () => 159 | screen.queryByRole("combobox"); 160 | 161 | const selectCategory = async (name: RegExp | string) => { 162 | await waitForElementToBeRemoved(getCategoriesSkeleton); 163 | const combobox = getCategoriesComboBox(); 164 | const user = userEvent.setup(); 165 | await user.click(combobox!); 166 | 167 | const option = screen.getByRole("option", { name }); 168 | await user.click(option); 169 | }; 170 | 171 | const expectProductsToBeInTheDocument = ( 172 | products: Product[] 173 | ) => { 174 | const rows = screen.getAllByRole("row"); 175 | const dataRows = rows.slice(1); 176 | expect(dataRows).toHaveLength(products.length); 177 | 178 | products.forEach((product) => { 179 | expect(screen.getByText(product.name)).toBeInTheDocument(); 180 | }); 181 | }; 182 | 183 | return { 184 | getProductsSkeleton, 185 | getCategoriesSkeleton, 186 | getCategoriesComboBox, 187 | selectCategory, 188 | expectProductsToBeInTheDocument, 189 | }; 190 | }; 191 | -------------------------------------------------------------------------------- /tests/pages/ProductDetailPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | screen, 3 | waitForElementToBeRemoved, 4 | } from "@testing-library/react"; 5 | import { Product } from "../../src/entities"; 6 | import { db } from "../mocks/db"; 7 | import { navigateTo } from "../utils"; 8 | 9 | describe("ProductDetailPage", () => { 10 | let product: Product; 11 | 12 | beforeAll(() => { 13 | product = db.product.create(); 14 | }); 15 | 16 | afterAll(() => { 17 | db.product.delete({ where: { id: { equals: product.id } } }); 18 | }); 19 | 20 | it("should render product details", async () => { 21 | navigateTo("/products/" + product.id); 22 | 23 | await waitForElementToBeRemoved(() => 24 | screen.queryByText(/loading/i) 25 | ); 26 | 27 | expect( 28 | screen.getByRole("heading", { name: product.name }) 29 | ).toBeInTheDocument(); 30 | 31 | expect( 32 | screen.getByText("$" + product.price) 33 | ).toBeInTheDocument(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/vitest"; 2 | import ResizeObserver from 'resize-observer-polyfill'; 3 | import { server } from "./mocks/server"; 4 | import { PropsWithChildren, ReactNode } from "react"; 5 | 6 | beforeAll(() => server.listen()); 7 | afterEach(() => server.resetHandlers()); 8 | afterAll(() => server.close()); 9 | 10 | vi.mock('@auth0/auth0-react', () => { 11 | return { 12 | useAuth0: vi.fn().mockReturnValue({ 13 | isAuthenticated: false, 14 | isLoading: false, 15 | user: undefined 16 | }), 17 | Auth0Provider: ({ children }: PropsWithChildren) => children, 18 | withAuthenticationRequired: (component: ReactNode) => component 19 | } 20 | }); 21 | 22 | global.ResizeObserver = ResizeObserver; 23 | 24 | window.HTMLElement.prototype.scrollIntoView = vi.fn(); 25 | window.HTMLElement.prototype.hasPointerCapture = vi.fn(); 26 | window.HTMLElement.prototype.releasePointerCapture = vi.fn(); 27 | 28 | Object.defineProperty(window, "matchMedia", { 29 | writable: true, 30 | value: vi.fn().mockImplementation((query) => ({ 31 | matches: false, 32 | media: query, 33 | onchange: null, 34 | addListener: vi.fn(), // deprecated 35 | removeListener: vi.fn(), // deprecated 36 | addEventListener: vi.fn(), 37 | removeEventListener: vi.fn(), 38 | dispatchEvent: vi.fn(), 39 | })), 40 | }); 41 | -------------------------------------------------------------------------------- /tests/utils.tsx: -------------------------------------------------------------------------------- 1 | import { User, useAuth0 } from "@auth0/auth0-react"; 2 | import { HttpResponse, delay, http } from "msw"; 3 | import { server } from "./mocks/server"; 4 | import { render } from '@testing-library/react' 5 | import { RouterProvider, createMemoryRouter } from "react-router-dom"; 6 | import routes from "../src/routes"; 7 | 8 | export const simulateDelay = (endpoint: string) => { 9 | server.use( 10 | http.get(endpoint, async () => { 11 | await delay(); 12 | return HttpResponse.json([]); 13 | }) 14 | ); 15 | } 16 | 17 | export const simulateError = (endpoint: string) => { 18 | server.use( 19 | http.get(endpoint, () => HttpResponse.error()) 20 | ); 21 | } 22 | 23 | type AuthState = { 24 | isAuthenticated: boolean; 25 | isLoading: boolean; 26 | user: User | undefined; 27 | } 28 | 29 | export const mockAuthState = (authState: AuthState) => { 30 | vi.mocked(useAuth0).mockReturnValue({ 31 | ...authState, 32 | getAccessTokenSilently: vi.fn().mockResolvedValue('a'), 33 | getAccessTokenWithPopup: vi.fn(), 34 | getIdTokenClaims: vi.fn(), 35 | loginWithRedirect: vi.fn(), 36 | loginWithPopup: vi.fn(), 37 | logout: vi.fn(), 38 | handleRedirectCallback: vi.fn() 39 | }) 40 | } 41 | 42 | export const navigateTo = (path: string) => { 43 | const router = createMemoryRouter(routes, { 44 | initialEntries: [path] 45 | }) 46 | 47 | render(); 48 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "types": ["vitest/globals"], 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": false, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": ["src", "tests", "src/environment.d.ts"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | globals: true, 7 | setupFiles: 'tests/setup.ts' 8 | } 9 | }); --------------------------------------------------------------------------------