├── public ├── _redirects ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── src ├── react-app-env.d.ts ├── core │ ├── domain │ │ ├── loaders │ │ │ └── PokemonLoader.ts │ │ └── entities │ │ │ └── pokemon.ts │ ├── adapters │ │ ├── primaries │ │ │ └── pokemon.module.ts │ │ └── secondaries │ │ │ ├── inmemory │ │ │ └── InMemoryPokemon.loader.ts │ │ │ └── real │ │ │ ├── mappers │ │ │ └── pokemon.mappers.ts │ │ │ ├── RESTPokemon.loader.ts │ │ │ └── DTO │ │ │ └── PokemonDTO.ts │ ├── usecases │ │ ├── pokemon.handler.ts │ │ └── pokemon.builder.ts │ └── configuration │ │ └── pokemonDI.factory.ts ├── setupTests.ts ├── App.test.tsx ├── tests │ ├── stubs │ │ └── stubPokemon.builder.ts │ ├── pokemonFetching.spec.ts │ └── integration │ │ └── RESTPokemonLoader.spec.ts ├── reportWebVitals.ts ├── App.tsx ├── index.tsx ├── App.css ├── index.css ├── views │ ├── Home.tsx │ └── PokemonDetails.tsx └── logo.svg ├── .gitignore ├── tsconfig.json ├── README.md ├── package.json └── .eslintcache /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevenpersia/clean-architecture-tdd-frontend-project/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevenpersia/clean-architecture-tdd-frontend-project/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevenpersia/clean-architecture-tdd-frontend-project/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/core/domain/loaders/PokemonLoader.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs"; 2 | import { Pokemon } from "core/domain/entities/pokemon"; 3 | 4 | export interface PokemonLoader { 5 | all(): Observable; 6 | 7 | get(number: string): Observable; 8 | } 9 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/core/adapters/primaries/pokemon.module.ts: -------------------------------------------------------------------------------- 1 | import { PokemonHandler } from "core/usecases/pokemon.handler"; 2 | import { PokemonDIFactory } from "core/configuration/pokemonDI.factory"; 3 | 4 | export const pokemonHandler: PokemonHandler = new PokemonHandler( 5 | PokemonDIFactory.pokemonLoader() 6 | ); 7 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/tests/stubs/stubPokemon.builder.ts: -------------------------------------------------------------------------------- 1 | import { PokemonBuilder } from "core/usecases/pokemon.builder"; 2 | 3 | export class StubPokemonBuilder extends PokemonBuilder { 4 | protected _number: string = "1"; 5 | protected _name: string = "Pikachu"; 6 | protected _description: string = "Lorem ipsum"; 7 | protected _weight: number = 1; 8 | protected _height: number = 1; 9 | protected _avatar: string = "avatar"; 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .idea 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/core/usecases/pokemon.handler.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs"; 2 | import { PokemonLoader } from "core/domain/loaders/PokemonLoader"; 3 | import { Pokemon } from "core/domain/entities/pokemon"; 4 | 5 | export class PokemonHandler { 6 | constructor(private pokemonSource: PokemonLoader) {} 7 | 8 | all(): Observable { 9 | return this.pokemonSource.all(); 10 | } 11 | 12 | get(number: string): Observable { 13 | return this.pokemonSource.get(number); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; 2 | import Home from "./views/Home"; 3 | import "./App.css"; 4 | import PokemonDetails from "./views/PokemonDetails"; 5 | 6 | const App = () => { 7 | return ( 8 | 9 |

Pokedex

10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | ); 21 | }; 22 | 23 | export default App; 24 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "strictPropertyInitialization": false, 19 | "baseUrl": "./src" 20 | }, 21 | "include": ["src"] 22 | } 23 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/core/domain/entities/pokemon.ts: -------------------------------------------------------------------------------- 1 | export class Pokemon { 2 | constructor( 3 | private _number: string, 4 | private _name: string, 5 | private _description: string, 6 | private _weight: number, 7 | private _height: number, 8 | private _avatar: string 9 | ) {} 10 | 11 | get number(): string { 12 | return this._number; 13 | } 14 | 15 | get name(): string { 16 | return this._name; 17 | } 18 | 19 | get weight(): number { 20 | return this._weight; 21 | } 22 | 23 | get description(): string { 24 | return this._description; 25 | } 26 | 27 | get height(): number { 28 | return this._height; 29 | } 30 | 31 | get avatar(): string { 32 | return this._avatar; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/core/adapters/secondaries/inmemory/InMemoryPokemon.loader.ts: -------------------------------------------------------------------------------- 1 | import { PokemonLoader } from "core/domain/loaders/PokemonLoader"; 2 | import { BehaviorSubject, Observable, Subject } from "rxjs"; 3 | import { Pokemon } from "core/domain/entities/pokemon"; 4 | import { map } from "rxjs/operators"; 5 | 6 | export class InMemoryPokemonLoader implements PokemonLoader { 7 | private pokemons$: Subject = new BehaviorSubject(this.pokemons); 8 | 9 | constructor(private pokemons: Pokemon[]) {} 10 | 11 | all(): Observable { 12 | return this.pokemons$; 13 | } 14 | 15 | get(number: string): Observable { 16 | return this.pokemons$.pipe( 17 | map( 18 | (pokemons) => pokemons.filter((pokemon) => pokemon.number === number)[0] 19 | ) 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | background: #f7fafc; 9 | text-align: center; 10 | } 11 | 12 | h2 { 13 | font-size: 18px; 14 | } 15 | 16 | ul { 17 | margin: 0; 18 | padding: 0; 19 | list-style-type: none; 20 | } 21 | 22 | .grid { 23 | display: grid; 24 | grid: auto / repeat(4, 1fr); 25 | } 26 | 27 | .card { 28 | background: #ffffff; 29 | border-radius: 0.5rem; 30 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 31 | 0 4px 6px -2px rgba(0, 0, 0, 0.05); 32 | padding: 16px; 33 | margin: 16px; 34 | text-align: center; 35 | } 36 | 37 | .card:hover { 38 | cursor: pointer; 39 | opacity: 0.6; 40 | } 41 | -------------------------------------------------------------------------------- /src/views/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { Pokemon } from "core/domain/entities/pokemon"; 4 | import { pokemonHandler } from "core/adapters/primaries/pokemon.module"; 5 | 6 | const Home = () => { 7 | const [pokemons, setPokemons] = useState([]); 8 | const history = useHistory(); 9 | 10 | useEffect(() => { 11 | pokemonHandler.all().subscribe((pokemons) => setPokemons(pokemons)); 12 | }, []); 13 | 14 | return ( 15 |
16 | {pokemons.map((pokemon) => ( 17 |
history.push(`/${pokemon.number}`)} 20 | > 21 |

22 | #{pokemon.number} {pokemon.name} 23 |

24 |
25 | ))} 26 |
27 | ); 28 | }; 29 | 30 | export default Home; 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clean architecture + TDD on front end project 2 | 3 | I've tried **TDD** with **Clean architecture** implementation in frontend side. It can run either with **InMemory** data or with data from **PokeApi** (rest API). It works with **React** but strength of this concept is to easily change framework and sources (database, ...). So if you fork it, you can add **VueJS** or **AngularJS** without any issues and without touching of core logic. 4 | 5 | **All core logic is completely independent from data source and framework.** 6 | 7 | I'll do more complex projects with this concept because it's kinda a prerequisite for my actual and future works as freelancer. If you are interested as I am, please contact me or follow me on Github to check next updates. 8 | 9 | It runs with **RxJS** for observable capabilities (next time, I will focus on **Redux** and **Redux-Thunk**). 10 | 11 | ## Installation 12 | 13 | ``` 14 | yarn 15 | ``` 16 | 17 | ## Start project 18 | 19 | You can start it with two differents sources : 20 | 21 | **InMemory** 22 | ``` 23 | yarn start 24 | ``` 25 | 26 | **Rest API** 27 | ``` 28 | yarn start:rest 29 | ``` 30 | -------------------------------------------------------------------------------- /src/core/adapters/secondaries/real/mappers/pokemon.mappers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PokemonDTO, 3 | PokemonPageDTO, 4 | PokemonSimpleDTO, 5 | } from "../DTO/PokemonDTO"; 6 | import { PokemonBuilder } from "core/usecases/pokemon.builder"; 7 | import { Pokemon } from "core/domain/entities/pokemon"; 8 | 9 | export class PokemonMappers { 10 | static mapToPokemon(pokemonDTO: PokemonDTO): Pokemon { 11 | return new PokemonBuilder() 12 | .withNumber(pokemonDTO.id.toString()) 13 | .withName(pokemonDTO.name) 14 | .withDescription(pokemonDTO.types.map((t) => t.type.name).join(", ")) 15 | .withHeight(pokemonDTO.height.valueOf()) 16 | .withWeight(pokemonDTO.weight.valueOf()) 17 | .withAvatar(pokemonDTO.sprites.front_default) 18 | .build(); 19 | } 20 | 21 | static mapPageToPokemons(pokemonPageDTO: PokemonPageDTO): Pokemon[] { 22 | return pokemonPageDTO.results.map( 23 | (pokemonDTO: PokemonSimpleDTO, index: number) => { 24 | return new PokemonBuilder() 25 | .withName(pokemonDTO.name) 26 | .withNumber((index + 1).toString()) 27 | .build(); 28 | } 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/views/PokemonDetails.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useHistory, useParams } from "react-router-dom"; 3 | import { pokemonHandler } from "core/adapters/primaries/pokemon.module"; 4 | import { Pokemon } from "core/domain/entities/pokemon"; 5 | 6 | const PokemonDetails = () => { 7 | const [pokemon, setPokemon] = useState(); 8 | const { number = "" }: { number: string } = useParams(); 9 | const history = useHistory(); 10 | 11 | useEffect(() => { 12 | pokemonHandler.get(number).subscribe((pokemon) => setPokemon(pokemon)); 13 | }, [number]); 14 | 15 | return ( 16 |
history.push(`/`)} 19 | style={{ maxWidth: 600, margin: "0 auto" }} 20 | > 21 | {pokemon?.name} 22 |

23 | #{pokemon?.number}
{pokemon?.name} 24 |

25 |
    26 |
  • Weight: {pokemon?.weight}
  • 27 |
  • Height: {pokemon?.height}
  • 28 |
  • Type: {pokemon?.description}
  • 29 |
30 |
31 | ); 32 | }; 33 | 34 | export default PokemonDetails; 35 | -------------------------------------------------------------------------------- /src/core/adapters/secondaries/real/RESTPokemon.loader.ts: -------------------------------------------------------------------------------- 1 | import { PokemonLoader } from "core/domain/loaders/PokemonLoader"; 2 | import { Pokemon } from "core/domain/entities/pokemon"; 3 | import { PokemonDTO, PokemonPageDTO } from "./DTO/PokemonDTO"; 4 | import { PokemonMappers } from "./mappers/pokemon.mappers"; 5 | import { from, Observable } from "rxjs"; 6 | import axios from "axios"; 7 | 8 | export class RESTPokemonLoader implements PokemonLoader { 9 | all(): Observable { 10 | const pokemons = axios 11 | .get("https://pokeapi.co/api/v2/pokemon?limit=10&offset=0") 12 | .then((reponse) => { 13 | const pokemonPageDTO = reponse.data as PokemonPageDTO; 14 | return PokemonMappers.mapPageToPokemons(pokemonPageDTO); 15 | }); 16 | 17 | return from(pokemons); 18 | } 19 | 20 | get(number: string): Observable { 21 | const pokemon = axios 22 | .get(`https://pokeapi.co/api/v2/pokemon/${number}`) 23 | .then((reponse) => { 24 | const pokemonDTO = (reponse.data as unknown) as PokemonDTO; 25 | return PokemonMappers.mapToPokemon(pokemonDTO); 26 | }); 27 | return from(pokemon); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/core/usecases/pokemon.builder.ts: -------------------------------------------------------------------------------- 1 | import { Pokemon } from "core/domain/entities/pokemon"; 2 | 3 | export class PokemonBuilder { 4 | protected _number: string; 5 | protected _name: string; 6 | protected _description: string; 7 | protected _weight: number; 8 | protected _height: number; 9 | protected _avatar: string; 10 | 11 | withNumber(value: string): PokemonBuilder { 12 | this._number = value; 13 | return this; 14 | } 15 | 16 | withName(value: string): PokemonBuilder { 17 | this._name = value; 18 | return this; 19 | } 20 | 21 | withDescription(value: string): PokemonBuilder { 22 | this._description = value; 23 | return this; 24 | } 25 | 26 | withWeight(value: number): PokemonBuilder { 27 | this._weight = value; 28 | return this; 29 | } 30 | 31 | withHeight(value: number): PokemonBuilder { 32 | this._height = value; 33 | return this; 34 | } 35 | 36 | withAvatar(value: string): PokemonBuilder { 37 | this._avatar = value; 38 | return this; 39 | } 40 | 41 | build(): Pokemon { 42 | return new Pokemon( 43 | this._number, 44 | this._name, 45 | this._description, 46 | this._weight, 47 | this._height, 48 | this._avatar 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pokedex", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "@types/jest": "^26.0.15", 10 | "@types/node": "^12.0.0", 11 | "@types/react": "^16.9.53", 12 | "@types/react-dom": "^16.9.8", 13 | "axios": "^0.21.1", 14 | "prettier": "^2.2.1", 15 | "react": "^17.0.1", 16 | "react-dom": "^17.0.1", 17 | "react-router-dom": "^5.2.0", 18 | "react-scripts": "4.0.1", 19 | "rxjs": "^6.6.3", 20 | "typescript": "^4.0.3", 21 | "web-vitals": "^0.2.4" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "start:dev": "REACT_APP_SOURCE=rest yarn start", 26 | "build": "CI= react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | }, 48 | "devDependencies": { 49 | "@types/react-router-dom": "^5.1.7" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/core/configuration/pokemonDI.factory.ts: -------------------------------------------------------------------------------- 1 | import { PokemonLoader } from "core/domain/loaders/PokemonLoader"; 2 | import { RESTPokemonLoader } from "core/adapters/secondaries/real/RESTPokemon.loader"; 3 | import { Pokemon } from "core/domain/entities/pokemon"; 4 | import { PokemonBuilder } from "core/usecases/pokemon.builder"; 5 | import { InMemoryPokemonLoader } from "core/adapters/secondaries/inmemory/InMemoryPokemon.loader"; 6 | 7 | export class PokemonDIFactory { 8 | static pokemonLoader(): PokemonLoader { 9 | switch (process.env.REACT_APP_SOURCE) { 10 | case "rest": 11 | return new RESTPokemonLoader(); 12 | 13 | default: 14 | const pika: Pokemon = new PokemonBuilder() 15 | .withName("pikachu") 16 | .withNumber("25") 17 | .withDescription("electric") 18 | .withWeight(3) 19 | .withHeight(5) 20 | .withAvatar( 21 | "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png" 22 | ) 23 | .build(); 24 | 25 | const salameche: Pokemon = new PokemonBuilder() 26 | .withName("charmander") 27 | .withNumber("4") 28 | .withWeight(2) 29 | .withHeight(3) 30 | .withDescription("fire") 31 | .withAvatar( 32 | "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/4.png" 33 | ) 34 | .build(); 35 | 36 | const mew: Pokemon = new PokemonBuilder() 37 | .withName("mewtwo") 38 | .withNumber("151") 39 | .withWeight(5) 40 | .withHeight(6) 41 | .withDescription("psy") 42 | .withAvatar( 43 | "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/151.png" 44 | ) 45 | .build(); 46 | 47 | return new InMemoryPokemonLoader([pika, salameche, mew]); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/core/adapters/secondaries/real/DTO/PokemonDTO.ts: -------------------------------------------------------------------------------- 1 | export interface PokemonPageDTO { 2 | count: number; 3 | next: string; 4 | previous: string; 5 | results: PokemonSimpleDTO[]; 6 | } 7 | 8 | export interface PokemonSimpleDTO { 9 | name: string; 10 | url: string; 11 | } 12 | 13 | export interface PokemonDTO { 14 | id: number; 15 | name: string; 16 | abilities: AbilityDTO[]; 17 | base_experience: number; 18 | location_area_encounters: string; 19 | forms: FormDTO[]; 20 | game_indices: GameIndiceDTO[]; 21 | height: number; 22 | held_items: []; 23 | is_default: boolean; 24 | moves: []; 25 | order: number; 26 | species: { 27 | name: string; 28 | url: string; 29 | }; 30 | sprites: SpritesDTO; 31 | stats: StatDTO[]; 32 | weight: number; 33 | types: TypeDTO[]; 34 | } 35 | 36 | export interface TypeDTO { 37 | slot: number; 38 | type: { 39 | name: string; 40 | url: string; 41 | }; 42 | } 43 | 44 | export interface StatDTO { 45 | base_stat: number; 46 | effort: number; 47 | stat: { 48 | name: string; 49 | url: string; 50 | }; 51 | } 52 | 53 | export interface SpritesDTO { 54 | back_default: string; 55 | back_female: string; 56 | back_shiny: string; 57 | back_shiny_female: string; 58 | front_default: string; 59 | front_female: string; 60 | front_shiny: string; 61 | front_shiny_female: string; 62 | other: { 63 | dream_world: { 64 | front_default: string; 65 | front_female: string; 66 | }; 67 | "official-artwork": { 68 | front_default: string; 69 | front_female: string; 70 | }; 71 | }; 72 | } 73 | 74 | export interface AbilityDTO { 75 | ability: { 76 | name: string; 77 | url: string; 78 | }; 79 | is_hidden: boolean; 80 | slot: number; 81 | } 82 | 83 | export interface FormDTO { 84 | name: string; 85 | url: string; 86 | } 87 | 88 | export interface GameIndiceDTO { 89 | game_index: number; 90 | version: { 91 | name: string; 92 | url: string; 93 | }; 94 | } 95 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/pokemonFetching.spec.ts: -------------------------------------------------------------------------------- 1 | import { PokemonHandler } from "core/usecases/pokemon.handler"; 2 | import { PokemonLoader } from "core/domain/loaders/PokemonLoader"; 3 | import { InMemoryPokemonLoader } from "core/adapters/secondaries/inmemory/InMemoryPokemon.loader"; 4 | import { Pokemon } from "core/domain/entities/pokemon"; 5 | import { StubPokemonBuilder } from "./stubs/stubPokemon.builder"; 6 | 7 | describe("Pokemon handler fetches", () => { 8 | let pikachu: Pokemon; 9 | let salameche: Pokemon; 10 | 11 | beforeEach(() => { 12 | pikachu = new StubPokemonBuilder() 13 | .withNumber("1") 14 | .withName("Pikachu") 15 | .build(); 16 | 17 | salameche = new StubPokemonBuilder() 18 | .withNumber("2") 19 | .withName("Salameche") 20 | .build(); 21 | }); 22 | 23 | describe("A list", () => { 24 | it("With zero pokemon if there is no pokemon in the source", (done) => { 25 | const pokemonHandler = createPokemonHandler([]); 26 | 27 | pokemonHandler.all().subscribe((pokemons) => { 28 | verifyListOfPokemons(pokemons, []); 29 | done(); 30 | }); 31 | }); 32 | 33 | it("With one pokemon if there is one pokemon in the source", (done) => { 34 | const pokemonHandler = createPokemonHandler([pikachu]); 35 | 36 | pokemonHandler.all().subscribe((pokemons) => { 37 | verifyListOfPokemons(pokemons, [pikachu]); 38 | done(); 39 | }); 40 | }); 41 | 42 | it("With two pokemons if there are two pokemons in the source", (done) => { 43 | const pokemonHandler = createPokemonHandler([pikachu, salameche]); 44 | 45 | pokemonHandler.all().subscribe((pokemons) => { 46 | verifyListOfPokemons(pokemons, [pikachu, salameche]); 47 | done(); 48 | }); 49 | }); 50 | }); 51 | 52 | it("A details of one pokemon", (done) => { 53 | const pokemonHandler = createPokemonHandler([pikachu, salameche]); 54 | 55 | pokemonHandler.get("2").subscribe((pokemon) => { 56 | verifyOnePokemon(pokemon, salameche); 57 | done(); 58 | }); 59 | }); 60 | 61 | function createPokemonHandler(pokemonPopulation: Pokemon[]) { 62 | const pokemonSource: PokemonLoader = new InMemoryPokemonLoader( 63 | pokemonPopulation 64 | ); 65 | return new PokemonHandler(pokemonSource); 66 | } 67 | 68 | function verifyOnePokemon(pokemon: Pokemon, expectedPokemon: Pokemon) { 69 | expect(pokemon.number).toEqual(expectedPokemon.number); 70 | expect(pokemon.name).toEqual(expectedPokemon.name); 71 | expect(pokemon.description).toEqual(expectedPokemon.description); 72 | expect(pokemon.weight).toEqual(expectedPokemon.weight); 73 | expect(pokemon.height).toEqual(expectedPokemon.height); 74 | expect(pokemon.avatar).toEqual(expectedPokemon.avatar); 75 | } 76 | 77 | function verifyListOfPokemons( 78 | pokemons: Pokemon[], 79 | expectedPokemons: Pokemon[] 80 | ) { 81 | expect(pokemons.length).toEqual(expectedPokemons.length); 82 | expectedPokemons.forEach((expectedPokemon, index) => 83 | verifyOnePokemon(pokemons[index], expectedPokemon) 84 | ); 85 | } 86 | }); 87 | -------------------------------------------------------------------------------- /src/tests/integration/RESTPokemonLoader.spec.ts: -------------------------------------------------------------------------------- 1 | import { PokemonHandler } from "core/usecases/pokemon.handler"; 2 | import { Pokemon } from "core/domain/entities/pokemon"; 3 | import { PokemonBuilder } from "core/usecases/pokemon.builder"; 4 | import { PokemonLoader } from "core/domain/loaders/PokemonLoader"; 5 | import { RESTPokemonLoader } from "core/adapters/secondaries/real/RESTPokemon.loader"; 6 | import axios from "axios"; 7 | import { 8 | PokemonDTO, 9 | PokemonPageDTO, 10 | } from "../../core/adapters/secondaries/real/DTO/PokemonDTO"; 11 | 12 | jest.mock("axios", () => Object.assign(jest.fn(), { get: jest.fn() })); 13 | 14 | describe("Integration | REST pokemon loader fetches", () => { 15 | let fakePokemonPageResponse: { data: PokemonPageDTO }; 16 | let fakePokemonResponse: { data: PokemonDTO }; 17 | let pokemonHandler: PokemonHandler; 18 | let expectedSimplePokemon: Pokemon; 19 | let expectedPokemon: Pokemon; 20 | 21 | beforeEach(() => { 22 | fakePokemonPageResponse = { 23 | data: { 24 | count: 100, 25 | next: "next", 26 | previous: "next", 27 | results: [{ name: "pokemon", url: "pokemon" }], 28 | }, 29 | }; 30 | 31 | fakePokemonResponse = { 32 | data: { 33 | id: 1, 34 | name: "pokemon", 35 | sprites: { 36 | back_default: "avatar", 37 | back_female: "sprite", 38 | back_shiny: "sprite", 39 | back_shiny_female: "sprite", 40 | front_default: "avatar", 41 | front_female: "sprite", 42 | front_shiny: "sprite", 43 | front_shiny_female: "sprite", 44 | other: { 45 | dream_world: { front_default: "sprite", front_female: "sprite" }, 46 | "official-artwork": { 47 | front_default: "sprite", 48 | front_female: "sprite", 49 | }, 50 | }, 51 | }, 52 | height: 2, 53 | weight: 5, 54 | abilities: [], 55 | base_experience: 64, 56 | location_area_encounters: "location", 57 | forms: [], 58 | game_indices: [], 59 | held_items: [], 60 | is_default: true, 61 | moves: [], 62 | order: 1, 63 | species: { name: "pokemon", url: "pokemon" }, 64 | stats: [], 65 | types: [ 66 | { slot: 1, type: { name: "normal", url: "url" } }, 67 | { slot: 2, type: { name: "feu", url: "url" } }, 68 | ], 69 | }, 70 | }; 71 | 72 | expectedSimplePokemon = new PokemonBuilder() 73 | .withNumber("1") 74 | .withName("pokemon") 75 | .build(); 76 | 77 | expectedPokemon = new PokemonBuilder() 78 | .withNumber("1") 79 | .withName("pokemon") 80 | .withDescription("normal, feu") 81 | .withAvatar("avatar") 82 | .withWeight(5) 83 | .withHeight(2) 84 | .build(); 85 | 86 | const pokemonLoader: PokemonLoader = new RESTPokemonLoader(); 87 | pokemonHandler = new PokemonHandler(pokemonLoader); 88 | }); 89 | 90 | it("A list of pokemons", (done) => { 91 | (axios.get as jest.Mocked).mockImplementationOnce(() => { 92 | return Promise.resolve(fakePokemonPageResponse); 93 | }); 94 | 95 | const spy = jest.spyOn(axios as any, "get"); 96 | 97 | pokemonHandler.all().subscribe((pokemons) => { 98 | expect(pokemons).toEqual([expectedSimplePokemon]); 99 | expect(spy).toHaveBeenCalledTimes(1); 100 | expect(axios.get).toHaveBeenCalledWith( 101 | "https://pokeapi.co/api/v2/pokemon?limit=10&offset=0" 102 | ); 103 | 104 | done(); 105 | }); 106 | }); 107 | 108 | it("A details of one pokemon", (done) => { 109 | (axios.get as jest.Mocked).mockImplementationOnce(() => { 110 | return Promise.resolve(fakePokemonResponse); 111 | }); 112 | 113 | const spy = jest.spyOn(axios as any, "get"); 114 | 115 | pokemonHandler.get("1").subscribe((pokemon) => { 116 | expect(pokemon).toEqual(expectedPokemon); 117 | expect(spy).toHaveBeenCalledTimes(1); 118 | expect(axios.get).toHaveBeenCalledWith( 119 | "https://pokeapi.co/api/v2/pokemon/1" 120 | ); 121 | 122 | done(); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /.eslintcache: -------------------------------------------------------------------------------- 1 | [{"/home/steven/Documents/GitHub/pokedex/src/reportWebVitals.ts":"1","/home/steven/Documents/GitHub/pokedex/src/core/adapters/secondaries/inmemory/InMemoryPokemon.loader.ts":"2","/home/steven/Documents/GitHub/pokedex/src/App.tsx":"3","/home/steven/Documents/GitHub/pokedex/src/core/domain/entities/pokemon.ts":"4","/home/steven/Documents/GitHub/pokedex/src/core/adapters/secondaries/real/RESTPokemon.loader.ts":"5","/home/steven/Documents/GitHub/pokedex/src/core/adapters/secondaries/real/mappers/pokemon.mappers.ts":"6","/home/steven/Documents/GitHub/pokedex/src/core/usecases/pokemon.builder.ts":"7","/home/steven/Documents/GitHub/pokedex/src/core/configuration/pokemonDI.factory.ts":"8","/home/steven/Documents/GitHub/pokedex/src/index.tsx":"9","/home/steven/Documents/GitHub/pokedex/src/core/usecases/pokemon.handler.ts":"10","/home/steven/Documents/GitHub/pokedex/src/views/PokemonDetails.tsx":"11","/home/steven/Documents/GitHub/pokedex/src/views/Home.tsx":"12"},{"size":425,"mtime":1611697349235,"results":"13","hashOfConfig":"14"},{"size":666,"mtime":1612193951443,"results":"15","hashOfConfig":"14"},{"size":463,"mtime":1612298511725,"results":"16","hashOfConfig":"14"},{"size":559,"mtime":1612192146349,"results":"17","hashOfConfig":"14"},{"size":1015,"mtime":1612283288683,"results":"18","hashOfConfig":"14"},{"size":977,"mtime":1612299222963,"results":"19","hashOfConfig":"14"},{"size":1015,"mtime":1612193900751,"results":"20","hashOfConfig":"14"},{"size":1689,"mtime":1612298820418,"results":"21","hashOfConfig":"14"},{"size":500,"mtime":1611697349235,"results":"22","hashOfConfig":"14"},{"size":414,"mtime":1612193933415,"results":"23","hashOfConfig":"14"},{"size":977,"mtime":1612299080245,"results":"24","hashOfConfig":"14"},{"size":765,"mtime":1612298619491,"results":"25","hashOfConfig":"14"},{"filePath":"26","messages":"27","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1kt8y25",{"filePath":"28","messages":"29","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"30","messages":"31","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"32","messages":"33","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"34","messages":"35","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"36","messages":"37","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"38","messages":"39","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"40","messages":"41","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"42","messages":"43","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"44","messages":"45","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"46","messages":"47","errorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"48"},{"filePath":"49","messages":"50","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/home/steven/Documents/GitHub/pokedex/src/reportWebVitals.ts",[],"/home/steven/Documents/GitHub/pokedex/src/core/adapters/secondaries/inmemory/InMemoryPokemon.loader.ts",[],"/home/steven/Documents/GitHub/pokedex/src/App.tsx",[],"/home/steven/Documents/GitHub/pokedex/src/core/domain/entities/pokemon.ts",[],"/home/steven/Documents/GitHub/pokedex/src/core/adapters/secondaries/real/RESTPokemon.loader.ts",[],"/home/steven/Documents/GitHub/pokedex/src/core/adapters/secondaries/real/mappers/pokemon.mappers.ts",[],"/home/steven/Documents/GitHub/pokedex/src/core/usecases/pokemon.builder.ts",[],"/home/steven/Documents/GitHub/pokedex/src/core/configuration/pokemonDI.factory.ts",[],"/home/steven/Documents/GitHub/pokedex/src/index.tsx",[],"/home/steven/Documents/GitHub/pokedex/src/core/usecases/pokemon.handler.ts",[],"/home/steven/Documents/GitHub/pokedex/src/views/PokemonDetails.tsx",["51","52"],"import React, { useEffect, useState } from \"react\";\nimport { useHistory, useParams } from \"react-router-dom\";\nimport { pokemonHandler } from \"core/adapters/primaries/pokemon.module\";\nimport { Pokemon } from \"core/domain/entities/pokemon\";\n\nconst PokemonDetails = () => {\n const [pokemon, setPokemon] = useState();\n const { number = \"\" }: { number: string } = useParams();\n const history = useHistory();\n\n useEffect(() => {\n pokemonHandler.get(number).subscribe((pokemon) => setPokemon(pokemon));\n }, []);\n\n return (\n history.push(`/`)}\n style={{ maxWidth: 600, margin: \"0 auto\" }}\n >\n \n

\n #{pokemon?.number}
{pokemon?.name}\n

\n
    \n
  • Weight: {pokemon?.weight}
  • \n
  • Height: {pokemon?.height}
  • \n
  • Type: {pokemon?.description}
  • \n
\n \n );\n};\n\nexport default PokemonDetails;\n","/home/steven/Documents/GitHub/pokedex/src/views/Home.tsx",[],{"ruleId":"53","severity":1,"message":"54","line":13,"column":6,"nodeType":"55","endLine":13,"endColumn":8,"suggestions":"56"},{"ruleId":"57","severity":1,"message":"58","line":21,"column":7,"nodeType":"59","endLine":21,"endColumn":36},"react-hooks/exhaustive-deps","React Hook useEffect has a missing dependency: 'number'. Either include it or remove the dependency array.","ArrayExpression",["60"],"jsx-a11y/alt-text","img elements must have an alt prop, either with meaningful text, or an empty string for decorative images.","JSXOpeningElement",{"desc":"61","fix":"62"},"Update the dependencies array to be: [number]",{"range":"63","text":"64"},[517,519],"[number]"] --------------------------------------------------------------------------------