├── .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 |    
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 | 
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 |
26 |
27 |
28 |
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 |
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 |
--------------------------------------------------------------------------------