├── .eslintrc.cjs ├── .gitignore ├── .hintrc ├── .prettierignore ├── .prettierrc ├── README.md ├── index.html ├── jest.config.ts ├── package.json ├── postcss.config.js ├── public └── vite.svg ├── server └── db.json ├── src ├── app │ ├── index.css │ └── main.tsx ├── lib │ ├── Car │ │ ├── domain │ │ │ ├── index.ts │ │ │ ├── model │ │ │ │ └── Car.ts │ │ │ ├── repository │ │ │ │ ├── GetCarsRepository.ts │ │ │ │ └── UpdateCarRepository.ts │ │ │ └── service │ │ │ │ ├── GetCarsService.spec.ts │ │ │ │ ├── GetCarsService.ts │ │ │ │ ├── UpdateCarService.spec.ts │ │ │ │ └── UpdateCarService.ts │ │ ├── feature │ │ │ ├── components │ │ │ │ └── CarDetails │ │ │ │ │ ├── CarDetails.component.spec.tsx │ │ │ │ │ └── CarDetails.component.tsx │ │ │ ├── hooks │ │ │ │ ├── useGetCar.hook.spec.tsx │ │ │ │ ├── useGetCar.hook.ts │ │ │ │ ├── useGetCars.hook.spec.tsx │ │ │ │ ├── useGetCars.hook.ts │ │ │ │ ├── useSearch.hook.spec.ts │ │ │ │ ├── useSearch.hook.ts │ │ │ │ ├── useSort.hook.spec.ts │ │ │ │ ├── useSort.hook.ts │ │ │ │ ├── useUpdateCar.hook.spec.tsx │ │ │ │ └── useUpdateCar.hook.ts │ │ │ ├── index.ts │ │ │ └── pages │ │ │ │ ├── Car │ │ │ │ ├── Car.page.spec.tsx │ │ │ │ └── Car.page.tsx │ │ │ │ └── Cars │ │ │ │ ├── Cars.page.spec.tsx │ │ │ │ └── Cars.page.tsx │ │ ├── index.ts │ │ ├── infrastructure │ │ │ ├── http │ │ │ │ ├── GetCarsHttpService.spec.ts │ │ │ │ ├── GetCarsHttpService.ts │ │ │ │ ├── UpdateCarHttpService.spec.ts │ │ │ │ └── UpdateCarHttpService.ts │ │ │ └── index.ts │ │ └── ui │ │ │ ├── CarsTable │ │ │ ├── CarsTable.component.spec.tsx │ │ │ ├── CarsTable.component.tsx │ │ │ └── components │ │ │ │ ├── CarsTableCell │ │ │ │ ├── CarsTableCell.component.spec.tsx │ │ │ │ └── CarsTableCell.component.tsx │ │ │ │ └── CarsTableHeader │ │ │ │ ├── CarsTableHeader.component.spec.tsx │ │ │ │ └── CarsTableHeader.component.tsx │ │ │ ├── InputSearch │ │ │ ├── InputSearch.component.spec.tsx │ │ │ └── InputSearch.component.tsx │ │ │ ├── SortSelect │ │ │ ├── SortSelect.component.spec.tsx │ │ │ └── SortSelect.component.tsx │ │ │ └── index.ts │ └── Shared │ │ ├── infrastructure │ │ ├── di │ │ │ ├── di.ts │ │ │ └── react │ │ │ │ └── ctx │ │ │ │ └── container.ctx.ts │ │ └── index.ts │ │ └── ui │ │ ├── components │ │ ├── Error │ │ │ ├── Error.component.spec.tsx │ │ │ └── Error.component.tsx │ │ ├── Header │ │ │ ├── Header.component.spec.tsx │ │ │ └── Header.component.tsx │ │ ├── Loader │ │ │ ├── Loader.component.spec.tsx │ │ │ └── Loader.component.tsx │ │ └── PageContainer │ │ │ ├── PageContainer.component.spec.tsx │ │ │ └── PageContainer.component.tsx │ │ └── index.ts ├── test │ ├── GetCarsHttpMock.service.ts │ ├── UpdateCarHttpMockService.ts │ ├── index.ts │ └── wrapper.tsx └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts ├── vitest.config.ts └── yarn.lock /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | root: true, 5 | env: { browser: true, es2020: true }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 10 | 'plugin:react-hooks/recommended', 11 | ], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | project: true, 17 | tsconfigRootDir: __dirname, 18 | }, 19 | plugins: ['react-refresh'], 20 | rules: { 21 | 'react-refresh/only-export-components': [ 22 | 'warn', 23 | { allowConstantExport: true }, 24 | ], 25 | '@typescript-eslint/no-non-null-assertion': 'off', 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /.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 | coverage 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 | -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "hints": { 6 | "axe/forms": [ 7 | "default", 8 | { 9 | "label": "off" 10 | } 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 120, 7 | "bracketSpacing": true, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DDD React Example 2 | 3 | ![DDD React](https://i.ibb.co/HpxwVJj/ezgif-1-23460ea87c.gif) 4 | 5 | ## 1. Install 6 | 7 | ```bash 8 | yarn install 9 | ``` 10 | 11 | ## 2. Run Server 12 | 13 | ```bash 14 | yarn server:start 15 | ``` 16 | 17 | ## 3. Build 18 | 19 | ```bash 20 | yarn build 21 | ``` 22 | 23 | ## 4. Run app 24 | 25 | ```bash 26 | yarn preview 27 | ``` 28 | 29 | go to 30 | 31 | ## Test 32 | 33 | ```bash 34 | yarn test 35 | ``` 36 | 37 | ## Coverage report 38 | 39 | ```bash 40 | yarn coverage 41 | ``` 42 | 43 | ## Architecture 44 | 45 | The architecture used is based on the DDD approach explained on this talk: 46 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | "^@/(.*)$": "/src/$1", 4 | }, 5 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "seatcode-rubensoler", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview --port 8080", 11 | "server:start": "json-server --watch server/db.json", 12 | "test": "vitest --config vitest.config.ts", 13 | "coverage": "vitest --config vitest.config.ts run --coverage" 14 | }, 15 | "dependencies": { 16 | "@tanstack/react-query": "^4.32.0", 17 | "awilix": "^8.0.1", 18 | "localforage": "^1.10.0", 19 | "lodash.debounce": "^4.0.8", 20 | "match-sorter": "^6.3.1", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "react-icons": "^4.10.1", 24 | "react-license-plate": "^1.0.0", 25 | "react-router-dom": "^6.14.2", 26 | "react-toastify": "^9.1.3", 27 | "sort-by": "^1.2.0" 28 | }, 29 | "devDependencies": { 30 | "@testing-library/react": "^14.0.0", 31 | "@testing-library/react-hooks": "^8.0.1", 32 | "@types/lodash.debounce": "^4.0.7", 33 | "@types/node": "^20.4.2", 34 | "@types/react": "^18.2.14", 35 | "@types/react-dom": "^18.2.6", 36 | "@typescript-eslint/eslint-plugin": "^5.61.0", 37 | "@typescript-eslint/parser": "^5.61.0", 38 | "@vitejs/plugin-react": "^4.0.1", 39 | "@vitest/coverage-v8": "^0.33.0", 40 | "autoprefixer": "^10.4.14", 41 | "eslint": "^8.44.0", 42 | "eslint-config-prettier": "^8.8.0", 43 | "eslint-plugin-react-hooks": "^4.6.0", 44 | "eslint-plugin-react-refresh": "^0.4.1", 45 | "jsdom": "^22.1.0", 46 | "json-server": "^0.17.3", 47 | "postcss": "^8.4.26", 48 | "prettier": "^3.0.0", 49 | "tailwindcss": "^3.3.3", 50 | "typescript": "^5.0.2", 51 | "vite": "^4.4.0", 52 | "vitest": "^0.33.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "cars": [ 3 | { 4 | "id": 0, 5 | "name": "Ibiza", 6 | "brand": "SEAT", 7 | "regNumber": "5259 JZM", 8 | "pictureUrl": "https://www.seat.com/content/dam/public/seat-website/global-header/global-navigation/models/seat-new-ibiza-pa-desirered.png", 9 | "details": { 10 | "engine": "TSI 100 290cv", 11 | "maxSpeedInKmh": 300, 12 | "extras": { 13 | "travelKit": true, 14 | "lightPack": false 15 | } 16 | } 17 | }, 18 | { 19 | "id": 1, 20 | "name": "Arona", 21 | "brand": "SEAT", 22 | "regNumber": "1359 JLP", 23 | "pictureUrl": "https://www.seat.com/content/dam/public/seat-website/myco/2325/global-header/global-navigation/models/seat-arona-pa.png", 24 | "details": { 25 | "engine": "TSI 100 2900cv", 26 | "maxSpeedInKmh": 300, 27 | "extras": { 28 | "travelKit": false, 29 | "lightPack": true 30 | } 31 | } 32 | }, 33 | { 34 | "id": 2, 35 | "name": "Leon", 36 | "brand": "SEAT", 37 | "regNumber": "4789 PJH", 38 | "pictureUrl": "https://www.seat.com/content/dam/public/seat-website/global-header/global-navigation/models/new-seat-leon-2020.png", 39 | "details": { 40 | "engine": "TSI 100 90cv", 41 | "maxSpeedInKmh": 180, 42 | "extras": { 43 | "travelKit": true, 44 | "lightPack": true 45 | } 46 | } 47 | }, 48 | { 49 | "id": 3, 50 | "name": "Leon Sportstourer", 51 | "brand": "SEAT", 52 | "regNumber": "9275 RMH", 53 | "pictureUrl": "https://www.seat.com/content/dam/public/seat-website/global-header/global-navigation/models/new-seat-leon-sportstourer-2020.png", 54 | "details": { 55 | "engine": "TDI 90cv", 56 | "maxSpeedInKmh": 190, 57 | "extras": { 58 | "travelKit": true, 59 | "lightPack": true 60 | } 61 | } 62 | }, 63 | { 64 | "id": 4, 65 | "name": "Ateca", 66 | "brand": "SEAT", 67 | "regNumber": "9235 RNK", 68 | "pictureUrl": "https://www.seat.com/content/dam/public/seat-website/global-header/global-navigation/models/new-ateca.png", 69 | "details": { 70 | "engine": "TDI 90cv", 71 | "maxSpeedInKmh": 190, 72 | "extras": { 73 | "travelKit": true, 74 | "lightPack": true 75 | } 76 | } 77 | }, 78 | { 79 | "id": 5, 80 | "name": "Tarraco", 81 | "brand": "SEAT", 82 | "regNumber": "5237 OKH", 83 | "pictureUrl": "https://www.seat.com/content/dam/public/seat-website/myco/2325/global-header/global-navigation/models/lateral-view-new-seat-tarraco-xperience-dark-camouflage-colour.png", 84 | "details": { 85 | "engine": "TDI 90cv", 86 | "maxSpeedInKmh": 190, 87 | "extras": { 88 | "travelKit": true, 89 | "lightPack": true 90 | } 91 | } 92 | } 93 | ] 94 | } -------------------------------------------------------------------------------- /src/app/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /src/app/main.tsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom/client'; 5 | import { createBrowserRouter, RouterProvider } from 'react-router-dom'; 6 | 7 | import { CarPage, CarsPage } from '@Car/Feature'; 8 | import { ContainerContext, createDIContainer } from '@Shared/Infrastructure'; 9 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 10 | 11 | const router = createBrowserRouter([ 12 | { 13 | path: '/', 14 | element: , 15 | }, 16 | { 17 | path: '/car/:id', 18 | element: , 19 | }, 20 | ]); 21 | 22 | const queryClient = new QueryClient(); 23 | 24 | ReactDOM.createRoot(document.getElementById('root')!).render( 25 | 26 | 27 | 28 | 29 | 30 | 31 | , 32 | ); 33 | -------------------------------------------------------------------------------- /src/lib/Car/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from './model/Car'; 2 | export * from './repository/GetCarsRepository'; 3 | export * from './repository/UpdateCarRepository'; 4 | export * from './service/GetCarsService'; 5 | export * from './service/UpdateCarService'; 6 | -------------------------------------------------------------------------------- /src/lib/Car/domain/model/Car.ts: -------------------------------------------------------------------------------- 1 | export interface Car { 2 | id: number; 3 | name: string; 4 | brand: string; 5 | pictureUrl: string; 6 | regNumber: string; 7 | details: CarDetails; 8 | } 9 | 10 | interface CarDetails { 11 | engine: string; 12 | maxSpeedInKmh: number; 13 | extras: CarExtras; 14 | } 15 | 16 | interface CarExtras { 17 | travelKit: boolean; 18 | lightPack: boolean; 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/Car/domain/repository/GetCarsRepository.ts: -------------------------------------------------------------------------------- 1 | import { Car } from '@Car/Domain'; 2 | 3 | export interface GetCarsRepository { 4 | getCars: ( 5 | searchQuery?: string, 6 | sort?: { 7 | order: 'asc' | 'desc'; 8 | property: keyof Car; 9 | }, 10 | ) => Promise; 11 | 12 | getCarById: (id: number) => Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/Car/domain/repository/UpdateCarRepository.ts: -------------------------------------------------------------------------------- 1 | import { Car } from '@Car/Domain'; 2 | 3 | export interface UpdateCarRepository { 4 | updateCar: (id: number, car: Car) => Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/Car/domain/service/GetCarsService.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { Car, GetCarsRepository, GetCarsService } from '@Car/Domain'; 4 | 5 | const mockedCars: Car[] = [ 6 | { 7 | id: 5, 8 | name: 'Tarraco', 9 | brand: 'SEAT', 10 | regNumber: '5237OKH', 11 | pictureUrl: 12 | 'https://www.seat.com/content/dam/public/seat-website/myco/2325/global-header/global-navigation/models/lateral-view-new-seat-tarraco-xperience-dark-camouflage-colour.png', 13 | details: { 14 | engine: 'TDI 90cv', 15 | maxSpeedInKmh: 190, 16 | extras: { 17 | travelKit: true, 18 | lightPack: true, 19 | }, 20 | }, 21 | }, 22 | ]; 23 | 24 | const getCarsMock = vi.fn(() => Promise.resolve(mockedCars)); 25 | 26 | class GetCarsMockService implements GetCarsRepository { 27 | getCars = getCarsMock; 28 | getCarById = () => Promise.resolve(mockedCars[0]); 29 | } 30 | 31 | describe('GetCarsService', () => { 32 | it('Should call carsGetter to fetch cars', async () => { 33 | const getCarsService: GetCarsService = new GetCarsService(new GetCarsMockService()); 34 | const cars: Car[] = await getCarsService.getCars(); 35 | 36 | expect(cars).toBe(mockedCars); 37 | expect(getCarsMock).toHaveBeenCalled(); 38 | }); 39 | 40 | it('Should call carsGetter with parametters to fetch cars', async () => { 41 | const getCarsService: GetCarsService = new GetCarsService(new GetCarsMockService()); 42 | const searchQuery = 'Ibiza'; 43 | const sort = { 44 | property: 'name' as keyof Car, 45 | order: 'asc' as 'asc' | 'desc', 46 | }; 47 | const cars: Car[] = await getCarsService.getCars(searchQuery, sort); 48 | 49 | expect(cars).toBe(mockedCars); 50 | expect(getCarsMock).toHaveBeenCalledWith(searchQuery, sort); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/lib/Car/domain/service/GetCarsService.ts: -------------------------------------------------------------------------------- 1 | import { Car, GetCarsRepository } from '@Car/Domain'; 2 | 3 | export class GetCarsService { 4 | constructor(private carsGetter: GetCarsRepository) {} 5 | 6 | getCars( 7 | searchQuery?: string, 8 | sort?: { 9 | order: 'asc' | 'desc'; 10 | property: keyof Car; 11 | }, 12 | ): Promise { 13 | return this.carsGetter.getCars(searchQuery, sort); 14 | } 15 | 16 | getCarById(id: number): Promise { 17 | return this.carsGetter.getCarById(id); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/Car/domain/service/UpdateCarService.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { Car, UpdateCarRepository, UpdateCarService } from '@Car/Domain'; 4 | 5 | const mockedCar: Car = { 6 | id: 5, 7 | name: 'Tarraco', 8 | brand: 'SEAT', 9 | regNumber: '5237OKH', 10 | pictureUrl: 11 | 'https://www.seat.com/content/dam/public/seat-website/myco/2325/global-header/global-navigation/models/lateral-view-new-seat-tarraco-xperience-dark-camouflage-colour.png', 12 | details: { 13 | engine: 'TDI 90cv', 14 | maxSpeedInKmh: 190, 15 | extras: { 16 | travelKit: true, 17 | lightPack: true, 18 | }, 19 | }, 20 | }; 21 | 22 | const updateCarMock = vi.fn(() => Promise.resolve(mockedCar)); 23 | 24 | class UpdateCarMockService implements UpdateCarRepository { 25 | updateCar = updateCarMock; 26 | } 27 | 28 | describe('GetCarsService', () => { 29 | it('Should call carsGetter to fetch cars', async () => { 30 | const updateCarService: UpdateCarService = new UpdateCarService(new UpdateCarMockService()); 31 | await updateCarService.updateCar(5, { 32 | id: 5, 33 | name: 'Tarraco', 34 | brand: 'SEAT', 35 | regNumber: '5237OKH', 36 | pictureUrl: 37 | 'https://www.seat.com/content/dam/public/seat-website/myco/2325/global-header/global-navigation/models/lateral-view-new-seat-tarraco-xperience-dark-camouflage-colour.png', 38 | details: { 39 | engine: 'TDI 90cv', 40 | maxSpeedInKmh: 190, 41 | extras: { 42 | travelKit: true, 43 | lightPack: true, 44 | }, 45 | }, 46 | }); 47 | expect(updateCarMock).toHaveBeenCalled(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/lib/Car/domain/service/UpdateCarService.ts: -------------------------------------------------------------------------------- 1 | import { Car, UpdateCarRepository } from '@Car/Domain'; 2 | 3 | export class UpdateCarService { 4 | constructor(private carUpdater: UpdateCarRepository) {} 5 | 6 | updateCar(id: number, car: Car) { 7 | return this.carUpdater.updateCar(id, car); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/Car/feature/components/CarDetails/CarDetails.component.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { Car } from '@Car/Domain'; 4 | import { render } from '@testing-library/react'; 5 | 6 | import * as db from '../../../../../../server/db.json'; 7 | import { CarDetails } from './CarDetails.component'; 8 | 9 | describe('CarDetails', () => { 10 | it('Should render CarDetails given a car', () => { 11 | const { getByTestId } = render(); 12 | 13 | expect(getByTestId('carImage')).toBeDefined(); 14 | expect(getByTestId('carNameInput')).toBeDefined(); 15 | expect(getByTestId('carNameLabel')).toBeDefined(); 16 | expect(getByTestId('carRegNumberLabel')).toBeDefined(); 17 | expect(getByTestId('carRegNumberInput')).toBeDefined(); 18 | expect(getByTestId('carEngineInput')).toBeDefined(); 19 | expect(getByTestId('carEngineLabel')).toBeDefined(); 20 | expect(getByTestId('carMaxSpeedInput')).toBeDefined(); 21 | expect(getByTestId('carMaxSpeedLabel')).toBeDefined(); 22 | expect(getByTestId('carLightPackInput')).toBeDefined(); 23 | expect(getByTestId('carLightPackLabel')).toBeDefined(); 24 | expect(getByTestId('carTravelKitInput')).toBeDefined(); 25 | expect(getByTestId('carTravelKitLabel')).toBeDefined(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/lib/Car/feature/components/CarDetails/CarDetails.component.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useState } from 'react'; 2 | import { FaArrowLeft, FaSave } from 'react-icons/fa'; 3 | import LicensePlate from 'react-license-plate'; 4 | 5 | import { Car } from '@Car/Domain'; 6 | 7 | export interface CarDetailsProps { 8 | car: Car; 9 | onBackButtonClick: (e: MouseEvent) => void; 10 | onSave: (car: Car) => void; 11 | } 12 | 13 | export const CarDetails = ({ car, onBackButtonClick, onSave }: CarDetailsProps) => { 14 | const [updatedCar, setUpdatedCar] = useState(car); 15 | 16 | return ( 17 |
18 |
19 | {car.name} 20 |
21 | 22 |
23 |
24 |
25 | Model 26 |
27 |
28 | setUpdatedCar({ ...updatedCar, name: e.target.value })} 33 | /> 34 |
35 |
36 | License plate 37 |
38 |
39 | setUpdatedCar({ ...updatedCar, regNumber: e.target.value })} 45 | /> 46 |
47 |
48 | Engine 49 |
50 |
51 | 56 | setUpdatedCar({ 57 | ...updatedCar, 58 | details: { 59 | ...car.details, 60 | engine: e.target.value, 61 | }, 62 | }) 63 | } 64 | /> 65 |
66 |
67 | Max speed in Km/h 68 |
69 |
70 | 74 | setUpdatedCar({ 75 | ...updatedCar, 76 | details: { 77 | ...car.details, 78 | maxSpeedInKmh: parseInt(e.target.value), 79 | }, 80 | }) 81 | } 82 | defaultValue={car.details.maxSpeedInKmh} 83 | /> 84 |
85 |
Extras
86 |
87 |
88 | 96 | setUpdatedCar({ 97 | ...updatedCar, 98 | details: { 99 | ...updatedCar.details, 100 | extras: { 101 | ...updatedCar.details.extras, 102 | lightPack: e.target.checked, 103 | }, 104 | }, 105 | }) 106 | } 107 | /> 108 | 111 |
112 |
113 | 121 | setUpdatedCar({ 122 | ...updatedCar, 123 | details: { 124 | ...updatedCar.details, 125 | extras: { 126 | ...updatedCar.details.extras, 127 | travelKit: e.target.checked, 128 | }, 129 | }, 130 | }) 131 | } 132 | /> 133 | 136 |
137 |
138 |
139 |
140 |
141 | 148 | 156 |
157 |
158 | ); 159 | }; 160 | -------------------------------------------------------------------------------- /src/lib/Car/feature/hooks/useGetCar.hook.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { createWrapper, GetCarsHttpMockService } from '@Test'; 4 | import { renderHook } from '@testing-library/react-hooks'; 5 | 6 | import * as db from '../../../../../server/db.json'; 7 | import { useGetCar } from './useGetCar.hook'; 8 | 9 | describe('useGetCar', () => { 10 | it('Should fetch the car given id', async () => { 11 | const { result, waitFor } = renderHook(() => useGetCar({ id: '1' }), { 12 | wrapper: createWrapper(new GetCarsHttpMockService()), 13 | }); 14 | 15 | await waitFor(() => result.current.isLoading === false); 16 | 17 | expect(result.current.error).toBeNull(); 18 | expect(result.current.isLoading).toBeFalsy(); 19 | expect(result.current.data).toBe(db.cars[0]); 20 | expect(result.current.isSuccess).toBeTruthy(); 21 | expect(result.current.queryKey).toBe('getCar'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/lib/Car/feature/hooks/useGetCar.hook.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { Car } from '@Car/Domain'; 4 | import { ContainerContext, GET_CARS_SERVICE } from '@Shared/Infrastructure'; 5 | import { useQuery } from '@tanstack/react-query'; 6 | 7 | export interface UseGetCarProps { 8 | id?: string; 9 | } 10 | 11 | export interface UseGetCarResult { 12 | isLoading: boolean; 13 | error: unknown; 14 | data: Car; 15 | isSuccess: boolean; 16 | queryKey: string; 17 | } 18 | 19 | export const useGetCar = ({ id }: UseGetCarProps) => { 20 | const QUERY_KEY = 'getCar'; 21 | const containerCtx = useContext(ContainerContext); 22 | const getCarsService = containerCtx.resolve(GET_CARS_SERVICE); 23 | const { isLoading, error, data, isSuccess } = useQuery( 24 | [QUERY_KEY, id], 25 | async () => id && getCarsService.getCarById(parseInt(id)), 26 | ); 27 | 28 | if (!id) { 29 | return { 30 | isLoading: false, 31 | data: undefined, 32 | error: { 33 | message: 'You need to provide a car id', 34 | }, 35 | }; 36 | } 37 | 38 | return { 39 | isLoading, 40 | error, 41 | data, 42 | isSuccess, 43 | queryKey: QUERY_KEY, 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/lib/Car/feature/hooks/useGetCars.hook.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { Car } from '@Car/Domain'; 4 | import { createWrapper, GetCarsHttpMockService } from '@Test'; 5 | import { renderHook } from '@testing-library/react-hooks'; 6 | 7 | import * as db from '../../../../../server/db.json'; 8 | import { useGetCars } from './useGetCars.hook'; 9 | 10 | const responseMock = db.cars; 11 | const searchQuery = ''; 12 | const sort = { 13 | property: 'regNumber' as keyof Car, 14 | order: 'asc' as 'asc' | 'desc', 15 | }; 16 | 17 | describe('useGetCars', () => { 18 | it('Should fetch the cars given searchQuery and sort', async () => { 19 | const { result, waitFor } = renderHook(() => useGetCars({ searchQuery, sort }), { 20 | wrapper: createWrapper(new GetCarsHttpMockService()), 21 | }); 22 | 23 | await waitFor(() => result.current.isLoading === false); 24 | 25 | expect(result.current.error).toBeNull(); 26 | expect(result.current.isLoading).toBeFalsy(); 27 | expect(result.current.data).toBe(responseMock); 28 | expect(result.current.isSuccess).toBeTruthy(); 29 | expect(result.current.queryKey).toBe('getCars'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/lib/Car/feature/hooks/useGetCars.hook.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { Car } from '@Car/Domain'; 4 | import { ContainerContext, GET_CARS_SERVICE } from '@Shared/Infrastructure'; 5 | import { useQuery } from '@tanstack/react-query'; 6 | 7 | export interface UseGetCarsProps { 8 | searchQuery?: string; 9 | sort: { 10 | property: keyof Car; 11 | order: 'asc' | 'desc'; 12 | }; 13 | } 14 | 15 | export interface UseGetCarsResult { 16 | isSuccess: boolean; 17 | isLoading: boolean; 18 | error: unknown; 19 | data?: Car[]; 20 | queryKey: string; 21 | } 22 | 23 | export const useGetCars = ({ searchQuery, sort }: UseGetCarsProps): UseGetCarsResult => { 24 | const QUERY_KEY = 'getCars'; 25 | const containerCtx = useContext(ContainerContext); 26 | const getCarsService = containerCtx.resolve(GET_CARS_SERVICE); 27 | const { isLoading, error, data, isSuccess } = useQuery([QUERY_KEY, searchQuery, sort], async () => 28 | getCarsService.getCars(searchQuery, sort), 29 | ); 30 | 31 | return { 32 | isSuccess, 33 | isLoading, 34 | error, 35 | data, 36 | queryKey: QUERY_KEY, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/lib/Car/feature/hooks/useSearch.hook.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { renderHook } from '@testing-library/react-hooks'; 4 | 5 | import { useSearch } from './useSearch.hook'; 6 | 7 | const search = 'Seat'; 8 | 9 | describe('useSearch', () => { 10 | it('Should set searchQuery', async () => { 11 | const { result, waitFor } = renderHook(() => useSearch()); 12 | 13 | result.current.debouncedSearchQuery(search); 14 | 15 | await waitFor(() => result.current.searchQuery === search); 16 | 17 | expect(result.current.searchQuery).toBe(search); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/lib/Car/feature/hooks/useSearch.hook.ts: -------------------------------------------------------------------------------- 1 | import debounce from 'lodash.debounce'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | export const useSearch = () => { 5 | const [searchQuery, setSearchQuery] = useState(); 6 | const [debouncedSearchQuery] = useState(() => debounce(setSearchQuery, 500)); 7 | 8 | useEffect(() => { 9 | return () => { 10 | debouncedSearchQuery.cancel(); 11 | }; 12 | }, [debouncedSearchQuery]); 13 | 14 | return { 15 | searchQuery, 16 | debouncedSearchQuery, 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/lib/Car/feature/hooks/useSort.hook.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { Car } from '@Car/Domain'; 4 | import { renderHook } from '@testing-library/react-hooks'; 5 | 6 | import { useSort } from './useSort.hook'; 7 | 8 | const sort = { 9 | property: 'regNumber' as keyof Car, 10 | order: 'asc' as 'asc' | 'desc', 11 | }; 12 | 13 | describe('useSort', () => { 14 | it('Should set sorting', () => { 15 | const { result } = renderHook(() => useSort()); 16 | 17 | result.current.setSort(sort); 18 | 19 | expect(result.current.sort).toBe(sort); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/lib/Car/feature/hooks/useSort.hook.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { Car } from '@Car/Domain'; 4 | 5 | export const useSort = () => { 6 | const [sort, setSort] = useState<{ 7 | property: keyof Car; 8 | order: 'asc' | 'desc'; 9 | }>({ property: 'regNumber', order: 'asc' }); 10 | 11 | return { 12 | sort, 13 | setSort, 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/Car/feature/hooks/useUpdateCar.hook.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { createWrapper, GetCarsHttpMockService, UpdateCarHttpMockService } from '@Test'; 4 | import { renderHook } from '@testing-library/react-hooks'; 5 | 6 | import * as db from '../../../../../server/db.json'; 7 | import { useUpdateCar } from './useUpdateCar.hook'; 8 | 9 | describe('useUpdateCar', () => { 10 | it('Should update a car', async () => { 11 | const { result, waitFor } = renderHook( 12 | () => 13 | useUpdateCar({ 14 | id: '1', 15 | updateQueryKey: 'getCar', 16 | }), 17 | { 18 | wrapper: createWrapper(new GetCarsHttpMockService(), new UpdateCarHttpMockService()), 19 | }, 20 | ); 21 | 22 | result.current.updateCar({ id: 1, updatedCar: db.cars[0] }); 23 | 24 | await waitFor(() => result.current.isLoading === false && result.current.isSuccess); 25 | 26 | expect(result.current.isError).toBeFalsy(); 27 | expect(result.current.isLoading).toBeFalsy(); 28 | expect(result.current.isSuccess).toBeTruthy(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/lib/Car/feature/hooks/useUpdateCar.hook.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { Car } from '@Car/Domain'; 4 | import { ContainerContext, UPDATE_CARS_SERVICE } from '@Shared/Infrastructure'; 5 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 6 | 7 | interface UseUpdateCarProps { 8 | updateQueryKey?: string; 9 | id?: string; 10 | } 11 | 12 | export const useUpdateCar = ({ id, updateQueryKey }: UseUpdateCarProps) => { 13 | const queryClient = useQueryClient(); 14 | const containerCtx = useContext(ContainerContext); 15 | const updateCarsService = containerCtx.resolve(UPDATE_CARS_SERVICE); 16 | const mutation = useMutation({ 17 | mutationFn: (variables: { id: number; updatedCar: Car }) => 18 | updateCarsService.updateCar(variables.id, variables.updatedCar), 19 | onSuccess: (data: Car) => { 20 | queryClient.setQueryData([updateQueryKey, id], data); 21 | }, 22 | }); 23 | 24 | return { 25 | updateCar: mutation.mutate, 26 | isLoading: mutation.isLoading, 27 | isError: mutation.isError, 28 | isSuccess: mutation.isSuccess, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/lib/Car/feature/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components/CarDetails/CarDetails.component'; 2 | export * from './hooks/useGetCar.hook'; 3 | export * from './hooks/useGetCars.hook'; 4 | export * from './hooks/useSearch.hook'; 5 | export * from './hooks/useSort.hook'; 6 | export * from './hooks/useUpdateCar.hook'; 7 | export * from './pages/Car/Car.page'; 8 | export * from './pages/Cars/Cars.page'; 9 | -------------------------------------------------------------------------------- /src/lib/Car/feature/pages/Car/Car.page.spec.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router-dom'; 2 | import { describe, expect, it, vi } from 'vitest'; 3 | 4 | import { createWrapper, GetCarsHttpMockService } from '@Test'; 5 | import { render, waitFor } from '@testing-library/react'; 6 | 7 | import * as db from '../../../../../../server/db.json'; 8 | import { CarPage } from './Car.page'; 9 | 10 | vi.mock('@Car/Feature', async () => { 11 | const actual = await vi.importActual('@Car/Feature'); 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 14 | return { 15 | ...(actual as any), 16 | useGetCar: vi.fn().mockImplementation(() => ({ 17 | data: db.cars[0], 18 | isLoading: false, 19 | error: null, 20 | })), 21 | }; 22 | }); 23 | 24 | describe('Car page', () => { 25 | it('Should render car page', async () => { 26 | const Wrapper = createWrapper(new GetCarsHttpMockService()); 27 | const { getByTestId } = render( 28 | 29 | 30 | 31 | 32 | , 33 | ); 34 | 35 | await waitFor(() => expect(getByTestId('carDetails')).toBeDefined()); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/lib/Car/feature/pages/Car/Car.page.tsx: -------------------------------------------------------------------------------- 1 | import 'react-toastify/dist/ReactToastify.css'; 2 | 3 | import { useEffect } from 'react'; 4 | import { useNavigate, useParams } from 'react-router-dom'; 5 | import { toast, ToastContainer } from 'react-toastify'; 6 | 7 | import { Car } from '@Car/Domain'; 8 | import { CarDetails, useGetCar, useUpdateCar } from '@Car/Feature'; 9 | import { Error, Header, Loader, PageContainer } from '@Shared/Ui'; 10 | 11 | export const CarPage = () => { 12 | const { id } = useParams(); 13 | const { isLoading, data: car, error, queryKey } = useGetCar({ id }); 14 | const { 15 | updateCar, 16 | isLoading: isUpdateLoading, 17 | isSuccess: isUpdateSuccess, 18 | isError: isUpdateError, 19 | } = useUpdateCar({ id, updateQueryKey: queryKey }); 20 | const navigate = useNavigate(); 21 | 22 | useEffect(() => { 23 | if (isUpdateSuccess) { 24 | toast.success(`Car updated!`, { 25 | position: 'bottom-right', 26 | autoClose: 5000, 27 | hideProgressBar: true, 28 | closeOnClick: true, 29 | pauseOnHover: true, 30 | draggable: false, 31 | progress: undefined, 32 | theme: 'dark', 33 | }); 34 | } 35 | }, [isUpdateSuccess]); 36 | 37 | useEffect(() => { 38 | if (isUpdateError) { 39 | toast.error(`Car update error!`, { 40 | position: 'bottom-right', 41 | autoClose: 5000, 42 | hideProgressBar: true, 43 | closeOnClick: true, 44 | pauseOnHover: true, 45 | draggable: false, 46 | progress: undefined, 47 | theme: 'dark', 48 | }); 49 | } 50 | }, [isUpdateError]); 51 | 52 | const onBackButtonClick = () => { 53 | navigate('/'); 54 | }; 55 | 56 | const onSave = (updatedCar: Car) => { 57 | if (id) { 58 | const intId = parseInt(id); 59 | updateCar({ id: intId, updatedCar }); 60 | } 61 | }; 62 | 63 | return ( 64 | }> 65 | {(isLoading || isUpdateLoading) && } 66 | {!isLoading && !isUpdateLoading && error !== null && } 67 | {!isLoading && !error && car && !isUpdateLoading && ( 68 | 69 | )} 70 | 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/lib/Car/feature/pages/Cars/Cars.page.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { createWrapper, GetCarsHttpMockErrorService, GetCarsHttpMockService } from '@Test'; 4 | import { render, waitFor } from '@testing-library/react'; 5 | 6 | import { CarsPage } from './Cars.page'; 7 | 8 | vi.mock('react-router-dom', () => ({ 9 | useNavigate: vi.fn().mockImplementation((path: string) => path), 10 | })); 11 | 12 | describe('Cars page', () => { 13 | it('Should render cars page with cars table', async () => { 14 | const Wrapper = createWrapper(new GetCarsHttpMockService()); 15 | 16 | const { getByTestId, getByTitle, getByRole } = render( 17 | 18 | 19 | , 20 | ); 21 | 22 | const loading = getByRole('status'); 23 | 24 | expect(loading).toBeDefined(); 25 | 26 | await waitFor(() => expect(getByTestId('carsTable')).toBeDefined()); 27 | 28 | const pageContainer = getByTestId('main'); 29 | const searchInput = getByTitle('search'); 30 | const sortSelects = getByTestId('sortSelects'); 31 | 32 | expect(pageContainer).toBeDefined(); 33 | expect(searchInput).toBeDefined(); 34 | expect(sortSelects).toBeDefined(); 35 | }); 36 | 37 | it('Should render error', async () => { 38 | const Wrapper = createWrapper(new GetCarsHttpMockErrorService()); 39 | 40 | const { getByText } = render( 41 | 42 | 43 | , 44 | ); 45 | 46 | await waitFor(() => expect(getByText('Error')).toBeDefined()); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/lib/Car/feature/pages/Cars/Cars.page.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, MouseEvent } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | import { Car } from '@Car/Domain'; 5 | import { useGetCars, useSearch, useSort } from '@Car/Feature'; 6 | import { CarsTable, InputSearch, SortSelect } from '@Car/Ui'; 7 | import { Error, Header, Loader, PageContainer } from '@Shared/Ui'; 8 | 9 | export const CarsPage = () => { 10 | const { sort, setSort } = useSort(); 11 | const { debouncedSearchQuery, searchQuery } = useSearch(); 12 | const { isLoading, error, data: cars } = useGetCars({ searchQuery, sort }); 13 | const navigate = useNavigate(); 14 | 15 | const onInputSearchChange = (ev: ChangeEvent) => { 16 | const value = ev.target.value; 17 | debouncedSearchQuery(value); 18 | }; 19 | 20 | const onSortPropertyChange = (e: ChangeEvent) => { 21 | setSort({ ...sort, property: e.target.value as keyof Car }); 22 | }; 23 | 24 | const onSortOrderChange = (e: ChangeEvent) => { 25 | setSort({ ...sort, order: e.target.value as 'asc' | 'desc' }); 26 | }; 27 | 28 | const onEditCarClick = (_e: MouseEvent, car: Car) => { 29 | navigate(`/car/${car.id}`); 30 | }; 31 | 32 | return ( 33 | }> 34 | 35 | 36 | {isLoading && } 37 | {error !== null && !isLoading && } 38 | {cars && !isLoading && !error && } 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/lib/Car/index.ts: -------------------------------------------------------------------------------- 1 | export * from './feature'; 2 | -------------------------------------------------------------------------------- /src/lib/Car/infrastructure/http/GetCarsHttpService.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import * as db from '../../../../../server/db.json'; 4 | import { GetCarsHttpService } from './GetCarsHttpService'; 5 | 6 | global.fetch = vi.fn().mockImplementation(() => 7 | Promise.resolve({ 8 | json: () => Promise.resolve(db.cars), 9 | }), 10 | ); 11 | 12 | describe('GetCarsHttpService', async () => { 13 | it('Should fetch cars', async () => { 14 | const getCarsHttpService = new GetCarsHttpService('http://localhost:3000'); 15 | 16 | expect(await getCarsHttpService.getCars()).toBe(db.cars); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/lib/Car/infrastructure/http/GetCarsHttpService.ts: -------------------------------------------------------------------------------- 1 | import { Car, GetCarsRepository } from '@Car/Domain'; 2 | 3 | export class GetCarsHttpService implements GetCarsRepository { 4 | constructor(private apiUrl: string) {} 5 | 6 | getCarById = async (id: number) => { 7 | const url = new URL(`${this.apiUrl}/cars/${id}`); 8 | const response = await fetch(url); 9 | const car = (await response.json()) as Car; 10 | 11 | return car; 12 | }; 13 | 14 | getCars = async ( 15 | searchQuery?: string, 16 | sort?: { 17 | order: 'asc' | 'desc'; 18 | property: keyof Car; 19 | }, 20 | ): Promise => { 21 | const url = new URL(`${this.apiUrl}/cars`); 22 | 23 | if (searchQuery) { 24 | url.searchParams.append('q', searchQuery); 25 | } 26 | 27 | if (sort) { 28 | url.searchParams.append('_sort', sort.property); 29 | url.searchParams.append('_order', sort.order); 30 | } 31 | 32 | const response = await fetch(url); 33 | const cars = (await response.json()) as Car[]; 34 | 35 | return cars; 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/Car/infrastructure/http/UpdateCarHttpService.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { UpdateCarHttpService } from '../'; 4 | import * as db from '../../../../../server/db.json'; 5 | 6 | global.fetch = vi.fn().mockImplementation(() => 7 | Promise.resolve({ 8 | json: () => Promise.resolve(db.cars[0]), 9 | }), 10 | ); 11 | 12 | describe('GetCarsHttpService', async () => { 13 | it('Should update a car', async () => { 14 | const udpateCarHttpService = new UpdateCarHttpService('http://localhost:3000'); 15 | 16 | expect(await udpateCarHttpService.updateCar(1, db.cars[0])).toBe(db.cars[0]); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/lib/Car/infrastructure/http/UpdateCarHttpService.ts: -------------------------------------------------------------------------------- 1 | import { Car, UpdateCarRepository } from '@Car/Domain'; 2 | 3 | export class UpdateCarHttpService implements UpdateCarRepository { 4 | constructor(private apiUrl: string) {} 5 | 6 | updateCar = async (id: number, car: Car) => { 7 | const response = await fetch(`${this.apiUrl}/cars/${id}`, { 8 | method: 'PATCH', 9 | headers: { 'Content-type': 'application/json' }, 10 | body: JSON.stringify({ ...car }), 11 | }); 12 | const updatedCar = (await response.json()) as Car; 13 | 14 | return updatedCar; 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/Car/infrastructure/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http/GetCarsHttpService'; 2 | export * from './http/UpdateCarHttpService'; 3 | -------------------------------------------------------------------------------- /src/lib/Car/ui/CarsTable/CarsTable.component.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import * as db from '../../../../../server/db.json'; 6 | import { CarsTable } from './CarsTable.component'; 7 | 8 | describe('CarsTable', () => { 9 | it('Should render the cars table', () => { 10 | const { getByText, getAllByTestId } = render(); 11 | 12 | // Header 13 | const imageHader = getByText('Image'); 14 | const licensePlateHeader = getByText('License plate'); 15 | const modelHeader = getByText('Model'); 16 | const brandHeader = getByText('Brand'); 17 | const actionsHeader = getByText('Actions'); 18 | 19 | expect(imageHader).toBeDefined(); 20 | expect(licensePlateHeader).toBeDefined(); 21 | expect(modelHeader).toBeDefined(); 22 | expect(brandHeader).toBeDefined(); 23 | expect(actionsHeader).toBeDefined(); 24 | 25 | // Rows 26 | const carImages = getAllByTestId('carImage'); 27 | const carLicensePlates = getAllByTestId('licensePlate'); 28 | const carModel = getAllByTestId('name'); 29 | const carBrand = getAllByTestId('brand'); 30 | const carActions = getAllByTestId('actions'); 31 | 32 | expect(carImages.length).toBe(db.cars.length); 33 | expect(carLicensePlates.length).toBe(db.cars.length); 34 | expect(carModel.length).toBe(db.cars.length); 35 | expect(carBrand.length).toBe(db.cars.length); 36 | expect(carActions.length).toBe(db.cars.length); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/lib/Car/ui/CarsTable/CarsTable.component.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from 'react'; 2 | import { FaEdit } from 'react-icons/fa'; 3 | import LicensePlate from 'react-license-plate'; 4 | 5 | import { Car } from '@Car/Domain'; 6 | 7 | import { CarsTableCell } from './components/CarsTableCell/CarsTableCell.component'; 8 | import { CarsTableHeader } from './components/CarsTableHeader/CarsTableHeader.component'; 9 | 10 | interface CarsTableProps { 11 | cars: Car[]; 12 | onEditCarClick: (e: MouseEvent, car: Car) => void; 13 | } 14 | 15 | export const CarsTable = ({ cars, onEditCarClick }: CarsTableProps) => ( 16 |
17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 | 26 |
27 |
28 | 29 |
30 |
31 | 32 |
33 |
34 | 35 | {cars.map((car) => ( 36 |
37 |
38 | {car.name} 39 |
40 | } 43 | /> 44 | 45 | 54 | ) : ( 55 | car.brand 56 | ) 57 | } 58 | /> 59 |
63 | 70 |
71 |
72 | ))} 73 |
74 | ); 75 | -------------------------------------------------------------------------------- /src/lib/Car/ui/CarsTable/components/CarsTableCell/CarsTableCell.component.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { CarsTableCell } from './CarsTableCell.component'; 6 | 7 | describe('CarsTableCell', () => { 8 | it('Should render label', () => { 9 | const label = 'test'; 10 | const { getByText } = render(); 11 | 12 | expect(getByText(label)).toBeDefined(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/lib/Car/ui/CarsTable/components/CarsTableCell/CarsTableCell.component.tsx: -------------------------------------------------------------------------------- 1 | interface CarsTableCellProps { 2 | label: string | JSX.Element; 3 | dataTestId?: string; 4 | } 5 | 6 | export const CarsTableCell = ({ label, dataTestId }: CarsTableCellProps) => ( 7 |
8 | {label} 9 |
10 | ); 11 | -------------------------------------------------------------------------------- /src/lib/Car/ui/CarsTable/components/CarsTableHeader/CarsTableHeader.component.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { CarsTableHeader } from './CarsTableHeader.component'; 6 | 7 | describe('CarsTableHeader', () => { 8 | it('Should render label', () => { 9 | const label = 'test'; 10 | const { getByText } = render(); 11 | 12 | expect(getByText(label)).toBeDefined(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/lib/Car/ui/CarsTable/components/CarsTableHeader/CarsTableHeader.component.tsx: -------------------------------------------------------------------------------- 1 | interface CarsTableHeaderProps { 2 | label: string; 3 | } 4 | 5 | export const CarsTableHeader = ({ label }: CarsTableHeaderProps) => ( 6 |
7 | {label} 8 |
9 | ); 10 | -------------------------------------------------------------------------------- /src/lib/Car/ui/InputSearch/InputSearch.component.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { InputSearch } from '../'; 6 | 7 | describe('InputSearch', () => { 8 | const onChange = vi.fn(); 9 | 10 | it('Should render input', () => { 11 | const { getByTitle } = render(); 12 | 13 | const input: HTMLInputElement = getByTitle('search') as HTMLInputElement; 14 | 15 | expect(input).toBeDefined(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/lib/Car/ui/InputSearch/InputSearch.component.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from 'react'; 2 | 3 | export interface InputSearchProps { 4 | onChange: (ev: ChangeEvent) => void; 5 | } 6 | 7 | export const InputSearch = ({ onChange }: InputSearchProps) => ( 8 |
9 | 16 |
17 | ); 18 | -------------------------------------------------------------------------------- /src/lib/Car/ui/SortSelect/SortSelect.component.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { SortSelect } from './SortSelect.component'; 6 | 7 | describe('SortSelect', () => { 8 | const onChange = vi.fn(); 9 | 10 | it('Should render selects', () => { 11 | const { getByTitle } = render(); 12 | 13 | const selectProperty = getByTitle('Sort by'); 14 | const selectOrder = getByTitle('Order'); 15 | 16 | expect(selectProperty).toBeDefined(); 17 | expect(selectOrder).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/lib/Car/ui/SortSelect/SortSelect.component.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from 'react'; 2 | 3 | interface SortSelectProps { 4 | onSortPropertyChange: (e: ChangeEvent) => void; 5 | onSortOrderChange: (e: ChangeEvent) => void; 6 | } 7 | 8 | export const SortSelect = ({ onSortPropertyChange, onSortOrderChange }: SortSelectProps) => ( 9 |
10 |
11 | 14 | 18 |
19 |
20 | 23 | 27 |
28 |
29 | ); 30 | -------------------------------------------------------------------------------- /src/lib/Car/ui/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CarsTable/CarsTable.component'; 2 | export * from './InputSearch/InputSearch.component'; 3 | export * from './SortSelect/SortSelect.component'; 4 | -------------------------------------------------------------------------------- /src/lib/Shared/infrastructure/di/di.ts: -------------------------------------------------------------------------------- 1 | import { asValue, createContainer } from 'awilix'; 2 | 3 | import { GetCarsService, UpdateCarService } from '@Car/Domain'; 4 | import { GetCarsHttpService, UpdateCarHttpService } from '@Car/Infrastructure'; 5 | 6 | export const GET_CARS_SERVICE = 'getCarsService'; 7 | export const UPDATE_CARS_SERVICE = 'updateCarsService'; 8 | export const API_URL = 'http://localhost:3000'; 9 | 10 | export interface ContainerRegisteredServices { 11 | apiUrl: string; 12 | [GET_CARS_SERVICE]: GetCarsService; 13 | [UPDATE_CARS_SERVICE]: UpdateCarService; 14 | } 15 | 16 | export const createDIContainer = () => { 17 | const container = createContainer(); 18 | 19 | container.register({ 20 | apiUrl: asValue(API_URL), 21 | [GET_CARS_SERVICE]: asValue(new GetCarsService(new GetCarsHttpService(API_URL))), 22 | [UPDATE_CARS_SERVICE]: asValue(new UpdateCarService(new UpdateCarHttpService(API_URL))), 23 | }); 24 | 25 | return container; 26 | }; 27 | -------------------------------------------------------------------------------- /src/lib/Shared/infrastructure/di/react/ctx/container.ctx.ts: -------------------------------------------------------------------------------- 1 | import { AwilixContainer } from "awilix"; 2 | import { createContext } from "react"; 3 | import { ContainerRegisteredServices, createDIContainer } from "../../di"; 4 | 5 | export const ContainerContext = createContext>( 6 | createDIContainer() 7 | ); -------------------------------------------------------------------------------- /src/lib/Shared/infrastructure/index.ts: -------------------------------------------------------------------------------- 1 | export * from './di/di'; 2 | export * from './di/react/ctx/container.ctx'; 3 | -------------------------------------------------------------------------------- /src/lib/Shared/ui/components/Error/Error.component.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { Error } from './Error.component'; 6 | 7 | describe('Error', () => { 8 | it('Should render error component', () => { 9 | const { getByText } = render(); 10 | 11 | expect(getByText('Error')).toBeDefined(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/lib/Shared/ui/components/Error/Error.component.tsx: -------------------------------------------------------------------------------- 1 | export const Error = () => ( 2 |
3 |

Error

4 |
5 | ); 6 | -------------------------------------------------------------------------------- /src/lib/Shared/ui/components/Header/Header.component.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { Header } from './Header.component'; 6 | 7 | describe('Header', () => { 8 | it('Should render header component', () => { 9 | const { getByTestId } = render(
); 10 | 11 | expect(getByTestId('header')).toBeDefined(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/lib/Shared/ui/components/Header/Header.component.tsx: -------------------------------------------------------------------------------- 1 | export const Header = (): JSX.Element => { 2 | return ( 3 |
4 |
5 | logo 6 |

RSGiner - React DDD Example

7 |
8 |
9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/lib/Shared/ui/components/Loader/Loader.component.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { Loader } from './Loader.component'; 6 | 7 | describe('Loader', () => { 8 | it('Should render loader component', () => { 9 | const { getByRole } = render(); 10 | 11 | expect(getByRole('status')).toBeDefined(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/lib/Shared/ui/components/Loader/Loader.component.tsx: -------------------------------------------------------------------------------- 1 | interface LoaderProps { 2 | size?: number; 3 | } 4 | 5 | export const Loader = ({ size = 20 }: LoaderProps) => ( 6 |
7 | 23 | Loading... 24 |
25 | ); 26 | -------------------------------------------------------------------------------- /src/lib/Shared/ui/components/PageContainer/PageContainer.component.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { Header } from '../Header/Header.component'; 6 | import { PageContainer } from './PageContainer.component'; 7 | 8 | describe('PageContainer', () => { 9 | it('Should render PageContainer component', () => { 10 | const { getByTestId } = render(} />); 11 | 12 | expect(getByTestId('main')).toBeDefined(); 13 | expect(getByTestId('header')).toBeDefined(); 14 | }); 15 | 16 | it('Should render PageContainer childrens', () => { 17 | const { getByTestId } = render( 18 | }> 19 |
20 | , 21 | ); 22 | 23 | expect(getByTestId('test')).toBeDefined(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/lib/Shared/ui/components/PageContainer/PageContainer.component.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | interface PageContainerProps { 4 | header: JSX.Element; 5 | } 6 | 7 | export const PageContainer = ({ header, children }: PropsWithChildren): JSX.Element => { 8 | return ( 9 | <> 10 | {header} 11 |
12 | {children} 13 |
14 | ; 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/Shared/ui/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components/Error/Error.component'; 2 | export * from './components/Header/Header.component'; 3 | export * from './components/Loader/Loader.component'; 4 | export * from './components/PageContainer/PageContainer.component'; 5 | -------------------------------------------------------------------------------- /src/test/GetCarsHttpMock.service.ts: -------------------------------------------------------------------------------- 1 | import { GetCarsRepository } from '@Car/Domain'; 2 | 3 | import * as db from '../../server/db.json'; 4 | 5 | export class GetCarsHttpMockService implements GetCarsRepository { 6 | getCarById = () => Promise.resolve(db.cars[0]); 7 | getCars = () => Promise.resolve(db.cars); 8 | } 9 | 10 | export class GetCarsHttpMockErrorService implements GetCarsRepository { 11 | getCarById = () => Promise.reject({}); 12 | getCars = () => Promise.reject({}); 13 | } 14 | -------------------------------------------------------------------------------- /src/test/UpdateCarHttpMockService.ts: -------------------------------------------------------------------------------- 1 | import { Car, UpdateCarRepository } from '@Car/Domain'; 2 | 3 | export class UpdateCarHttpMockService implements UpdateCarRepository { 4 | updateCar = (_id: number, car: Car) => Promise.resolve(car); 5 | } 6 | -------------------------------------------------------------------------------- /src/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GetCarsHttpMock.service'; 2 | export * from './UpdateCarHttpMockService'; 3 | export * from './wrapper'; 4 | -------------------------------------------------------------------------------- /src/test/wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { asValue, createContainer } from 'awilix'; 2 | import { PropsWithChildren } from 'react'; 3 | 4 | import { GetCarsRepository, GetCarsService, UpdateCarRepository, UpdateCarService } from '@Car/Domain'; 5 | import { 6 | ContainerContext, 7 | ContainerRegisteredServices, 8 | GET_CARS_SERVICE, 9 | UPDATE_CARS_SERVICE, 10 | } from '@Shared/Infrastructure'; 11 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 12 | 13 | import { GetCarsHttpMockService } from './GetCarsHttpMock.service'; 14 | import { UpdateCarHttpMockService } from './UpdateCarHttpMockService'; 15 | 16 | export const createWrapper = ( 17 | getCarsRepository: GetCarsRepository = new GetCarsHttpMockService(), 18 | updateCarRepository: UpdateCarRepository = new UpdateCarHttpMockService(), 19 | ) => { 20 | const queryClient = new QueryClient({ 21 | defaultOptions: { 22 | queries: { 23 | retry: false, 24 | }, 25 | }, 26 | }); 27 | 28 | const container = createContainer(); 29 | 30 | container.register({ 31 | [GET_CARS_SERVICE]: asValue(new GetCarsService(getCarsRepository)), 32 | [UPDATE_CARS_SERVICE]: asValue(new UpdateCarService(updateCarRepository)), 33 | }); 34 | 35 | return ({ children }: PropsWithChildren) => ( 36 | 37 | {children} 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "baseUrl": "src", 9 | "paths": { 10 | "@Car/Domain": ["lib/Car/domain"], 11 | "@Car/Feature": ["lib/Car/feature"], 12 | "@Car/Ui": ["lib/Car/ui"], 13 | "@Car/Infrastructure": ["lib/Car/infrastructure"], 14 | "@Shared/Infrastructure": ["lib/Shared/infrastructure"], 15 | "@Shared/Ui": ["lib/Shared/ui"], 16 | "@Test": ["test"] 17 | }, 18 | /* Bundler mode */ 19 | "moduleResolution": "bundler", 20 | "allowImportingTsExtensions": true, 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": true, 24 | "jsx": "react-jsx", 25 | 26 | /* Linting */ 27 | "strict": true, 28 | "noUnusedLocals": true, 29 | "noUnusedParameters": true, 30 | "noFallthroughCasesInSwitch": true 31 | }, 32 | "include": ["src"], 33 | "references": [{ "path": "./tsconfig.node.json" }] 34 | } 35 | -------------------------------------------------------------------------------- /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 path from 'path'; 2 | import { defineConfig } from 'vite'; 3 | 4 | import react from '@vitejs/plugin-react'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | resolve: { 9 | alias: { 10 | '@Car/Feature': path.resolve(__dirname, './src/lib/Car/feature'), 11 | '@Car/Ui': path.resolve(__dirname, './src/lib/Car/ui'), 12 | '@Car/Domain': path.resolve(__dirname, './src/lib/Car/domain'), 13 | '@Car/Infrastructure': path.resolve(__dirname, './src/lib/Car/infrastructure'), 14 | '@Shared/Infrastructure': path.resolve(__dirname, './src/lib/Shared/infrastructure'), 15 | '@Shared/Ui': path.resolve(__dirname, './src/lib/Shared/ui'), 16 | '@Test': path.resolve(__dirname, './src/test'), 17 | }, 18 | }, 19 | plugins: [react()], 20 | }); 21 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | '@Car/Feature': path.resolve(__dirname, './src/lib/Car/feature'), 8 | '@Car/Ui': path.resolve(__dirname, './src/lib/Car/ui'), 9 | '@Car/Domain': path.resolve(__dirname, './src/lib/Car/domain'), 10 | '@Car/Infrastructure': path.resolve(__dirname, './src/lib/Car/infrastructure'), 11 | '@Shared/Infrastructure': path.resolve(__dirname, './src/lib/Shared/infrastructure'), 12 | '@Shared/Ui': path.resolve(__dirname, './src/lib/Shared/ui'), 13 | '@Test': path.resolve(__dirname, './src/test'), 14 | }, 15 | }, 16 | test: { 17 | environment: 'jsdom', 18 | coverage: { 19 | all: true, 20 | include: ['src/**/*.tsx', 'src/**/*.ts'], 21 | exclude: ['src/**/index.ts', 'src/**/app/main.tsx', 'src/**/*.d.ts'], 22 | }, 23 | include: ['**/*.spec.ts', '**/*.spec.tsx'], 24 | }, 25 | }); 26 | --------------------------------------------------------------------------------