├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── build.yml │ ├── cypress.yml │ ├── lints.yml │ └── tests.yml ├── .gitignore ├── .lintstagedrc.cjs ├── .prettierignore ├── .prettierrc.cjs ├── .stylelintignore ├── .stylelintrc.json ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── commitlint.config.cjs ├── cypress.config.ts ├── cypress ├── e2e │ └── app.cy.ts └── support │ ├── commands.ts │ ├── component-index.html │ ├── component.ts │ └── e2e.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public └── vite.svg ├── scripts ├── uninstall-cypress.cjs ├── uninstall-packages.cjs └── uninstall-tailwind.cjs ├── shells ├── husky ├── lint ├── lint-fix ├── lint-report ├── validate ├── validate-fix └── validate-staged ├── src ├── App.test.tsx ├── App.tsx ├── assets │ ├── react.svg │ └── vite.svg ├── configs │ ├── router.ts │ ├── store │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── rootActions.ts │ │ ├── rootReducer.ts │ │ └── rootSaga.ts │ └── theme.tsx ├── constants │ └── routes.enum.ts ├── features │ ├── Counter │ │ ├── Counter.cy.tsx │ │ ├── Counter.test.tsx │ │ ├── Counter.tsx │ │ ├── index.ts │ │ └── redux │ │ │ ├── index.ts │ │ │ ├── saga.test.ts │ │ │ ├── saga.ts │ │ │ ├── selectors.ts │ │ │ ├── slice.test.ts │ │ │ └── slice.ts │ └── WelcomeCard │ │ ├── Logo.style.ts │ │ ├── WelcomeCard.test.tsx │ │ ├── WelcomeCard.tsx │ │ └── index.ts ├── helpers │ ├── configureStore.ts │ ├── createTestRouter.tsx │ └── getRootSaga.ts ├── index.scss ├── layouts │ └── Navbar.tsx ├── libs │ └── vitest.tsx ├── main.tsx ├── pages │ ├── About.tsx │ └── Home.tsx ├── setupTests.ts └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .husky 15 | scripts 16 | reports 17 | coverage 18 | package-lock.json 19 | 20 | # Editor directories and files 21 | .vscode 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | *.md 30 | *ignore 31 | LICENSE 32 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 3 | /* eslint-disable @typescript-eslint/no-var-requires */ 4 | /* eslint-disable import/no-extraneous-dependencies */ 5 | 6 | // @ts-check 7 | const { defineConfig } = require('eslint-define-config'); 8 | 9 | module.exports = defineConfig({ 10 | env: { browser: true, es2020: true }, 11 | extends: [ 12 | 'airbnb', 13 | 'airbnb-typescript', 14 | 'airbnb/hooks', 15 | 'plugin:@typescript-eslint/recommended', 16 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 17 | 'plugin:react-hooks/recommended', 18 | 'plugin:jsx-a11y/recommended', 19 | 'plugin:prettier/recommended', 20 | 'plugin:promise/recommended', 21 | 'plugin:import/recommended', 22 | 'plugin:import/typescript', 23 | 'plugin:tailwindcss/recommended', 24 | ], 25 | overrides: [ 26 | { 27 | files: ['*.test.{ts|tsx}', '*.cy.ts'], 28 | rules: { 29 | 'import/no-extraneous-dependencies': 'off', 30 | }, 31 | }, 32 | { 33 | files: ['*.cy.ts'], 34 | rules: { 35 | '@typescript-eslint/no-unused-expressions': 'off', 36 | '@typescript-eslint/no-unused-vars': 'off', 37 | 'func-names': 'off', 38 | 'no-console': 'off', 39 | 'promise/always-return': 'off', 40 | 'promise/catch-or-return': 'off', 41 | 'promise/no-nesting': 'off', 42 | }, 43 | }, 44 | ], 45 | parser: '@typescript-eslint/parser', 46 | parserOptions: { ecmaVersion: 'latest', project: true, sourceType: 'module' }, 47 | plugins: ['react', 'react-refresh', '@typescript-eslint', 'jsx-a11y', 'promise', 'no-loops', 'import', 'prettier', 'sort-keys-fix'], 48 | rules: { 49 | '@typescript-eslint/consistent-type-imports': 'warn', 50 | '@typescript-eslint/no-unused-vars': 'error', 51 | '@typescript-eslint/prefer-as-const': 'warn', 52 | 'import/default': 'error', 53 | 'import/export': 'error', 54 | 'import/named': 'error', 55 | 'import/no-absolute-path': 0, 56 | 'import/no-anonymous-default-export': 'off', 57 | 'import/no-duplicates': 'error', 58 | 'import/no-named-as-default': 'error', 59 | 'import/no-named-as-default-member': 'off', 60 | 'import/no-namespace': 'off', 61 | 'linebreak-style': 0, 62 | 'no-loops/no-loops': 1, 63 | 'prettier/prettier': ['error', {}, { properties: { usePrettierrc: true } }], 64 | quotes: ['error', 'single'], 65 | 'react-hooks/rules-of-hooks': 'error', 66 | 'react-refresh/only-export-components': 0, 67 | 'react/react-in-jsx-scope': 0, 68 | 'react/require-default-props': 0, 69 | semi: 1, 70 | 'sort-keys-fix/sort-keys-fix': 'error', 71 | 'tailwindcss/no-custom-classname': 0, 72 | }, 73 | settings: { 74 | 'import/parsers': { 75 | '@typescript-eslint/parser': ['.ts', '.tsx'], 76 | }, 77 | 'import/resolver': { 78 | node: { 79 | extensions: ['.ts', '.tsx'], 80 | }, 81 | typescript: { 82 | alwaysTryTypes: true, 83 | project: ['tsconfig.json'], 84 | }, 85 | }, 86 | react: { 87 | version: 'detect', 88 | }, 89 | tailwindcss: { 90 | callees: ['classnames', 'clsx', 'ctl'], 91 | classRegex: '^class(Name)?$', 92 | config: 'tailwind.config.js', 93 | cssFiles: ['**/*.css', '**/*.scss', '!**/node_modules', '!**/.*', '!**/dist', '!**/build'], 94 | cssFilesRefreshRate: 5_000, 95 | removeDuplicates: true, 96 | skipClassAttribute: false, 97 | tags: [], 98 | whitelist: [], // can be modified to support custom attributes. E.g. "^tw$" for `twin.macro` 99 | }, 100 | }, 101 | }); 102 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: ['main'] 9 | pull_request: 10 | branches: ['main'] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: 'npm' 30 | 31 | - name: Install Dependencies 32 | run: npm install 33 | 34 | - name: Run Build 35 | run: npm run build 36 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Cypress 5 | 6 | on: 7 | push: 8 | branches: ['main'] 9 | pull_request: 10 | branches: ['main'] 11 | 12 | jobs: 13 | E2E: 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | # Install npm dependencies, cache them correctly 19 | # and run all Cypress tests 20 | - name: Cypress run 21 | uses: cypress-io/github-action@v5 22 | with: 23 | build: npm run build 24 | start: npm start 25 | Component: 26 | runs-on: ubuntu-22.04 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v3 30 | - name: Cypress run 31 | uses: cypress-io/github-action@v5 32 | with: 33 | component: true 34 | -------------------------------------------------------------------------------- /.github/workflows/lints.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Lints 5 | 6 | on: 7 | push: 8 | branches: ['main'] 9 | pull_request: 10 | branches: ['main'] 11 | 12 | jobs: 13 | lints: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: 'npm' 30 | 31 | - name: Install Dependencies 32 | run: npm install 33 | 34 | - name: ESLint 35 | run: npm run lint:scripts 36 | 37 | - name: Stylelint 38 | run: npm run lint:styles 39 | 40 | - name: Prettier Lint 41 | run: npm run format 42 | 43 | - name: Compile TS 44 | run: npm run lint:ts 45 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: ['main'] 9 | pull_request: 10 | branches: ['main'] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: 'npm' 30 | 31 | - name: Install Dependencies 32 | run: npm install 33 | 34 | - name: Run Tests 35 | run: npm run test 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .husky 15 | reports 16 | coverage 17 | cypress/screenshots 18 | cypress/videos 19 | cypress/reports 20 | 21 | # Editor directories and files 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /.lintstagedrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{ts,tsx}': 'eslint --report-unused-disable-directives --max-warnings 0', // Check only staged Scripts 3 | '*.{css,scss}': 'stylelint', // Check only staged Styles 4 | '*.{json,yml,html,js,jsx,cjs,ts,tsx,css,scss}': 'prettier --loglevel silent --ignore-unknown --find-config-path --check', // Check only staged File Format 5 | // Given command inside a function so that the matched files wont be passed to tsc --args 6 | // By default tsc compiles all the files based on the tsconfig.json includes and files 7 | '*.{ts,tsx}': () => 'tsc --noEmit', // Check only staged File Types 8 | '*.{js,ts,ts,tsx}': 'vitest related --run', // run only related tests 9 | }; 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .husky 15 | shells 16 | reports 17 | coverage 18 | 19 | # Editor directories and files 20 | .vscode 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | *ignore 29 | LICENSE 30 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /* eslint-disable global-require */ 3 | 4 | module.exports = { 5 | plugins: [require('prettier-plugin-tailwindcss')], 6 | tailwindConfig: './tailwind.config.cjs', 7 | trailingComma: 'es5', 8 | singleQuote: true, 9 | tabWidth: 2, 10 | semi: true, 11 | endOfLine: 'auto', 12 | printWidth: 140, 13 | }; 14 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .husky 15 | scripts 16 | reports 17 | coverage 18 | package-lock.json 19 | 20 | # Editor directories and files 21 | .vscode 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | *.md 30 | *ignore 31 | LICENSE 32 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard", "stylelint-config-recommended", "stylelint-config-standard-scss"], 3 | "rules": { 4 | "function-parentheses-space-inside": null, 5 | "no-descending-specificity": null, 6 | "max-nesting-depth": 2, 7 | "selector-max-id": 1 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "foxundermoon.shell-format", 5 | "stylelint.vscode-stylelint", 6 | "esbenp.prettier-vscode", 7 | "bradlc.vscode-tailwindcss", 8 | "syler.sass-indented" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "command": "npm start", 5 | "name": "Run npm start", 6 | "request": "launch", 7 | "type": "node-terminal" 8 | }, 9 | { 10 | "command": "npm test", 11 | "name": "Test All Files", 12 | "request": "launch", 13 | "type": "node-terminal" 14 | }, 15 | { 16 | "command": "npm run coverage", 17 | "name": "Test All Files with Coverage", 18 | "request": "launch", 19 | "type": "node-terminal" 20 | }, 21 | { 22 | "command": "npm test --silent '${relativeFileDirname}\\${fileBasename}'", 23 | "name": "Test Current File", 24 | "request": "launch", 25 | "type": "node-terminal" 26 | }, 27 | { 28 | "command": "npm run test:coverage '${relativeFileDirname}\\${fileBasename}'", 29 | "name": "Test Current File with Coverage", 30 | "request": "launch", 31 | "type": "node-terminal" 32 | } 33 | ], 34 | "version": "0.2.0" 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll": true, 5 | "source.organizeImports": true 6 | }, 7 | "eslint.format.enable": true, 8 | "editor.defaultFormatter": "esbenp.prettier-vscode", 9 | "typescript.tsdk": "node_modules\\typescript\\lib", 10 | "prettier.configPath": ".prettierrc.cjs", 11 | "stylelint.configFile": ".stylelintrc.json", 12 | "stylelint.snippet": ["css", "less", "postcss", "scss"], 13 | "stylelint.validate": [ 14 | "css", 15 | "less", 16 | "postcss", 17 | "scss" 18 | ], 19 | "[javascript]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | "[scss]": { 23 | "editor.defaultFormatter": "vscode.css-language-features" 24 | }, 25 | "[typescriptreact]": { 26 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Siva 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 | # Vite React Typescript Template 2 | 3 | ![Build](https://github.com/R35007/vite-react-typescript/actions/workflows/build.yml/badge.svg) ![Lints](https://github.com/R35007/vite-react-typescript/actions/workflows/lints.yml/badge.svg) ![Tests](https://github.com/R35007/vite-react-typescript/actions/workflows/tests.yml/badge.svg) ![Cypress](https://github.com/R35007/vite-react-typescript/actions/workflows/cypress.yml/badge.svg) 4 | 5 | > A simple vite react typescript starter template with husky, conventional commit, eslint, stylelint, prettier, sass, tailwindcss, material ui, tanstack routing, redux and saga, vitest and cypress 6 | 7 | ## [Trying this Online!](https://codesandbox.io/p/github/R35007/vite-react-typescript/main?file=/src/main.tsx) 8 | 9 | ![image](https://github.com/R35007/vite-react-typescript/assets/23217228/09dfc7f4-bf2f-4b6b-9885-3476099164ff) 10 | 11 | ## Features 12 | 13 | This template setup will include following features. 14 | 15 | | ✅ | Feature | Branch Name | 16 | | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------- | 17 | | ✅ | [Generate Vite](https://vitejs.dev/) + [React](https://react.dev/) + [Typescript](https://www.typescriptlang.org/) | feature/1/starter | 18 | | ✅ | [Husky](https://typicode.github.io/husky/) | feature/2/husky | 19 | | ✅ | [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) + [Commitlint](https://commitlint.js.org/#/) + [Commitizen](https://commitizen-tools.github.io/commitizen/) | feature/3/commitlint | 20 | | ✅ | [ESLint](https://eslint.org/) | feature/4/eslint | 21 | | ✅ | [StyleLint](https://stylelint.io/) | feature/5/stylelint | 22 | | ✅ | [Prettier format](https://prettier.io/) | feature/6/prettier | 23 | | ✅ | [Lint Staged](https://github.com/okonet/lint-staged#readme) | feature/7/lint-staged | 24 | | ✅ | [Sass](https://sass-lang.com/) + [Tailwind Css](https://tailwindcss.com/) | feature/8/tailwindcss | 25 | | ✅ | [Material UI](https://mui.com/) | feature/9/material-ui | 26 | | ✅ | [Tanstack Router](https://tanstack.com/router/v1) | feature/10/tanstack-router | 27 | | ✅ | [Redux](https://redux.js.org/) + [Redux Toolkit](https://redux-toolkit.js.org/) | feature/11/react-redux | 28 | | ✅ | [Redux Saga](https://redux-saga.js.org/) | feature/12/react-saga | 29 | | ✅ | [Vitest](https://vitest.dev/) + [RTL](https://testing-library.com/docs/react-testing-library/intro/) | feature/13/vitest | 30 | | ✅ | [Cypress](https://www.cypress.io/) | feature/14/cypress | 31 | 32 | ## Installation 33 | 34 | ```bash 35 | # For full template 36 | npx degit R35007/vite-react-typescript#main myapp # main branch 37 | 38 | # For starter template 39 | npx degit R35007/vite-react-typescript#feature/1/starter myapp # feature/1/starter branch 40 | ``` 41 | 42 | ## NPM Scripts 43 | 44 | ### Vite scripts 45 | 46 | ```bash 47 | npm run start # start development server 48 | npm run dev # start development server 49 | npm run build # build production bundle to 'dist' directly 50 | npm run preview # start production server 51 | ``` 52 | 53 | ### Lint scripts 54 | 55 | ```bash 56 | npm run lint:scripts # check scripts 57 | npm run lint:scripts:fix # fix scripts 58 | npm run lint:styles # check styles 59 | npm run lint:styles:fix # fix styles 60 | npm run format # check code formatting 61 | npm run format:fix # fix code formatting 62 | npm run lint:ts # check types 63 | npm run lint # check scripts, check styles, check formats and check types 64 | npm run lint:fix # fix scripts, fix styles, fix formats and check types 65 | npm run lint:staged # does npm run lint only for staged files 66 | ``` 67 | 68 | ### Test scripts 69 | 70 | ```bash 71 | npm run test # run test 72 | npm run test:coverage # run test with code coverage 73 | npm run cy:open # open cypress ui 74 | npm run cy:run # run cypress test in headless mode 75 | npm run cy:run:e2e # run cypress end 2 end test in headless mode 76 | npm run cy:run:component # run cypress component test in headless mode 77 | ``` 78 | 79 | ### Report scripts 80 | 81 | ```bash 82 | npm run lint:scripts:report # generate eslint reports in reports/eslint.html 83 | npm run lint:report # generate eslint reports 84 | ``` 85 | 86 | ### Utility scripts 87 | 88 | ```bash 89 | npm run validate # check scripts, check styles, check formats, check types and builds the project 90 | npm run validate:fix # fix scripts, fix styles, fix formats, check types and builds the project 91 | npm run validate:staged # does npm run lint only for staged files and builds the project 92 | npm run prepare # create Husky hooks 93 | npm run clean # removes node_modules package-lock.json .husky dist reports 94 | npm run uninstall:husky # uninstall husky and remove .husky folder 95 | npm run uninstall:tailwindcss # uninstall tailwindcss and its related plugins 96 | npm run uninstall:cypress # uninstall cypress and its related plugins and test files 97 | npm run commit # cli prompt for conventional commit 98 | ``` 99 | 100 | # License 101 | 102 | MIT 103 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | helpUrl: ` 4 | 5 | * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * 6 | * * 7 | * Please ensure that you are following conventional commit format for your commit message. * 8 | * https://www.conventionalcommits.org * 9 | * * 10 | * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * 11 | `, 12 | rules: { 13 | 'subject-case': [2, 'never', ['upper-case', 'pascal-case', 'start-case']], 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | import { defineConfig } from 'cypress'; 4 | 5 | export default defineConfig({ 6 | component: { 7 | devServer: { 8 | bundler: 'vite', 9 | framework: 'react', 10 | }, 11 | }, 12 | e2e: { 13 | baseUrl: 'http://localhost:3000', 14 | setupNodeEvents(_on, _config) { 15 | // implement node event listeners here 16 | }, 17 | viewportHeight: 1049, 18 | viewportWidth: 1905, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /cypress/e2e/app.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Vite React Typescript Template Test Suite', () => { 2 | it('should navigate to about page', () => { 3 | cy.visit('/'); 4 | cy.findByRole('heading', { level: 5 }).should('contain.text', 'Home Page'); 5 | cy.get('button>a:contains("About")').click(); 6 | cy.findByRole('heading', { level: 5 }).should('contain.text', 'About Page'); 7 | }); 8 | 9 | it('should perform counter operations', () => { 10 | cy.visit('/'); 11 | 12 | cy.findByRole('button', { name: 'Decrement' }).click(); 13 | cy.get('.Counter [role="note"]').should('contain.text', 'count is -1'); 14 | 15 | cy.findByRole('button', { name: 'Increment' }).click(); 16 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 0'); 17 | 18 | cy.get('input.MuiInput-input').clear().type('5'); 19 | cy.findByRole('button', { name: 'Increment By Value' }).click(); 20 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 5'); 21 | 22 | cy.get('input.MuiInput-input').clear().type('-5'); 23 | cy.findByRole('button', { name: 'Increment By Value' }).click(); 24 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 0'); 25 | 26 | cy.findByRole('button', { name: 'Decrement Async' }).click(); 27 | cy.get('.Counter [role="note"]').should('contain.text', 'count is -1'); 28 | 29 | cy.findByRole('button', { name: 'Increment Async' }).click(); 30 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 0'); 31 | 32 | cy.get('input.MuiInput-input').clear().type('5'); 33 | cy.findByRole('button', { name: 'Increment Async By Value' }).click(); 34 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 5'); 35 | 36 | cy.get('input.MuiInput-input').clear().type('-5'); 37 | cy.findByRole('button', { name: 'Increment Async By Value' }).click(); 38 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 0'); 39 | }); 40 | 41 | it('should count value persist on routing', () => { 42 | cy.visit('/'); 43 | 44 | cy.contains('Decrement').click(); 45 | cy.get('.Counter [role="note"]').should('contain.text', 'count is -1'); 46 | 47 | cy.get('button>a:contains("About")').click(); 48 | cy.findByRole('heading', { level: 5 }).should('contain.text', 'About Page'); 49 | 50 | cy.get('.Counter [role="note"]').should('contain.text', 'count is -1'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /// 3 | // *********************************************** 4 | // This example commands.ts shows you how to 5 | // create various custom commands and overwrite 6 | // existing commands. 7 | // 8 | // For more comprehensive examples of custom 9 | // commands please read more here: 10 | // https://on.cypress.io/custom-commands 11 | // *********************************************** 12 | // 13 | // 14 | // -- This is a parent command -- 15 | // Cypress.Commands.add('login', (email, password) => { ... }) 16 | // 17 | // 18 | // -- This is a child command -- 19 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 20 | // 21 | // 22 | // -- This is a dual command -- 23 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 24 | // 25 | // 26 | // -- This will overwrite an existing command -- 27 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 28 | // 29 | // declare global { 30 | // namespace Cypress { 31 | // interface Chainable { 32 | // login(email: string, password: string): Chainable 33 | // drag(subject: string, options?: Partial): Chainable 34 | // dismiss(subject: string, options?: Partial): Chainable 35 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 36 | // } 37 | // } 38 | // } 39 | 40 | import '@testing-library/cypress/add-commands'; 41 | -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | // *********************************************************** 4 | // This example support/component.ts is processed and 5 | // loaded automatically before your test files. 6 | // 7 | // This is a great place to put global configuration and 8 | // behavior that modifies Cypress. 9 | // 10 | // You can change the location of this file or turn off 11 | // automatically serving support files with the 12 | // 'supportFile' configuration option. 13 | // 14 | // You can read more here: 15 | // https://on.cypress.io/configuration 16 | // *********************************************************** 17 | 18 | // Import commands.js using ES2015 syntax: 19 | import './commands'; 20 | 21 | // Alternatively you can use CommonJS syntax: 22 | // require('./commands') 23 | 24 | import { mount } from 'cypress/react18'; 25 | 26 | import 'tailwindcss/base.css'; 27 | import 'tailwindcss/utilities.css'; 28 | 29 | // Augment the Cypress namespace to include type definitions for 30 | // your custom command. 31 | // Alternatively, can be defined in cypress/support/component.d.ts 32 | // with a at the top of your spec. 33 | declare global { 34 | namespace Cypress { 35 | interface Chainable { 36 | mount: typeof mount; 37 | console(method: 'log' | 'error' | 'warn' | 'info'): Chainable; 38 | } 39 | } 40 | } 41 | 42 | Cypress.Commands.add('mount', mount); 43 | 44 | // Example use: 45 | // cy.mount() 46 | -------------------------------------------------------------------------------- /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.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-react-typescript", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite --port=3000", 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint:scripts": "eslint --report-unused-disable-directives --max-warnings 0 .", 11 | "lint:scripts:fix": "npm run lint:scripts -- --fix", 12 | "lint:scripts:report": "npm run lint:scripts -- --format html --output-file ./reports/eslint.html", 13 | "lint:styles": "stylelint ./**/*.{css,scss}", 14 | "lint:styles:fix": "npm run lint:styles -- --fix", 15 | "format": "prettier --ignore-unknown --find-config-path --check .", 16 | "format:fix": "prettier --ignore-unknown --find-config-path --write .", 17 | "lint:ts": "tsc --noEmit", 18 | "lint": "sh shells/lint", 19 | "lint:fix": "sh shells/lint-fix", 20 | "lint:report": "sh shells/lint-report", 21 | "lint:staged": "lint-staged", 22 | "cy:open": "cypress open", 23 | "cy:run": "cypress run", 24 | "cy:run:e2e": "cypress run --e2e", 25 | "cy:run:component": "cypress run --component", 26 | "test": "vitest run", 27 | "test:coverage": "vitest run --coverage", 28 | "test:ui": "vitest --ui", 29 | "preview": "vite preview", 30 | "validate": "sh ./shells/validate", 31 | "validate:fix": "sh ./shells/validate-fix", 32 | "validate:staged": "sh ./shells/validate-staged", 33 | "prepare": "npx rimraf .husky && husky install && sh ./shells/husky", 34 | "clean": "rimraf node_modules package-lock.json .husky dist reports coverage cypress/screenshots cypress/videos", 35 | "uninstall:husky": "npm uninstall husky --no-save && git config --unset core.hooksPath && npx rimraf .husky", 36 | "uninstall:tailwindcss": "node ./scripts/uninstall-tailwind.cjs", 37 | "uninstall:cypress": "node ./scripts/uninstall-cypress.cjs", 38 | "commit": "cz" 39 | }, 40 | "dependencies": { 41 | "@emotion/react": "^11.10.8", 42 | "@emotion/styled": "^11.10.8", 43 | "@fontsource/roboto": "^4.5.8", 44 | "@mui/icons-material": "^5.11.16", 45 | "@mui/material": "^5.12.3", 46 | "@reduxjs/toolkit": "^1.9.5", 47 | "@tanstack/router": "^0.0.1-beta.86", 48 | "react": "^18.2.0", 49 | "react-dom": "^18.2.0", 50 | "react-redux": "^8.1.1", 51 | "redux-saga": "^1.2.3" 52 | }, 53 | "devDependencies": { 54 | "@commitlint/cli": "^17.6.1", 55 | "@commitlint/config-conventional": "^17.6.1", 56 | "@tailwindcss/aspect-ratio": "^0.4.2", 57 | "@tailwindcss/forms": "^0.5.3", 58 | "@tailwindcss/typography": "^0.5.9", 59 | "@testing-library/cypress": "^9.0.0", 60 | "@testing-library/jest-dom": "^5.17.0", 61 | "@testing-library/react": "^14.0.0", 62 | "@testing-library/user-event": "^14.4.3", 63 | "@types/node": "^18.16.3", 64 | "@types/react": "^18.0.28", 65 | "@types/react-dom": "^18.0.11", 66 | "@typescript-eslint/eslint-plugin": "^5.57.1", 67 | "@typescript-eslint/parser": "^5.57.1", 68 | "@vitejs/plugin-react-swc": "^3.0.0", 69 | "@vitest/coverage-v8": "^0.33.0", 70 | "@vitest/ui": "^0.33.0", 71 | "autoprefixer": "^10.4.14", 72 | "concurrently": "^8.0.1", 73 | "cypress": "^12.17.2", 74 | "cz-conventional-changelog": "^3.3.0", 75 | "eslint": "^8.38.0", 76 | "eslint-config-airbnb": "^19.0.4", 77 | "eslint-config-airbnb-typescript": "^17.0.0", 78 | "eslint-config-prettier": "^8.8.0", 79 | "eslint-define-config": "^1.20.0", 80 | "eslint-import-resolver-typescript": "^3.5.5", 81 | "eslint-plugin-import": "^2.27.5", 82 | "eslint-plugin-jsx-a11y": "^6.7.1", 83 | "eslint-plugin-no-loops": "^0.3.0", 84 | "eslint-plugin-prettier": "^4.2.1", 85 | "eslint-plugin-promise": "^6.1.1", 86 | "eslint-plugin-react": "^7.32.2", 87 | "eslint-plugin-react-hooks": "^4.6.0", 88 | "eslint-plugin-react-refresh": "^0.3.4", 89 | "eslint-plugin-sort-keys-fix": "^1.1.2", 90 | "eslint-plugin-tailwindcss": "^3.11.0", 91 | "glob": "^10.3.3", 92 | "husky": "^8.0.0", 93 | "jsdom": "^22.1.0", 94 | "lint-staged": "^13.2.2", 95 | "postcss": "^8.4.23", 96 | "prettier": "^2.8.8", 97 | "prettier-plugin-tailwindcss": "^0.2.8", 98 | "redux-saga-test-plan": "^4.0.6", 99 | "rimraf": "^5.0.0", 100 | "sass": "^1.62.1", 101 | "stylelint": "^15.6.1", 102 | "stylelint-config-recommended": "^12.0.0", 103 | "stylelint-config-standard": "^33.0.0", 104 | "stylelint-config-standard-scss": "^9.0.0", 105 | "tailwindcss": "^3.3.2", 106 | "typescript": "^5.0.2", 107 | "vite": "^4.3.2", 108 | "vite-plugin-svgr": "^3.2.0", 109 | "vite-tsconfig-paths": "^4.2.0", 110 | "vitest": "^0.33.0" 111 | }, 112 | "config": { 113 | "commitizen": { 114 | "path": "cz-conventional-changelog" 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/uninstall-cypress.cjs: -------------------------------------------------------------------------------- 1 | const { glob } = require('glob'); 2 | const fsProm = require('node:fs/promises'); 3 | const path = require('node:path'); 4 | const uninstallPackages = require('./uninstall-packages.cjs'); 5 | 6 | const rootDir = path.join(__dirname, '..'); 7 | 8 | const deleteCypressFolder = async () => { 9 | const cypressFolder = path.resolve(rootDir, 'cypress'); 10 | try { 11 | await fsProm.rm(cypressFolder, { recursive: true }); 12 | console.log(`/cypress folder was removed.`); 13 | } catch (err) { 14 | if (e.message.includes('no such file or directory')) console.log(`/cypress folder has already been removed.`); 15 | } 16 | }; 17 | 18 | const deleteCypressTestFiles = async () => { 19 | const pattern = 'src/**/*.cy.{ts,tsx,js,jsx}'; 20 | const files = await glob(pattern); 21 | 22 | if (!files.length) { 23 | console.log('Cypress test files has already been removed.'); 24 | return; 25 | } 26 | 27 | const promises = files.map((file) => 28 | fsProm.unlink(file).then((_) => { 29 | console.log(`${file} was deleted`); 30 | }) 31 | ); 32 | 33 | await Promise.all(promises); 34 | }; 35 | 36 | const deleteCypressConfigFiles = async () => { 37 | const cypressConfigFile = rootDir + '/cypress.config.ts'; 38 | try { 39 | await fsProm.unlink(cypressConfigFile); 40 | console.log(`cypress.config.ts was removed`); 41 | } catch (err) { 42 | if (e.message.includes('no such file or directory')) console.log(`cypress.config.ts has already been removed.`); 43 | } 44 | }; 45 | 46 | const removeCypress = async () => { 47 | try { 48 | await deleteCypressFolder(); 49 | await deleteCypressTestFiles(); 50 | await deleteCypressConfigFiles(); 51 | await uninstallPackages('cypress'); 52 | 53 | console.log(); 54 | console.log('Completed remove Cypress.'); 55 | } catch (error) { 56 | console.error(error); 57 | } 58 | }; 59 | 60 | // run 61 | removeCypress(); 62 | -------------------------------------------------------------------------------- /scripts/uninstall-packages.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { chdir, exit } = require('node:process'); 3 | const { execSync } = require('node:child_process'); 4 | 5 | const rootDir = path.join(__dirname, '..'); 6 | 7 | module.exports = (packageName) => { 8 | // npm uninstall 9 | const packageJson = require(rootDir + '/package.json'); 10 | 11 | const dependencies = packageJson.dependencies || {}; 12 | const devDependencies = packageJson.devDependencies || {}; 13 | 14 | const packagesToUninstall = []; 15 | 16 | for (const dep in dependencies) { 17 | if (dep.includes(packageName)) { 18 | packagesToUninstall.push(dep); 19 | } 20 | } 21 | 22 | for (const dep in devDependencies) { 23 | if (dep.includes(packageName)) { 24 | packagesToUninstall.push(dep); 25 | } 26 | } 27 | 28 | if (!packagesToUninstall.length) { 29 | console.log(`${packageName} has already been removed.\n`); 30 | exit(); 31 | } 32 | 33 | const uninstallCommand = 'npm uninstall ' + packagesToUninstall.join(' '); 34 | 35 | chdir(rootDir); 36 | // execSync return null when command successful 37 | const res = execSync(uninstallCommand, { 38 | stdio: [0, 1, 2], 39 | }); 40 | 41 | if (res !== null && res.status !== 0) console.error('Command Failed: ' + uninstallCommand); 42 | 43 | console.log(packagesToUninstall.join('\n')); 44 | console.log('Above packages uninstall has been successful.'); 45 | }; 46 | -------------------------------------------------------------------------------- /scripts/uninstall-tailwind.cjs: -------------------------------------------------------------------------------- 1 | const fsProm = require('node:fs/promises'); 2 | const path = require('node:path'); 3 | const uninstallPackages = require('./uninstall-packages.cjs'); 4 | 5 | const rootDir = path.join(__dirname, '..'); 6 | 7 | const deleteTailwindCssConfigFiles = async () => { 8 | try { 9 | await fsProm.unlink(rootDir + '/postcss.config.cjs'); 10 | console.log('postcss.config.cjs was removed.'); 11 | await fsProm.unlink(rootDir + '/tailwind.config.cjs'); 12 | console.log('tailwind.config.cjs was removed.'); 13 | } catch (e) { 14 | if (e.message.includes('no such file or directory')) console.log('TailwindCss config has already been removed.'); 15 | } 16 | }; 17 | 18 | const removeTailwind = async () => { 19 | await deleteTailwindCssConfigFiles(); 20 | await uninstallPackages('tailwindcss'); 21 | await uninstallPackages('postcss'); 22 | 23 | console.log(); 24 | console.log('Completed remove TailwindCSS.'); 25 | }; 26 | 27 | // run 28 | removeTailwind(); 29 | -------------------------------------------------------------------------------- /shells/husky: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Pre Commit Hooks 4 | husky add .husky/pre-commit 'npm run validate:staged' 5 | 6 | # Commit Message Hooks 7 | husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"' 8 | -------------------------------------------------------------------------------- /shells/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | concurrently \ 4 | --kill-others-on-fail \ 5 | --names "Check Scripts,Check Styles,Check Formats,Check Types" \ 6 | --prefix-colors "bgRed.bold,bgBlue.bold,bgYellow.bold,bgCyan.bold" \ 7 | "npm:lint:scripts --silent" \ 8 | "npm:lint:styles --silent" \ 9 | "npm:format --silent" \ 10 | "npm:lint:ts --silent" 11 | -------------------------------------------------------------------------------- /shells/lint-fix: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | concurrently --names "Fix Scripts" --prefix-colors "bgRed.bold" "npm:lint:scripts:fix --silent" && 4 | concurrently --names "Fix Styles" --prefix-colors "bgBlue.bold" "npm:lint:styles:fix --silent" && 5 | concurrently --names "Fix Formats" --prefix-colors "bgYellow.bold" "npm:format:fix --silent" && 6 | concurrently --names "Check Types" --prefix-colors "bgCyan.bold" "npm:lint:ts --silent" 7 | -------------------------------------------------------------------------------- /shells/lint-report: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | concurrently \ 4 | --kill-others-on-fail \ 5 | --names "ESLint Report" \ 6 | --prefix-colors "bgRed.bold" \ 7 | "npm:lint:scripts:report --silent" \ 8 | "npm:test:coverage --silent" 9 | -------------------------------------------------------------------------------- /shells/validate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | concurrently \ 4 | --kill-others-on-fail \ 5 | --names "Lints,Build,Unit Test,Test Component" \ 6 | --prefix-colors "yellow.bold,bgGreen.bold,bgCyan.bold" \ 7 | "npm:lint --silent" \ 8 | "npm:build --silent" \ 9 | "npm:test --silent" \ 10 | "npm:cy:run:component --silent" 11 | -------------------------------------------------------------------------------- /shells/validate-fix: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | concurrently --names "Lints" --prefix-colors "yellow.bold" "npm:lint:fix --silent" && 4 | echo && 5 | concurrently \ 6 | --kill-others-on-fail \ 7 | --names "Build,Unit Test,Test Component" \ 8 | --prefix-colors "bgGreen.bold,bgMagenta.bold,bgCyan.bold" \ 9 | "npm:build --silent" \ 10 | "npm:test --silent" \ 11 | "npm:cy:run:component --silent" 12 | -------------------------------------------------------------------------------- /shells/validate-staged: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | concurrently \ 4 | --kill-others-on-fail \ 5 | --names "Lint Staged,Build" \ 6 | --prefix-colors "yellow.bold,bgGreen.bold" \ 7 | "npm:lint:staged --silent" \ 8 | "npm:build --silent" 9 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import render, { describe, expect, it, screen, userEvent } from '~libs/vitest'; 2 | 3 | // configs 4 | import appRouter from '~configs/router'; 5 | 6 | describe('App', () => { 7 | beforeEach(async () => { 8 | await render(undefined, { router: appRouter }); 9 | }); 10 | 11 | it('should render Home Page by root route', () => { 12 | expect(screen.getByRole('heading', { name: /home page/i })).toBeInTheDocument(); 13 | expect(screen.getByRole('heading', { name: /vite react typescript template/i })).toBeInTheDocument(); 14 | }); 15 | 16 | it('should render About Page on clicking about link', async () => { 17 | await userEvent.click(screen.getByRole('link', { name: /about/i })); 18 | 19 | expect(screen.getByRole('heading', { name: /about page/i })).toBeInTheDocument(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { Outlet, RootRoute } from '@tanstack/router'; 3 | 4 | // Layouts 5 | import Navbar from '~layouts/Navbar'; 6 | 7 | function App() { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | ); 14 | } 15 | 16 | // Create a root route 17 | export const rootRoute = new RootRoute({ component: App }); 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/configs/router.ts: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { Router } from '@tanstack/router'; 3 | 4 | // routes 5 | import { rootRoute } from 'src/App'; 6 | import { aboutRoute } from '~pages/About'; 7 | import { homeRoute, notFoundRoute } from '~pages/Home'; 8 | 9 | // Create the route tree using your routes 10 | const routeTree = rootRoute.addChildren([homeRoute, aboutRoute, notFoundRoute]); 11 | 12 | // Create the router using your route tree 13 | const router = new Router({ routeTree }); 14 | 15 | export default router; 16 | -------------------------------------------------------------------------------- /src/configs/store/hooks.ts: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | // types 5 | import type { Dispatch } from '@reduxjs/toolkit'; 6 | import type { TypedUseSelectorHook } from 'react-redux'; 7 | import type { StateType } from './index'; 8 | import type { RootActionType } from './rootActions'; 9 | 10 | export type DispatchType = Dispatch; 11 | export type SelectorType = TypedUseSelectorHook; 12 | 13 | export const useAppDispatch = () => useDispatch(); 14 | export const useAppSelector = useSelector as SelectorType; 15 | -------------------------------------------------------------------------------- /src/configs/store/index.ts: -------------------------------------------------------------------------------- 1 | // helpers 2 | import configureStore from '~helpers/configureStore'; 3 | 4 | // Root Reducer, Actions and Sagas 5 | import rootReducer from './rootReducer'; 6 | import rootSaga from './rootSaga'; 7 | 8 | const store = configureStore({ 9 | reducer: rootReducer, 10 | sagaActionWatcher: rootSaga, 11 | }); 12 | 13 | export type StateType = ReturnType; 14 | 15 | export default store; 16 | -------------------------------------------------------------------------------- /src/configs/store/rootActions.ts: -------------------------------------------------------------------------------- 1 | // type 2 | import type { SliceActions } from '~helpers/configureStore'; 3 | 4 | // feature redux 5 | import * as counter from '~features/Counter/redux'; 6 | 7 | const rootActions = { 8 | ...counter.actions, 9 | }; 10 | 11 | export type RootActionType = SliceActions; 12 | 13 | export default rootActions; 14 | -------------------------------------------------------------------------------- /src/configs/store/rootReducer.ts: -------------------------------------------------------------------------------- 1 | // feature redux 2 | import * as counter from '~features/Counter/redux'; 3 | 4 | const rootReducer = { 5 | counter: counter.reducer, 6 | }; 7 | 8 | export type RootReducerType = typeof rootReducer; 9 | 10 | export default rootReducer; 11 | -------------------------------------------------------------------------------- /src/configs/store/rootSaga.ts: -------------------------------------------------------------------------------- 1 | // feature redux 2 | import * as counter from '~features/Counter/redux'; 3 | 4 | // helpers 5 | import getRootSaga from '~helpers/getRootSaga'; 6 | 7 | const rootSagas = [counter.saga]; 8 | 9 | export default getRootSaga(rootSagas); 10 | -------------------------------------------------------------------------------- /src/configs/theme.tsx: -------------------------------------------------------------------------------- 1 | import { deepPurple } from '@mui/material/colors'; 2 | import { createTheme } from '@mui/material/styles'; 3 | 4 | export default createTheme({ 5 | palette: { 6 | mode: 'dark', 7 | primary: { main: deepPurple[500] }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/constants/routes.enum.ts: -------------------------------------------------------------------------------- 1 | enum Routes { 2 | HOME = '/', 3 | ABOUT = '/about', 4 | NOT_FOUND = '/*', 5 | } 6 | 7 | export default Routes; 8 | -------------------------------------------------------------------------------- /src/features/Counter/Counter.cy.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline, ThemeProvider } from '@mui/material'; 2 | import { Provider } from 'react-redux'; 3 | import store from '~configs/store'; 4 | import theme from '~configs/theme'; 5 | import Counter from './Counter'; 6 | 7 | describe('', () => { 8 | it('renders', () => { 9 | // see: https://on.cypress.io/mounting-react 10 | cy.mount( 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | 19 | cy.findByRole('button', { name: 'Decrement' }).click(); 20 | cy.get('.Counter [role="note"]').should('contain.text', 'count is -1'); 21 | 22 | cy.findByRole('button', { name: 'Increment' }).click(); 23 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 0'); 24 | 25 | cy.get('input.MuiInput-input').clear().type('5'); 26 | cy.findByRole('button', { name: 'Increment By Value' }).click(); 27 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 5'); 28 | 29 | cy.get('input.MuiInput-input').clear().type('-5'); 30 | cy.findByRole('button', { name: 'Increment By Value' }).click(); 31 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 0'); 32 | 33 | cy.findByRole('button', { name: 'Decrement Async' }).click(); 34 | cy.get('.Counter [role="note"]').should('contain.text', 'count is -1'); 35 | 36 | cy.findByRole('button', { name: 'Increment Async' }).click(); 37 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 0'); 38 | 39 | cy.get('input.MuiInput-input').clear().type('5'); 40 | cy.findByRole('button', { name: 'Increment Async By Value' }).click(); 41 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 5'); 42 | 43 | cy.get('input.MuiInput-input').clear().type('-5'); 44 | cy.findByRole('button', { name: 'Increment Async By Value' }).click(); 45 | cy.get('.Counter [role="note"]').should('contain.text', 'count is 0'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/features/Counter/Counter.test.tsx: -------------------------------------------------------------------------------- 1 | import render, { describe, expect, it, screen, userEvent, waitFor } from '~libs/vitest'; 2 | 3 | // components 4 | import Counter from '~features/Counter'; 5 | 6 | // configs 7 | import rootReducer from '~configs/store/rootReducer'; 8 | import rootSaga from '~configs/store/rootSaga'; 9 | 10 | // helpers 11 | import configureStore from '~helpers/configureStore'; 12 | 13 | describe('Counter Feature Test Suite', () => { 14 | beforeEach(async () => { 15 | await render(, { 16 | store: configureStore({ 17 | reducer: rootReducer, 18 | sagaActionWatcher: rootSaga, 19 | }), 20 | }); 21 | }); 22 | 23 | it('should increment by 1', async () => { 24 | const $incrementButton = screen.getByRole('button', { name: 'Increment' }); 25 | await userEvent.click($incrementButton); 26 | expect(screen.getByRole('note').textContent).toBe('count is 1'); 27 | }); 28 | 29 | it('should decrement by 1', async () => { 30 | const $decrementButton = screen.getByRole('button', { name: 'Decrement' }); 31 | await userEvent.click($decrementButton); 32 | expect(screen.getByRole('note').textContent).toBe('count is -1'); 33 | }); 34 | 35 | it('should increment positive by value', async () => { 36 | const $textbox = screen.getByLabelText('Count Increment Value'); 37 | await userEvent.type($textbox, '5'); 38 | 39 | const $incrementByValueButton = screen.getByRole('button', { name: 'Increment By Value' }); 40 | await userEvent.click($incrementByValueButton); 41 | expect(screen.getByRole('note').textContent).toBe('count is 5'); 42 | }); 43 | 44 | it('should increment negative by value', async () => { 45 | const $textbox = screen.getByLabelText('Count Increment Value'); 46 | await userEvent.type($textbox, '-5'); 47 | 48 | const $incrementByValueButton = screen.getByRole('button', { name: 'Increment By Value' }); 49 | await userEvent.click($incrementByValueButton); 50 | expect(screen.getByRole('note').textContent).toBe('count is -5'); 51 | }); 52 | 53 | it('should increment async by 1', async () => { 54 | const $incrementAsyncButton = screen.getByRole('button', { name: 'Increment Async' }); 55 | await userEvent.click($incrementAsyncButton); 56 | 57 | await waitFor(() => expect(screen.getByRole('note').textContent).toBe('count is 1'), { timeout: 1000 }); 58 | }); 59 | 60 | it('should decrement async by 1', async () => { 61 | const $decrementAsyncButton = screen.getByRole('button', { name: 'Decrement Async' }); 62 | await userEvent.click($decrementAsyncButton); 63 | 64 | await waitFor(() => expect(screen.getByRole('note').textContent).toBe('count is -1'), { timeout: 1000 }); 65 | }); 66 | 67 | it('should increment async positive by value', async () => { 68 | const $textbox = screen.getByLabelText('Count Increment Value'); 69 | await userEvent.type($textbox, '5'); 70 | 71 | const $decrementAsyncButton = screen.getByRole('button', { name: 'Increment Async By Value' }); 72 | await userEvent.click($decrementAsyncButton); 73 | 74 | await waitFor(() => expect(screen.getByRole('note').textContent).toBe('count is 5'), { timeout: 1000 }); 75 | }); 76 | 77 | it('should increment async negative by value', async () => { 78 | const $textbox = screen.getByLabelText('Count Increment Value'); 79 | await userEvent.type($textbox, '-5'); 80 | 81 | const $decrementAsyncButton = screen.getByRole('button', { name: 'Increment Async By Value' }); 82 | await userEvent.click($decrementAsyncButton); 83 | 84 | await waitFor(() => expect(screen.getByRole('note').textContent).toBe('count is -5'), { timeout: 1000 }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/features/Counter/Counter.tsx: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { useRef } from 'react'; 3 | 4 | // Material ui components 5 | import Box from '@mui/material/Box'; 6 | import Button from '@mui/material/Button'; 7 | import CircularProgress from '@mui/material/CircularProgress'; 8 | import TextField from '@mui/material/TextField'; 9 | import Typography from '@mui/material/Typography'; 10 | 11 | // hooks 12 | import { useAppDispatch, useAppSelector } from '~configs/store/hooks'; 13 | 14 | // redux 15 | import { actions, selectors } from './redux'; 16 | 17 | function Counter() { 18 | const inputRef = useRef(null); 19 | const dispatch = useAppDispatch(); 20 | 21 | const count = useAppSelector(selectors.getCount); 22 | const isLoading = useAppSelector(selectors.getLoading); 23 | 24 | return ( 25 | 26 | {isLoading && ( 27 | 28 | 29 | 30 | )} 31 | 32 | 39 | 40 | 41 | 44 | 47 | 50 | 53 | 56 | 59 | 60 | 61 | count is {count} 62 | 63 | 64 | ); 65 | } 66 | 67 | export default Counter; 68 | -------------------------------------------------------------------------------- /src/features/Counter/index.ts: -------------------------------------------------------------------------------- 1 | import Counter from './Counter'; 2 | 3 | export * from './redux'; 4 | export default Counter; 5 | -------------------------------------------------------------------------------- /src/features/Counter/redux/index.ts: -------------------------------------------------------------------------------- 1 | import saga from './saga'; 2 | import * as selectors from './selectors'; 3 | import reducer, { actions, initialState } from './slice'; 4 | 5 | export { actions, initialState, reducer, saga, selectors }; 6 | -------------------------------------------------------------------------------- /src/features/Counter/redux/saga.test.ts: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { expectSaga } from 'redux-saga-test-plan'; 3 | import { delay } from 'redux-saga/effects'; 4 | import { describe, it } from '~libs/vitest'; 5 | 6 | // redux 7 | import { actions, initialState, reducer } from '~features/Counter/redux'; 8 | import { watchDecrementAsync, watchIncrementAsync, watchIncrementByAmountAsync } from './saga'; 9 | 10 | describe('Counter Saga Test Suite', () => { 11 | it('should run watchIncrementAsync saga', () => { 12 | return expectSaga(watchIncrementAsync) 13 | .withReducer(reducer) 14 | .provide([[delay(1000), null]]) 15 | .put(actions.increment()) 16 | .put(actions.onIncrementSuccess()) 17 | .hasFinalState({ 18 | ...initialState, 19 | count: 1, 20 | }) 21 | .run(); 22 | }); 23 | 24 | it('should run watchDecrementAsync saga', () => { 25 | return expectSaga(watchDecrementAsync) 26 | .withReducer(reducer) 27 | .provide([[delay(1000), null]]) 28 | .put(actions.decrement()) 29 | .put(actions.onIncrementSuccess()) 30 | .hasFinalState({ 31 | ...initialState, 32 | count: -1, 33 | }) 34 | .run(); 35 | }); 36 | 37 | it('should run watchIncrementByAmountAsync saga', () => { 38 | const action = actions.incrementByAmountAsync('5'); 39 | return expectSaga(watchIncrementByAmountAsync, action) 40 | .withReducer(reducer) 41 | .provide([[delay(1000), null]]) 42 | .put(actions.incrementByAmount(parseInt(action.payload as string, 10))) 43 | .put(actions.onIncrementSuccess()) 44 | .hasFinalState({ 45 | ...initialState, 46 | count: 5, 47 | }) 48 | .run(); 49 | }); 50 | 51 | it('should fail watchIncrementByAmountAsync saga', () => { 52 | const action = actions.incrementByAmountAsync('xxx'); 53 | return expectSaga(watchIncrementByAmountAsync, action) 54 | .withReducer(reducer) 55 | .provide([[delay(1000), null]]) 56 | .put(actions.onIncrementFailure()) 57 | .hasFinalState({ 58 | ...initialState, 59 | count: 0, 60 | }) 61 | .run(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/features/Counter/redux/saga.ts: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { delay, put, takeEvery } from 'redux-saga/effects'; 3 | 4 | // types 5 | import type { PayloadAction } from '@reduxjs/toolkit'; 6 | import type { Effect, ForkEffect } from 'redux-saga/effects'; 7 | 8 | // actions 9 | import { actions } from './slice'; 10 | 11 | export function* watchIncrementAsync(): Generator { 12 | yield delay(1000); 13 | yield put(actions.increment()); 14 | yield put(actions.onIncrementSuccess()); 15 | } 16 | 17 | export function* watchDecrementAsync(): Generator { 18 | yield delay(1000); 19 | yield put(actions.decrement()); 20 | yield put(actions.onIncrementSuccess()); 21 | } 22 | 23 | export function* watchIncrementByAmountAsync(action: PayloadAction): Generator { 24 | try { 25 | if (!action.payload || Number.isNaN(parseInt(action.payload, 10))) { 26 | throw new Error('Invalid parameter'); 27 | } 28 | yield delay(1000); 29 | yield put(actions.incrementByAmount(parseInt(action.payload, 10))); 30 | yield put(actions.onIncrementSuccess()); 31 | } catch (error) { 32 | yield put(actions.onIncrementFailure()); 33 | } 34 | } 35 | 36 | export function* watchCounterSagas(): Generator { 37 | yield takeEvery(actions.incrementAsync, watchIncrementAsync); 38 | yield takeEvery(actions.decrementAsync, watchDecrementAsync); 39 | yield takeEvery(actions.incrementByAmountAsync, watchIncrementByAmountAsync); 40 | } 41 | 42 | const counterSagas = watchCounterSagas; 43 | 44 | export default counterSagas; 45 | -------------------------------------------------------------------------------- /src/features/Counter/redux/selectors.ts: -------------------------------------------------------------------------------- 1 | // store 2 | import type { StateType } from '~configs/store'; 3 | 4 | export const getCount = (state: StateType) => state.counter.count; 5 | export const getLoading = (state: StateType) => state.counter.loading; 6 | -------------------------------------------------------------------------------- /src/features/Counter/redux/slice.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi, waitFor } from '~libs/vitest'; 2 | 3 | // helpers 4 | import configureStore from '~helpers/configureStore'; 5 | 6 | // redux 7 | import { actions, initialState, reducer, saga } from '~features/Counter/redux'; 8 | import getRootSaga from '~helpers/getRootSaga'; 9 | 10 | describe('Counter Reducer Test Suite', () => { 11 | const store = configureStore({ 12 | reducer: { counter: reducer }, 13 | sagaActionWatcher: getRootSaga([saga]), 14 | }); 15 | 16 | it('should dispatch increment by 1', () => { 17 | store.dispatch(actions.increment()); 18 | 19 | const state = store.getState(); 20 | 21 | expect(state).toEqual({ counter: { ...initialState, count: 1 } }); 22 | }); 23 | 24 | it('should dispatch increment by positive value', () => { 25 | store.dispatch(actions.incrementByAmount(5)); 26 | 27 | const state = store.getState(); 28 | 29 | expect(state).toEqual({ counter: { ...initialState, count: 6 } }); 30 | }); 31 | 32 | it('should dispatch decrement by 1', () => { 33 | store.dispatch(actions.decrement()); 34 | const state = store.getState(); 35 | 36 | expect(state).toEqual({ counter: { ...initialState, count: 5 } }); 37 | }); 38 | 39 | it('should dispatch increment by negative value', () => { 40 | store.dispatch(actions.incrementByAmount(-4)); 41 | const state = store.getState(); 42 | 43 | expect(state).toEqual({ counter: { ...initialState, count: 1 } }); 44 | }); 45 | 46 | it('should dispatch increment async by 1', async () => { 47 | const dispatchSpy = vi.spyOn(actions, 'increment'); 48 | 49 | store.dispatch(actions.incrementAsync()); 50 | await waitFor(() => expect(dispatchSpy).toBeCalled(), { timeout: 2000 }); 51 | 52 | const state = store.getState(); 53 | await waitFor(() => expect(state).toEqual({ counter: { ...initialState, count: 2, loading: false } })); 54 | }); 55 | 56 | it('should dispatch decrement async by 1', async () => { 57 | const dispatchSpy = vi.spyOn(actions, 'decrement'); 58 | 59 | store.dispatch(actions.decrementAsync()); 60 | await waitFor(() => expect(dispatchSpy).toBeCalled(), { timeout: 2000 }); 61 | 62 | const state = store.getState(); 63 | await waitFor(() => expect(state).toEqual({ counter: { ...initialState, count: 1, loading: false } })); 64 | }); 65 | 66 | it('should dispatch increment async by positive value', async () => { 67 | const dispatchSpy = vi.spyOn(actions, 'incrementByAmount'); 68 | 69 | store.dispatch(actions.incrementByAmountAsync('4')); 70 | await waitFor(() => expect(dispatchSpy).toBeCalled(), { timeout: 2000 }); 71 | 72 | const state = store.getState(); 73 | await waitFor(() => expect(state).toEqual({ counter: { ...initialState, count: 5, loading: false } })); 74 | }); 75 | 76 | it('should dispatch increment async by negative value', async () => { 77 | const dispatchSpy = vi.spyOn(actions, 'incrementByAmount'); 78 | 79 | store.dispatch(actions.incrementByAmountAsync('-4')); 80 | await waitFor(() => expect(dispatchSpy).toBeCalled(), { timeout: 2000 }); 81 | 82 | const state = store.getState(); 83 | await waitFor(() => expect(state).toEqual({ counter: { ...initialState, count: 1, loading: false } })); 84 | }); 85 | 86 | it('should set count to 0 when a saga fails', async () => { 87 | const dispatchSpy = vi.spyOn(actions, 'onIncrementFailure'); 88 | 89 | store.dispatch(actions.incrementByAmountAsync('xxx')); 90 | await waitFor(() => expect(dispatchSpy).toBeCalled(), { timeout: 2000 }); 91 | 92 | const state = store.getState(); 93 | await waitFor(() => expect(state).toEqual({ counter: { ...initialState, count: 0, loading: false } })); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/features/Counter/redux/slice.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | // built-ins 4 | import { createSlice } from '@reduxjs/toolkit'; 5 | 6 | // type 7 | import type { PayloadAction } from '@reduxjs/toolkit'; 8 | 9 | export type InitialState = { 10 | loading: boolean; 11 | count: number; 12 | }; 13 | 14 | export const initialState: InitialState = { 15 | count: 0, 16 | loading: false, 17 | }; 18 | 19 | const reducers = { 20 | decrement: (state: InitialState) => { 21 | state.count -= 1; 22 | }, 23 | increment: (state: InitialState) => { 24 | state.count += 1; 25 | }, 26 | incrementByAmount: (state: InitialState, action: PayloadAction) => { 27 | state.count += action.payload; 28 | }, 29 | onIncrementFailure: (state: InitialState) => { 30 | state.count = 0; 31 | state.loading = false; 32 | }, 33 | onIncrementSuccess: (state: InitialState) => { 34 | state.loading = false; 35 | }, 36 | }; 37 | 38 | const sagaActions = { 39 | decrementAsync: (state: InitialState) => { 40 | state.loading = true; 41 | }, 42 | 43 | incrementAsync: (state: InitialState) => { 44 | state.loading = true; 45 | }, 46 | 47 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 48 | incrementByAmountAsync: (state: InitialState, _action: PayloadAction) => { 49 | state.loading = true; 50 | }, 51 | }; 52 | 53 | export const counterSlice = createSlice({ 54 | initialState, 55 | name: 'counter', 56 | reducers: { 57 | ...reducers, 58 | ...sagaActions, 59 | }, 60 | }); 61 | 62 | export default counterSlice.reducer; 63 | export const { actions } = counterSlice; 64 | -------------------------------------------------------------------------------- /src/features/WelcomeCard/Logo.style.ts: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { keyframes } from '@emotion/react'; 3 | import styled from '@emotion/styled'; 4 | 5 | const logoSpin = keyframes` 6 | from { transform: rotate(0deg); } 7 | to { transform: rotate(360deg); } 8 | `; 9 | 10 | const Logo = styled.a` 11 | &:nth-of-type(2) { 12 | animation: ${logoSpin} infinite 20s linear; 13 | } 14 | 15 | .logo { 16 | height: 8em; 17 | padding: 1.5em; 18 | will-change: filter; 19 | transition: filter 300ms; 20 | 21 | &:hover { 22 | filter: drop-shadow(0 0 2em #646cffaa); 23 | } 24 | 25 | &.react { 26 | &:hover { 27 | filter: drop-shadow(0 0 2em #61dafbaa); 28 | } 29 | } 30 | } 31 | `; 32 | 33 | export default Logo; 34 | -------------------------------------------------------------------------------- /src/features/WelcomeCard/WelcomeCard.test.tsx: -------------------------------------------------------------------------------- 1 | import render, { describe, expect, it, screen } from '~libs/vitest'; 2 | 3 | // components 4 | import WelcomeCard from '~features/WelcomeCard'; 5 | 6 | // helpers 7 | 8 | describe('Welcome Card Feature Test Suite', () => { 9 | it('should render welcome card with title', async () => { 10 | const title = 'WelcomeCard'; 11 | 12 | await render(); 13 | 14 | expect(screen.getByRole('heading', { name: title })).toBeInTheDocument(); 15 | }); 16 | 17 | it('should render Counter component', async () => { 18 | const { container } = await render(); 19 | 20 | expect(container.querySelector('.Counter')).toBeInTheDocument(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/features/WelcomeCard/WelcomeCard.tsx: -------------------------------------------------------------------------------- 1 | // Material ui components 2 | import { Typography } from '@mui/material'; 3 | import Card from '@mui/material/Card'; 4 | import CardActionArea from '@mui/material/CardActions'; 5 | import CardContent from '@mui/material/CardContent'; 6 | 7 | // svgs 8 | import reactLogo from '~assets/react.svg'; 9 | import viteLogo from '~assets/vite.svg'; 10 | 11 | // features 12 | import Counter from '~features/Counter'; 13 | 14 | // styled components 15 | import Logo from './Logo.style'; 16 | 17 | function WelcomeCard({ title = 'Welcome' }: { title: string }) { 18 | return ( 19 | 20 | 21 | Vite React Typescript Template 22 | 23 | 24 | 25 | Vite logo 26 | 27 | 28 | React logo 29 | 30 | 31 | {title} 32 | 33 | 34 | 35 | Edit src/App.tsx and save to test HMR 36 | 37 | 38 | 39 | Click on the Vite and React logos to learn more 40 | 41 | 42 | ); 43 | } 44 | 45 | export default WelcomeCard; 46 | -------------------------------------------------------------------------------- /src/features/WelcomeCard/index.ts: -------------------------------------------------------------------------------- 1 | import WelcomeCard from './WelcomeCard'; 2 | 3 | export default WelcomeCard; 4 | -------------------------------------------------------------------------------- /src/helpers/configureStore.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // built-ins 3 | import { configureStore as createStore } from '@reduxjs/toolkit'; 4 | import createSagaMiddleware from 'redux-saga'; 5 | 6 | // types 7 | import type { 8 | Action, 9 | AnyAction, 10 | ConfigureStoreOptions, 11 | Dispatch, 12 | EnhancedStore, 13 | Middleware, 14 | StoreEnhancer, 15 | ThunkMiddleware, 16 | } from '@reduxjs/toolkit'; 17 | import type { Saga } from 'redux-saga'; 18 | 19 | export interface ConfigureStoreProps< 20 | S, 21 | A extends Action = AnyAction, 22 | M extends ReadonlyArray> = [ThunkMiddleware], 23 | E extends ReadonlyArray = [StoreEnhancer] 24 | > extends Omit, 'middleware'> { 25 | middleware?: Array>>; 26 | sagaActionWatcher?: Saga; 27 | } 28 | 29 | const configureStore = < 30 | S = any, 31 | A extends Action = AnyAction, 32 | M extends ReadonlyArray> = [ThunkMiddleware], 33 | E extends ReadonlyArray = [StoreEnhancer] 34 | >({ 35 | middleware = [], 36 | sagaActionWatcher, 37 | ...configureStoreProps 38 | }: ConfigureStoreProps) => { 39 | const rootMiddlewares = [...middleware]; 40 | 41 | // create the saga middleware 42 | const sagaMiddleware = createSagaMiddleware(); 43 | 44 | if (sagaActionWatcher) { 45 | rootMiddlewares.push(sagaMiddleware); 46 | } 47 | 48 | const store: EnhancedStore>[], E> = createStore({ 49 | ...configureStoreProps, 50 | middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(rootMiddlewares), 51 | }); 52 | 53 | // run saga middleware 54 | if (sagaActionWatcher) { 55 | sagaMiddleware.run(sagaActionWatcher); 56 | } 57 | 58 | return store; 59 | }; 60 | 61 | export type SliceActions = { 62 | [K in keyof T]: T[K] extends (...args: any[]) => infer A ? A : never; 63 | }[keyof T]; 64 | 65 | export default configureStore; 66 | -------------------------------------------------------------------------------- /src/helpers/createTestRouter.tsx: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { Outlet, RootRoute, Route, Router, createMemoryHistory } from '@tanstack/router'; 3 | 4 | export default function createTestRouter(component: JSX.Element): Router { 5 | // Create a root route 6 | const rootRoute = new RootRoute({ component: Outlet }); 7 | 8 | // Create a index route 9 | const indexRoute = new Route({ component: () => component, getParentRoute: () => rootRoute, path: '/' }); 10 | 11 | // create route tree 12 | const routeTree = rootRoute.addChildren([indexRoute]); 13 | 14 | // Create the router using your route tree 15 | const router = new Router({ history: createMemoryHistory(), routeTree }); 16 | 17 | return router; 18 | } 19 | -------------------------------------------------------------------------------- /src/helpers/getRootSaga.ts: -------------------------------------------------------------------------------- 1 | // effects 2 | import { all, spawn } from 'redux-saga/effects'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export default (sagas: Array<(...args: any[]) => any>) => 6 | function* rootSaga() { 7 | yield all(sagas.map((saga) => spawn(saga))); 8 | }; 9 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | // @import 'tailwindcss/components'; -> instead we use mui components 2 | @import 'tailwindcss/base'; 3 | @import 'tailwindcss/utilities'; 4 | 5 | // Fonts 6 | @import '@fontsource/roboto/300.css'; 7 | @import '@fontsource/roboto/400.css'; 8 | @import '@fontsource/roboto/500.css'; 9 | @import '@fontsource/roboto/700.css'; 10 | -------------------------------------------------------------------------------- /src/layouts/Navbar.tsx: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { Link } from '@tanstack/router'; 3 | import React, { useState } from 'react'; 4 | 5 | // icons 6 | import MenuIcon from '@mui/icons-material/Menu'; 7 | 8 | // material ui components 9 | import AppBar from '@mui/material/AppBar'; 10 | import Box from '@mui/material/Box'; 11 | import Button from '@mui/material/Button'; 12 | import Container from '@mui/material/Container'; 13 | import Icon from '@mui/material/Icon'; 14 | import IconButton from '@mui/material/IconButton'; 15 | import Menu from '@mui/material/Menu'; 16 | import MenuItem from '@mui/material/MenuItem'; 17 | import Toolbar from '@mui/material/Toolbar'; 18 | import Typography from '@mui/material/Typography'; 19 | 20 | // enums 21 | import Routes from '~constants/routes.enum'; 22 | 23 | // svgs 24 | import { ReactComponent as ViteLogo } from '~assets/vite.svg'; 25 | 26 | const links: Array<[Routes, string]> = [ 27 | [Routes.HOME, 'Home'], 28 | [Routes.ABOUT, 'About'], 29 | ]; 30 | 31 | function Navbar() { 32 | const [anchorElNav, setAnchorElNav] = useState(null); 33 | 34 | const handleOpenNavMenu = (event: React.MouseEvent) => { 35 | setAnchorElNav(event.currentTarget); 36 | }; 37 | const handleCloseNavMenu = () => { 38 | setAnchorElNav(null); 39 | }; 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | 59 | React Typescript App 60 | 61 | 62 | 63 | {links.map(([to, label]) => ( 64 | 67 | ))} 68 | 69 | 70 | 71 | 89 | {links.map(([to, label]) => ( 90 | 91 | {label} 92 | 93 | ))} 94 | 95 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | ); 110 | } 111 | export default Navbar; 112 | -------------------------------------------------------------------------------- /src/libs/vitest.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | // built-ins 3 | import { RouterProvider } from '@tanstack/router'; 4 | import { act, render as rtlRender } from '@testing-library/react'; 5 | import user from '@testing-library/user-event'; 6 | import { Provider } from 'react-redux'; 7 | 8 | // material ui 9 | import CssBaseline from '@mui/material/CssBaseline'; 10 | import { ThemeProvider } from '@mui/material/styles'; 11 | 12 | // types 13 | import type { Theme } from '@mui/material'; 14 | import type { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore'; 15 | import type { Router } from '@tanstack/router'; 16 | import type { RenderOptions as RTLRenderOptions } from '@testing-library/react'; 17 | 18 | // helpers 19 | import configureStore from '~helpers/configureStore'; 20 | import createTestRouter from '~helpers/createTestRouter'; 21 | 22 | // configs 23 | import appRouter from '~configs/router'; 24 | import rootReducer from '~configs/store/rootReducer'; 25 | import rootSaga from '~configs/store/rootSaga'; 26 | import appTheme from '~configs/theme'; 27 | 28 | export interface RenderOptions extends Omit { 29 | theme?: Theme; 30 | store?: ToolkitStore; 31 | router?: Router; 32 | } 33 | 34 | const render = async ( 35 | component?: JSX.Element, 36 | { 37 | theme = appTheme, 38 | store = configureStore({ reducer: rootReducer, sagaActionWatcher: rootSaga }), 39 | router = component ? createTestRouter(component) : appRouter, 40 | ...options 41 | }: RenderOptions = {} as RenderOptions 42 | ) => { 43 | return act(() => 44 | rtlRender( 45 | 46 | 47 | 48 | 49 | 50 | , 51 | options 52 | ) 53 | ); 54 | }; 55 | 56 | export const userEvent = user; 57 | export * from '@testing-library/react'; 58 | export * from 'vitest'; 59 | 60 | export default render; 61 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { RouterProvider } from '@tanstack/router'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom/client'; 5 | import { Provider } from 'react-redux'; 6 | 7 | // material ui 8 | import CssBaseline from '@mui/material/CssBaseline'; 9 | import { ThemeProvider } from '@mui/material/styles'; 10 | 11 | // styles 12 | import './index.scss'; 13 | 14 | // configs 15 | import router from '~configs/router'; 16 | import store from '~configs/store'; 17 | import theme from '~configs/theme'; 18 | 19 | // Register your router for maximum type safety 20 | declare module '@tanstack/router' { 21 | interface Register { 22 | router: typeof router; 23 | } 24 | } 25 | 26 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | -------------------------------------------------------------------------------- /src/pages/About.tsx: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { Route } from '@tanstack/router'; 3 | 4 | // material ui components 5 | import Container from '@mui/material/Container'; 6 | 7 | // enums 8 | import Routes from '~constants/routes.enum'; 9 | 10 | // features 11 | import WelcomeCard from '~features/WelcomeCard'; 12 | 13 | // routes 14 | import { rootRoute } from 'src/App'; 15 | 16 | function About() { 17 | return ( 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | // About route 25 | export const aboutRoute = new Route({ component: About, getParentRoute: () => rootRoute, path: Routes.ABOUT }); 26 | 27 | export default About; 28 | -------------------------------------------------------------------------------- /src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | // built-ins 2 | import { Route } from '@tanstack/router'; 3 | 4 | // material ui components 5 | import Container from '@mui/material/Container'; 6 | 7 | // enums 8 | import Routes from '~constants/routes.enum'; 9 | 10 | // features 11 | import WelcomeCard from '~features/WelcomeCard'; 12 | 13 | // routes 14 | import { rootRoute } from 'src/App'; 15 | 16 | function Home() { 17 | return ( 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | // Index route 25 | export const homeRoute = new Route({ component: Home, getParentRoute: () => rootRoute, path: Routes.HOME }); 26 | 27 | // Not Found route 28 | export const notFoundRoute = new Route({ component: Home, getParentRoute: () => rootRoute, path: Routes.NOT_FOUND }); 29 | 30 | export default Home; 31 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import '@testing-library/jest-dom'; 3 | import matchers from '@testing-library/jest-dom/matchers'; 4 | import { expect } from 'vitest'; 5 | 6 | expect.extend(matchers); 7 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | /* eslint-disable @typescript-eslint/no-var-requires */ 5 | /* eslint-disable import/no-extraneous-dependencies */ 6 | const forms = require('@tailwindcss/forms'); 7 | const typography = require('@tailwindcss/typography'); 8 | const aspectRatio = require('@tailwindcss/aspect-ratio'); 9 | 10 | /** @type {import('tailwindcss').Config} */ 11 | module.exports = { 12 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 13 | important: true, 14 | plugins: [forms, typography, aspectRatio], 15 | theme: {}, 16 | }; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "baseUrl": ".", 10 | "paths": { 11 | "~components/*": ["./src/components/*"], 12 | "~assets/*": ["./src/assets/*"], 13 | "~configs/*": ["./src/configs/*"], 14 | "~layouts/*": ["./src/layouts/*"], 15 | "~pages/*": ["./src/pages/*"], 16 | "~features/*": ["./src/features/*"], 17 | "~constants/*": ["./src/constants/*"], 18 | "~helpers/*": ["./src/helpers/*"], 19 | "~libs/*": ["./src/libs/*"] 20 | }, 21 | "moduleResolution": "bundler", 22 | "allowImportingTsExtensions": true, 23 | "resolveJsonModule": true, 24 | "isolatedModules": true, 25 | "noEmit": true, 26 | "jsx": "react-jsx", 27 | 28 | /* Linting */ 29 | "types": ["cypress", "@testing-library/cypress", "vite-plugin-svgr/client", "vitest"], 30 | "strict": true, 31 | "noUnusedLocals": true, 32 | "noUnusedParameters": true, 33 | "noFallthroughCasesInSwitch": true 34 | }, 35 | "include": [ 36 | ".eslintrc.cjs", 37 | "commitlint.config.cjs", 38 | "cypress.config.ts", 39 | "vite.config.ts", 40 | ".prettierrc.cjs", 41 | "postcss.config.cjs", 42 | "tailwind.config.cjs", 43 | "src", 44 | "cypress" 45 | ], 46 | "references": [{ "path": "./tsconfig.node.json" }] 47 | } 48 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "types": ["vitest"] 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import react from '@vitejs/plugin-react-swc'; 3 | import svgr from 'vite-plugin-svgr'; 4 | import tsconfigPaths from 'vite-tsconfig-paths'; 5 | 6 | /// 7 | import { defineConfig } from 'vite'; 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | plugins: [tsconfigPaths(), react(), svgr()], 12 | test: { 13 | environment: 'jsdom', 14 | globals: true, 15 | setupFiles: ['./src/setupTests.ts'], 16 | }, 17 | }); 18 | --------------------------------------------------------------------------------