├── .eslintignore ├── .eslintrc.json ├── .huskyrc.json ├── .lintstagedrc.json ├── LICENSE ├── README.md ├── declaration.d.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── global.css └── index.html ├── src ├── data │ ├── protocols │ │ └── http │ │ │ ├── http-post-client.ts │ │ │ ├── http-response.ts │ │ │ └── index.ts │ ├── test │ │ ├── index.ts │ │ ├── mock-http-client.ts │ │ └── mock-http-post.ts │ └── use-cases │ │ └── authentication │ │ ├── remote-authentication.spec.ts │ │ └── remote-authentication.ts ├── domain │ ├── errors │ │ ├── index.ts │ │ ├── invalid-credentials-error.ts │ │ └── unexpected-error.ts │ ├── models │ │ ├── account-model.ts │ │ └── index.ts │ ├── test │ │ ├── index.ts │ │ └── mock-account.ts │ └── use-cases │ │ ├── authentication.ts │ │ └── index.ts ├── infra │ └── http │ │ ├── axios-http-client │ │ ├── axios-http-client.spec.ts │ │ └── axios-http-client.ts │ │ └── test │ │ ├── index.ts │ │ └── mock-axios.ts ├── main │ └── index.tsx └── presentation │ ├── components │ ├── footer │ │ ├── footer-styles.scss │ │ └── footer.tsx │ ├── formStatus │ │ ├── form-status-styles.scss │ │ └── form-status.tsx │ ├── header │ │ ├── login-header-styles.scss │ │ └── login-header.tsx │ ├── icons │ │ └── logo.tsx │ ├── input │ │ ├── input-styles.scss │ │ └── input.tsx │ └── spinner │ │ ├── spinner-styles.scss │ │ └── spinner.tsx │ ├── contexts │ └── form-context.ts │ ├── pages │ ├── index.tsx │ ├── login │ │ ├── login-styles.scss │ │ ├── login.spec.tsx │ │ └── login.tsx │ └── router.tsx │ ├── protocols │ └── validation.ts │ ├── styles │ ├── colors.scss │ └── global.scss │ └── test │ ├── mock-authentication.ts │ └── mock-validation.ts ├── tsconfig.json └── webpack.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | coverage 3 | public 4 | *.scss 5 | *.d.ts 6 | *.json -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "react": { 4 | "version": "detect" 5 | } 6 | }, 7 | "extends": ["standard-with-typescript", "plugin:react/recommended"], 8 | "plugins":["react"], 9 | "parserOptions": { 10 | "project": "./tsconfig.json" 11 | }, 12 | "rules": { 13 | "@typescript-eslint/consistent-type-definitions": "off", 14 | "@typescript-eslint/strict-boolean-expressions": "off", 15 | "react/jsx-uses-react": "error", 16 | "react/jsx-uses-vars": "error" 17 | } 18 | } -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "pre-push": "npm run test:ci" 5 | } 6 | } -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx}": [ 3 | "eslint 'src/**' --fix", 4 | "npm run test:staged" 5 | ] 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Fernanda Kipper Bucoski de Sousa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Clean Architecture 3 |

4 | 5 |

6 | Technolgies • 7 | Clone • 8 | Contribute • 9 | License 10 |

11 | 12 |

13 | This application consists in a polling platform for programmers with login but its main objective is to apply the concepts of Clean Architecture and Test Driven Development in a Frontend application using React JS 14 |

15 | 16 | 17 |

Technologies

18 | This application architecture is organized in the following lawyers: 19 | 20 | - **Domain** - the use cases of the application AK Application Business Rules 21 | - **Infrastructure** - implementation of dependencies of the domain some times using third party libraries, making the domain layer decoupled from the implementation with 3rd party libraries 22 | - **Presentation** - interface, presentation responsible for making the communication of your use cases to users 23 | - **Main** - unique location in an application where modules are composed together 24 | 25 |

Clone

26 | 27 |

Prerequisites

28 | 29 | - Node >= 10.16 e npm >= 5.6 30 | - Package manager - NPM or YARN 31 | 32 |

Starting

33 | 34 | ```bash 35 | git clone https://github.com/Fernanda-Kipper/Clean-Archictecture-React.git clean-react 36 | npm install 37 | ``` 38 | 39 |
Start server
40 | 41 | ```bash 42 | cd clean-react 43 | npm start 44 | ``` 45 | 46 |

Contribute 🚀

47 | 48 | If you want to contribute, clone this repo, create your work branch and get your hands dirty! 49 | 50 | ```bash 51 | git clone https://github.com/Fernanda-Kipper/Clean-Archictecture-React.git 52 | git checkout -b feature/NAME 53 | ``` 54 | 55 | At the end, open a Pull Request explaining the problem solved or feature made, if exists, append screenshot of visual modifications and wait for the review! 56 | 57 | [How to create a Pull Request](https://www.atlassian.com/br/git/tutorials/making-a-pull-request) 58 | 59 | [Commit pattern](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716) 60 | 61 | 62 |

License 📃

63 | 64 | This project is under [MIT](LICENSE) license 65 | 66 | 67 | -------------------------------------------------------------------------------- /declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' { 2 | const content: Record; 3 | export default content; 4 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | collectCoverageFrom: [ 4 | '/src/**/*.{ts,tsx}', 5 | '/src/*.{ts,tsx}', 6 | '!**/*.d.ts' 7 | ], 8 | coverageDirectory: 'coverage', 9 | testEnvironment: 'jsdom', 10 | transform: { 11 | '.+\\.(ts|tsx)$': 'ts-jest' 12 | }, 13 | moduleNameMapper: { 14 | '@/(.*)': '/src/$1', 15 | // Test double - gera um dummy -obj vazio- de todos arquivos scss que encontrar para que eles não interferiram nos testes 16 | '\\.scss$': 'identity-obj-proxy' 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clean-react", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --open", 8 | "test": "jest --passWithNoTests --no-cache --runInBand", 9 | "test:watch": "npm test -- --watch", 10 | "test:staged": "npm test -- --findRelatedTests", 11 | "test:ci": "npm test -- --coverage" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@testing-library/react": "^12.1.3", 18 | "@types/axios": "^0.14.0", 19 | "@types/faker": "^5.5.9", 20 | "@types/jest": "^27.4.0", 21 | "@types/node": "^17.0.13", 22 | "@types/react": "^17.0.38", 23 | "@types/react-dom": "^17.0.11", 24 | "@types/react-router-dom": "^5.3.3", 25 | "@typescript-eslint/eslint-plugin": "^4.33.0", 26 | "clean-webpack-plugin": "^4.0.0", 27 | "css-loader": "^6.6.0", 28 | "eslint": "^7.32.0", 29 | "eslint-config-standard-with-typescript": "^21.0.1", 30 | "eslint-plugin-import": "^2.25.4", 31 | "eslint-plugin-node": "^11.1.0", 32 | "eslint-plugin-promise": "^5.2.0", 33 | "eslint-plugin-react": "^7.28.0", 34 | "eslint-plugin-standard": "^5.0.0", 35 | "faker": "^5.5.3", 36 | "git-commit-msg-linter": "^4.0.7", 37 | "husky": "^7.0.4", 38 | "identity-obj-proxy": "^3.0.0", 39 | "jest": "^27.4.7", 40 | "lint-staged": "^12.3.2", 41 | "node-sass": "^7.0.1", 42 | "sass-loader": "^12.6.0", 43 | "style-loader": "^3.3.1", 44 | "ts-jest": "^27.1.3", 45 | "ts-loader": "^9.2.6", 46 | "typescript": "^4.5.5", 47 | "webpack": "^5.68.0", 48 | "webpack-cli": "^4.9.2", 49 | "webpack-dev-server": "^4.7.4" 50 | }, 51 | "dependencies": { 52 | "axios": "^0.25.0", 53 | "jest-localstorage-mock": "^2.4.19", 54 | "react": "^17.0.2", 55 | "react-dom": "^17.0.2", 56 | "react-router-dom": "^6.2.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | 3 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 4Dev 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/data/protocols/http/http-post-client.ts: -------------------------------------------------------------------------------- 1 | import { HttpResponse } from '.' 2 | 3 | export type HttpPostParams = { 4 | url: string 5 | body?: T 6 | } 7 | 8 | export interface HttpPostClient { 9 | post: (params: HttpPostParams) => Promise> 10 | } 11 | -------------------------------------------------------------------------------- /src/data/protocols/http/http-response.ts: -------------------------------------------------------------------------------- 1 | export enum HttpStatusCode { 2 | ok = 200, 3 | noContent = 204, 4 | unauthorized = 401, 5 | badRequest = 400, 6 | notFound = 404, 7 | internalServerError = 500, 8 | } 9 | 10 | export type HttpResponse = { 11 | statusCode: HttpStatusCode 12 | body?: T 13 | } 14 | -------------------------------------------------------------------------------- /src/data/protocols/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http-post-client' 2 | export * from './http-response' 3 | -------------------------------------------------------------------------------- /src/data/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mock-http-client' 2 | export * from './mock-http-post' 3 | -------------------------------------------------------------------------------- /src/data/test/mock-http-client.ts: -------------------------------------------------------------------------------- 1 | import { HttpPostClient, HttpPostParams, HttpResponse, HttpStatusCode } from '@/data/protocols/http' 2 | 3 | export class HttpPostClientSpy implements HttpPostClient { 4 | url?: string 5 | body?: T 6 | response: HttpResponse = { 7 | statusCode: HttpStatusCode.ok 8 | } 9 | 10 | async post (params: HttpPostParams): Promise> { 11 | this.url = params.url 12 | this.body = params.body 13 | return await Promise.resolve(this.response) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/data/test/mock-http-post.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import { HttpPostParams } from '../protocols/http' 3 | 4 | export const mockPostRequest = (): HttpPostParams => ({ 5 | url: faker.internet.url(), 6 | body: faker.random.objectElement() 7 | }) 8 | -------------------------------------------------------------------------------- /src/data/use-cases/authentication/remote-authentication.spec.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | 3 | import { RemoteAuthentication } from './remote-authentication' 4 | 5 | import { mockAccountModel, mockAuthentication } from '@/domain/test/mock-account' 6 | import { HttpPostClientSpy } from '@/data/test' 7 | import { HttpStatusCode } from '@/data/protocols/http' 8 | import { InvalidCredentialsError, UnexpectedError } from '@/domain/errors' 9 | import { AuthenticationParams } from '@/domain/use-cases' 10 | import { AccountModel } from '@/domain/models' 11 | 12 | type SutTypes = { 13 | sut: RemoteAuthentication 14 | httpPostClientSpy: HttpPostClientSpy 15 | } 16 | 17 | const makeSut = (url: string = faker.internet.url()): SutTypes => { 18 | const httpPostClientSpy = new HttpPostClientSpy() 19 | const sut = new RemoteAuthentication(url, httpPostClientSpy) 20 | 21 | return { sut, httpPostClientSpy } 22 | } 23 | 24 | describe('Remote Authentication', () => { 25 | test('Should call httpPostClient with correct URL', async () => { 26 | const url = faker.internet.url() 27 | const { sut, httpPostClientSpy } = makeSut(url) 28 | 29 | await sut.auth(mockAuthentication()) 30 | 31 | expect(httpPostClientSpy.url).toBe(url) 32 | }) 33 | 34 | test('Should call httpPostClient with correct body', async () => { 35 | const { sut, httpPostClientSpy } = makeSut() 36 | const authParams = mockAuthentication() 37 | 38 | await sut.auth(authParams) 39 | 40 | expect(httpPostClientSpy.body).toEqual(authParams) 41 | }) 42 | 43 | test('Should throw InvalidCredentialsError if HttpPostClient returns 401', async () => { 44 | const { sut, httpPostClientSpy } = makeSut() 45 | httpPostClientSpy.response = { 46 | statusCode: HttpStatusCode.unauthorized 47 | } 48 | 49 | const promise = sut.auth(mockAuthentication()) 50 | 51 | await expect(promise).rejects.toThrow(new InvalidCredentialsError()) 52 | }) 53 | 54 | test('Should throw UnexpectedError if HttpPostClient returns 400', async () => { 55 | const { sut, httpPostClientSpy } = makeSut() 56 | httpPostClientSpy.response = { 57 | statusCode: HttpStatusCode.badRequest 58 | } 59 | 60 | const promise = sut.auth(mockAuthentication()) 61 | 62 | await expect(promise).rejects.toThrow(new UnexpectedError()) 63 | }) 64 | 65 | test('Should throw UnexpectedError if HttpPostClient returns 404', async () => { 66 | const { sut, httpPostClientSpy } = makeSut() 67 | httpPostClientSpy.response = { 68 | statusCode: HttpStatusCode.notFound 69 | } 70 | 71 | const promise = sut.auth(mockAuthentication()) 72 | 73 | await expect(promise).rejects.toThrow(new UnexpectedError()) 74 | }) 75 | 76 | test('Should throw UnexpectedError if HttpPostClient returns 500', async () => { 77 | const { sut, httpPostClientSpy } = makeSut() 78 | httpPostClientSpy.response = { 79 | statusCode: HttpStatusCode.internalServerError 80 | } 81 | 82 | const promise = sut.auth(mockAuthentication()) 83 | 84 | await expect(promise).rejects.toThrow(new UnexpectedError()) 85 | }) 86 | 87 | test('Should return an AccountModel if HttpPostClient returns 200', async () => { 88 | const httpResult = mockAccountModel() 89 | const { sut, httpPostClientSpy } = makeSut() 90 | httpPostClientSpy.response = { 91 | statusCode: HttpStatusCode.ok, 92 | body: httpResult 93 | } 94 | 95 | const account = await sut.auth(mockAuthentication()) 96 | 97 | expect(account).toEqual(httpResult) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /src/data/use-cases/authentication/remote-authentication.ts: -------------------------------------------------------------------------------- 1 | import { HttpPostClient, HttpStatusCode } from '@/data/protocols/http' 2 | import { InvalidCredentialsError, UnexpectedError } from '@/domain/errors' 3 | import { AccountModel } from '@/domain/models' 4 | import { Authentication, AuthenticationParams } from '@/domain/use-cases' 5 | 6 | export class RemoteAuthentication implements Authentication { 7 | constructor ( 8 | private readonly url: string, 9 | private readonly httpPostClient: HttpPostClient 10 | ) {} 11 | 12 | async auth (params: AuthenticationParams): Promise { 13 | const httpResponse = await this.httpPostClient.post({ url: this.url, body: params }) 14 | 15 | switch (httpResponse.statusCode) { 16 | case HttpStatusCode.ok: return httpResponse.body 17 | case HttpStatusCode.unauthorized: throw new InvalidCredentialsError() 18 | default: throw new UnexpectedError() 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/domain/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './invalid-credentials-error' 2 | export * from './unexpected-error' 3 | -------------------------------------------------------------------------------- /src/domain/errors/invalid-credentials-error.ts: -------------------------------------------------------------------------------- 1 | export class InvalidCredentialsError extends Error { 2 | constructor () { 3 | super('Credenciais inválidas') 4 | this.name = 'InvalidCredentialsError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/domain/errors/unexpected-error.ts: -------------------------------------------------------------------------------- 1 | export class UnexpectedError extends Error { 2 | constructor () { 3 | super('Algo de errado aconteceu, tente novamente mais tarde.') 4 | this.name = 'UnexpectedError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/domain/models/account-model.ts: -------------------------------------------------------------------------------- 1 | export type AccountModel = { 2 | accessToken: string 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './account-model' 2 | -------------------------------------------------------------------------------- /src/domain/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mock-account' 2 | -------------------------------------------------------------------------------- /src/domain/test/mock-account.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | 3 | import { AuthenticationParams } from '@/domain/use-cases/authentication' 4 | import { AccountModel } from '../models/account-model' 5 | 6 | export const mockAuthentication = (): AuthenticationParams => ({ 7 | email: faker.internet.email(), 8 | password: faker.internet.password() 9 | }) 10 | 11 | export const mockAccountModel = (): AccountModel => ({ 12 | accessToken: faker.random.alphaNumeric() 13 | }) 14 | -------------------------------------------------------------------------------- /src/domain/use-cases/authentication.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '../models/account-model' 2 | 3 | export type AuthenticationParams = { 4 | email: string 5 | password: string 6 | } 7 | 8 | export interface Authentication { 9 | auth: (params: AuthenticationParams) => Promise 10 | } 11 | -------------------------------------------------------------------------------- /src/domain/use-cases/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authentication' 2 | -------------------------------------------------------------------------------- /src/infra/http/axios-http-client/axios-http-client.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import { AxiosHttpClient } from './axios-http-client' 4 | import { mockAxios } from '../test' 5 | import { mockPostRequest } from '@/data/test' 6 | 7 | jest.mock('axios') 8 | 9 | type SutTypes = { 10 | sut: AxiosHttpClient 11 | mockedAxios: jest.Mocked 12 | } 13 | 14 | const makeSut = (): SutTypes => { 15 | const sut = new AxiosHttpClient() 16 | const mockedAxios = mockAxios() 17 | 18 | return { sut, mockedAxios } 19 | } 20 | 21 | describe('AxiosHttpClient', () => { 22 | test('Should call axios with correct values', async () => { 23 | const request = mockPostRequest() 24 | const { sut, mockedAxios } = makeSut() 25 | 26 | await sut.post(request) 27 | 28 | expect(mockedAxios.post).toHaveBeenCalledWith(request.url, request.body) 29 | }) 30 | 31 | test('Should return the correct statusCode and payload', async () => { 32 | const { sut, mockedAxios } = makeSut() 33 | 34 | const promise = sut.post(mockPostRequest()) 35 | 36 | expect(promise).toEqual(mockedAxios.post.mock.results[0].value) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/infra/http/axios-http-client/axios-http-client.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import { HttpPostClient, HttpPostParams, HttpResponse } from '@/data/protocols/http' 4 | 5 | export class AxiosHttpClient implements HttpPostClient { 6 | async post (params: HttpPostParams): Promise> { 7 | const response = await axios.post(params.url, params.body) 8 | return { 9 | statusCode: response.status, 10 | body: response.data 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/infra/http/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mock-axios' 2 | -------------------------------------------------------------------------------- /src/infra/http/test/mock-axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import faker from 'faker' 3 | 4 | export const mockAxios = (): jest.Mocked => { 5 | const mockedAxiosResult = { 6 | status: faker.datatype.number(), 7 | data: faker.random.objectElement() 8 | } 9 | 10 | const mockedAxios = axios as jest.Mocked 11 | mockedAxios.post.mockResolvedValue(mockedAxiosResult) 12 | 13 | return mockedAxios 14 | } 15 | -------------------------------------------------------------------------------- /src/main/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Router } from '@/presentation/pages' 4 | 5 | ReactDOM.render(, document.getElementById('root')) 6 | -------------------------------------------------------------------------------- /src/presentation/components/footer/footer-styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .footer { 4 | background-color: $primary; 5 | height: 48px; 6 | } -------------------------------------------------------------------------------- /src/presentation/components/footer/footer.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, ReactElement } from 'react' 2 | import Styles from './footer-styles.scss' 3 | 4 | export function Footer (): ReactElement { 5 | return
6 | } 7 | 8 | export default memo(Footer) 9 | -------------------------------------------------------------------------------- /src/presentation/components/formStatus/form-status-styles.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fernanda-Kipper/Clean-Archictecture-React/e11baca8271bfb8a1550a4273dba02f8ae78e0fb/src/presentation/components/formStatus/form-status-styles.scss -------------------------------------------------------------------------------- /src/presentation/components/formStatus/form-status.tsx: -------------------------------------------------------------------------------- 1 | import { Context } from '@/presentation/contexts/form-context' 2 | import React, { ReactElement, useContext } from 'react' 3 | import { Spinner } from '../spinner/spinner' 4 | import Styles from './form-status-styles.scss' 5 | 6 | export function FormStatus (): ReactElement { 7 | const { state } = useContext(Context) 8 | const { isLoading, formErrors } = state 9 | 10 | return ( 11 |
12 | {isLoading && } 13 | {formErrors?.all ??

{formErrors.all}

} 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/presentation/components/header/login-header-styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .header{ 4 | background-color: $primary; 5 | 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | border-top: 40px solid $primaryDark; 10 | 11 | svg { 12 | margin-top: 24px; 13 | } 14 | 15 | h1 { 16 | color: $white; 17 | margin: 16px 0px 24px; 18 | } 19 | } -------------------------------------------------------------------------------- /src/presentation/components/header/login-header.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, ReactElement } from 'react' 2 | import { LogoIcon } from '../icons/logo' 3 | import Styles from './login-header-styles.scss' 4 | 5 | export function LoginHeader (): ReactElement { 6 | return ( 7 |
8 | 9 |

4Dev - Enquetes para Programadores

10 |
11 | ) 12 | } 13 | 14 | export default memo(LoginHeader) 15 | -------------------------------------------------------------------------------- /src/presentation/components/icons/logo.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react' 2 | 3 | export const LogoIcon = (): ReactElement => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/presentation/components/input/input-styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | input[type="password"], input[type="email"] { 4 | border: 1px solid $primaryLight; 5 | border-radius: 4px; 6 | line-height: 40px; 7 | padding: 0 40px 0 8px; 8 | 9 | &:focus { 10 | outline-color: $primaryLight; 11 | } 12 | } 13 | 14 | .inputWrapper { 15 | margin: 8px 0; 16 | display: flex; 17 | align-items: center; 18 | position: relative; 19 | 20 | input { 21 | flex-grow: 1; 22 | } 23 | 24 | .inputStatus { 25 | position: absolute; 26 | right: 0px; 27 | margin-right: 4px; 28 | font-size: 12px; 29 | cursor: help; 30 | } 31 | } -------------------------------------------------------------------------------- /src/presentation/components/input/input.tsx: -------------------------------------------------------------------------------- 1 | import { Context } from '@/presentation/contexts/form-context' 2 | import React, { FocusEvent, InputHTMLAttributes, ReactElement, useContext } from 'react' 3 | import Styles from './input-styles.scss' 4 | 5 | type Props = InputHTMLAttributes 6 | 7 | export function Input (props: Props): ReactElement { 8 | const { state, setState } = useContext(Context) 9 | const { formErrors } = state 10 | const error = formErrors[props.name] 11 | 12 | const getCurrentStatus = (): string => { 13 | if (error) return '🔴' 14 | return '🟢' 15 | } 16 | 17 | const getTitle = (): string => { 18 | if (error) return error 19 | return 'Tudo certo!' 20 | } 21 | 22 | const updateValue = (event: FocusEvent): void => { 23 | setState(prev => ({ 24 | ...prev, 25 | [event.target.name]: event.target.value 26 | })) 27 | } 28 | 29 | return ( 30 |
31 | 32 | 36 | {getCurrentStatus()} 37 | 38 |
39 | ) 40 | } 41 | 42 | export default Input 43 | -------------------------------------------------------------------------------- /src/presentation/components/spinner/spinner-styles.scss: -------------------------------------------------------------------------------- 1 | .spinner { 2 | display: inline-block; 3 | width: 54px; 4 | height: 54px; 5 | align-self: center; 6 | 7 | &:after { 8 | content: " "; 9 | display: block; 10 | width: 30px; 11 | height: 30px; 12 | margin: 8px; 13 | border-radius: 50%; 14 | border: 6px solid #c6c6c6 ; 15 | border-color: #c6c6c6 transparent #c6c6c6 transparent; 16 | animation: loading 1.2s linear infinite; 17 | } 18 | 19 | @keyframes loading { 20 | 0% { 21 | transform: rotate(0deg); 22 | } 23 | 100% { 24 | transform: rotate(360deg); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/presentation/components/spinner/spinner.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLAttributes, ReactElement } from 'react' 2 | import Styles from './spinner-styles.scss' 3 | 4 | type Props = HTMLAttributes 5 | 6 | export function Spinner (props: Props): ReactElement { 7 | return ( 8 |
12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/presentation/contexts/form-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | export const Context = createContext(null) 4 | -------------------------------------------------------------------------------- /src/presentation/pages/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Login } from './login/login' 2 | export { default as Router } from './router' 3 | -------------------------------------------------------------------------------- /src/presentation/pages/login/login-styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | button { 4 | background-color: $primary; 5 | color: $white; 6 | border-radius: 8px; 7 | font-size: 16px; 8 | border: none; 9 | line-height: 60px; 10 | cursor: pointer; 11 | 12 | &:hover { 13 | opacity: 0.9; 14 | } 15 | 16 | &:disabled { 17 | background-color: $disabledBackground; 18 | 19 | &:hover { 20 | opacity: 1; 21 | } 22 | } 23 | } 24 | 25 | .login { 26 | display: flex; 27 | flex-direction: column; 28 | justify-content: space-between; 29 | align-items: stretch; 30 | height: 100vh; 31 | 32 | .form { 33 | display: flex; 34 | flex-direction: column; 35 | align-self: center; 36 | 37 | width: 90%; 38 | padding: 40px; 39 | border-radius: 8px; 40 | 41 | background-color: $white; 42 | box-shadow: 0px 1px 3px -1px rgba(0,0,0,0.5); 43 | 44 | @media(min-width: 700px) { 45 | width: 50%; 46 | } 47 | 48 | h2 { 49 | color: $primaryDark; 50 | text-align: center; 51 | font-size: 24px; 52 | text-transform: uppercase; 53 | margin: 16px 0; 54 | } 55 | 56 | button { 57 | margin-top: 32px; 58 | } 59 | 60 | .link { 61 | text-align: center; 62 | color: $primary; 63 | text-transform: lowercase; 64 | margin-top: 16px; 65 | 66 | cursor: pointer; 67 | 68 | &:hover { 69 | text-decoration: underline; 70 | } 71 | } 72 | 73 | .errorWrapper { 74 | display: flex; 75 | flex-direction: column; 76 | align-items: center; 77 | justify-content: center; 78 | align-self: center; 79 | 80 | .spinner { 81 | margin-top: 32px; 82 | } 83 | 84 | .error { 85 | color: $primaryDark; 86 | font-size: 16px; 87 | margin-top: 32px; 88 | } 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /src/presentation/pages/login/login.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import 'jest-localstorage-mock' 3 | import { cleanup, fireEvent, render, RenderResult, waitFor } from '@testing-library/react' 4 | import { Login } from '..' 5 | import { ValidationSpy } from '@/presentation/test/mock-validation' 6 | import faker from 'faker' 7 | import { AuthenticationSpy } from '@/presentation/test/mock-authentication' 8 | import { BrowserRouter } from 'react-router-dom' 9 | 10 | type SutTypes = { 11 | sut: RenderResult 12 | validationSpy: ValidationSpy 13 | authenticationSpy: AuthenticationSpy 14 | } 15 | 16 | type SutParams = { 17 | validationError?: string 18 | } 19 | 20 | const makeSut = (params?: SutParams): SutTypes => { 21 | const validationSpy = new ValidationSpy() 22 | const authenticationSpy = new AuthenticationSpy() 23 | validationSpy.errorMessage = params?.validationError 24 | const sut = render( 25 | 26 | 27 | 28 | ) 29 | 30 | return { 31 | sut, 32 | validationSpy, 33 | authenticationSpy 34 | } 35 | } 36 | 37 | const simulateValidSubmit = async (sut: RenderResult, email = faker.internet.email(), password = faker.internet.password()): Promise => { 38 | populateEmailField(sut, email) 39 | populatePasswordField(sut, password) 40 | const form = sut.getByTestId('form') 41 | await waitFor(() => form) 42 | fireEvent.submit(form) 43 | } 44 | 45 | const populateEmailField = (sut: RenderResult, email = faker.internet.email()): void => { 46 | const emailInput = sut.getByTestId('email') 47 | fireEvent.input(emailInput, { target: { value: email } }) 48 | } 49 | 50 | const populatePasswordField = (sut: RenderResult, password = faker.internet.password()): void => { 51 | const passwordInput = sut.getByTestId('password') 52 | fireEvent.input(passwordInput, { target: { value: password } }) 53 | } 54 | 55 | const assertFieldStatus = (sut: RenderResult, fieldName: string, validationError?: string): void => { 56 | const fieldStatus = sut.getByTestId(`${fieldName}-status`) 57 | expect(fieldStatus.title).toBe(validationError || 'Tudo certo!') 58 | expect(fieldStatus.textContent).toBe(validationError ? '🔴' : '🟢') 59 | } 60 | 61 | const assertButtonIsDisabled = (sut: RenderResult, fieldName: string, isDisabled: boolean): void => { 62 | expect((sut.getByTestId(fieldName) as HTMLButtonElement).disabled).toBe(isDisabled) 63 | } 64 | 65 | describe('Login Page', () => { 66 | beforeEach(() => { 67 | cleanup() 68 | localStorage.clear() 69 | }) 70 | 71 | test('Should initial render should be correct initial state', () => { 72 | const validationError = faker.random.words() 73 | const { sut } = makeSut({ validationError }) 74 | 75 | expect(sut.getByTestId('error-wrapper').childElementCount).toBe(0) 76 | assertButtonIsDisabled(sut, 'submit', true) 77 | assertFieldStatus(sut, 'email', validationError) 78 | assertFieldStatus(sut, 'password', validationError) 79 | }) 80 | 81 | test('Should call validation with correct email', () => { 82 | const validationError = faker.random.words() 83 | const { sut, validationSpy } = makeSut({ validationError }) 84 | const email = faker.internet.email() 85 | 86 | populateEmailField(sut, email) 87 | 88 | expect(validationSpy.fieldName).toEqual('email') 89 | expect(validationSpy.fieldValue).toEqual(email) 90 | }) 91 | 92 | test('Should call validation with correct password', () => { 93 | const validationError = faker.random.words() 94 | const { sut, validationSpy } = makeSut({ validationError }) 95 | const password = faker.internet.email() 96 | 97 | populatePasswordField(sut, password) 98 | 99 | expect(validationSpy.fieldName).toEqual('password') 100 | expect(validationSpy.fieldValue).toEqual(password) 101 | }) 102 | 103 | test('Should show email error if validation fails', () => { 104 | const validationError = faker.random.words() 105 | const { sut } = makeSut({ validationError }) 106 | const email = faker.internet.email() 107 | 108 | populateEmailField(sut, email) 109 | 110 | assertFieldStatus(sut, 'email', validationError) 111 | }) 112 | 113 | test('Should show password error if validation fails', () => { 114 | const validationError = faker.random.words() 115 | const { sut } = makeSut({ validationError }) 116 | const password = faker.internet.password() 117 | 118 | populatePasswordField(sut, password) 119 | 120 | assertFieldStatus(sut, 'password', validationError) 121 | }) 122 | 123 | test('Should show valid password state if validation succeed', () => { 124 | const { sut } = makeSut() 125 | const password = faker.internet.password() 126 | 127 | populatePasswordField(sut, password) 128 | 129 | assertFieldStatus(sut, 'password') 130 | }) 131 | 132 | test('Should show valid email state if validation succeed', () => { 133 | const { sut } = makeSut() 134 | const email = faker.internet.email() 135 | 136 | populateEmailField(sut, email) 137 | 138 | assertFieldStatus(sut, 'email') 139 | }) 140 | 141 | test('Should enable submit button if form is valid', () => { 142 | const { sut } = makeSut() 143 | 144 | populateEmailField(sut) 145 | populatePasswordField(sut) 146 | 147 | assertButtonIsDisabled(sut, 'submit', false) 148 | }) 149 | 150 | test('Should show loading spinner on submit', async () => { 151 | const { sut } = makeSut() 152 | 153 | await simulateValidSubmit(sut) 154 | 155 | const spinner = sut.getByTestId('spinner') 156 | expect(spinner).toBeTruthy() 157 | }) 158 | 159 | test('Should call authentication with correct values', async () => { 160 | const { sut, authenticationSpy } = makeSut() 161 | const email = faker.internet.email() 162 | const password = faker.internet.email() 163 | 164 | await simulateValidSubmit(sut, email, password) 165 | 166 | expect(authenticationSpy.params).toEqual({ email, password }) 167 | }) 168 | 169 | test('Should call authentication with correct values', async () => { 170 | const { sut, authenticationSpy } = makeSut() 171 | const email = faker.internet.email() 172 | const password = faker.internet.email() 173 | 174 | await simulateValidSubmit(sut, email, password) 175 | 176 | expect(authenticationSpy.params).toEqual({ email, password }) 177 | }) 178 | 179 | test('Should call authentication only once', async () => { 180 | const { sut, authenticationSpy } = makeSut() 181 | const email = faker.internet.email() 182 | const password = faker.internet.email() 183 | 184 | await simulateValidSubmit(sut, email, password) 185 | await simulateValidSubmit(sut, email, password) 186 | 187 | expect(authenticationSpy.callsCount).toBe(1) 188 | }) 189 | 190 | test('Should not call authentication if form is invalid', () => { 191 | const validationError = faker.random.words() 192 | const { sut, authenticationSpy } = makeSut({ validationError }) 193 | const email = faker.internet.email() 194 | 195 | populateEmailField(sut, email) 196 | fireEvent.submit(sut.getByTestId('form')) 197 | 198 | expect(authenticationSpy.callsCount).toBe(0) 199 | }) 200 | 201 | test('Should present error if authentication fails', async () => { 202 | const { sut, authenticationSpy } = makeSut() 203 | const authenticationError = { message: 'some random error' } 204 | jest.spyOn(authenticationSpy, 'auth').mockRejectedValue(authenticationError) 205 | 206 | await simulateValidSubmit(sut) 207 | 208 | const errorWrapper = sut.getByTestId('error-wrapper') 209 | await waitFor(() => expect(errorWrapper.textContent).toBe(authenticationError.message)) 210 | }) 211 | 212 | test('Should add accessToken on localStorage on success', async () => { 213 | const { sut, authenticationSpy } = makeSut() 214 | 215 | await simulateValidSubmit(sut) 216 | 217 | await waitFor(() => sut.getByTestId('form')) 218 | expect(localStorage.setItem).toBeCalledWith('accessToken', authenticationSpy.account.accessToken) 219 | }) 220 | 221 | test('Should go to sign up page', async () => { 222 | const { sut } = makeSut() 223 | const signUpButton = sut.getByTestId('sign-up') 224 | 225 | fireEvent.click(signUpButton) 226 | 227 | expect(window.location.pathname).toBe('/sign-up') 228 | }) 229 | }) 230 | -------------------------------------------------------------------------------- /src/presentation/pages/login/login.tsx: -------------------------------------------------------------------------------- 1 | import React, { FormEvent, ReactElement, useEffect, useState } from 'react' 2 | 3 | import LoginHeader from '@/presentation/components/header/login-header' 4 | import Styles from './login-styles.scss' 5 | import { Footer } from '@/presentation/components/footer/footer' 6 | import { Input } from '@/presentation/components/input/input' 7 | import { FormStatus } from '@/presentation/components/formStatus/form-status' 8 | import { Context } from '@/presentation/contexts/form-context' 9 | import { Validation } from '@/presentation/protocols/validation' 10 | import { Authentication } from '@/domain/use-cases' 11 | import { Link, useNavigate } from 'react-router-dom' 12 | 13 | type Props = { 14 | validation?: Validation 15 | authentication?: Authentication 16 | } 17 | 18 | function Login (props: Props): ReactElement { 19 | const navigateTo = useNavigate() 20 | const [state, setState] = useState({ 21 | isLoading: false, 22 | email: '', 23 | password: '', 24 | formErrors: { 25 | email: '', 26 | password: '', 27 | all: '' 28 | } 29 | }) 30 | 31 | const updateFormError = (field: string, message: string): void => { 32 | setState(prev => ({ 33 | ...prev, 34 | formErrors: { 35 | ...prev.formErrors, 36 | [field]: message 37 | } 38 | })) 39 | } 40 | 41 | useEffect(() => { 42 | updateFormError('email', props.validation.validate('email', state.email)) 43 | }, [state.email]) 44 | 45 | useEffect(() => { 46 | updateFormError('password', props.validation.validate('password', state.password)) 47 | }, [state.password]) 48 | 49 | const handleSubmit = async (event: FormEvent): Promise => { 50 | event.preventDefault() 51 | if (state.isLoading) return 52 | if (state.formErrors.email || state.formErrors.password) return 53 | setState(prev => ({ 54 | ...prev, 55 | isLoading: true 56 | })) 57 | try { 58 | const account = await props.authentication.auth({ email: state.email, password: state.password }) 59 | localStorage.setItem('accessToken', account.accessToken) 60 | navigateTo('/') 61 | } catch (err) { 62 | setState(prev => ({ 63 | ...prev, 64 | isLoading: false, 65 | formErrors: { 66 | ...prev.formErrors, 67 | all: err.message 68 | } 69 | })) 70 | } 71 | } 72 | 73 | return ( 74 |
75 | 76 | 77 |
78 |

Login

79 | 80 | 81 | 87 | Criar conta 88 | 89 | 90 |
91 |
92 |
93 | ) 94 | } 95 | 96 | export default Login 97 | -------------------------------------------------------------------------------- /src/presentation/pages/router.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react' 2 | import { BrowserRouter, Route, Routes } from 'react-router-dom' 3 | import { Login } from '.' 4 | 5 | import '../styles/global.scss' 6 | 7 | function Router (): ReactElement { 8 | return ( 9 | 10 | 11 | }/> 12 | 13 | 14 | ) 15 | } 16 | 17 | export default Router 18 | -------------------------------------------------------------------------------- /src/presentation/protocols/validation.ts: -------------------------------------------------------------------------------- 1 | export interface Validation { 2 | validate: (fieldName: string, fieldValue: string) => string 3 | } 4 | -------------------------------------------------------------------------------- /src/presentation/styles/colors.scss: -------------------------------------------------------------------------------- 1 | $white: #FFF; 2 | $black: #000; 3 | $background: #F2F2F2; 4 | $primary: #880E4F; 5 | $primaryDark: #560027; 6 | $primaryLight: #BC477B; 7 | $disabledBackground: #ccc; -------------------------------------------------------------------------------- /src/presentation/styles/global.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/colors.scss'; 2 | 3 | * { 4 | font-family: 'Roboto', sans-serif; 5 | font-size: 14px; 6 | padding: 0; 7 | margin: 0; 8 | box-sizing: border-box; 9 | } 10 | 11 | body { 12 | background-color: $background; 13 | } -------------------------------------------------------------------------------- /src/presentation/test/mock-authentication.ts: -------------------------------------------------------------------------------- 1 | import { Authentication, AuthenticationParams } from '@/domain/use-cases' 2 | import { AccountModel } from '@/domain/models' 3 | import { mockAccountModel } from '@/domain/test' 4 | 5 | export class AuthenticationSpy implements Authentication { 6 | account = mockAccountModel() 7 | params: AuthenticationParams 8 | callsCount = 0 9 | 10 | async auth (params: AuthenticationParams): Promise { 11 | this.params = params 12 | this.callsCount += 1 13 | return await Promise.resolve(this.account) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/presentation/test/mock-validation.ts: -------------------------------------------------------------------------------- 1 | import { Validation } from '../protocols/validation' 2 | 3 | export class ValidationSpy implements Validation { 4 | errorMessage: string 5 | fieldName: string 6 | fieldValue: string 7 | 8 | validate (fieldName: string, fieldValue: string): string { 9 | this.fieldValue = fieldValue 10 | this.fieldName = fieldName 11 | return this.errorMessage 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "jsx": "react", 13 | "rootDir": "src", 14 | "baseUrl": "src", 15 | "paths": { 16 | "@/*": ["*"] 17 | }, 18 | "allowJs": true, 19 | "allowSyntheticDefaultImports": true, 20 | "resolveJsonModule": true, 21 | "forceConsistentCasingInFileNames": true 22 | }, 23 | "include": ["src", "tests", "./declaration.d.ts"], 24 | "exclude": ["tests/e2e/cypress"] 25 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 3 | 4 | module.exports = { 5 | mode: 'development', 6 | entry: './src/main/index.tsx', 7 | output: { 8 | path: path.join(__dirname, 'public/js'), 9 | publicPath: '/public/js', 10 | filename: 'bundle.js' 11 | }, 12 | resolve: { 13 | extensions: ['.ts', '.tsx', '.js', '.scss'], 14 | alias: { 15 | '@': path.join(__dirname, 'src') 16 | } 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.ts(x?)$/, 22 | loader: 'ts-loader', 23 | exclude: /node_modules/ 24 | }, 25 | { 26 | test: /\.scss$/, 27 | use: [ 28 | { loader: 'style-loader' }, 29 | { loader: 'css-loader', options: { modules: true } }, 30 | { loader: 'sass-loader' } 31 | ] 32 | } 33 | ] 34 | }, 35 | devServer: { 36 | static: { 37 | directory: path.join(__dirname, './public') 38 | }, 39 | devMiddleware: { 40 | writeToDisk: true 41 | }, 42 | historyApiFallback: true, 43 | open: true, 44 | port: 3000 45 | }, 46 | // do not include this libraries at bundle, because we are importing via JS in index.html 47 | externals: { 48 | react: 'React', 49 | 'react-dom': 'ReactDOM' 50 | }, 51 | plugins: [ 52 | new CleanWebpackPlugin() 53 | ] 54 | } 55 | --------------------------------------------------------------------------------