├── .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 |
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 |
--------------------------------------------------------------------------------