├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── config.yaml │ ├── documentation.md │ ├── feature.md │ └── other.md ├── pull_request_template.md └── workflows │ └── unit-test.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-push ├── .npmignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── cypress.config.ts ├── cypress ├── e2e │ └── loginFlow.spec.cy.ts ├── fixtures │ └── user.json └── support │ ├── commands.ts │ └── e2e.ts ├── index.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── data │ ├── protocols │ │ ├── cache │ │ │ ├── getStorage.ts │ │ │ ├── index.ts │ │ │ └── setStorage.ts │ │ └── http │ │ │ ├── HttpClient.ts │ │ │ └── index.ts │ ├── test │ │ ├── MockCache.ts │ │ ├── MockHttp.ts │ │ └── index.ts │ └── usecases │ │ ├── Auth │ │ └── authentication │ │ │ ├── RemoteAuthentication.spec.ts │ │ │ └── RemoteAuthentication.ts │ │ ├── User │ │ └── addAccount │ │ │ ├── RemoteAddAccount.spec.ts │ │ │ └── RemoteAddAccount.ts │ │ └── index.ts ├── domain │ ├── enums │ │ ├── DomainErrors.enum.ts │ │ └── index.ts │ ├── errors │ │ ├── AccessDeniedError.ts │ │ ├── EmailInUseError.ts │ │ ├── InvalidCredentialsError.ts │ │ ├── UnexpectedError.ts │ │ └── index.ts │ ├── models │ │ ├── AccountModel.ts │ │ └── index.ts │ ├── test │ │ ├── MockAccount.ts │ │ ├── MockAddAccount.ts │ │ ├── MockAuthentication.ts │ │ └── index.ts │ └── usecases │ │ ├── Auth │ │ └── Authentication.ts │ │ ├── User │ │ └── AddAccount.ts │ │ └── index.ts ├── infra │ ├── cache │ │ ├── LocalStorageAdapter.spec.ts │ │ └── LocalStorageAdapter.ts │ ├── http │ │ └── axiosHttpClient │ │ │ ├── AxiosHttpClient.spec.ts │ │ │ └── AxiosHttpClient.ts │ └── test │ │ ├── MockAxios.ts │ │ └── index.ts ├── main │ ├── adapters │ │ └── CurrentAccountAdapter │ │ │ ├── index.spec.ts │ │ │ └── index.ts │ ├── config │ │ ├── fonts-module.d.ts │ │ ├── png-module.d.ts │ │ ├── sass-module.d.ts │ │ └── svg-module.d.ts │ ├── decorators │ │ ├── AuthorizeHttpDecorator │ │ │ ├── index.spec.ts │ │ │ └── index.ts │ │ └── index.ts │ ├── factories │ │ ├── cache │ │ │ └── LocalStorageAdapter │ │ │ │ └── index.ts │ │ ├── decorators │ │ │ └── AuthorizeHttpClientDecoratorFactory │ │ │ │ └── index.ts │ │ ├── http │ │ │ ├── ApiURLFactory │ │ │ │ └── index.ts │ │ │ ├── AxiosHttpClient │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── pages │ │ │ ├── Dashboard │ │ │ │ └── index.tsx │ │ │ ├── Login │ │ │ │ ├── LoginValidation │ │ │ │ │ ├── index.spec.ts │ │ │ │ │ └── index.ts │ │ │ │ └── index.tsx │ │ │ ├── SignUp │ │ │ │ ├── SignUpValidation │ │ │ │ │ ├── index.spec.ts │ │ │ │ │ └── index.ts │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ └── usecases │ │ │ ├── Auth │ │ │ └── RemoteAuthentication │ │ │ │ └── index.ts │ │ │ ├── User │ │ │ └── RemoteAddAccount │ │ │ │ └── index.ts │ │ │ └── index.ts │ ├── index.tsx │ ├── routes │ │ └── router.tsx │ └── scripts │ │ ├── assetsTransformer.ts │ │ ├── bin.js │ │ └── jestSetup.ts ├── presentation │ ├── Routes │ │ ├── private.routes.spec.tsx │ │ └── private.routes.tsx │ ├── assets │ │ └── fonts │ │ │ ├── GothamBold.ttf │ │ │ ├── GothamBoldItalic.ttf │ │ │ ├── GothamBook.ttf │ │ │ ├── GothamBookItalic.ttf │ │ │ ├── GothamLight.ttf │ │ │ ├── GothamLightItalic.ttf │ │ │ ├── GothamMedium.ttf │ │ │ ├── GothamMediumItalic.ttf │ │ │ └── GothamMedium_1.ttf │ ├── components │ │ ├── Button │ │ │ ├── index.spec.tsx │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ ├── FormLoaderStatus │ │ │ ├── Spinner │ │ │ │ ├── index.tsx │ │ │ │ └── styles.scss │ │ │ ├── index.spec.tsx │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ ├── Input │ │ │ ├── index.spec.tsx │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ ├── Template │ │ │ ├── Header │ │ │ │ ├── index.tsx │ │ │ │ └── styles.scss │ │ │ ├── Navigation │ │ │ │ ├── index.tsx │ │ │ │ └── styles.scss │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ └── index.tsx │ ├── hooks │ │ ├── api │ │ │ └── index.ts │ │ ├── form │ │ │ └── index.ts │ │ └── index.ts │ ├── pages │ │ ├── Dashboard │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ ├── Login │ │ │ ├── index.spec.tsx │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ ├── SignUp │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ └── index.tsx │ ├── protocols │ │ └── validation.ts │ ├── styles │ │ ├── colors.scss │ │ └── global.scss │ └── test │ │ ├── FormHelper.ts │ │ ├── MockAuthentication.ts │ │ ├── MockValidation.ts │ │ └── index.ts └── validation │ ├── enums │ ├── ValidationErrors.enum.ts │ └── index.ts │ ├── errors │ ├── InvalidFieldError.ts │ ├── InvalidFileTypeError.ts │ ├── MatchFieldError.ts │ ├── RequiredFieldError.ts │ └── index.ts │ ├── protocols │ ├── FieldValidation.ts │ └── index.ts │ ├── test │ ├── MockFieldValidation.ts │ └── index.ts │ └── validators │ ├── builder │ ├── ValidationBuilder.spec.ts │ └── ValidationBuilder.ts │ ├── compareFields │ ├── CompareFieldsValidation.spec.ts │ └── CompareFieldsValidation.ts │ ├── emailField │ ├── EmailFieldValidation.spec.ts │ └── EmailFieldValidation.ts │ ├── fileType │ ├── FileTypeValidation.spec.ts │ └── FileTypeValidation.ts │ ├── index.ts │ ├── matchField │ ├── MatchFieldValidation.spec.ts │ └── MatchFieldValidation.ts │ ├── maxLength │ ├── MaxLengthValidation.spec.ts │ └── MaxLengthValidation.ts │ ├── minLength │ ├── MinLengthValidation.spec.ts │ └── MinLengthValidation.ts │ ├── requiredField │ ├── RequiredFieldValidation.spec.ts │ └── RequiredFieldValidation.ts │ └── validationComposite │ ├── ValidationComposite.spec.ts │ └── ValidationComposite.ts ├── template.dev.html ├── template.prod.html ├── tsconfig.json ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | quote_type = single 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 BUG 3 | about: Report an issue to help improve the project. 4 | title: '[BUG] ' 5 | labels: [] 6 | assignees: [] 7 | --- 8 | 9 | **Description** 10 | 11 | Please describe the Bug in detail. 12 | 13 | **Expected Behavior** 14 | 15 | What should happen? 16 | 17 | **Actual Behavior** 18 | 19 | What happens instead? 20 | 21 | **Screenshots (if applicable)** 22 | 23 | Add screenshots or other media to help explain the issue. 24 | 25 | **Additional Information** 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | issue_template: 2 | feature: .github/ISSUE_TEMPLATE/feature.md 3 | documentation: .github/ISSUE_TEMPLATE/documentation.md 4 | bug: .github/ISSUE_TEMPLATE/bug.md 5 | other: .github/ISSUE_TEMPLATE/other.md 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 📄 Documentation issue 3 | about: Found an issue in the documentation? You can use this one! 4 | title: '[DOCS] ' 5 | labels: [] 6 | assignees: [] 7 | --- 8 | 9 | **Description** 10 | 11 | Please describe the issue in detail. 12 | 13 | **Expected Behavior** 14 | 15 | What should happen? 16 | 17 | **Actual Behavior** 18 | 19 | What happens instead? 20 | 21 | **Screenshots (if applicable)** 22 | 23 | Add screenshots or other media to help explain the issue. 24 | 25 | **Additional Information** 26 | 27 | Add any other context or information about the issue here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 💡General Feature Request 3 | about: Have a new idea/feature for Clean-React-App? Please suggest! 4 | title: '[FEATURE] ' 5 | labels: [] 6 | assignees: [] 7 | --- 8 | 9 | **Description** 10 | 11 | Please describe the Feature in detail. 12 | 13 | **Expected Behavior** 14 | 15 | What should happen? 16 | 17 | **Screenshots (if applicable)** 18 | 19 | Add screenshots or other media to help explain the issue. 20 | 21 | **Additional Information** 22 | 23 | Add any other context or information about the issue here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other 3 | about: Use this for any other issues. Please do NOT create blank issues 4 | title: '[OTHER] ' 5 | labels: [] 6 | assignees: [] 7 | --- 8 | 9 | **Description** 10 | 11 | Please add your question in detail. 12 | 13 | **Expected Behavior** 14 | 15 | What should happen? 16 | 17 | **Actual Behavior** 18 | 19 | What happens instead? 20 | 21 | **Screenshots (if applicable)** 22 | 23 | Add screenshots or other media to help explain the issue. 24 | 25 | **Additional Information** 26 | 27 | Add any other context or information about the issue here. 28 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Thank you for making a pull request! 2 | 3 | Clean React App is built and maintained by developers like you, and we appreciate contributions very much. You are awesome! 4 | 5 | Here is a short checklist to give your PR the best start: 6 | 7 | _Everything above can be removed once checked_ 8 | 9 | - [ ] Commit messages are ready to go in the changelog (see below for details) 10 | - [ ] PR template filled in (see below for details) 11 | 12 | # Commit messages 13 | 14 | Our changelog is automatically built from our commit history, using conventional changelog. This means we'd like to take care that: 15 | 16 | - commit messages with the prefix `fix:` or `fix(foo):` are suitable to be added to the changelog under "Fixes and improvements" 17 | - commit messages with the prefix `feat:` or `feat(foo):` are suitable to be added to the changelog under "New features" 18 | 19 | If you've made many commits that don't adhere to this style, we recommend squashing 20 | your commits to a new branch before making a PR. Alternatively, we can do a squash 21 | merge, but you'll lose attribution for your change. 22 | 23 | For more information please see CONTRIBUTING.md 24 | 25 | _Everything above can be removed_ 26 | 27 | ### PR Template 28 | 29 | _Please describe what this PR is for, or link the issue that this PR fixes_ 30 | 31 | _You may add as much or as little context as you like here, whatever you think is right_ 32 | 33 | _Thanks again!_ 34 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [16.x, 18.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'npm' 24 | - run: npm ci 25 | - run: npm run build --if-present 26 | - run: npm test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | coverage 4 | dist 5 | package-lock.json 6 | public/js 7 | *.DS_Store 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run format:fix 5 | npm test 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Directory generally used for GitHub configuration files 2 | .github 3 | 4 | # Project documentation and information files 5 | CODE_OF_CONDUCT.md 6 | CONTRIBUTING.md 7 | LICENSE 8 | CHANGELOG.md 9 | 10 | # Directory containing node modules, not needed in the package 11 | node_modules 12 | 13 | # Visual Studio Code configuration files 14 | .vscode 15 | 16 | # Directory that may contain code coverage reports 17 | coverage 18 | 19 | # Directory that may contain built code 20 | dist 21 | 22 | # Lock file for npm, not needed in the package 23 | package-lock.json 24 | 25 | # Directory that may contain public JavaScript files 26 | public/js 27 | 28 | # System files for macOS, not needed in the package 29 | *.DS_Store 30 | 31 | # Lock file for yarn, not needed in the package 32 | yarn.lock 33 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | package-lock.json 4 | yarn.lock -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "endOfLine": "lf" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. 4 | 5 | 6 | 7 | # [1.1.4](https://www.npmjs.com/package/@rubemfsv/clean-react-app/v/1.1.4) (2023-10-26) 8 | 9 | ## Features 10 | 11 | - Added Template with Header 12 | - Included File Type Validator 13 | - Integrated test cases for the button component 14 | - Created CHANGELOG.md 15 | - Added commitlints 16 | - Integrated Prettier for code formatting 17 | - Enhanced enum error handling 18 | - Added npmignore file 19 | - Implemented a pull request template 20 | - Created CONTRIBUTING.md 21 | - Added a project license 22 | - Implemented test cases for the FormLoaderStatus component 23 | - Added Match Field Validator 24 | 25 | ## Bug Fixes 26 | 27 | - Replaced Yarn with npm in package.json 28 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as owners, contributors, and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at rfsv@cesar.school. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality concerning the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 1.4, available [here](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html). 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love contributors! Clean React App is maintained and contrbuted to by developers just like you. 4 | 5 | ## Raising issues 6 | 7 | Before raising an issue, make sure you have checked the open and closed issues to see if an answer is provided there. 8 | 9 | Please provide the following information with your issue to enable us to respond as quickly as possible. 10 | 11 | - The relevant versions of the packages you are using. 12 | - The steps to recreate your issue. 13 | - An executable code example where possible. 14 | 15 | ## Key Branches 16 | 17 | - `main` - this is the current main version supporting the 1.x.x release line. Most investment will be here, including major new features, enhancements, bug fixes, security patches etc. 18 | 19 | ## I want to contribute code but don't know where to start 20 | 21 | If you're not sure where to start, look for the [open issues](https://github.com/rubemfsv/clean-react-app/issues). 22 | 23 | ## I'm ready to contribute code 24 | 25 | Awesome! We have some guidelines here that will help your PR be accepted: 26 | 27 | ### Commit messages 28 | 29 | Clean React App uses the [Conventional Changelog](https://github.com/bcoe/conventional-changelog-standard/blob/master/convention.md) 30 | commit message conventions. Please ensure you follow the guidelines, as they 31 | help us automate our release process. 32 | 33 | You can take a look at the git history (`git log`) to get the gist of it. 34 | 35 | If you'd like to get some CLI assistance, getting setup is easy: 36 | 37 | ```shell 38 | npm install commitizen -g 39 | npm i -g cz-conventional-changelog 40 | ``` 41 | 42 | `git cz` to commit and commitizen will guide you. 43 | 44 | #### Release notes 45 | 46 | Commit messages with `fix` or `feat` prefixes will appear in the release notes. 47 | These communicate changes that users may want to know about. 48 | 49 | - `feat():` or `feat:` messages appear under "New Features", and trigger minor version bumps. 50 | - `fix():` or `fix:` messages appear under "Fixes and improvements", and trigger patch version bumps. 51 | 52 | If your commit message introduces a breaking change, please include a footer that starts with `BREAKING CHANGE:`. 53 | For more information, please see the [Conventional Changelog](https://github.com/bcoe/conventional-changelog-standard/blob/master/convention.md) 54 | guidelines. 55 | 56 | (Also, if you are committing breaking changes, you may want to check with the other maintainers on slack first). 57 | 58 | Examples of `fix` include bug fixes and dependency bumps that users of pact-js may want to know about. 59 | 60 | Examples of `feat` include new features and substantial modifications to existing features. 61 | 62 | Examples of things that we'd prefer not to appear in the release notes include documentation updates, 63 | modified or new examples, refactorings, new tests, etc. We usually use one of `chore`, `style`, 64 | `refactor`, or `test` as appropriate. 65 | 66 | ## Code style and formatting 67 | 68 | We use [Prettier](https://prettier.io/) for formatting, and for linting we use [ESLint](https://eslint.org/) (for TypeScript). 69 | 70 | The recommended approach is to configure Prettier to format on save in your editor 👌. If not, don't worry, our lint step will catch it. 71 | 72 | ## Pull requests 73 | 74 | - Please write tests for any changes 75 | - Please follow existing code style and conventions 76 | - Please separate unrelated changes into multiple pull requests 77 | - For bigger changes, make sure you start a discussion first by creating an issue and explaining the intended change 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Original work Copyright (c) 2023 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Clean React App

2 | 3 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-green.svg)](https://github.com/rubemfsv/clean-react-app) 4 | [![Open Source](https://badges.frapsoft.com/os/v1/open-source.svg?v=103)](https://opensource.org/) 5 | ![Node Version](https://img.shields.io/static/v1?label=node&message=18.18.0&color=00579d) 6 | ![React Version](https://img.shields.io/static/v1?label=react&message=18.2.0&color=42a5f5) 7 | 8 | Logo 9 | 10 | Create React apps using [Clean Architecture](https://dev.to/rubemfsv/clean-architecture-the-concept-behind-the-code-52do) with no build configuration. 11 | 12 | - [User Guide](https://dev.to/rubemfsv/clean-architecture-applying-with-react-40h6) – How to develop apps bootstrapped with Clean React App. 13 | 14 | Clean React App works on macOS, Windows, and Linux.
15 | If something doesn’t work, please [file an issue](https://github.com/rubemfsv/clean-react-app/issues/new).
16 | 17 | ## Quick Overview 18 | 19 | ```sh 20 | npx @rubemfsv/clean-react-app my-app 21 | cd my-app 22 | npm start or npm run dev 23 | ``` 24 | 25 |
26 |
27 | 28 | **This boilerplate contains the following settings:** 29 | 30 | - Local storage adapter; 31 | - Axios as HTTP Client; 32 | - Webpack configured for development and production environments; 33 | - Basic end-to-end test settings with Cypress; 34 | - Unit tests with Jest; 35 | - Husky with pre-push to run unit tests; 36 | - Authentication with validations; 37 | - Validation layer for reuse of validations; 38 | - Some hooks to help with API calls and form submissions; 39 | - Private route configured; 40 | - Three pages to help improve productivity: 41 | - Login page 42 | - Sign up page 43 | - Dashboard 44 | 45 |
46 |
47 | 48 | ## :construction_worker: **Installation** 49 | 50 | **You must first have installed [NodeJS](https://nodejs.org/) (I recommend [nvm](https://github.com/nvm-sh/nvm) to deal with versions), [Yarn](https://yarnpkg.com/), and then:** 51 | 52 | `git clone https://github.com/rubemfsv/clean-react-app.git` 53 | 54 | Step 1: 55 | 56 | `cd clean-react-app` - access the project files 57 | 58 | Step 2: 59 | 60 | `yarn` (or `npm install`) - to install dependencies 61 | 62 | Step 3: 63 | 64 | Change your `webpack.dev.js` and `webpack.prod.js` env url to your real urls 65 | 66 | Step 4: 67 | 68 | `yarn dev` (or `npm run dev`) - to initialize the project under development 69 | 70 | Observations: 71 | 72 | `yarn test` (or `npm run test`) - to run jest unit testing 73 | 74 | `yarn test:e2e` (or `npm run test:e2e`) - to run cypress e2e testing (if you use linux or windows, the command may change because of the \, but you can change the script or run it by `node_modules/.bin/cypress open`) 75 | 76 | `yarn start` (or `npm start`) - to initialize the project under production webpack; 77 | 78 | In the package.json file, there are scripts that you can run with node and yarn 79 | 80 |
81 |
82 | 83 | ## :open_file_folder: **Architecture** 84 | 85 | The architecture used in this project was the [Clean Architecture](https://dev.to/rubemfsv/clean-architecture-the-concept-behind-the-code-52do), using the concepts proposed by Roberto Martin. To know how to implement this architecture, there is an [article applying this Architecture with React](https://dev.to/rubemfsv/arquitetura-limpa-aplicando-com-react-1eo0) that describes very well the thought line. 86 | 87 | ``` 88 | cypress/ 89 | src/ 90 | data/ 91 | protocols/ 92 | test/ 93 | usecases/ 94 | domain/ 95 | errors/ 96 | models/ 97 | test/ 98 | usecases/ 99 | infra/ 100 | cache/ 101 | http/ 102 | test/ 103 | main/ 104 | adapters/ 105 | config/ 106 | decorators/ 107 | factories/ 108 | cache/ 109 | decorators/ 110 | http/ 111 | pages/ 112 | usecases/ 113 | routes/ 114 | scripts/ 115 | index.tsx 116 | presentation/ 117 | assets/ 118 | fonts/ 119 | images/ 120 | components/ 121 | hooks/ 122 | pages/ 123 | protocols/ 124 | routes/ 125 | styles/ 126 | test/ 127 | validation/ 128 | errors/ 129 | protocols/ 130 | test/ 131 | validators/ 132 | ``` 133 | 134 |
135 | 136 | 🖥️ **Login page** 137 | 138 | It's a simple login page with a form and error handling. It already has input, button, field and loader components. 139 | 140 | ![Login page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vyruv5eroc1eb5p7ferj.png) 141 | 142 | 🖥️ **Sign up page** 143 | 144 | It is a registration page with a form that receives the username, email, password and password confirmation. It already has error handling and reused components. 145 | 146 | ![Sign up page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r3ua2l7ybbsd9f06m57t.png) 147 | 148 | 🖥️ **Dashboard page** 149 | 150 | It is an empty page that is redirected after successful login. It's there to help with development, saving time by being the starting point. 151 | 152 | ![Dashboard page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fxpg1sfmkt1dkfv12pbm.png) 153 | 154 |
155 |
156 | 157 | ## :bug: Issues 158 | 159 | If something doesn’t work, please [file an issue](https://github.com/rubemfsv/clean-react-app/issues/new). 160 | 161 |
162 |
163 | 164 | ## 📜 Code of Conduct 165 | 166 | We are committed to providing a welcoming and safe environment in our community. All contributors, maintainers, and everyone participating in this project are expected to adhere to our Code of Conduct. Please read the [full text](./CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated. 167 | 168 |
169 |
170 | 171 | ## :bookmark_tabs: Branches and contributions 172 | 173 | As this project is intended to be open source and free for everyone to use, feel free to contribute improvements. 174 | 175 | If something can be improved, just create a branch from `main` and make a Pull Request with the suggestions. 176 | 177 |
178 |
179 | 180 | ## Contributors 181 | 182 | Meet the talented individuals who have contributed to Clean React App project: 183 | 184 | 185 | 186 | 187 | 188 |
189 |
190 | 191 | ## License 192 | 193 | **Clean React App** is available under MIT. See [LICENSE](./LICENSE) for more details. 194 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | chromeWebSecurity: false, 5 | viewportWidth: 1200, 6 | viewportHeight: 660, 7 | 8 | env: { 9 | baseUrl: 'http://localhost:8081', 10 | user: { 11 | login: 'test@test.com', 12 | password: '12345', 13 | }, 14 | }, 15 | 16 | retries: { 17 | runMode: 1, 18 | }, 19 | 20 | defaultCommandTimeout: 40000, 21 | pageLoadTimeout: 50000, 22 | 23 | e2e: { 24 | setupNodeEvents(on, config) { 25 | // implement node event listeners here 26 | }, 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /cypress/e2e/loginFlow.spec.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Login flow', () => { 4 | it('should login in the application', () => { 5 | cy.visit(Cypress.env('baseUrl') + '/login') 6 | 7 | cy.get('[data-testid="email"]').click().type(Cypress.env('user').login) 8 | cy.get('[data-testid="password"]') 9 | .click() 10 | .type(Cypress.env('user').password) 11 | cy.get('[data-testid="loginButton"]').click() 12 | cy.contains('Desconectar').should('be.visible') // When logged, see the favorite item inside the menu 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /cypress/fixtures/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "login": "test@test.com", 3 | "password": "12345" 4 | } 5 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fallback = require('express-history-api-fallback') 2 | const express = require('express') 3 | const app = express() 4 | const root = `${__dirname}/dist` 5 | app.use(express.static(root)) 6 | app.get('/', function (req, res) { 7 | res.render(fallback('index.html', { root })) 8 | }) 9 | app.listen(process.env.port || 3000) 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | collectCoverageFrom: [ 4 | '/src/**/*.{ts,tsx}', 5 | '!/src/main/**/*', 6 | '!/src/validation/test/index.ts', 7 | '!/src/validation/protocols/index.ts', 8 | '!/src/domain/models/index.ts', 9 | '!/src/domain/usecases/index.ts', 10 | '!**/*.d.ts', 11 | '!**/*.png', 12 | '!**/*.svg', 13 | ], 14 | coverageDirectory: 'coverage', 15 | setupFilesAfterEnv: ['/src/main/scripts/jestSetup.ts'], 16 | testEnvironment: 'jest-environment-jsdom', 17 | transform: { 18 | '.+\\.(ts|tsx)$': 'ts-jest', 19 | }, 20 | moduleNameMapper: { 21 | '@/(.*)': '/src/$1', 22 | '\\.scss$': 'identity-obj-proxy', 23 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 24 | '/src/main/scripts/assetsTransformer.ts', 25 | '\\.(css|less)$': '/src/main/scripts/assetsTransformer.ts', 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rubemfsv/clean-react-app", 3 | "author": "rubemfsv", 4 | "version": "1.1.4", 5 | "description": "Clean React App", 6 | "main": "index.js", 7 | "keywords": [ 8 | "React", 9 | "Clean Architecture", 10 | "Typescript", 11 | "Webpack", 12 | "Clean React App" 13 | ], 14 | "publishConfig": { 15 | "registry": "https://npm.pkg.github.com/" 16 | }, 17 | "scripts": { 18 | "start": "node index", 19 | "dev:base": "webpack-dev-server --config webpack.dev.js", 20 | "dev": "npm run dev:base -- --open", 21 | "build": "webpack --config webpack.prod.js", 22 | "test": "jest --passWithNoTests --no-cache --runInBand", 23 | "test:watch": "npm test -- --watch", 24 | "test:e2e": "node_modules/.bin/cypress open", 25 | "postinstall": "npx husky install", 26 | "format": "prettier --check --no-error-on-unmatched-pattern --ignore-path .gitignore . --ext ts,tsx,js,jsx,json,md", 27 | "format:fix": "prettier --write --no-error-on-unmatched-pattern --ignore-path .gitignore . --ext ts,tsx,js,jsx,json,md" 28 | }, 29 | "bin": "./src/main/scripts/bin.js", 30 | "repository": "https://github.com/rubemfsv/clean-react-app.git", 31 | "license": "ISC", 32 | "bugs": { 33 | "url": "https://github.com/rubemfsv/clean-react-app/issues" 34 | }, 35 | "homepage": "https://github.com/rubemfsv/clean-react-app#readme", 36 | "devDependencies": { 37 | "@commitlint/cli": "^17.7.2", 38 | "@commitlint/config-conventional": "^17.7.0", 39 | "@testing-library/jest-dom": "^6.1.3", 40 | "@testing-library/react": "^14.0.0", 41 | "@types/faker": "^5.1.5", 42 | "@types/jest": "^29.5.5", 43 | "@types/node": "^14.14.17", 44 | "@types/react": "^18.0.15", 45 | "@types/react-dom": "^18.0.6", 46 | "@types/react-router-dom": "^5.1.7", 47 | "clean-webpack-plugin": "^3.0.0", 48 | "css-loader": "^5.0.1", 49 | "cypress": "^13.2.0", 50 | "express": "^4.17.1", 51 | "express-history-api-fallback": "^2.2.1", 52 | "faker": "^5.1.0", 53 | "file-loader": "^6.2.0", 54 | "html-webpack-plugin": "^4.5.2", 55 | "husky": "^8.0.1", 56 | "identity-obj-proxy": "^3.0.0", 57 | "jest": "^29.7.0", 58 | "jest-environment-jsdom": "^29.7.0", 59 | "jest-localstorage-mock": "^2.4.6", 60 | "mini-css-extract-plugin": "^1.3.4", 61 | "netlify-webpack-plugin": "^1.1.1", 62 | "node-sass": "^8.0.0", 63 | "prettier": "3.0.3", 64 | "sass-loader": "^10.1.0", 65 | "style-loader": "^2.0.0", 66 | "ts-jest": "^29.1.1", 67 | "ts-loader": "^8.0.13", 68 | "typescript": "^4.1.3", 69 | "url-loader": "^4.1.1", 70 | "webpack": "^5.88.2", 71 | "webpack-cli": "^5.1.4", 72 | "webpack-dev-server": "^4.15.1", 73 | "webpack-merge": "^5.7.3" 74 | }, 75 | "dependencies": { 76 | "axios": "^0.21.1", 77 | "react": "^18.2.0", 78 | "react-dom": "^18.2.0", 79 | "react-icons": "^4.1.0", 80 | "react-router-dom": "^5.2.0" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/data/protocols/cache/getStorage.ts: -------------------------------------------------------------------------------- 1 | export interface IGetStorage { 2 | get: (key: string) => any 3 | } 4 | -------------------------------------------------------------------------------- /src/data/protocols/cache/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getStorage' 2 | export * from './setStorage' 3 | -------------------------------------------------------------------------------- /src/data/protocols/cache/setStorage.ts: -------------------------------------------------------------------------------- 1 | export interface ISetStorage { 2 | set: (key: string, value: object) => void 3 | } 4 | -------------------------------------------------------------------------------- /src/data/protocols/http/HttpClient.ts: -------------------------------------------------------------------------------- 1 | export type HttpRequest = { 2 | url: string 3 | method: HttpMethod 4 | body?: any 5 | headers?: any 6 | } 7 | 8 | export interface IHttpClient { 9 | request: (data: HttpRequest) => Promise> 10 | } 11 | 12 | export type HttpMethod = 'post' | 'get' | 'put' | 'delete' 13 | 14 | export enum HttpStatusCode { 15 | ok = 200, 16 | created = 201, 17 | noContent = 204, 18 | badRequest = 400, 19 | unauthorized = 401, 20 | forbidden = 403, 21 | notFound = 404, 22 | serverError = 500, 23 | } 24 | 25 | export type HttpResponse = { 26 | statusCode: HttpStatusCode 27 | body?: T 28 | } 29 | -------------------------------------------------------------------------------- /src/data/protocols/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HttpClient' 2 | -------------------------------------------------------------------------------- /src/data/test/MockCache.ts: -------------------------------------------------------------------------------- 1 | import { IGetStorage } from '../protocols/cache' 2 | export class GetStorageSpy implements IGetStorage { 3 | key: string 4 | value: any 5 | get(key: string): any { 6 | this.key = key 7 | 8 | return this.value 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/data/test/MockHttp.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpRequest, 3 | HttpResponse, 4 | HttpStatusCode, 5 | IHttpClient, 6 | } from '@/data/protocols/http' 7 | 8 | import faker from 'faker' 9 | 10 | export const mockHttpRequest = (): HttpRequest => ({ 11 | url: faker.internet.url(), 12 | method: faker.random.arrayElement(['get', 'post', 'put', 'delete']), 13 | body: faker.random.objectElement(), 14 | headers: faker.random.objectElement(), 15 | }) 16 | 17 | export class HttpClientSpy implements IHttpClient { 18 | url?: string 19 | method?: string 20 | body?: any 21 | headers?: any 22 | response: HttpResponse = { 23 | statusCode: HttpStatusCode.ok, 24 | } 25 | 26 | async request(data: HttpRequest): Promise> { 27 | this.url = data.url 28 | this.method = data.method 29 | this.body = data.body 30 | this.headers = data.headers 31 | return this.response 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/data/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MockHttp' 2 | export * from './MockCache' 3 | -------------------------------------------------------------------------------- /src/data/usecases/Auth/authentication/RemoteAuthentication.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RemoteAuthentication, 3 | RemoteAuthenticationamespace, 4 | } from './RemoteAuthentication' 5 | import { HttpClientSpy } from '@/data/test' 6 | import { HttpStatusCode } from '@/data/protocols/http/' 7 | import { mockAuthenticationModel, mockAuthentication } from '@/domain/test/' 8 | import { InvalidCredentialsError, UnexpectedError } from '@/domain/errors/' 9 | import faker from 'faker' 10 | 11 | type SutTypes = { 12 | systemUnderTest: RemoteAuthentication 13 | httpClientSpy: HttpClientSpy 14 | } 15 | 16 | const makeSystemUnderTest = (url: string = faker.internet.url()): SutTypes => { 17 | const httpClientSpy = new HttpClientSpy() 18 | const systemUnderTest = new RemoteAuthentication(url, httpClientSpy) 19 | 20 | return { 21 | systemUnderTest, 22 | httpClientSpy, 23 | } 24 | } 25 | 26 | describe('RemoteAuthentication', () => { 27 | test('Should call HttpClient with correct values', async () => { 28 | const url = faker.internet.url() 29 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest(url) 30 | const authenticationParams = mockAuthentication() 31 | await systemUnderTest.auth(authenticationParams) 32 | 33 | expect(httpClientSpy.url).toBe(url) 34 | expect(httpClientSpy.method).toBe('post') 35 | expect(httpClientSpy.body).toEqual(authenticationParams) 36 | }) 37 | 38 | test('Should return an AccountModel if HttpPostClient returns 200', async () => { 39 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest() 40 | const httpResult = mockAuthenticationModel() 41 | httpClientSpy.response = { 42 | statusCode: HttpStatusCode.ok, 43 | body: httpResult, 44 | } 45 | const account = await systemUnderTest.auth(mockAuthentication()) 46 | 47 | expect(account).toEqual(httpResult) 48 | }) 49 | 50 | test('Should throw InvalidCredentialsError if HttpPostClient returns 401', async () => { 51 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest() 52 | httpClientSpy.response = { 53 | statusCode: HttpStatusCode.unauthorized, 54 | } 55 | const promise = systemUnderTest.auth(mockAuthentication()) 56 | 57 | await expect(promise).rejects.toThrow(new InvalidCredentialsError()) 58 | }) 59 | 60 | test('Should throw UnexpectedError if HttpPostClient returns 400', async () => { 61 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest() 62 | httpClientSpy.response = { 63 | statusCode: HttpStatusCode.badRequest, 64 | } 65 | const promise = systemUnderTest.auth(mockAuthentication()) 66 | 67 | await expect(promise).rejects.toThrow(new UnexpectedError()) 68 | }) 69 | 70 | test('Should throw UnexpectedError if HttpPostClient returns 404', async () => { 71 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest() 72 | httpClientSpy.response = { 73 | statusCode: HttpStatusCode.notFound, 74 | } 75 | const promise = systemUnderTest.auth(mockAuthentication()) 76 | 77 | await expect(promise).rejects.toThrow(new UnexpectedError()) 78 | }) 79 | 80 | test('Should throw UnexpectedError if HttpPostClient returns 500', async () => { 81 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest() 82 | httpClientSpy.response = { 83 | statusCode: HttpStatusCode.serverError, 84 | } 85 | const promise = systemUnderTest.auth(mockAuthentication()) 86 | 87 | await expect(promise).rejects.toThrow(new UnexpectedError()) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /src/data/usecases/Auth/authentication/RemoteAuthentication.ts: -------------------------------------------------------------------------------- 1 | import { IHttpClient, HttpStatusCode } from '@/data/protocols/http' 2 | import { UnexpectedError, InvalidCredentialsError } from '@/domain/errors' 3 | import { IAuthentication, Authentication } from '@/domain/usecases' 4 | 5 | export class RemoteAuthentication implements IAuthentication { 6 | constructor( 7 | private readonly url: string, 8 | private readonly httpPostClient: IHttpClient 9 | ) {} 10 | 11 | async auth( 12 | params: Authentication.Params 13 | ): Promise { 14 | const httpResponse = await this.httpPostClient.request({ 15 | url: this.url, 16 | method: 'post', 17 | body: params, 18 | }) 19 | 20 | switch (httpResponse.statusCode) { 21 | case HttpStatusCode.ok: 22 | return httpResponse.body 23 | case HttpStatusCode.unauthorized: 24 | throw new InvalidCredentialsError() 25 | default: 26 | throw new UnexpectedError() 27 | } 28 | } 29 | } 30 | 31 | export namespace RemoteAuthenticationamespace { 32 | export type Model = Authentication.Model 33 | } 34 | -------------------------------------------------------------------------------- /src/data/usecases/User/addAccount/RemoteAddAccount.spec.ts: -------------------------------------------------------------------------------- 1 | import { RemoteAddAccount, RemoteAddAccountNamespace } from './RemoteAddAccount' 2 | import { HttpClientSpy } from '@/data/test' 3 | import { HttpStatusCode } from '@/data/protocols/http/' 4 | import { mockAddAccountModel, mockAddAccount } from '@/domain/test/' 5 | import { EmailInUseError, UnexpectedError } from '@/domain/errors/' 6 | import faker from 'faker' 7 | 8 | type SutTypes = { 9 | systemUnderTest: RemoteAddAccount 10 | httpClientSpy: HttpClientSpy 11 | } 12 | 13 | const makeSystemUnderTest = (url: string = faker.internet.url()): SutTypes => { 14 | const httpClientSpy = new HttpClientSpy() 15 | const systemUnderTest = new RemoteAddAccount(url, httpClientSpy) 16 | 17 | return { 18 | systemUnderTest, 19 | httpClientSpy, 20 | } 21 | } 22 | 23 | describe('RemoteAddAccount', () => { 24 | test('Should call HttpClient with correct values', async () => { 25 | const url = faker.internet.url() 26 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest(url) 27 | const addAccountParams = mockAddAccount() 28 | 29 | await systemUnderTest.add(addAccountParams) 30 | 31 | expect(httpClientSpy.url).toBe(url) 32 | expect(httpClientSpy.method).toBe('post') 33 | expect(httpClientSpy.body).toEqual(addAccountParams) 34 | }) 35 | 36 | test('Should throw UnexpectedError if HttpClient returns 400', async () => { 37 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest() 38 | httpClientSpy.response = { 39 | statusCode: HttpStatusCode.badRequest, 40 | } 41 | const promise = systemUnderTest.add(mockAddAccount()) 42 | 43 | await expect(promise).rejects.toThrow(new UnexpectedError()) 44 | }) 45 | 46 | test('Should throw EmailInUseError if HttpClient returns 403', async () => { 47 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest() 48 | httpClientSpy.response = { 49 | statusCode: HttpStatusCode.forbidden, 50 | } 51 | const promise = systemUnderTest.add(mockAddAccount()) 52 | 53 | await expect(promise).rejects.toThrow(new EmailInUseError()) 54 | }) 55 | 56 | test('Should throw UnexpectedError if HttpClient returns 404', async () => { 57 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest() 58 | httpClientSpy.response = { 59 | statusCode: HttpStatusCode.notFound, 60 | } 61 | const promise = systemUnderTest.add(mockAddAccount()) 62 | 63 | await expect(promise).rejects.toThrow(new UnexpectedError()) 64 | }) 65 | 66 | test('Should throw UnexpectedError if HttpClient returns 500', async () => { 67 | const { systemUnderTest, httpClientSpy } = makeSystemUnderTest() 68 | httpClientSpy.response = { 69 | statusCode: HttpStatusCode.serverError, 70 | } 71 | const promise = systemUnderTest.add(mockAddAccount()) 72 | 73 | await expect(promise).rejects.toThrow(new UnexpectedError()) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /src/data/usecases/User/addAccount/RemoteAddAccount.ts: -------------------------------------------------------------------------------- 1 | import { IHttpClient, HttpStatusCode } from '@/data/protocols/http' 2 | import { AddAccount, IAddAccount } from '@/domain/usecases' 3 | import { EmailInUseError, UnexpectedError } from '@/domain/errors' 4 | 5 | export class RemoteAddAccount implements IAddAccount { 6 | constructor( 7 | private readonly url: string, 8 | private readonly httpClient: IHttpClient 9 | ) {} 10 | 11 | async add(params: AddAccount.Params): Promise { 12 | const httpResponse = await this.httpClient.request({ 13 | url: this.url, 14 | method: 'post', 15 | body: params, 16 | }) 17 | switch (httpResponse.statusCode) { 18 | case HttpStatusCode.ok: 19 | return httpResponse.body 20 | case HttpStatusCode.forbidden: 21 | throw new EmailInUseError() 22 | default: 23 | throw new UnexpectedError() 24 | } 25 | } 26 | } 27 | 28 | export namespace RemoteAddAccountNamespace { 29 | export type Model = AddAccount.Model 30 | } 31 | -------------------------------------------------------------------------------- /src/data/usecases/index.ts: -------------------------------------------------------------------------------- 1 | // Auth 2 | export * from './Auth/authentication/RemoteAuthentication' 3 | 4 | // User 5 | export * from './User/addAccount/RemoteAddAccount' 6 | -------------------------------------------------------------------------------- /src/domain/enums/DomainErrors.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @enum {DomainErrorMessagesEnum} DomainErrorMessagesEnum - Definition of messages displayed in errors 3 | * 4 | * @arg {string} AccessDeniedError - Access denied message error 5 | * @arg {string} EmailInUseError - Email in use message error 6 | * @arg {string} InvalidCredentialsError - Invalid credentials message error 7 | * @arg {string} UnexpectedError - Unexpected message error 8 | * 9 | */ 10 | export enum DomainErrorMessagesEnum { 11 | AccessDeniedError = 'Access denied!', 12 | EmailInUseError = 'This email is already in use', 13 | InvalidCredentialsError = 'Invalid credentials', 14 | UnexpectedError = 'Something went wrong. Try again later.', 15 | } 16 | 17 | /** 18 | * @enum {DomainErrorNamesEnum} DomainErrorNamesEnum - Definition of messages names used in errors 19 | * 20 | * @arg {string} AccessDeniedError - Access denied name error 21 | * @arg {string} EmailInUseError - Email in use name error 22 | * @arg {string} InvalidCredentialsError - Invalid credentials name error 23 | * @arg {string} UnexpectedError - Unexpected name error 24 | */ 25 | export enum DomainErrorNamesEnum { 26 | AccessDeniedError = 'AccessDeniedError', 27 | EmailInUseError = 'EmailInUseError', 28 | InvalidCredentialsError = 'InvalidCredentialsError', 29 | UnexpectedError = 'UnexpectedError', 30 | } 31 | -------------------------------------------------------------------------------- /src/domain/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DomainErrors.enum' 2 | -------------------------------------------------------------------------------- /src/domain/errors/AccessDeniedError.ts: -------------------------------------------------------------------------------- 1 | import { DomainErrorMessagesEnum, DomainErrorNamesEnum } from '../enums' 2 | 3 | export class AccessDeniedError extends Error { 4 | constructor() { 5 | super(DomainErrorMessagesEnum.AccessDeniedError) 6 | this.name = DomainErrorNamesEnum.AccessDeniedError 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/errors/EmailInUseError.ts: -------------------------------------------------------------------------------- 1 | import { DomainErrorMessagesEnum, DomainErrorNamesEnum } from '../enums' 2 | 3 | export class EmailInUseError extends Error { 4 | constructor() { 5 | super(DomainErrorMessagesEnum.EmailInUseError) 6 | this.name = DomainErrorNamesEnum.EmailInUseError 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/errors/InvalidCredentialsError.ts: -------------------------------------------------------------------------------- 1 | import { DomainErrorMessagesEnum, DomainErrorNamesEnum } from '../enums' 2 | 3 | export class InvalidCredentialsError extends Error { 4 | constructor() { 5 | super(DomainErrorMessagesEnum.InvalidCredentialsError) 6 | this.name = DomainErrorNamesEnum.InvalidCredentialsError 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/errors/UnexpectedError.ts: -------------------------------------------------------------------------------- 1 | import { DomainErrorMessagesEnum, DomainErrorNamesEnum } from '../enums' 2 | 3 | export class UnexpectedError extends Error { 4 | constructor(message?: string) { 5 | super(message || DomainErrorMessagesEnum.UnexpectedError) 6 | this.name = DomainErrorNamesEnum.UnexpectedError 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AccessDeniedError' 2 | export * from './EmailInUseError' 3 | export * from './InvalidCredentialsError' 4 | export * from './UnexpectedError' 5 | -------------------------------------------------------------------------------- /src/domain/models/AccountModel.ts: -------------------------------------------------------------------------------- 1 | export type AccountModel = { 2 | accessToken: string 3 | name: string 4 | } 5 | -------------------------------------------------------------------------------- /src/domain/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AccountModel' 2 | -------------------------------------------------------------------------------- /src/domain/test/MockAccount.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import { AccountModel } from '../models' 3 | 4 | export const mockAccountModel = (): AccountModel => ({ 5 | accessToken: faker.datatype.uuid(), 6 | name: faker.random.word(), 7 | }) 8 | -------------------------------------------------------------------------------- /src/domain/test/MockAddAccount.ts: -------------------------------------------------------------------------------- 1 | import { AddAccount, IAddAccount } from '@/domain/usecases' 2 | import faker from 'faker' 3 | import { mockAccountModel } from './MockAccount' 4 | 5 | export const mockAddAccount = (): AddAccount.Params => { 6 | const password = faker.internet.password() 7 | return { 8 | name: faker.name.lastName(), 9 | email: faker.internet.email(), 10 | password: password, 11 | passwordConfirmation: password, 12 | } 13 | } 14 | 15 | export const mockAddAccountModel = (): AddAccount.Model => mockAccountModel() 16 | 17 | export class AddAccountSpy implements IAddAccount { 18 | account = mockAddAccountModel() 19 | params: AddAccount.Params 20 | callsCount = 0 21 | 22 | async add(params: AddAccount.Params): Promise { 23 | this.params = params 24 | this.callsCount++ 25 | return this.account 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/domain/test/MockAuthentication.ts: -------------------------------------------------------------------------------- 1 | import { Authentication, IAuthentication } from '@/domain/usecases' 2 | import faker from 'faker' 3 | import { mockAccountModel } from './MockAccount' 4 | 5 | export const mockAuthentication = (): Authentication.Params => ({ 6 | email: faker.internet.email(), 7 | password: faker.internet.password(), 8 | }) 9 | 10 | export const mockAuthenticationModel = (): Authentication.Model => 11 | mockAccountModel() 12 | 13 | export class AuthenticationSpy implements IAuthentication { 14 | account = mockAuthenticationModel() 15 | params: Authentication.Params 16 | callsCount = 0 17 | 18 | async auth(params: Authentication.Params): Promise { 19 | this.params = params 20 | this.callsCount++ 21 | return this.account 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/domain/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MockAuthentication' 2 | export * from './MockAccount' 3 | export * from './MockAddAccount' 4 | -------------------------------------------------------------------------------- /src/domain/usecases/Auth/Authentication.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '@/domain/models/' 2 | 3 | export interface IAuthentication { 4 | auth(params: Authentication.Params): Promise 5 | } 6 | 7 | export namespace Authentication { 8 | export type Params = { 9 | email: string 10 | password: string 11 | } 12 | 13 | export type Model = AccountModel 14 | } 15 | -------------------------------------------------------------------------------- /src/domain/usecases/User/AddAccount.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '@/domain/models' 2 | 3 | export interface IAddAccount { 4 | add: (params: AddAccount.Params) => Promise 5 | } 6 | 7 | export namespace AddAccount { 8 | export type Params = { 9 | name: string 10 | email: string 11 | password: string 12 | passwordConfirmation: string 13 | } 14 | 15 | export type Model = AccountModel 16 | } 17 | -------------------------------------------------------------------------------- /src/domain/usecases/index.ts: -------------------------------------------------------------------------------- 1 | // Auth 2 | export * from './Auth/Authentication' 3 | 4 | // User 5 | export * from './User/AddAccount' 6 | -------------------------------------------------------------------------------- /src/infra/cache/LocalStorageAdapter.spec.ts: -------------------------------------------------------------------------------- 1 | import 'jest-localstorage-mock' 2 | import { LocalStorageAdapter } from './LocalStorageAdapter' 3 | import faker from 'faker' 4 | 5 | const makeSut = (): LocalStorageAdapter => new LocalStorageAdapter() 6 | 7 | describe('LocalStorageAdapter', () => { 8 | beforeEach(() => { 9 | localStorage.clear() 10 | }) 11 | 12 | test('Should call localStorage.setItems with correct values', () => { 13 | const sut = makeSut() 14 | const key = faker.database.column() 15 | const value = faker.random.objectElement<{}>() 16 | sut.set(key, value) 17 | expect(localStorage.setItem).toHaveBeenCalledWith( 18 | key, 19 | JSON.stringify(value) 20 | ) 21 | }) 22 | 23 | test('Should call localStorage.removeItems with values is null', () => { 24 | const sut = makeSut() 25 | const key = faker.database.column() 26 | sut.set(key, undefined) 27 | expect(localStorage.removeItem).toHaveBeenCalledWith(key) 28 | }) 29 | 30 | test('Should call localStorage.getItem with correct value', () => { 31 | const sut = makeSut() 32 | const key = faker.database.column() 33 | const value = faker.random.objectElement<{}>() 34 | const getItemSpy = jest 35 | .spyOn(localStorage, 'getItem') 36 | .mockReturnValueOnce(JSON.stringify(value)) 37 | const obj = sut.get(key) 38 | expect(getItemSpy).toHaveBeenCalledWith(key) 39 | expect(obj).toEqual(value) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/infra/cache/LocalStorageAdapter.ts: -------------------------------------------------------------------------------- 1 | import { IGetStorage, ISetStorage } from '@/data/protocols/cache/' 2 | 3 | export class LocalStorageAdapter implements ISetStorage, IGetStorage { 4 | set(key: string, value: object): void { 5 | if (value) { 6 | localStorage.setItem(key, JSON.stringify(value)) 7 | } else { 8 | localStorage.removeItem(key) 9 | } 10 | } 11 | 12 | get(key: string): any { 13 | return JSON.parse(localStorage.getItem(key)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/infra/http/axiosHttpClient/AxiosHttpClient.spec.ts: -------------------------------------------------------------------------------- 1 | import { AxiosHttpClient } from '@/infra/http/axiosHttpClient/AxiosHttpClient' 2 | import { mockAxios, mockHttpResponse } from '@/infra/test' 3 | import { mockHttpRequest } from '@/data/test' 4 | 5 | import axios from 'axios' 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 | return { 18 | sut, 19 | mockedAxios, 20 | } 21 | } 22 | 23 | describe('AxiosHttpClient', () => { 24 | test('Should call axios with correct values', async () => { 25 | const request = mockHttpRequest() 26 | const { sut, mockedAxios } = makeSut() 27 | 28 | await sut.request(request) 29 | 30 | expect(mockedAxios.request).toHaveBeenCalledWith({ 31 | url: request.url, 32 | data: request.body, 33 | headers: request.headers, 34 | method: request.method, 35 | }) 36 | }) 37 | 38 | test('Should return correct response', async () => { 39 | const { sut, mockedAxios } = makeSut() 40 | 41 | const httpResponse = await sut.request(mockHttpRequest()) 42 | const axiosResponse = await mockedAxios.request.mock.results[0].value 43 | 44 | expect(httpResponse).toEqual({ 45 | statusCode: axiosResponse.status, 46 | body: axiosResponse.data, 47 | }) 48 | }) 49 | 50 | test('Should return correct error', () => { 51 | const { sut, mockedAxios } = makeSut() 52 | mockedAxios.request.mockRejectedValueOnce({ 53 | response: mockHttpResponse(), 54 | }) 55 | 56 | const promise = sut.request(mockHttpRequest()) 57 | 58 | expect(promise).toEqual(mockedAxios.request.mock.results[0].value) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /src/infra/http/axiosHttpClient/AxiosHttpClient.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest, HttpResponse, IHttpClient } from '@/data/protocols/http' 2 | 3 | import axios, { AxiosResponse } from 'axios' 4 | 5 | export class AxiosHttpClient implements IHttpClient { 6 | async request(data: HttpRequest): Promise { 7 | let axiosResponse: AxiosResponse 8 | try { 9 | axiosResponse = await axios.request({ 10 | url: data.url, 11 | method: data.method, 12 | data: data.body, 13 | headers: data.headers, 14 | }) 15 | } catch (error) { 16 | axiosResponse = error.response 17 | } 18 | return { 19 | statusCode: axiosResponse.status, 20 | body: axiosResponse.data, 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/infra/test/MockAxios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import faker from 'faker' 3 | 4 | export const mockHttpResponse = (): any => ({ 5 | data: faker.random.objectElement(), 6 | status: faker.datatype.number(), 7 | }) 8 | 9 | export const mockAxios = (): jest.Mocked => { 10 | const mockedAxios = axios as jest.Mocked 11 | 12 | mockedAxios.request.mockClear().mockResolvedValue(mockHttpResponse) 13 | 14 | return mockedAxios 15 | } 16 | -------------------------------------------------------------------------------- /src/infra/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MockAxios' 2 | -------------------------------------------------------------------------------- /src/main/adapters/CurrentAccountAdapter/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { UnexpectedError } from '@/domain/errors' 2 | import { mockAccountModel } from '@/domain/test' 3 | import { LocalStorageAdapter } from '@/infra/cache/LocalStorageAdapter' 4 | import { setCurrentAccountAdapter, getCurrentAccountAdapter } from '.' 5 | 6 | jest.mock('@/infra/cache/LocalStorageAdapter') 7 | 8 | describe('CurrentAccountAdapter', () => { 9 | test('Should call LocalStorageAdapter.set with correct values', () => { 10 | const account = mockAccountModel() 11 | const setSpy = jest.spyOn(LocalStorageAdapter.prototype, 'set') 12 | setCurrentAccountAdapter(account) 13 | expect(setSpy).toHaveBeenCalledWith('account', account) 14 | }) 15 | 16 | test('Should call LocalStorageAdapter.get with correct values', () => { 17 | const account = mockAccountModel() 18 | const getSpy = jest 19 | .spyOn(LocalStorageAdapter.prototype, 'get') 20 | .mockReturnValueOnce(account) 21 | const result = getCurrentAccountAdapter() 22 | expect(getSpy).toHaveBeenCalledWith('account') 23 | expect(result).toEqual(account) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/main/adapters/CurrentAccountAdapter/index.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '@/domain/models' 2 | import { makeLocalStorageAdapter } from '@/main/factories/cache/LocalStorageAdapter' 3 | 4 | export const setCurrentAccountAdapter = (account: AccountModel): void => { 5 | makeLocalStorageAdapter().set('account', account) 6 | } 7 | 8 | export const getCurrentAccountAdapter = (): AccountModel => { 9 | return makeLocalStorageAdapter().get('account') 10 | } 11 | -------------------------------------------------------------------------------- /src/main/config/fonts-module.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.ttf' 2 | -------------------------------------------------------------------------------- /src/main/config/png-module.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const value: any 3 | export default value 4 | } 5 | -------------------------------------------------------------------------------- /src/main/config/sass-module.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' { 2 | const content: { [className: string]: string } 3 | export = content 4 | } 5 | -------------------------------------------------------------------------------- /src/main/config/svg-module.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const value: any 3 | export default value 4 | } 5 | -------------------------------------------------------------------------------- /src/main/decorators/AuthorizeHttpDecorator/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizeHttpClientDecorator } from '@/main/decorators' 2 | import { mockHttpRequest, GetStorageSpy, HttpClientSpy } from '@/data/test' 3 | import { HttpRequest } from '@/data/protocols/http' 4 | import { mockAccountModel } from '@/domain/test' 5 | 6 | import faker from 'faker' 7 | 8 | type SutTypes = { 9 | sut: AuthorizeHttpClientDecorator 10 | getStorageSpy: GetStorageSpy 11 | httpClientSpy: HttpClientSpy 12 | } 13 | 14 | const makeSut = (): SutTypes => { 15 | const getStorageSpy = new GetStorageSpy() 16 | const httpClientSpy = new HttpClientSpy() 17 | const sut = new AuthorizeHttpClientDecorator(getStorageSpy, httpClientSpy) 18 | return { 19 | sut, 20 | getStorageSpy, 21 | httpClientSpy, 22 | } 23 | } 24 | 25 | describe('AuthorizeHttpGetClientDecorator', () => { 26 | test('Should call GetStorage with correct value', async () => { 27 | const { sut, getStorageSpy } = makeSut() 28 | 29 | await sut.request(mockHttpRequest()) 30 | 31 | expect(getStorageSpy.key).toBe('account') 32 | }) 33 | 34 | test('Should not add headers if GetStorage is invalid', async () => { 35 | const { sut, httpClientSpy } = makeSut() 36 | const httpRequest: HttpRequest = { 37 | url: faker.internet.url(), 38 | method: faker.random.arrayElement(['get', 'post', 'put', 'delete']), 39 | headers: { 40 | field: faker.random.words(), 41 | }, 42 | } 43 | 44 | await sut.request(httpRequest) 45 | 46 | expect(httpClientSpy.url).toBe(httpRequest.url) 47 | expect(httpClientSpy.method).toBe(httpRequest.method) 48 | expect(httpClientSpy.headers).toEqual(httpRequest.headers) 49 | }) 50 | 51 | test('Should add headers to HttpClient', async () => { 52 | const { sut, getStorageSpy, httpClientSpy } = makeSut() 53 | getStorageSpy.value = mockAccountModel() 54 | const httpRequest: HttpRequest = { 55 | url: faker.internet.url(), 56 | method: faker.random.arrayElement(['get', 'post', 'put', 'delete']), 57 | } 58 | 59 | await sut.request(httpRequest) 60 | 61 | expect(httpClientSpy.url).toBe(httpRequest.url) 62 | expect(httpClientSpy.method).toBe(httpRequest.method) 63 | expect(httpClientSpy.headers).toEqual({ 64 | Authorization: 'Bearer ' + getStorageSpy.value.accessToken, 65 | }) 66 | }) 67 | 68 | test('Should merge headers to HttpClient', async () => { 69 | const { sut, getStorageSpy, httpClientSpy } = makeSut() 70 | getStorageSpy.value = mockAccountModel() 71 | const field = faker.random.words() 72 | const httpRequest: HttpRequest = { 73 | url: faker.internet.url(), 74 | method: faker.random.arrayElement(['get', 'post', 'put', 'delete']), 75 | headers: { 76 | field, 77 | }, 78 | } 79 | 80 | await sut.request(httpRequest) 81 | 82 | expect(httpClientSpy.url).toBe(httpRequest.url) 83 | expect(httpClientSpy.method).toBe(httpRequest.method) 84 | expect(httpClientSpy.headers).toEqual({ 85 | field, 86 | Authorization: 'Bearer ' + getStorageSpy.value.accessToken, 87 | }) 88 | }) 89 | 90 | test('Should return the same result as HttpClient', async () => { 91 | const { sut, httpClientSpy } = makeSut() 92 | 93 | const httpResponse = await sut.request(mockHttpRequest()) 94 | 95 | expect(httpResponse).toEqual(httpClientSpy.response) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /src/main/decorators/AuthorizeHttpDecorator/index.ts: -------------------------------------------------------------------------------- 1 | import { IHttpClient, HttpRequest, HttpResponse } from '@/data/protocols/http' 2 | import { IGetStorage } from '@/data/protocols/cache/getStorage' 3 | 4 | export class AuthorizeHttpClientDecorator implements IHttpClient { 5 | constructor( 6 | private readonly getStorage: IGetStorage, 7 | private readonly httpClient: IHttpClient 8 | ) {} 9 | 10 | async request(data: HttpRequest): Promise { 11 | const account = this.getStorage.get('account') 12 | if (account?.accessToken) { 13 | Object.assign(data, { 14 | headers: Object.assign(data.headers || {}, { 15 | Authorization: 'Bearer ' + account.accessToken, 16 | }), 17 | }) 18 | } 19 | const httpResponse = await this.httpClient.request(data) 20 | return httpResponse 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthorizeHttpDecorator' 2 | -------------------------------------------------------------------------------- /src/main/factories/cache/LocalStorageAdapter/index.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorageAdapter } from '@/infra/cache/LocalStorageAdapter' 2 | 3 | export const makeLocalStorageAdapter = (): LocalStorageAdapter => 4 | new LocalStorageAdapter() 5 | -------------------------------------------------------------------------------- /src/main/factories/decorators/AuthorizeHttpClientDecoratorFactory/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizeHttpClientDecorator } from '@/main/decorators' 2 | import { makeLocalStorageAdapter } from '@/main/factories/cache/LocalStorageAdapter' 3 | import { makeAxiosHttpClient } from '@/main/factories/http' 4 | import { IHttpClient } from '@/data/protocols/http' 5 | 6 | export const makeAuthorizeHttpClientDecorator = (): IHttpClient => 7 | new AuthorizeHttpClientDecorator( 8 | makeLocalStorageAdapter(), 9 | makeAxiosHttpClient() 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/factories/http/ApiURLFactory/index.ts: -------------------------------------------------------------------------------- 1 | export const makeApiUrl = (path: string): string => { 2 | const url = `${process.env.API_URL}${path}` 3 | return url 4 | } 5 | -------------------------------------------------------------------------------- /src/main/factories/http/AxiosHttpClient/index.ts: -------------------------------------------------------------------------------- 1 | import { AxiosHttpClient } from '@/infra/http/axiosHttpClient/AxiosHttpClient' 2 | 3 | export const makeAxiosHttpClient = (): AxiosHttpClient => { 4 | return new AxiosHttpClient() 5 | } 6 | -------------------------------------------------------------------------------- /src/main/factories/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ApiURLFactory' 2 | export * from './AxiosHttpClient' 3 | -------------------------------------------------------------------------------- /src/main/factories/pages/Dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Dashboard } from '@/presentation/pages' 3 | 4 | const makeDashboard: React.FC = () => { 5 | return 6 | } 7 | 8 | export default makeDashboard 9 | -------------------------------------------------------------------------------- /src/main/factories/pages/Login/LoginValidation/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { ValidationBuilder, ValidationComposite } from '@/validation/validators' 2 | import { makeLoginValidation } from '.' 3 | 4 | describe('LoginValidationFactory', () => { 5 | test('Should make ValidationComposite with correct validations', () => { 6 | const composite = makeLoginValidation() 7 | expect(composite).toEqual( 8 | ValidationComposite.build([ 9 | ...ValidationBuilder.field('email').required().email().build(), 10 | ...ValidationBuilder.field('password').required().min(4).build(), 11 | ]) 12 | ) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/main/factories/pages/Login/LoginValidation/index.ts: -------------------------------------------------------------------------------- 1 | import { ValidationBuilder, ValidationComposite } from '@/validation/validators' 2 | 3 | export const makeLoginValidation = (): ValidationComposite => { 4 | return ValidationComposite.build([ 5 | ...ValidationBuilder.field('email').required().email().build(), 6 | ...ValidationBuilder.field('password').required().min(4).build(), 7 | ]) 8 | } 9 | -------------------------------------------------------------------------------- /src/main/factories/pages/Login/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Login } from '@/presentation/pages' 3 | import { makeRemoteAuthentication } from '@/main/factories/usecases/' 4 | import { makeLoginValidation } from './LoginValidation' 5 | 6 | const makeLogin: React.FC = () => { 7 | const remoteAuthentication = makeRemoteAuthentication() 8 | const validationComposite = makeLoginValidation() 9 | 10 | return ( 11 | 15 | ) 16 | } 17 | 18 | export default makeLogin 19 | -------------------------------------------------------------------------------- /src/main/factories/pages/SignUp/SignUpValidation/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { ValidationBuilder, ValidationComposite } from '@/validation/validators' 2 | import { makeSignUpValidation } from '.' 3 | 4 | describe('SignUpValidationFactory', () => { 5 | test('Should make ValidationComposite with correct validations', () => { 6 | const composite = makeSignUpValidation() 7 | expect(composite).toEqual( 8 | ValidationComposite.build([ 9 | ...ValidationBuilder.field('name').required().min(2).build(), 10 | ...ValidationBuilder.field('email').required().email().build(), 11 | ...ValidationBuilder.field('password').required().min(4).build(), 12 | ...ValidationBuilder.field('passwordConfirmation') 13 | .required() 14 | .sameAs('password') 15 | .build(), 16 | ]) 17 | ) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/main/factories/pages/SignUp/SignUpValidation/index.ts: -------------------------------------------------------------------------------- 1 | import { ValidationBuilder, ValidationComposite } from '@/validation/validators' 2 | 3 | export const makeSignUpValidation = (): ValidationComposite => { 4 | return ValidationComposite.build([ 5 | ...ValidationBuilder.field('name').required().min(2).build(), 6 | ...ValidationBuilder.field('email').required().email().build(), 7 | ...ValidationBuilder.field('password').required().min(4).build(), 8 | ...ValidationBuilder.field('passwordConfirmation') 9 | .required() 10 | .sameAs('password') 11 | .build(), 12 | ]) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/factories/pages/SignUp/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { SignUp } from '@/presentation/pages' 3 | import { makeRemoteAddAccount } from '@/main/factories/usecases' 4 | import { makeSignUpValidation } from './SignUpValidation' 5 | 6 | const makeSignUp: React.FC = () => { 7 | const addAccountComposite = makeRemoteAddAccount() 8 | const validationComposite = makeSignUpValidation() 9 | 10 | return ( 11 | 12 | ) 13 | } 14 | 15 | export default makeSignUp 16 | -------------------------------------------------------------------------------- /src/main/factories/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as makeDashboard } from './Dashboard' 2 | export { default as makeLogin } from './Login' 3 | export { default as makeSignup } from './SignUp' 4 | -------------------------------------------------------------------------------- /src/main/factories/usecases/Auth/RemoteAuthentication/index.ts: -------------------------------------------------------------------------------- 1 | import { RemoteAuthentication } from '@/data/usecases/' 2 | import { IAuthentication } from '@/domain/usecases' 3 | import { makeAxiosHttpClient, makeApiUrl } from '@/main/factories/http' 4 | 5 | export const makeRemoteAuthentication = (): IAuthentication => { 6 | const remoteAuthentication = new RemoteAuthentication( 7 | makeApiUrl('/login'), 8 | makeAxiosHttpClient() 9 | ) 10 | 11 | return remoteAuthentication 12 | } 13 | -------------------------------------------------------------------------------- /src/main/factories/usecases/User/RemoteAddAccount/index.ts: -------------------------------------------------------------------------------- 1 | import { RemoteAddAccount } from '@/data/usecases/' 2 | import { IAddAccount } from '@/domain/usecases' 3 | import { makeAxiosHttpClient, makeApiUrl } from '@/main/factories/http' 4 | 5 | export const makeRemoteAddAccount = (): IAddAccount => { 6 | const remoteAddAccount = new RemoteAddAccount( 7 | makeApiUrl('/signup'), 8 | makeAxiosHttpClient() 9 | ) 10 | 11 | return remoteAddAccount 12 | } 13 | -------------------------------------------------------------------------------- /src/main/factories/usecases/index.ts: -------------------------------------------------------------------------------- 1 | // Auth 2 | export * from './Auth/RemoteAuthentication' 3 | 4 | // User 5 | export * from './User/RemoteAddAccount' 6 | -------------------------------------------------------------------------------- /src/main/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { StrictMode } from 'react' 3 | import { createRoot } from 'react-dom/client' 4 | import Router from '@/main/routes/router' 5 | 6 | const rootElement = document.getElementById('main') 7 | const root = createRoot(rootElement) 8 | 9 | root.render( 10 | 11 | 12 | 13 | ) 14 | -------------------------------------------------------------------------------- /src/main/routes/router.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter, Switch, Route } from 'react-router-dom' 3 | import { makeLogin, makeDashboard, makeSignup } from '@/main/factories/pages' 4 | import PrivateRoute from '@/presentation/Routes/private.routes' 5 | import { ApiContext } from '@/presentation/hooks' 6 | import { 7 | getCurrentAccountAdapter, 8 | setCurrentAccountAdapter, 9 | } from '../adapters/CurrentAccountAdapter' 10 | 11 | import '@/presentation/styles/global.scss' 12 | 13 | const Router: React.FC = () => { 14 | return ( 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | export default Router 33 | -------------------------------------------------------------------------------- /src/main/scripts/assetsTransformer.ts: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | process(src, filename, config, options) { 5 | return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';' 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /src/main/scripts/bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { execSync } = require('child_process') 4 | 5 | const runCommand = (command) => { 6 | try { 7 | execSync(`${command}`, { stdio: 'inherit' }) 8 | } catch (error) { 9 | console.error(`Failed executing ${command} `, error) 10 | return false 11 | } 12 | return true 13 | } 14 | 15 | const repoName = process.argv[2] 16 | 17 | const gitCheckoutCommand = `git clone --depth 1 https://github.com/rubemfsv/clean-react-app ${repoName}` 18 | const installDependenciesCommand = `cd ${repoName} && npm install` 19 | 20 | console.log(`Cloning the repository with the name ${repoName}`) 21 | 22 | const checkedOut = runCommand(gitCheckoutCommand) 23 | if (!checkedOut) process.exit(-1) 24 | 25 | console.log(`Installing dependencies for ${repoName}`) 26 | 27 | const installedDependencies = runCommand(installDependenciesCommand) 28 | if (!installedDependencies) process.exit(-1) 29 | 30 | console.log( 31 | 'Congratulations! You are ready. Follow the following commands to start' 32 | ) 33 | 34 | console.log(`cd ${repoName} && npm run dev`) 35 | -------------------------------------------------------------------------------- /src/main/scripts/jestSetup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /src/presentation/Routes/private.routes.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from '@testing-library/react' 3 | import { Router } from 'react-router-dom' 4 | import { createMemoryHistory, MemoryHistory } from 'history' 5 | import PrivateRoute from '@/presentation/Routes/private.routes' 6 | import { ApiContext } from '@/presentation/hooks' 7 | import { mockAccountModel } from '@/domain/test' 8 | 9 | type SutTypes = { 10 | history: MemoryHistory 11 | } 12 | 13 | const makeSut = (account = mockAccountModel()): SutTypes => { 14 | const history = createMemoryHistory({ initialEntries: ['/'] }) 15 | 16 | render( 17 | account }}> 18 | 19 | 20 | 21 | 22 | ) 23 | return { history } 24 | } 25 | 26 | describe('PrivateRoute', () => { 27 | test('Should redirect to /login if token is empty', () => { 28 | const { history } = makeSut(null) 29 | 30 | expect(history.location.pathname).toBe('/login') 31 | }) 32 | 33 | test('Should render current component if token is not empty', () => { 34 | const { history } = makeSut() 35 | 36 | expect(history.location.pathname).toBe('/') 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/presentation/Routes/private.routes.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { Redirect, Route, RouteProps } from 'react-router-dom' 3 | import { ApiContext } from '@/presentation/hooks' 4 | 5 | const PrivateRoute: React.FC = (props: RouteProps) => { 6 | const { getCurrentAccount } = useContext(ApiContext) 7 | 8 | return getCurrentAccount() ? ( 9 | 10 | ) : ( 11 | } /> 12 | ) 13 | } 14 | 15 | export default PrivateRoute 16 | -------------------------------------------------------------------------------- /src/presentation/assets/fonts/GothamBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubemfsv/clean-react-app/40bec4806bbc9a6838730bb40dca6ee2431eafc1/src/presentation/assets/fonts/GothamBold.ttf -------------------------------------------------------------------------------- /src/presentation/assets/fonts/GothamBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubemfsv/clean-react-app/40bec4806bbc9a6838730bb40dca6ee2431eafc1/src/presentation/assets/fonts/GothamBoldItalic.ttf -------------------------------------------------------------------------------- /src/presentation/assets/fonts/GothamBook.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubemfsv/clean-react-app/40bec4806bbc9a6838730bb40dca6ee2431eafc1/src/presentation/assets/fonts/GothamBook.ttf -------------------------------------------------------------------------------- /src/presentation/assets/fonts/GothamBookItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubemfsv/clean-react-app/40bec4806bbc9a6838730bb40dca6ee2431eafc1/src/presentation/assets/fonts/GothamBookItalic.ttf -------------------------------------------------------------------------------- /src/presentation/assets/fonts/GothamLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubemfsv/clean-react-app/40bec4806bbc9a6838730bb40dca6ee2431eafc1/src/presentation/assets/fonts/GothamLight.ttf -------------------------------------------------------------------------------- /src/presentation/assets/fonts/GothamLightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubemfsv/clean-react-app/40bec4806bbc9a6838730bb40dca6ee2431eafc1/src/presentation/assets/fonts/GothamLightItalic.ttf -------------------------------------------------------------------------------- /src/presentation/assets/fonts/GothamMedium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubemfsv/clean-react-app/40bec4806bbc9a6838730bb40dca6ee2431eafc1/src/presentation/assets/fonts/GothamMedium.ttf -------------------------------------------------------------------------------- /src/presentation/assets/fonts/GothamMediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubemfsv/clean-react-app/40bec4806bbc9a6838730bb40dca6ee2431eafc1/src/presentation/assets/fonts/GothamMediumItalic.ttf -------------------------------------------------------------------------------- /src/presentation/assets/fonts/GothamMedium_1.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubemfsv/clean-react-app/40bec4806bbc9a6838730bb40dca6ee2431eafc1/src/presentation/assets/fonts/GothamMedium_1.ttf -------------------------------------------------------------------------------- /src/presentation/components/Button/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent, screen } from '@testing-library/react' 3 | import { Button, IButtonProps } from '@/presentation/components' 4 | import faker from 'faker' 5 | 6 | const makeSut = (props: IButtonProps) => { 7 | render( 23 | ) 24 | } 25 | 26 | export default Button 27 | -------------------------------------------------------------------------------- /src/presentation/components/Button/styles.scss: -------------------------------------------------------------------------------- 1 | .buttonComponent { 2 | flex: 1; 3 | } 4 | -------------------------------------------------------------------------------- /src/presentation/components/FormLoaderStatus/Spinner/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Styles from './styles.scss' 4 | 5 | type Props = React.HTMLAttributes & { 6 | isNegative?: boolean 7 | 'data-testid'?: string 8 | } 9 | 10 | const Spinner: React.FC = (props: Props) => { 11 | return ( 12 |
17 |
18 |
19 |
20 |
21 |
22 | ) 23 | } 24 | 25 | export default Spinner 26 | -------------------------------------------------------------------------------- /src/presentation/components/FormLoaderStatus/Spinner/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/colors.scss'; 2 | 3 | .spinner { 4 | display: inline-block; 5 | position: relative; 6 | width: 80px; 7 | height: 13px; 8 | 9 | &.negative { 10 | div { 11 | background: $white; 12 | } 13 | } 14 | 15 | div { 16 | position: absolute; 17 | top: 0px; 18 | width: 13px; 19 | height: 13px; 20 | border-radius: 50%; 21 | background: $yellowAux; 22 | animation-timing-function: cubic-bezier(0, 1, 1, 0); 23 | 24 | &:nth-child(1) { 25 | left: 8px; 26 | animation: spinner1 0.6s infinite; 27 | } 28 | 29 | &:nth-child(2) { 30 | left: 8px; 31 | animation: spinner2 0.6s infinite; 32 | } 33 | 34 | &:nth-child(3) { 35 | left: 32px; 36 | animation: spinner2 0.6s infinite; 37 | } 38 | 39 | &:nth-child(4) { 40 | left: 56px; 41 | animation: spinner3 0.6s infinite; 42 | } 43 | } 44 | } 45 | 46 | @keyframes spinner1 { 47 | 0% { 48 | transform: scale(0); 49 | } 50 | 51 | 100% { 52 | transform: scale(1); 53 | } 54 | } 55 | 56 | @keyframes spinner3 { 57 | 0% { 58 | transform: scale(1); 59 | } 60 | 61 | 100% { 62 | transform: scale(0); 63 | } 64 | } 65 | 66 | @keyframes spinner2 { 67 | 0% { 68 | transform: translate(0, 0); 69 | } 70 | 71 | 100% { 72 | transform: translate(24px, 0); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/presentation/components/FormLoaderStatus/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { RenderResult, render } from '@testing-library/react' 3 | import { FormLoaderStatus } from '@/presentation/components' 4 | import Context from '@/presentation/hooks/form' 5 | import faker from 'faker' 6 | 7 | let mainError = '' 8 | let isLoading = false 9 | 10 | const makeSut = (isLoading: boolean, mainError: string): RenderResult => { 11 | return render( 12 | 13 | 14 | 15 | ) 16 | } 17 | 18 | describe('FormLoaderStatus Component', () => { 19 | beforeEach(() => { 20 | mainError = '' 21 | isLoading = false 22 | }) 23 | 24 | test('Renders spinner when isLoading is true', () => { 25 | isLoading = true 26 | const { getByTestId } = makeSut(isLoading, mainError) 27 | const spinner = getByTestId('spinner') 28 | expect(spinner).toBeInTheDocument() 29 | }) 30 | 31 | test('Does not render spinner when isLoading is false', () => { 32 | const { queryByTestId } = makeSut(isLoading, mainError) 33 | const spinner = queryByTestId('spinner') 34 | expect(spinner).toBeNull() 35 | }) 36 | 37 | test('Shows error message when mainError is provided', () => { 38 | mainError = faker.random.words() 39 | const { getByTestId } = makeSut(isLoading, mainError) 40 | const errorElement = getByTestId('mainError') 41 | expect(errorElement).toBeInTheDocument() 42 | expect(errorElement.textContent).toBe(mainError) 43 | }) 44 | 45 | test('Does not show error message when mainError is not provided', () => { 46 | const { queryByTestId } = makeSut(isLoading, mainError) 47 | const errorElement = queryByTestId('mainError') 48 | expect(errorElement).toBeNull() 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/presentation/components/FormLoaderStatus/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import Spinner from './Spinner' 3 | import Context from '@/presentation/hooks/form' 4 | 5 | import Styles from './styles.scss' 6 | 7 | const FormLoaderStatus: React.FC = () => { 8 | const { state } = useContext(Context) 9 | const { isLoading, mainError } = state 10 | 11 | return ( 12 |
13 | {isLoading && } 14 | {mainError && ( 15 | 16 | {mainError} 17 | 18 | )} 19 |
20 | ) 21 | } 22 | 23 | export default FormLoaderStatus 24 | -------------------------------------------------------------------------------- /src/presentation/components/FormLoaderStatus/styles.scss: -------------------------------------------------------------------------------- 1 | @import '@/presentation/styles/colors.scss'; 2 | 3 | .errorWrap { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | 8 | .spinner { 9 | margin-top: 30px; 10 | } 11 | 12 | .error { 13 | margin-top: 30px; 14 | color: $yellowAux; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/presentation/components/Input/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { fireEvent, render, RenderResult } from '@testing-library/react' 3 | import { Input } from '@/presentation/components' 4 | import Context from '@/presentation/hooks/form' 5 | import faker from 'faker' 6 | 7 | const makeSut = (fieldName: string): RenderResult => { 8 | return render( 9 | 10 | 11 | 12 | ) 13 | } 14 | 15 | describe('Input Component', () => { 16 | test('Should begin with readOnly ', () => { 17 | const field = faker.database.column() 18 | const { getByTestId } = makeSut(field) 19 | const input = getByTestId(field) as HTMLInputElement 20 | expect(input.readOnly).toBe(true) 21 | }) 22 | 23 | test('Should remove readOnly on focus', () => { 24 | const field = faker.database.column() 25 | const { getByTestId } = makeSut(field) 26 | const input = getByTestId(field) as HTMLInputElement 27 | fireEvent.focus(input) 28 | expect(input.readOnly).toBe(false) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/presentation/components/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react' 2 | import Context from '@/presentation/hooks/form' 3 | 4 | import Styles from './styles.scss' 5 | 6 | interface IInputProps 7 | extends React.DetailedHTMLProps< 8 | React.InputHTMLAttributes, 9 | HTMLInputElement 10 | > { 11 | title?: string 12 | hideStatus?: boolean 13 | } 14 | 15 | const Input: React.FC = (props: IInputProps) => { 16 | const { state, setState } = useContext(Context) 17 | const stateName = `${props.name}Error` 18 | const error = state[stateName] 19 | const [isActiveFocus, setIsActiveFocus] = useState(false) 20 | 21 | const enableInput = (event: React.FocusEvent): void => { 22 | event.target.readOnly = false 23 | setIsActiveFocus(true) 24 | } 25 | 26 | const handleInputChange = ( 27 | event: React.FocusEvent 28 | ): void => { 29 | setState({ ...state, [event.target.name]: event.target.value }) 30 | } 31 | 32 | return ( 33 |
34 | setIsActiveFocus(false)} 41 | placeholder=" " 42 | className={Styles.Input} 43 | /> 44 | 45 |
46 | ) 47 | } 48 | 49 | export default Input 50 | -------------------------------------------------------------------------------- /src/presentation/components/Input/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .InputWrapper { 4 | position: relative; 5 | width: 300px; 6 | height: 50px; 7 | margin-bottom: 2rem; 8 | 9 | .Input { 10 | border: solid 1px rgb(177, 177, 177); 11 | background-color: transparent; 12 | width: 100%; 13 | height: 50px; 14 | border-radius: 0px; 15 | position: absolute; 16 | transition: 0.1s; 17 | outline: none; 18 | padding: 0 16px; 19 | color: $black; 20 | 21 | &:not(:placeholder-shown) + .Label { 22 | color: $yellow; 23 | top: -12px; 24 | font-size: 12px; 25 | font-weight: 700; 26 | } 27 | 28 | &:focus { 29 | border: solid 2px $yellow; 30 | 31 | + .Label { 32 | color: $yellow; 33 | top: -12px; 34 | font-size: 12px; 35 | font-weight: 700; 36 | } 37 | } 38 | 39 | &:invalid { 40 | border: solid 2px $red; 41 | 42 | + .Label { 43 | color: $red; 44 | } 45 | } 46 | } 47 | 48 | .Label { 49 | z-index: 10px; 50 | position: absolute; 51 | top: 12px; 52 | left: 13px; 53 | background-color: $white; 54 | padding: 2px; 55 | font-size: 16px; 56 | color: #aaa; 57 | pointer-events: none; 58 | transition: 0.2s; 59 | 60 | .RequiredField { 61 | font-size: 16px; 62 | color: red; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/presentation/components/Template/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Styles from './styles.scss' 3 | import Navigation from '../Navigation' 4 | 5 | const Header: React.FC = () => { 6 | return ( 7 |
8 |

My App

9 | 10 | 11 |
12 | ) 13 | } 14 | 15 | export default Header 16 | -------------------------------------------------------------------------------- /src/presentation/components/Template/Header/styles.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | padding: 1.5rem; 6 | background-color: #333; 7 | 8 | .logo { 9 | font-size: 1.5rem; 10 | color: white; 11 | 12 | &:hover { 13 | color: lightgray; 14 | cursor: pointer; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/presentation/components/Template/Navigation/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Styles from './styles.scss' 3 | 4 | const Navigation: React.FC = () => { 5 | return ( 6 | 14 | ) 15 | } 16 | 17 | export default Navigation 18 | -------------------------------------------------------------------------------- /src/presentation/components/Template/Navigation/styles.scss: -------------------------------------------------------------------------------- 1 | .navList { 2 | display: flex; 3 | gap: 15px; 4 | list-style-type: none; 5 | margin-right: 100px; 6 | .navLink { 7 | color: white; 8 | text-decoration: none; 9 | 10 | &:hover { 11 | color: lightgray; 12 | text-decoration: underline; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/presentation/components/Template/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Styles from './styles.scss' 3 | import Header from './Header' 4 | 5 | type TemplateProps = { 6 | children: React.ReactNode 7 | } 8 | 9 | const Template: React.FC = ({ children }) => { 10 | return ( 11 |
12 |
13 | {children} 14 |
15 | ) 16 | } 17 | 18 | export default Template -------------------------------------------------------------------------------- /src/presentation/components/Template/styles.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100vw; 3 | height: 100vh; 4 | } 5 | -------------------------------------------------------------------------------- /src/presentation/components/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Button, IButtonProps } from './Button' 2 | export { default as Input } from './Input' 3 | export { default as FormLoaderStatus } from './FormLoaderStatus' 4 | export { default as Template } from './Template' 5 | -------------------------------------------------------------------------------- /src/presentation/hooks/api/index.ts: -------------------------------------------------------------------------------- 1 | import { AccountModel } from '@/domain/models' 2 | import { createContext } from 'react' 3 | 4 | type ApiProps = { 5 | setCurrentAccount?: (account: AccountModel) => void 6 | getCurrentAccount?: () => AccountModel 7 | } 8 | 9 | export default createContext(null) 10 | -------------------------------------------------------------------------------- /src/presentation/hooks/form/index.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | export default createContext(null) 4 | -------------------------------------------------------------------------------- /src/presentation/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ApiContext } from './api/' 2 | export { default as FormContext } from './form' 3 | -------------------------------------------------------------------------------- /src/presentation/pages/Dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Styles from './styles.scss' 4 | import Template from '@/presentation/components/Template' 5 | 6 | type DashboardProps = {} 7 | 8 | const Dashboard: React.FC = () => { 9 | return ( 10 | 16 | ) 17 | } 18 | 19 | export default Dashboard 20 | -------------------------------------------------------------------------------- /src/presentation/pages/Dashboard/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .app { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: center; 8 | height: 100vh; 9 | 10 | img { 11 | width: 150px; 12 | height: 150px; 13 | margin-bottom: 30px; 14 | } 15 | 16 | span { 17 | font-size: 26px; 18 | color: $black; 19 | font-weight: 700; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/presentation/pages/Login/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Router } from 'react-router-dom' 3 | import { createMemoryHistory } from 'history' 4 | import { fireEvent, render, waitFor, screen } from '@testing-library/react' 5 | import faker from 'faker' 6 | import { Login } from '@/presentation/pages' 7 | import { 8 | AuthenticationSpy, 9 | ValidationStub, 10 | FormHelper, 11 | } from '@/presentation/test/' 12 | import { InvalidCredentialsError } from '@/domain/errors' 13 | import { ApiContext } from '@/presentation/hooks' 14 | import { Authentication } from '@/domain/usecases' 15 | 16 | type SutTypes = { 17 | authenticationSpy: AuthenticationSpy 18 | setCurrentAccoutMock: (account: Authentication.Model) => void 19 | } 20 | 21 | type SutParams = { 22 | validationError: string 23 | } 24 | 25 | const history = createMemoryHistory({ initialEntries: ['/login'] }) 26 | 27 | const makeSystemUnderTest = (params?: SutParams): SutTypes => { 28 | const validationStub = new ValidationStub() 29 | const authenticationSpy = new AuthenticationSpy() 30 | const setCurrentAccoutMock = jest.fn() 31 | 32 | validationStub.errorMessage = params?.validationError 33 | render( 34 | 35 | 36 | 37 | 38 | 39 | ) 40 | 41 | return { 42 | authenticationSpy, 43 | setCurrentAccoutMock, 44 | } 45 | } 46 | 47 | const simulateValidSubmit = async ( 48 | email = faker.internet.email(), 49 | password = faker.internet.password() 50 | ): Promise => { 51 | FormHelper.populateField('email', email) 52 | FormHelper.populateField('password', password) 53 | 54 | const form = screen.getByTestId('loginForm') as HTMLButtonElement 55 | 56 | fireEvent.submit(form) 57 | 58 | await waitFor(() => form) 59 | } 60 | 61 | describe('Login Component', () => { 62 | test('Should enable submit button if form is valid', () => { 63 | makeSystemUnderTest() 64 | FormHelper.populateField('email') 65 | FormHelper.populateField('password') 66 | FormHelper.testButtonIsEnabled('loginButton') 67 | }) 68 | 69 | test('Should show spinner on submit', async () => { 70 | makeSystemUnderTest() 71 | await simulateValidSubmit() 72 | FormHelper.testElementExists('spinner') 73 | }) 74 | 75 | test('Should call Authentication with correct values', async () => { 76 | const { authenticationSpy } = makeSystemUnderTest() 77 | const email = faker.internet.email() 78 | const password = faker.internet.password() 79 | await simulateValidSubmit(email, password) 80 | 81 | expect(authenticationSpy.params).toEqual({ email, password }) 82 | }) 83 | 84 | test('Should call Authentication only once', async () => { 85 | const { authenticationSpy } = makeSystemUnderTest() 86 | await simulateValidSubmit() 87 | await simulateValidSubmit() 88 | 89 | expect(authenticationSpy.callsCount).toBe(1) 90 | }) 91 | 92 | test('Should not call Authentication if form is invalid', async () => { 93 | const validationError = faker.random.words() 94 | const { authenticationSpy } = makeSystemUnderTest({ validationError }) 95 | await simulateValidSubmit() 96 | 97 | expect(authenticationSpy.callsCount).toBe(0) 98 | }) 99 | 100 | test('Should present error if Authentication fails', async () => { 101 | const { authenticationSpy } = makeSystemUnderTest() 102 | const error = new InvalidCredentialsError() 103 | jest.spyOn(authenticationSpy, 'auth').mockRejectedValueOnce(error) 104 | await simulateValidSubmit() 105 | FormHelper.testElementText('mainError', error.message) 106 | FormHelper.testChildCount('errorWrap', 1) 107 | }) 108 | 109 | test('Should call UpdateCurrentAccount on success', async () => { 110 | const { authenticationSpy, setCurrentAccoutMock } = makeSystemUnderTest() 111 | await simulateValidSubmit() 112 | expect(setCurrentAccoutMock).toHaveBeenCalledWith(authenticationSpy.account) 113 | }) 114 | 115 | test('Should redirect to / after Authentication on success', async () => { 116 | const { authenticationSpy, setCurrentAccoutMock } = makeSystemUnderTest() 117 | await simulateValidSubmit() 118 | expect(setCurrentAccoutMock).toHaveBeenCalledWith(authenticationSpy.account) 119 | expect(history.length).toBe(1) 120 | expect(history.location.pathname).toBe('/') 121 | }) 122 | 123 | test('Should go to signUpPage page', () => { 124 | makeSystemUnderTest() 125 | const signUp = screen.getByTestId('signUpPage') 126 | fireEvent.click(signUp) 127 | 128 | expect(history.length).toBe(2) 129 | expect(history.location.pathname).toBe('/signup') 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /src/presentation/pages/Login/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react' 2 | import { Link, useHistory } from 'react-router-dom' 3 | import { ApiContext, FormContext } from '@/presentation/hooks' 4 | import { IValidation } from '@/presentation/protocols/validation' 5 | import { IAuthentication } from '@/domain/usecases' 6 | import { Button, Input, FormLoaderStatus } from '@/presentation/components/' 7 | import Styles from './styles.scss' 8 | 9 | type LoginProps = { 10 | validation: IValidation 11 | authentication: IAuthentication 12 | } 13 | 14 | const Login: React.FC = ({ 15 | validation, 16 | authentication, 17 | }: LoginProps) => { 18 | const { setCurrentAccount } = useContext(ApiContext) 19 | const history = useHistory() 20 | const [state, setState] = useState({ 21 | isLoading: false, 22 | isFormInvalid: true, 23 | email: '', 24 | password: '', 25 | emailError: '', 26 | passwordError: '', 27 | mainError: '', 28 | }) 29 | 30 | useEffect(() => { 31 | const { email, password } = state 32 | const formData = { email, password } 33 | const emailError = validation.validate('email', formData) 34 | const passwordError = validation.validate('password', formData) 35 | 36 | setState({ 37 | ...state, 38 | emailError, 39 | passwordError, 40 | isFormInvalid: !!emailError || !!passwordError, 41 | }) 42 | }, [state.email, state.password]) 43 | 44 | const handleSubmit = async ( 45 | event: React.FormEvent 46 | ): Promise => { 47 | event.preventDefault() 48 | try { 49 | if (state.isLoading || state.isFormInvalid) { 50 | return 51 | } 52 | setState({ ...state, isLoading: true }) 53 | 54 | const account = await authentication.auth({ 55 | email: state.email, 56 | password: state.password, 57 | }) 58 | 59 | setCurrentAccount(account) 60 | history.replace('/') 61 | } catch (error) { 62 | setState({ 63 | ...state, 64 | isLoading: false, 65 | mainError: error.message, 66 | }) 67 | } 68 | } 69 | 70 | return ( 71 |
72 |
73 | 74 |
79 |

Login with your account

80 | 81 | 87 | 88 | 95 |
96 |
111 | 112 | 113 |
114 |
115 |
116 | ) 117 | } 118 | 119 | export default Login 120 | -------------------------------------------------------------------------------- /src/presentation/pages/Login/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .login { 4 | display: flex; 5 | height: 100vh; 6 | align-items: center; 7 | justify-content: center; 8 | 9 | .content { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: space-between; 14 | width: 100%; 15 | max-width: 700px; 16 | 17 | @media (max-width: 1000px) { 18 | max-width: 1000px; 19 | } 20 | 21 | .form { 22 | display: flex; 23 | flex-direction: column; 24 | align-items: center; 25 | justify-content: center; 26 | width: 600px; 27 | height: 600px; 28 | background-color: $white; 29 | border-radius: 50px; 30 | background-repeat: no-repeat; 31 | background-size: 500px 500px; 32 | animation: LoginAnimation 1.2s; 33 | 34 | .loginTitle { 35 | font-size: 28px; 36 | color: $black; 37 | align-self: center; 38 | margin-bottom: 50px; 39 | } 40 | } 41 | 42 | .buttonsContainer { 43 | position: relative; 44 | margin-top: 30px; 45 | align-self: center; 46 | 47 | .forgetPassword { 48 | position: absolute; 49 | top: -60px; 50 | left: 0; 51 | text-align: center; 52 | color: $black; 53 | font-weight: bold; 54 | font-size: 14px; 55 | margin-top: 16px; 56 | cursor: pointer; 57 | text-decoration: none; 58 | 59 | &:hover { 60 | text-decoration: underline; 61 | } 62 | } 63 | 64 | .loginBtn { 65 | border: 1px solid $darkWhite; 66 | width: 300px; 67 | height: 50px; 68 | background-color: $yellow; 69 | color: $black; 70 | font-weight: 700; 71 | font-size: 24px; 72 | border-radius: 30px; 73 | } 74 | } 75 | } 76 | } 77 | 78 | @keyframes LoginAnimation { 79 | from { 80 | transform: rotateY(180deg); 81 | opacity: 0; 82 | } 83 | to { 84 | transform: rotateY(0deg); 85 | opacity: 1; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/presentation/pages/SignUp/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useEffect, useState } from 'react' 2 | import { useHistory, Link } from 'react-router-dom' 3 | import Context from '@/presentation/hooks/form' 4 | import { Button, Input, FormLoaderStatus } from '@/presentation/components/' 5 | import { IValidation } from '@/presentation/protocols/validation' 6 | import { IAddAccount } from '@/domain/usecases' 7 | import Styles from './styles.scss' 8 | import { ApiContext } from '@/presentation/hooks' 9 | 10 | type SignUpProps = { 11 | validation: IValidation 12 | addAccount: IAddAccount 13 | } 14 | 15 | const SignUp: React.FC = ({ 16 | validation, 17 | addAccount, 18 | }: SignUpProps) => { 19 | const { setCurrentAccount } = useContext(ApiContext) 20 | const history = useHistory() 21 | const [state, setState] = useState({ 22 | isLoading: false, 23 | isFormInvalid: true, 24 | email: '', 25 | password: '', 26 | passwordConfirmation: '', 27 | name: '', 28 | emailError: '', 29 | nameError: '', 30 | passwordError: '', 31 | passwordConfirmationError: '', 32 | mainError: '', 33 | }) 34 | 35 | useEffect(() => { 36 | const { email, name, password, passwordConfirmation } = state 37 | const formData = { 38 | email, 39 | name, 40 | password, 41 | passwordConfirmation, 42 | } 43 | const emailError = validation.validate('email', formData) 44 | const nameError = validation.validate('name', formData) 45 | const passwordError = validation.validate('password', formData) 46 | const passwordConfirmationError = validation.validate( 47 | 'passwordConfirmation', 48 | formData 49 | ) 50 | 51 | setState({ 52 | ...state, 53 | emailError, 54 | nameError, 55 | passwordError, 56 | passwordConfirmationError, 57 | isFormInvalid: 58 | !!emailError || 59 | !!nameError || 60 | !!passwordError || 61 | !!passwordConfirmationError, 62 | }) 63 | }, [state.email, state.name, state.password, state.passwordConfirmation]) 64 | 65 | const handleSubmit = async ( 66 | event: React.FormEvent 67 | ): Promise => { 68 | event.preventDefault() 69 | try { 70 | if (state.isLoading || state.isFormInvalid) { 71 | return 72 | } 73 | setState({ 74 | ...state, 75 | isLoading: true, 76 | }) 77 | let account = await addAccount.add({ 78 | email: state.email, 79 | name: state.name, 80 | password: state.password, 81 | passwordConfirmation: state.passwordConfirmation, 82 | }) 83 | 84 | setCurrentAccount(account) 85 | history.replace('/') 86 | } catch (error) { 87 | setState({ 88 | ...state, 89 | isLoading: false, 90 | mainError: error.message, 91 | }) 92 | } 93 | } 94 | 95 | return ( 96 |
97 |
98 | 99 |
104 |
105 |

Create account

106 | 112 | 118 | 125 | 132 | 133 |
134 |
150 | 151 | 152 | 153 | 154 |
155 |
156 | ) 157 | } 158 | 159 | export default SignUp 160 | -------------------------------------------------------------------------------- /src/presentation/pages/SignUp/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .sigup { 4 | display: flex; 5 | max-height: 100%; 6 | height: 100vh; 7 | align-items: center; 8 | justify-content: center; 9 | 10 | .content { 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | justify-content: space-between; 15 | width: 100%; 16 | max-width: 700px; 17 | 18 | @media (max-width: 1000px) { 19 | max-width: 1000px; 20 | } 21 | 22 | .form { 23 | position: relative; 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | justify-content: center; 28 | width: 600px; 29 | height: 600px; 30 | 31 | animation: SignUpAnimation 1.2s; 32 | 33 | .invertedMold { 34 | position: absolute; 35 | top: 0; 36 | left: 0; 37 | z-index: -1; 38 | background-color: $white; 39 | border-radius: 50px; 40 | background-repeat: no-repeat; 41 | background-size: 500px 500px; 42 | transform: rotateY(180deg); 43 | width: 600px; 44 | height: 600px; 45 | } 46 | 47 | .title { 48 | margin-top: -32px; 49 | font-size: 28px; 50 | color: $black; 51 | align-self: center; 52 | margin-bottom: 22px; 53 | } 54 | } 55 | 56 | .buttonsContainer { 57 | position: relative; 58 | margin-top: 12px; 59 | align-self: center; 60 | 61 | .submitBtn { 62 | border: 1px solid $darkWhite; 63 | width: 300px; 64 | height: 50px; 65 | background-color: $yellow; 66 | color: $black; 67 | font-weight: 700; 68 | font-size: 24px; 69 | border-radius: 30px; 70 | } 71 | 72 | .goBack { 73 | position: absolute; 74 | bottom: -60px; 75 | left: 0; 76 | text-align: center; 77 | color: $black; 78 | font-weight: bold; 79 | font-size: 14px; 80 | margin-top: 16px; 81 | cursor: pointer; 82 | text-decoration: none; 83 | 84 | &:hover { 85 | text-decoration: underline; 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | @keyframes SignUpAnimation { 93 | from { 94 | transform: rotateY(180deg); 95 | opacity: 0; 96 | } 97 | to { 98 | transform: rotateY(0deg); 99 | opacity: 1; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/presentation/pages/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Dashboard } from './Dashboard' 2 | export { default as Login } from './Login' 3 | export { default as SignUp } from './SignUp' 4 | -------------------------------------------------------------------------------- /src/presentation/protocols/validation.ts: -------------------------------------------------------------------------------- 1 | export interface IValidation { 2 | validate: (fieldName: string, input: object) => string 3 | } 4 | -------------------------------------------------------------------------------- /src/presentation/styles/colors.scss: -------------------------------------------------------------------------------- 1 | // Base colors 2 | $yellowAux: #d3b40f; 3 | $white: #fff; 4 | $black: #000; 5 | $orange: #fb7b01; 6 | $yellow: #fff159; 7 | $red: #fb5b57; 8 | $yellowMouseHover: #fae407; 9 | 10 | // Aux colors 11 | $darkWhite: #f2f0e4; 12 | $darkGrey: #a9a9a9; 13 | $lightGrey: #c0c0c0; 14 | $grey: #696969; 15 | -------------------------------------------------------------------------------- /src/presentation/styles/global.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/colors.scss'; 2 | 3 | @font-face { 4 | font-family: 'GothamMedium'; 5 | src: 6 | local('GothamMedium'), 7 | url(../assets/fonts/GothamMedium.ttf) format('ttf'); 8 | } 9 | 10 | * { 11 | font-family: 'GothamMedium', sans-serif; 12 | font-size: 14px; 13 | padding: 0; 14 | margin: 0; 15 | box-sizing: border-box; 16 | } 17 | 18 | table { 19 | border-collapse: collapse !important; 20 | 21 | tbody tr { 22 | &:hover { 23 | background-color: $yellowMouseHover; 24 | } 25 | } 26 | } 27 | 28 | body { 29 | background-color: $yellow; 30 | } 31 | 32 | input[type='password'], 33 | input[type='email'], 34 | input[type='text'] { 35 | border: 1px solid $darkWhite; 36 | background-color: transparent; 37 | line-height: 40px; 38 | border-radius: 0px; 39 | height: 40px; 40 | width: 290px; 41 | padding: 0px 40px 0px 16px; 42 | color: $black; 43 | outline: none; 44 | 45 | &:focus { 46 | border-color: $yellowAux; 47 | } 48 | } 49 | 50 | button { 51 | border: none; 52 | outline: none; 53 | cursor: pointer; 54 | 55 | &:hover { 56 | opacity: 0.9; 57 | } 58 | 59 | &:disabled { 60 | color: $darkWhite; 61 | opacity: 0.5; 62 | &:hover { 63 | opacity: 0.5; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/presentation/test/FormHelper.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent, screen } from '@testing-library/react' 2 | import faker from 'faker' 3 | 4 | export const populateField = ( 5 | fieldName: string, 6 | value = faker.random.word() 7 | ): void => { 8 | const input = screen.getByTestId(fieldName) 9 | 10 | fireEvent.input(input, { target: { value } }) 11 | } 12 | 13 | export const testElementExists = (fieldName: string): void => { 14 | expect(screen.queryByTestId(fieldName)).toBeInTheDocument() 15 | } 16 | 17 | export const testElementText = (fieldName: string, text: string): void => { 18 | expect(screen.getByTestId(fieldName)).toHaveTextContent(text) 19 | } 20 | 21 | export const testChildCount = (fieldName: string, count: number): void => { 22 | expect(screen.getByTestId(fieldName).children).toHaveLength(count) 23 | } 24 | 25 | export const testButtonIsDisabled = (fieldName: string): void => { 26 | expect(screen.getByTestId(fieldName)).toBeDisabled() 27 | } 28 | 29 | export const testButtonIsEnabled = (fieldName: string): void => { 30 | expect(screen.getByTestId(fieldName)).toBeEnabled() 31 | } 32 | -------------------------------------------------------------------------------- /src/presentation/test/MockAuthentication.ts: -------------------------------------------------------------------------------- 1 | import { RemoteAuthenticationamespace } from '@/data/usecases/' 2 | import { mockAccountModel } from '@/domain/test' 3 | import { Authentication, IAuthentication } from '@/domain/usecases' 4 | 5 | export class AuthenticationSpy implements IAuthentication { 6 | account = mockAccountModel() 7 | params: Authentication.Params 8 | callsCount = 0 9 | 10 | async auth( 11 | params: Authentication.Params 12 | ): Promise { 13 | this.params = params 14 | this.callsCount++ 15 | return Promise.resolve(this.account) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/presentation/test/MockValidation.ts: -------------------------------------------------------------------------------- 1 | import { IValidation } from '@/presentation/protocols/validation' 2 | 3 | export class ValidationStub implements IValidation { 4 | errorMessage: string 5 | 6 | validate(fieldName: string, input: object): string { 7 | return this.errorMessage 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/presentation/test/index.ts: -------------------------------------------------------------------------------- 1 | export * as FormHelper from './FormHelper' 2 | export * from './MockAuthentication' 3 | export * from './MockValidation' 4 | -------------------------------------------------------------------------------- /src/validation/enums/ValidationErrors.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @enum {ValidationErrorMessagesEnum} ValidationErrorMessagesEnum - Definition of messages displayed in errors 3 | * 4 | * @arg {string} InvalidFieldError - Invalid field message error 5 | * @arg {string} RequiredFieldError - Required field message error 6 | * @arg {string} MatchFieldError - Match field message error 7 | * @arg {string} InvalidFileTypeError - Invalid file format message error 8 | */ 9 | export enum ValidationErrorMessagesEnum { 10 | InvalidFieldError = 'Invalid value', 11 | RequiredFieldError = 'Required field', 12 | MatchFieldError = 'Value does not match pattern', 13 | InvalidFileTypeError = 'Invalid file format', 14 | } 15 | 16 | /** 17 | * @enum {ValidationErrorNamesEnum} ValidationErrorNamesEnum - Definition of messages names used in errors 18 | * 19 | * @arg {string} RequiredFieldError - Required field name error 20 | * @arg {string} MatchFieldError - Match field name error 21 | * @arg {string} InvalidFileTypeError - Invalid file type name error 22 | */ 23 | export enum ValidationErrorNamesEnum { 24 | RequiredFieldError = 'RequiredFieldError', 25 | MatchFieldError = 'MatchFieldError', 26 | InvalidFileTypeError = 'InvalidFileTypeError', 27 | } 28 | -------------------------------------------------------------------------------- /src/validation/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ValidationErrors.enum' 2 | -------------------------------------------------------------------------------- /src/validation/errors/InvalidFieldError.ts: -------------------------------------------------------------------------------- 1 | import { ValidationErrorMessagesEnum } from '../enums' 2 | 3 | export class InvalidFieldError extends Error { 4 | constructor() { 5 | super(ValidationErrorMessagesEnum.InvalidFieldError) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/validation/errors/InvalidFileTypeError.ts: -------------------------------------------------------------------------------- 1 | import { ValidationErrorMessagesEnum, ValidationErrorNamesEnum } from '../enums' 2 | 3 | export class InvalidFileTypeError extends Error { 4 | constructor() { 5 | super(ValidationErrorMessagesEnum.InvalidFileTypeError) 6 | this.name = ValidationErrorNamesEnum.InvalidFileTypeError 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/validation/errors/MatchFieldError.ts: -------------------------------------------------------------------------------- 1 | import { ValidationErrorMessagesEnum, ValidationErrorNamesEnum } from '../enums' 2 | 3 | export class MatchFieldError extends Error { 4 | constructor(message?: string) { 5 | super(message || ValidationErrorMessagesEnum.MatchFieldError) 6 | this.name = ValidationErrorNamesEnum.MatchFieldError 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/validation/errors/RequiredFieldError.ts: -------------------------------------------------------------------------------- 1 | import { ValidationErrorMessagesEnum, ValidationErrorNamesEnum } from '../enums' 2 | 3 | export class RequiredFieldError extends Error { 4 | constructor() { 5 | super(ValidationErrorMessagesEnum.RequiredFieldError) 6 | this.name = ValidationErrorNamesEnum.RequiredFieldError 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/validation/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RequiredFieldError' 2 | export * from './InvalidFieldError' 3 | export * from './MatchFieldError' 4 | export * from './InvalidFileTypeError' 5 | -------------------------------------------------------------------------------- /src/validation/protocols/FieldValidation.ts: -------------------------------------------------------------------------------- 1 | export interface IFieldValidation { 2 | field: string 3 | validate: (input: object) => Error 4 | } 5 | -------------------------------------------------------------------------------- /src/validation/protocols/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FieldValidation' 2 | -------------------------------------------------------------------------------- /src/validation/test/MockFieldValidation.ts: -------------------------------------------------------------------------------- 1 | import { IFieldValidation } from '../protocols' 2 | 3 | export class FieldValidationSpy implements IFieldValidation { 4 | error: Error = null 5 | 6 | constructor(readonly field: string) {} 7 | 8 | validate(input: object): Error { 9 | return this.error 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/validation/test/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MockFieldValidation' 2 | -------------------------------------------------------------------------------- /src/validation/validators/builder/ValidationBuilder.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CompareFieldsValidation, 3 | RequiredFieldValidation, 4 | EmailFieldValidation, 5 | MinLengthValidation, 6 | MatchFieldValidation, 7 | MaxLengthValidation, 8 | FileTypeValidation, 9 | } from '@/validation/validators' 10 | import { ValidationBuilder } from './ValidationBuilder' 11 | import faker from 'faker' 12 | 13 | describe('ValidationBuilder', () => { 14 | test('Should return RequiredFieldValidation ', () => { 15 | const field = faker.database.column() 16 | const validations = ValidationBuilder.field(field).required().build() 17 | expect(validations).toEqual([new RequiredFieldValidation(field)]) 18 | }) 19 | 20 | test('Should return EmailValidation ', () => { 21 | const field = faker.database.column() 22 | const validations = ValidationBuilder.field(field).email().build() 23 | expect(validations).toEqual([new EmailFieldValidation(field)]) 24 | }) 25 | 26 | test('Should return MinLengthValidation ', () => { 27 | const field = faker.database.column() 28 | const length = faker.datatype.number() 29 | const validations = ValidationBuilder.field(field).min(length).build() 30 | expect(validations).toEqual([new MinLengthValidation(field, length)]) 31 | }) 32 | 33 | test('Should return MaxLengthValidation ', () => { 34 | const field = faker.database.column() 35 | const length = faker.datatype.number() 36 | const validations = ValidationBuilder.field(field).max(length).build() 37 | expect(validations).toEqual([new MaxLengthValidation(field, length)]) 38 | }) 39 | 40 | test('Should return CompareFieldsValidation ', () => { 41 | const field = faker.database.column() 42 | const fieldToCompare = faker.database.column() 43 | const validations = ValidationBuilder.field(field) 44 | .sameAs(fieldToCompare) 45 | .build() 46 | expect(validations).toEqual([ 47 | new CompareFieldsValidation(field, fieldToCompare), 48 | ]) 49 | }) 50 | 51 | test('Should return MatchFieldValidation', () => { 52 | const field = faker.database.column() 53 | const validations = ValidationBuilder.field(field) 54 | .match(/^[0-9]*$/, false) 55 | .build() 56 | 57 | expect(validations).toEqual([ 58 | new MatchFieldValidation(field, /^[0-9]*$/, false), 59 | ]) 60 | }) 61 | 62 | test('Should return FileTypeValidation', () => { 63 | const field = faker.database.column() 64 | const allowedFileExtensions = ['png', 'jpg', 'jpeg', 'pdf', 'txt'] 65 | const validations = ValidationBuilder.field(field) 66 | .fileType(allowedFileExtensions) 67 | .build() 68 | 69 | expect(validations).toEqual([ 70 | new FileTypeValidation(field, allowedFileExtensions), 71 | ]) 72 | }) 73 | 74 | test('Should return a list of validations ', () => { 75 | const field = faker.database.column() 76 | const fieldToCompare = faker.database.column() 77 | const minLength = faker.datatype.number() 78 | const maxLength = minLength + faker.datatype.number() 79 | const DIGITS_REGEX = /^[0-9]*$/ 80 | const allowedFileExtensions = ['png', 'jpg', 'jpeg', 'pdf', 'txt'] 81 | const validations = ValidationBuilder.field(field) 82 | .required() 83 | .min(minLength) 84 | .max(maxLength) 85 | .sameAs(fieldToCompare) 86 | .email() 87 | .match(DIGITS_REGEX) 88 | .fileType(allowedFileExtensions) 89 | .build() 90 | expect(validations).toEqual([ 91 | new RequiredFieldValidation(field), 92 | new MinLengthValidation(field, minLength), 93 | new MaxLengthValidation(field, maxLength), 94 | new CompareFieldsValidation(field, fieldToCompare), 95 | new EmailFieldValidation(field), 96 | new MatchFieldValidation(field, DIGITS_REGEX, false), 97 | new FileTypeValidation(field, allowedFileExtensions), 98 | ]) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /src/validation/validators/builder/ValidationBuilder.ts: -------------------------------------------------------------------------------- 1 | import { IFieldValidation } from '@/validation/protocols' 2 | import { 3 | CompareFieldsValidation, 4 | RequiredFieldValidation, 5 | EmailFieldValidation, 6 | MinLengthValidation, 7 | MatchFieldValidation, 8 | MaxLengthValidation, 9 | FileTypeValidation, 10 | } from '@/validation/validators' 11 | 12 | export class ValidationBuilder { 13 | private constructor( 14 | private readonly fieldName: string, 15 | private readonly validations: IFieldValidation[] 16 | ) {} 17 | 18 | static field(fieldName: string): ValidationBuilder { 19 | return new ValidationBuilder(fieldName, []) 20 | } 21 | 22 | required(): ValidationBuilder { 23 | this.validations.push(new RequiredFieldValidation(this.fieldName)) 24 | return this 25 | } 26 | 27 | email(): ValidationBuilder { 28 | this.validations.push(new EmailFieldValidation(this.fieldName)) 29 | return this 30 | } 31 | 32 | min(length: number): ValidationBuilder { 33 | this.validations.push(new MinLengthValidation(this.fieldName, length)) 34 | return this 35 | } 36 | 37 | max(length: number): ValidationBuilder { 38 | this.validations.push(new MaxLengthValidation(this.fieldName, length)) 39 | return this 40 | } 41 | 42 | sameAs(fieldToCompare: string): ValidationBuilder { 43 | this.validations.push( 44 | new CompareFieldsValidation(this.fieldName, fieldToCompare) 45 | ) 46 | return this 47 | } 48 | 49 | match( 50 | pattern: RegExp, 51 | ignoreCase: boolean = false, 52 | message?: string 53 | ): ValidationBuilder { 54 | this.validations.push( 55 | new MatchFieldValidation(this.fieldName, pattern, ignoreCase, message) 56 | ) 57 | return this 58 | } 59 | 60 | fileType(allowedFileExtensions: string[]): ValidationBuilder { 61 | this.validations.push( 62 | new FileTypeValidation(this.fieldName, allowedFileExtensions) 63 | ) 64 | return this 65 | } 66 | 67 | build(): IFieldValidation[] { 68 | return this.validations 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/validation/validators/compareFields/CompareFieldsValidation.spec.ts: -------------------------------------------------------------------------------- 1 | import { InvalidFieldError } from '@/validation/errors' 2 | import { CompareFieldsValidation } from './CompareFieldsValidation' 3 | import faker from 'faker' 4 | 5 | const makeSut = ( 6 | fieldName: string, 7 | fieldToCompare: string 8 | ): CompareFieldsValidation => 9 | new CompareFieldsValidation(fieldName, fieldToCompare) 10 | 11 | describe('CompareFieldsValidation', () => { 12 | test('Should return error if compare is invalid', () => { 13 | const field = faker.database.column() 14 | const fieldToCompare = faker.database.column() 15 | const sut = makeSut(field, fieldToCompare) 16 | const error = sut.validate({ 17 | [field]: faker.random.words(3), 18 | [fieldToCompare]: faker.random.words(4), 19 | }) 20 | expect(error).toEqual(new InvalidFieldError()) 21 | }) 22 | 23 | test('Should return falsy if compare is valid', () => { 24 | const field = faker.database.column() 25 | const fieldToCompare = faker.database.column() 26 | const value = faker.random.word() 27 | const sut = makeSut(field, fieldToCompare) 28 | const error = sut.validate({ 29 | [field]: value, 30 | [fieldToCompare]: value, 31 | }) 32 | expect(error).toBeFalsy() 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/validation/validators/compareFields/CompareFieldsValidation.ts: -------------------------------------------------------------------------------- 1 | import { InvalidFieldError } from '@/validation/errors' 2 | import { IFieldValidation } from '@/validation/protocols' 3 | 4 | export class CompareFieldsValidation implements IFieldValidation { 5 | constructor( 6 | readonly field: string, 7 | private readonly valueToCompare: string 8 | ) {} 9 | 10 | validate(input: object): Error { 11 | return input[this.field] !== input[this.valueToCompare] 12 | ? new InvalidFieldError() 13 | : null 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/validation/validators/emailField/EmailFieldValidation.spec.ts: -------------------------------------------------------------------------------- 1 | import { InvalidFieldError } from '@/validation/errors' 2 | import { EmailFieldValidation } from './EmailFieldValidation' 3 | import faker from 'faker' 4 | 5 | const makeSut = (fieldName: string): EmailFieldValidation => 6 | new EmailFieldValidation(fieldName) 7 | 8 | describe('EmailFieldValidation', () => { 9 | test('Should return error if email is invalid', () => { 10 | const field = faker.database.column() 11 | const sut = makeSut(field) 12 | const error = sut.validate({ [field]: faker.random.word() }) 13 | expect(error).toEqual(new InvalidFieldError()) 14 | }) 15 | 16 | test('Should return falsy if email is valid', () => { 17 | const field = faker.database.column() 18 | const sut = makeSut(field) 19 | const error = sut.validate({ [field]: faker.internet.email() }) 20 | expect(error).toBeFalsy() 21 | }) 22 | 23 | test('Should return falsy if email is empty', () => { 24 | const field = faker.database.column() 25 | const sut = makeSut(field) 26 | const error = sut.validate({ [field]: '' }) 27 | expect(error).toBeFalsy() 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/validation/validators/emailField/EmailFieldValidation.ts: -------------------------------------------------------------------------------- 1 | import { InvalidFieldError } from '@/validation/errors' 2 | import { IFieldValidation } from '@/validation/protocols' 3 | 4 | export class EmailFieldValidation implements IFieldValidation { 5 | constructor(readonly field: string) {} 6 | 7 | validate(input: object): Error { 8 | const emailRegex = 9 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ 10 | 11 | return !input[this.field] || emailRegex.test(input[this.field]) 12 | ? null 13 | : new InvalidFieldError() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/validation/validators/fileType/FileTypeValidation.spec.ts: -------------------------------------------------------------------------------- 1 | import { FileTypeValidation } from './FileTypeValidation' 2 | import { InvalidFileTypeError } from '@/validation/errors' 3 | import faker from 'faker' 4 | 5 | const field = faker.database.column() 6 | const allowedFileExtensions = ['png', 'jpg', 'jpeg', 'pdf', 'txt'] 7 | 8 | const makeSut = (fieldName: string): FileTypeValidation => 9 | new FileTypeValidation(fieldName, allowedFileExtensions) 10 | 11 | describe('FileTypeValidation', () => { 12 | test('Should return error if file extension is not allowed', () => { 13 | const sut = makeSut(field) 14 | const error = sut.validate({ [field]: 'document.exe' }) 15 | expect(error).toEqual(new InvalidFileTypeError()) 16 | }) 17 | 18 | test('Should return falsy if file extension is allowed', () => { 19 | const sut = makeSut(field) 20 | const error = sut.validate({ [field]: 'document.pdf' }) 21 | expect(error).toBeFalsy() 22 | }) 23 | 24 | test('Should return error if field does not exist in the input', () => { 25 | const sut = makeSut(faker.database.column()) 26 | const error = sut.validate({ [faker.database.column()]: 'file.txt' }) 27 | expect(error).toEqual(new InvalidFileTypeError()) 28 | }) 29 | 30 | test('Should return error if input has no file extension', () => { 31 | const sut = makeSut(field) 32 | const error = sut.validate({ [field]: 'fileWithoutExtension' }) 33 | expect(error).toEqual(new InvalidFileTypeError()) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/validation/validators/fileType/FileTypeValidation.ts: -------------------------------------------------------------------------------- 1 | import { InvalidFileTypeError } from '@/validation/errors' 2 | import { IFieldValidation } from '@/validation/protocols' 3 | 4 | export class FileTypeValidation implements IFieldValidation { 5 | constructor( 6 | readonly field: string, 7 | private readonly allowedFileExtensions: string[] 8 | ) {} 9 | 10 | private getFileExtension(fileName: string): string { 11 | const ext = fileName.lastIndexOf('.') 12 | if (ext === -1) { 13 | return '' // No extension found 14 | } 15 | return fileName.slice(ext + 1).toLowerCase() 16 | } 17 | 18 | validate(input: object): Error { 19 | const fileExtension = this.getFileExtension( 20 | (input[this.field] || '').toLowerCase() 21 | ) 22 | const isAllowed = this.allowedFileExtensions.find( 23 | (ext) => ext.toLowerCase() === fileExtension 24 | ) 25 | return fileExtension && isAllowed ? null : new InvalidFileTypeError() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/validation/validators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './builder/ValidationBuilder' 2 | export * from './compareFields/CompareFieldsValidation' 3 | export * from './emailField/EmailFieldValidation' 4 | export * from './minLength/MinLengthValidation' 5 | export * from './maxLength/MaxLengthValidation' 6 | export * from './requiredField/RequiredFieldValidation' 7 | export * from './validationComposite/ValidationComposite' 8 | export * from './matchField/MatchFieldValidation' 9 | export * from './fileType/FileTypeValidation' 10 | -------------------------------------------------------------------------------- /src/validation/validators/matchField/MatchFieldValidation.spec.ts: -------------------------------------------------------------------------------- 1 | import { MatchFieldError } from '@/validation/errors' 2 | import { MatchFieldValidation } from './MatchFieldValidation' 3 | import faker from 'faker' 4 | 5 | const ONLY_DIGITS_REGEX = /^[0-9]*$/ 6 | const CAPITAL_LETTERS_REGEX = /[A-Z]+/ 7 | 8 | const makeSut = ( 9 | fieldName: string, 10 | pattern: RegExp, 11 | ignoreCase?: boolean, 12 | message?: string 13 | ): MatchFieldValidation => 14 | new MatchFieldValidation(fieldName, pattern, ignoreCase, message) 15 | 16 | describe('MatchFieldValidation', () => { 17 | test('Should return error if match is invalid for Invalid Digit Regex', () => { 18 | const field = faker.database.column() 19 | const sut = makeSut(field, ONLY_DIGITS_REGEX) 20 | const error = sut.validate({ [field]: faker.random.alphaNumeric(4) }) 21 | 22 | expect(error).toEqual(new MatchFieldError()) 23 | }) 24 | 25 | test('Should return falsy if match is valid for Invalid Digit Regex', () => { 26 | const field = faker.database.column() 27 | const sut = makeSut(field, ONLY_DIGITS_REGEX) 28 | const error = sut.validate({ [field]: faker.datatype.number() }) 29 | 30 | expect(error).toBeFalsy() 31 | }) 32 | 33 | test('Should return falsy if field does not exist in schema', () => { 34 | const sut = makeSut(faker.database.column(), ONLY_DIGITS_REGEX) 35 | const error = sut.validate({}) 36 | 37 | expect(error).toBeFalsy() 38 | }) 39 | 40 | test('Should return a custom error message if match is invalid', () => { 41 | const message = `Only uppercase letters are allowed!` 42 | const field = faker.database.column() 43 | const sut = makeSut(field, CAPITAL_LETTERS_REGEX, false, message) 44 | const error = sut.validate({ 45 | [field]: faker.random.alpha({ count: 5 }), 46 | }) 47 | 48 | expect(error).toEqual(new MatchFieldError(message)) 49 | }) 50 | 51 | test('Should return falsy if match is valid for Capital Letters Regex', () => { 52 | const field = faker.database.column() 53 | const sut = makeSut(field, CAPITAL_LETTERS_REGEX, true) 54 | const error = sut.validate({ [field]: faker.random.alpha({ count: 5 }) }) 55 | 56 | expect(error).toBeFalsy() 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /src/validation/validators/matchField/MatchFieldValidation.ts: -------------------------------------------------------------------------------- 1 | import { MatchFieldError } from '@/validation/errors' 2 | import { IFieldValidation } from '@/validation/protocols' 3 | 4 | export class MatchFieldValidation implements IFieldValidation { 5 | constructor( 6 | readonly field: string, 7 | private readonly pattern: RegExp, 8 | private readonly ignoreCase?: boolean, 9 | private readonly message?: string 10 | ) {} 11 | 12 | validate(input: object): Error { 13 | const combinedRegex = new RegExp( 14 | this.pattern.source, 15 | this.ignoreCase ? 'i' : '' 16 | ) 17 | 18 | return !input[this.field] || combinedRegex.test(input[this.field]) 19 | ? null 20 | : new MatchFieldError(this.message) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/validation/validators/maxLength/MaxLengthValidation.spec.ts: -------------------------------------------------------------------------------- 1 | import { MaxLengthValidation } from './MaxLengthValidation' 2 | import { InvalidFieldError } from '@/validation/errors' 3 | import faker from 'faker' 4 | 5 | const maxLength = faker.datatype.number() 6 | 7 | const makeSut = (fieldName: string): MaxLengthValidation => 8 | new MaxLengthValidation(fieldName, maxLength) 9 | 10 | describe('MaxLengthValidation', () => { 11 | test('Should return error if value is longer than maxLength', () => { 12 | const field = faker.database.column() 13 | const sut = makeSut(field) 14 | const error = sut.validate({ 15 | [field]: faker.random.alphaNumeric(maxLength + 1), 16 | }) 17 | expect(error).toEqual(new InvalidFieldError()) 18 | }) 19 | 20 | test('Should return falsy if value is equal to maxLength', () => { 21 | const field = faker.database.column() 22 | const sut = makeSut(field) 23 | const error = sut.validate({ 24 | [field]: faker.random.alphaNumeric(maxLength), 25 | }) 26 | expect(error).toBeFalsy() 27 | }) 28 | 29 | test('Should return falsy if value is shorter than maxLength', () => { 30 | const field = faker.database.column() 31 | const sut = makeSut(field) 32 | const error = sut.validate({ 33 | [field]: faker.random.alphaNumeric(maxLength - 1), 34 | }) 35 | expect(error).toBeFalsy() 36 | }) 37 | 38 | test('Should return falsy if field does not exists in schema', () => { 39 | const sut = makeSut(faker.database.column()) 40 | const error = sut.validate({ 41 | [faker.database.column()]: faker.random.alphaNumeric(maxLength - 1), 42 | }) 43 | expect(error).toBeFalsy() 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/validation/validators/maxLength/MaxLengthValidation.ts: -------------------------------------------------------------------------------- 1 | import { InvalidFieldError } from '@/validation/errors' 2 | import { IFieldValidation } from '@/validation/protocols' 3 | 4 | export class MaxLengthValidation implements IFieldValidation { 5 | constructor( 6 | readonly field: string, 7 | private readonly maxLength: number 8 | ) {} 9 | 10 | validate(input: object): Error { 11 | const fieldLength = input[this.field]?.length 12 | return fieldLength && fieldLength > this.maxLength 13 | ? new InvalidFieldError() 14 | : null 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/validation/validators/minLength/MinLengthValidation.spec.ts: -------------------------------------------------------------------------------- 1 | import { MinLengthValidation } from './MinLengthValidation' 2 | import { InvalidFieldError } from '@/validation/errors' 3 | import faker from 'faker' 4 | 5 | const minLength = faker.datatype.number() 6 | 7 | const makeSut = (fieldName: string): MinLengthValidation => 8 | new MinLengthValidation(fieldName, minLength) 9 | 10 | describe('MinLengthValidation', () => { 11 | test('Should return error if value is shorter than minLength', () => { 12 | const field = faker.database.column() 13 | const sut = makeSut(field) 14 | const error = sut.validate({ 15 | [field]: faker.random.alphaNumeric(minLength - 1), 16 | }) 17 | expect(error).toEqual(new InvalidFieldError()) 18 | }) 19 | 20 | test('Should return falsy if value is equal to minLength', () => { 21 | const field = faker.database.column() 22 | const sut = makeSut(field) 23 | const error = sut.validate({ 24 | [field]: faker.random.alphaNumeric(minLength), 25 | }) 26 | expect(error).toBeFalsy() 27 | }) 28 | 29 | test('Should return falsy if value is longer than minLength', () => { 30 | const field = faker.database.column() 31 | const sut = makeSut(field) 32 | const error = sut.validate({ 33 | [field]: faker.random.alphaNumeric(minLength + 1), 34 | }) 35 | expect(error).toBeFalsy() 36 | }) 37 | 38 | test('Should return falsy if field does not exists in schema', () => { 39 | const sut = makeSut(faker.database.column()) 40 | const error = sut.validate({ 41 | [faker.database.column()]: faker.random.alphaNumeric(minLength - 1), 42 | }) 43 | expect(error).toBeFalsy() 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/validation/validators/minLength/MinLengthValidation.ts: -------------------------------------------------------------------------------- 1 | import { InvalidFieldError } from '@/validation/errors' 2 | import { IFieldValidation } from '@/validation/protocols' 3 | 4 | export class MinLengthValidation implements IFieldValidation { 5 | constructor( 6 | readonly field: string, 7 | private readonly minLength: number 8 | ) {} 9 | 10 | validate(input: object): Error { 11 | const fieldLength = input[this.field]?.length 12 | return fieldLength && fieldLength < this.minLength 13 | ? new InvalidFieldError() 14 | : null 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/validation/validators/requiredField/RequiredFieldValidation.spec.ts: -------------------------------------------------------------------------------- 1 | import { RequiredFieldError } from '@/validation/errors' 2 | import { RequiredFieldValidation } from './RequiredFieldValidation' 3 | import faker from 'faker' 4 | 5 | const makeSut = (fieldName: string): RequiredFieldValidation => 6 | new RequiredFieldValidation(fieldName) 7 | 8 | describe('RequiredFieldValidation', () => { 9 | test('Should return error if field is empty', () => { 10 | const field = faker.database.column() 11 | const sut = makeSut(field) 12 | const error = sut.validate({ [field]: '' }) 13 | expect(error).toEqual(new RequiredFieldError()) 14 | }) 15 | 16 | test('Should return falsy if field is not empty', () => { 17 | const field = faker.database.column() 18 | const sut = makeSut(field) 19 | const error = sut.validate({ [field]: faker.database.column() }) 20 | expect(error).toBeFalsy() 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/validation/validators/requiredField/RequiredFieldValidation.ts: -------------------------------------------------------------------------------- 1 | import { IFieldValidation } from '@/validation/protocols' 2 | import { RequiredFieldError } from '@/validation/errors' 3 | 4 | export class RequiredFieldValidation implements IFieldValidation { 5 | constructor(readonly field: string) {} 6 | 7 | validate(input: object): Error { 8 | return input[this.field] ? null : new RequiredFieldError() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/validation/validators/validationComposite/ValidationComposite.spec.ts: -------------------------------------------------------------------------------- 1 | import { FieldValidationSpy } from '@/validation/test/MockFieldValidation' 2 | import { ValidationComposite } from './ValidationComposite' 3 | import faker from 'faker' 4 | 5 | type SutTypes = { 6 | sut: ValidationComposite 7 | fieldValidationsSpy: FieldValidationSpy[] 8 | } 9 | 10 | const makeSut = (fieldName: string): SutTypes => { 11 | const fieldValidationsSpy = [ 12 | new FieldValidationSpy(fieldName), 13 | new FieldValidationSpy(fieldName), 14 | ] 15 | 16 | const sut = ValidationComposite.build(fieldValidationsSpy) 17 | 18 | return { 19 | sut, 20 | fieldValidationsSpy, 21 | } 22 | } 23 | 24 | describe('ValidationComposite', () => { 25 | test('Should return error if any validation fails', () => { 26 | const first_error_message = faker.random.words() 27 | const second_error_message = faker.random.words() 28 | const fieldName = faker.database.column() 29 | const { sut, fieldValidationsSpy } = makeSut(fieldName) 30 | fieldValidationsSpy[0].error = new Error(first_error_message) 31 | fieldValidationsSpy[1].error = new Error(second_error_message) 32 | const error = sut.validate(fieldName, { 33 | [fieldName]: faker.random.words(), 34 | }) 35 | expect(error).toBe(error) 36 | }) 37 | 38 | test('Should return falsy if there is no error', () => { 39 | const fieldName = faker.database.column() 40 | const { sut } = makeSut(fieldName) 41 | 42 | const error = sut.validate(fieldName, { 43 | [fieldName]: faker.random.words(), 44 | }) 45 | expect(error).toBeFalsy() 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/validation/validators/validationComposite/ValidationComposite.ts: -------------------------------------------------------------------------------- 1 | import { IValidation } from '@/presentation/protocols/validation' 2 | import { IFieldValidation } from '@/validation/protocols' 3 | 4 | export class ValidationComposite implements IValidation { 5 | private constructor(private readonly validators: IFieldValidation[]) {} 6 | 7 | static build(validators: IFieldValidation[]): ValidationComposite { 8 | return new ValidationComposite(validators) 9 | } 10 | 11 | validate(fieldName: string, input: object): string { 12 | const validators = this.validators.filter( 13 | (validator) => validator.field === fieldName 14 | ) 15 | 16 | for (const validator of validators) { 17 | const error = validator.validate(input) 18 | if (error) { 19 | return error.message 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /template.dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 14 | 15 | Clean React App 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /template.prod.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 14 | 15 | Clean React App 16 | 17 | 18 |
19 | 20 | 24 | 28 | 32 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "esModuleInterop": true, 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "jsx": "react", 9 | "baseUrl": "src", 10 | "paths": { 11 | "@/*": ["*"] 12 | }, 13 | "allowJs": true, 14 | "resolveJsonModule": true 15 | }, 16 | "include": ["src"], 17 | "exclude": ["src/main/test/cypress"] 18 | } 19 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 3 | 4 | module.exports = { 5 | entry: './src/main/index.tsx', 6 | output: { 7 | path: path.join(__dirname, 'dist'), 8 | filename: 'main-bundle-[fullhash].js', 9 | }, 10 | resolve: { 11 | extensions: ['.ts', '.tsx', '.js', '.scss'], 12 | alias: { 13 | '@': path.join(__dirname, 'src'), 14 | }, 15 | }, 16 | plugins: [new CleanWebpackPlugin()], 17 | } 18 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { DefinePlugin } = require('webpack') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const { merge } = require('webpack-merge') 4 | const common = require('./webpack.common') 5 | 6 | module.exports = merge(common, { 7 | mode: 'development', 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.ts(x?)$/, 12 | exclude: /node_modules/, 13 | use: { 14 | loader: 'ts-loader', 15 | }, 16 | }, 17 | { 18 | test: /\.(jpg|jpeg|png|woff|woff2|eot|ttf|svg)$/, 19 | use: { 20 | loader: 'url-loader?limit=100000', 21 | }, 22 | }, 23 | { 24 | test: /\.(css|scss)$/, 25 | use: [ 26 | { 27 | loader: 'style-loader', 28 | }, 29 | { 30 | loader: 'css-loader', 31 | options: { 32 | modules: true, 33 | }, 34 | }, 35 | { 36 | loader: 'sass-loader', 37 | }, 38 | ], 39 | }, 40 | ], 41 | }, 42 | devServer: { 43 | static: { 44 | directory: './public', 45 | }, 46 | devMiddleware: { 47 | writeToDisk: true, 48 | }, 49 | historyApiFallback: true, 50 | port: 8081, 51 | }, 52 | plugins: [ 53 | new DefinePlugin({ 54 | 'process.env.API_URL': JSON.stringify('your_local_env_url'), 55 | }), 56 | new HtmlWebpackPlugin({ 57 | template: './template.dev.html', 58 | }), 59 | ], 60 | }) 61 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { DefinePlugin } = require('webpack') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 4 | const { NetlifyPlugin } = require('netlify-webpack-plugin') 5 | const common = require('./webpack.common') 6 | const { merge } = require('webpack-merge') 7 | 8 | module.exports = merge(common, { 9 | mode: 'production', 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.ts(x?)$/, 14 | exclude: /node_modules/, 15 | use: { 16 | loader: 'ts-loader', 17 | }, 18 | }, 19 | { 20 | test: /\.(jpg|jpeg|png|woff|woff2|eot|ttf|svg)$/, 21 | use: { 22 | loader: 'url-loader?limit=100000', 23 | }, 24 | }, 25 | { 26 | test: /\.(css|scss)$/, 27 | use: [ 28 | { 29 | loader: MiniCssExtractPlugin.loader, 30 | }, 31 | { 32 | loader: 'css-loader', 33 | options: { 34 | modules: true, 35 | }, 36 | }, 37 | { 38 | loader: 'sass-loader', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | externals: { 45 | react: 'React', 46 | axios: 'axios', 47 | 'react-dom': 'ReactDOM', 48 | 'react-router-dom': 'ReactRouterDOM', 49 | }, 50 | plugins: [ 51 | new DefinePlugin({ 52 | 'process.env.API_URL': JSON.stringify('your_prod_env_url'), 53 | }), 54 | new HtmlWebpackPlugin({ 55 | template: './template.prod.html', 56 | }), 57 | new MiniCssExtractPlugin({ 58 | filename: 'main-bundle-[fullhash].css', 59 | }), 60 | 61 | new NetlifyPlugin({ 62 | redirects: [ 63 | { 64 | from: '/*', 65 | to: '/index.html', 66 | status: 200, 67 | force: false, 68 | }, 69 | ], 70 | }), 71 | ], 72 | }) 73 | --------------------------------------------------------------------------------