├── .changeset ├── README.md ├── big-ties-accept.md ├── blue-wombats-push.md ├── chilled-dogs-sing.md ├── clever-pots-collect.md ├── cold-pots-shave.md ├── cold-squids-chew.md ├── config.json ├── dull-mayflies-double.md ├── eighty-turkeys-cough.md ├── empty-bottles-fold.md ├── khaki-mails-listen.md ├── khaki-numbers-drive.md ├── lemon-berries-beg.md ├── lucky-cheetahs-count.md ├── many-crabs-fail.md ├── mighty-pets-thank.md ├── orange-wombats-give.md ├── pink-peas-drum.md ├── polite-moons-lie.md ├── pretty-experts-develop.md ├── rotten-frogs-mate.md ├── silent-ears-greet.md ├── silver-crabs-rhyme.md ├── sixty-monkeys-eat.md ├── stale-peaches-sniff.md ├── sweet-seals-laugh.md ├── tall-pumpkins-tie.md ├── twelve-emus-hear.md ├── witty-pans-applaud.md ├── yellow-deers-raise.md ├── young-ducks-march.md ├── young-gorillas-help.md └── young-suits-melt.md ├── .credentials.json.dist ├── .editorconfig ├── .env.babelsheet.dist ├── .env.dist ├── .env.e2e.dist ├── .env.test ├── .eslintignore ├── .eslintrc ├── .github ├── dependabot.yml ├── images │ └── react-starter.svg └── workflows │ ├── default.yml │ ├── deploy-staging.yml │ ├── e2e.yml │ └── release.yml ├── .gitignore ├── .gitlab-ci.yml ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .stylelintrc ├── .validate-branch-namerc.cjs ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── PROJECT_README.md ├── README.md ├── babelsheet.json.dist ├── bitbucket-pipelines.yml ├── commitlint.config.cjs ├── docker └── e2e-runner │ └── Dockerfile ├── docs ├── 01-technology-stack.md ├── 02-application-structure.md ├── 03-react-query-abstraction.md ├── 04-using-plop-commands.md └── 05-e2e-tests.md ├── e2e ├── .env.dist ├── .eslintrc ├── .gitignore ├── README.md ├── actions │ ├── homePage.ts │ └── navigation.ts ├── package-lock.json ├── package.json ├── playwright.config.ts └── tests │ └── home.test.ts ├── index.html ├── package-lock.json ├── package.json ├── plop ├── generators │ ├── apiActionsCollection.mjs │ ├── apiMutation.mjs │ ├── apiQuery.mjs │ ├── customHook.mjs │ ├── reactAppComponent.mjs │ ├── reactContainerComponent.mjs │ ├── reactContext.mjs │ └── reactUiComponent.mjs ├── templates │ ├── apiActions │ │ ├── apiActions.mutations.hbs │ │ ├── apiActions.queries.hbs │ │ └── apiActions.types.hbs │ ├── apiMutation │ │ ├── apiMutation.hbs │ │ └── apiMutation.types.hbs │ ├── apiQuery │ │ ├── apiQuery.hbs │ │ └── apiQuery.types.hbs │ ├── component │ │ ├── Component.hbs │ │ ├── Component.index.hbs │ │ ├── Component.test.hbs │ │ ├── Component.types.hbs │ │ ├── Container.hbs │ │ └── ContainerComponent.types.hbs │ ├── context │ │ ├── Context.hbs │ │ ├── Context.test.hbs │ │ ├── Context.types.hbs │ │ ├── ContextController.hbs │ │ ├── ContextController.types.hbs │ │ ├── useContext.hbs │ │ └── useContext.test.hbs │ └── hook │ │ ├── hook.hbs │ │ ├── hook.index.hbs │ │ └── hook.test.hbs └── utils.mjs ├── plopfile.mjs ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json ├── mockServiceWorker.js └── robots.txt ├── scripts └── fetch-translations.ts ├── src ├── api │ ├── actions │ │ ├── auth │ │ │ ├── auth.mutations.ts │ │ │ ├── auth.queries.ts │ │ │ └── auth.types.ts │ │ └── index.ts │ ├── axios │ │ └── index.ts │ ├── mocks │ │ ├── handlers.ts │ │ ├── http.ts │ │ └── mock-worker.ts │ ├── types │ │ └── types.ts │ └── utils │ │ └── queryFactoryOptions.ts ├── assets │ ├── images │ │ ├── logo.svg │ │ ├── vite-logo.svg │ │ └── vitest-logo.svg │ └── styles │ │ ├── loader.css │ │ └── main.css ├── context │ ├── apiClient │ │ ├── apiClientContext │ │ │ ├── ApiClientContext.test.tsx │ │ │ ├── ApiClientContext.ts │ │ │ └── ApiClientContext.types.ts │ │ └── apiClientContextController │ │ │ ├── ApiClientContextController.test.tsx │ │ │ ├── ApiClientContextController.tsx │ │ │ ├── ApiClientContextController.types.ts │ │ │ ├── apiError │ │ │ ├── apiError.test.ts │ │ │ ├── apiError.ts │ │ │ └── apiError.types.ts │ │ │ └── interceptors │ │ │ ├── requestInterceptors.ts │ │ │ └── responseInterceptors.ts │ ├── auth │ │ ├── authActionCreators │ │ │ ├── authActionCreators.ts │ │ │ └── authActionCreators.types.ts │ │ ├── authContext │ │ │ ├── AuthContext.test.tsx │ │ │ ├── AuthContext.ts │ │ │ └── AuthContext.types.ts │ │ ├── authContextController │ │ │ ├── AuthContextController.tsx │ │ │ └── AuthContextController.types.ts │ │ ├── authReducer │ │ │ ├── authReducer.ts │ │ │ └── authReducer.types.ts │ │ └── authStorage │ │ │ ├── AuthStorage.ts │ │ │ └── AuthStorage.types.ts │ └── locale │ │ ├── AppLocale.enum.ts │ │ ├── defaultLocale.ts │ │ ├── localeContext │ │ ├── LocaleContext.test.tsx │ │ ├── LocaleContext.ts │ │ └── LocaleContext.types.ts │ │ └── localeContextController │ │ ├── LocaleContextController.test.tsx │ │ ├── LocaleContextController.tsx │ │ └── LocaleContextController.types.ts ├── env.d.ts ├── hooks │ ├── index.ts │ ├── useApiClient │ │ └── useApiClient.ts │ ├── useAuth │ │ ├── useAuth.test.tsx │ │ └── useAuth.ts │ ├── useHandleQueryErrors │ │ ├── useHandleQueryErrors.test.ts │ │ └── useHandleQueryErrors.ts │ ├── useInfiniteQuery │ │ ├── useInfiniteQuery.ts │ │ └── useInfiniteQuery.types.ts │ ├── useLocale │ │ ├── useLocale.test.tsx │ │ ├── useLocale.ts │ │ └── useLocale.types.ts │ ├── useMutation │ │ ├── useMutation.test.tsx │ │ ├── useMutation.ts │ │ └── useMutation.types.ts │ ├── useQuery │ │ ├── useQuery.test.tsx │ │ ├── useQuery.ts │ │ └── useQuery.types.ts │ ├── useUser │ │ └── useUser.ts │ └── useUsers │ │ └── useUsers.ts ├── i18n │ ├── data │ │ ├── en.json │ │ └── pl.json │ ├── messages.test.ts │ └── messages.ts ├── index.tsx ├── integrations │ └── logger.ts ├── providers │ ├── AppProviders.tsx │ └── AppProviders.types.ts ├── react-app-env.d.ts ├── routeTree.gen.ts ├── routes │ ├── -components │ │ ├── Home.test.tsx │ │ └── Home.tsx │ ├── -layout │ │ ├── Layout.css │ │ └── Layout.tsx │ ├── __root.tsx │ ├── about │ │ ├── -components │ │ │ ├── About.test.tsx │ │ │ └── About.tsx │ │ └── index.tsx │ ├── help │ │ ├── -components │ │ │ ├── Help.test.tsx │ │ │ └── Help.tsx │ │ └── index.lazy.tsx │ ├── index.tsx │ └── users │ │ ├── $id │ │ ├── -components │ │ │ ├── User.test.tsx │ │ │ └── User.tsx │ │ └── index.tsx │ │ ├── -components │ │ └── UsersList.tsx │ │ └── index.tsx ├── setupMSW.ts ├── setupTests.ts ├── tests │ ├── index.tsx │ └── types.ts ├── types │ ├── simplify.ts │ └── split.ts ├── ui │ ├── codeBlock │ │ ├── CodeBlock.css │ │ ├── CodeBlock.test.tsx │ │ ├── CodeBlock.tsx │ │ └── CodeBlock.types.ts │ ├── errorBoundary │ │ ├── ErrorBoundary.test.tsx │ │ ├── ErrorBoundary.tsx │ │ └── ErrorBoundary.types.ts │ ├── loader │ │ └── Loader.tsx │ ├── locationInfo │ │ ├── LocationInfo.test.tsx │ │ └── LocationInfo.tsx │ └── translation │ │ ├── Translation.tsx │ │ └── Translation.types.ts └── utils │ ├── apiErrorStatuses.ts │ └── startsWith.ts ├── tsconfig.json └── vite.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/big-ties-accept.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | bump typescript to 5.4.3 6 | -------------------------------------------------------------------------------- /.changeset/blue-wombats-push.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | feat: [ZN-388] New starter documentation 6 | -------------------------------------------------------------------------------- /.changeset/chilled-dogs-sing.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": minor 3 | --- 4 | 5 | update node version to v20 & update dependencies 6 | -------------------------------------------------------------------------------- /.changeset/clever-pots-collect.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": minor 3 | --- 4 | 5 | replace react router with tanstack router 6 | -------------------------------------------------------------------------------- /.changeset/cold-pots-shave.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': minor 3 | --- 4 | 5 | refactor: [ZN-411] Improve API action plop commands 6 | -------------------------------------------------------------------------------- /.changeset/cold-squids-chew.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": patch 3 | --- 4 | 5 | Remove wdyr from application -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/dull-mayflies-double.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": patch 3 | --- 4 | 5 | exclude whyDidYouRender from production bundle 6 | -------------------------------------------------------------------------------- /.changeset/eighty-turkeys-cough.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | feat: restore msw in development in new version 6 | -------------------------------------------------------------------------------- /.changeset/empty-bottles-fold.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': minor 3 | --- 4 | 5 | add ErrorBoundary component 6 | -------------------------------------------------------------------------------- /.changeset/khaki-mails-listen.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | build: remove unnecessary packages from starter project 6 | -------------------------------------------------------------------------------- /.changeset/khaki-numbers-drive.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | bump axios to 1.6.8 6 | -------------------------------------------------------------------------------- /.changeset/lemon-berries-beg.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | feat: [ZN-522] Update project Node version to the latest LTS 6 | -------------------------------------------------------------------------------- /.changeset/lucky-cheetahs-count.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | feat: [ZN-504] Update dependencies to latest versions 6 | -------------------------------------------------------------------------------- /.changeset/many-crabs-fail.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | feat: refactor plopfile 6 | -------------------------------------------------------------------------------- /.changeset/mighty-pets-thank.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": patch 3 | --- 4 | 5 | Remove full-icu package since it is a default since Node 13 6 | -------------------------------------------------------------------------------- /.changeset/orange-wombats-give.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": patch 3 | --- 4 | 5 | fix: regenerate lock file with a bigger `semver` package version to satisfy a `npm ci` command 6 | -------------------------------------------------------------------------------- /.changeset/pink-peas-drum.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": patch 3 | --- 4 | 5 | Change type to module to disable warning about using cjs vite 6 | -------------------------------------------------------------------------------- /.changeset/polite-moons-lie.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | feat: Add new utils for handling translations in application 6 | -------------------------------------------------------------------------------- /.changeset/pretty-experts-develop.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | feat: [ZN-527] Remove unnecessary auto-changelog package 6 | -------------------------------------------------------------------------------- /.changeset/rotten-frogs-mate.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | fix: Improve useInfiniteQuery hook typing 6 | -------------------------------------------------------------------------------- /.changeset/silent-ears-greet.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | feat: Add plop commands for React contexts 6 | -------------------------------------------------------------------------------- /.changeset/silver-crabs-rhyme.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": patch 3 | --- 4 | 5 | chore: bump `actions/upload-artifact` and `actions/download-artifact` to v4 6 | -------------------------------------------------------------------------------- /.changeset/sixty-monkeys-eat.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | fix: add ts-node config to tsconfig.json and use it during translations generation 6 | -------------------------------------------------------------------------------- /.changeset/stale-peaches-sniff.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": patch 3 | --- 4 | 5 | fix sentry type errors & import integrations from correct package 6 | -------------------------------------------------------------------------------- /.changeset/sweet-seals-laugh.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": minor 3 | --- 4 | 5 | Change /help page to be a lazy route as an example 6 | -------------------------------------------------------------------------------- /.changeset/tall-pumpkins-tie.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | feat: Create hook in React Context plop command 6 | -------------------------------------------------------------------------------- /.changeset/twelve-emus-hear.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": patch 3 | --- 4 | 5 | Enhance README with instructions on setting up the .env.local file 6 | -------------------------------------------------------------------------------- /.changeset/witty-pans-applaud.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": minor 3 | --- 4 | 5 | Split sentry packages to a dedicated sentry chunk 6 | -------------------------------------------------------------------------------- /.changeset/yellow-deers-raise.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | feat: Add container component plop command, add few other improvements to plopfile 6 | -------------------------------------------------------------------------------- /.changeset/young-ducks-march.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": minor 3 | --- 4 | 5 | add api error standardization & handling global api errors 6 | -------------------------------------------------------------------------------- /.changeset/young-gorillas-help.md: -------------------------------------------------------------------------------- 1 | --- 2 | "react-starter-boilerplate": minor 3 | --- 4 | 5 | Add build:analyze command 6 | -------------------------------------------------------------------------------- /.changeset/young-suits-melt.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'react-starter-boilerplate': patch 3 | --- 4 | 5 | fixup: fix Vite config file 6 | -------------------------------------------------------------------------------- /.credentials.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "example_project_id", 4 | "private_key_id": "example private_key_id", 5 | "private_key": "example private_key", 6 | "client_email": "example client_email", 7 | "client_id": "example client_id", 8 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 9 | "token_uri": "https://oauth2.googleapis.com/token", 10 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "example client_cert_url" 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | indent_size = 2 5 | indent_style = space 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | -------------------------------------------------------------------------------- /.env.babelsheet.dist: -------------------------------------------------------------------------------- 1 | BABELSHEET_CLIENT_ID= 2 | BABELSHEET_CLIENT_SECRET= 3 | BABELSHEET_SPREADSHEET_ID= 4 | BABELSHEET_SPREADSHEET_NAME=translations 5 | BABELSHEET_REFRESH_TOKEN= 6 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | VITE_DEFAULT_LOCALE=en 2 | VITE_API_URL=mock 3 | VITE_SENTRY_DSN= 4 | -------------------------------------------------------------------------------- /.env.e2e.dist: -------------------------------------------------------------------------------- 1 | CYPRESS_HOST=http://localhost:1337 2 | CYPRESS_USER_LOGIN=login 3 | CYPRESS_USER_PASSWORD=password -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | VITE_DEFAULT_LOCALE=en 2 | VITE_API_URL=mock 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | scripts -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:react-hooks/recommended", 4 | "plugin:react/recommended", 5 | "plugin:prettier/recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:jest-dom/recommended", 8 | "plugin:jsx-a11y/recommended" 9 | ], 10 | "plugins": ["prettier", "import", "vitest", "jsx-a11y"], 11 | "rules": { 12 | "prettier/prettier": "error", 13 | "no-console": "warn", 14 | 15 | "react/jsx-props-no-spreading": "error", 16 | "react-hooks/exhaustive-deps": "warn", 17 | "react/display-name": "off", 18 | "react/react-in-jsx-scope": "off", 19 | 20 | "import/no-default-export": "error", 21 | "import/order": [ 22 | "error", 23 | { 24 | "groups": [ 25 | ["external", "builtin"], 26 | ["parent", "internal"], 27 | ["index", "sibling"] 28 | ], 29 | "newlines-between": "always" 30 | } 31 | ], 32 | "@typescript-eslint/no-explicit-any": "error", 33 | "@typescript-eslint/explicit-function-return-type": "off", 34 | "@typescript-eslint/no-empty-function": "off", 35 | "@typescript-eslint/explicit-module-boundary-types": "off" 36 | }, 37 | "settings": { 38 | "import/resolver": { 39 | "node": { 40 | "paths": ["src"], 41 | "moduleDirectory": ["node_modules", "src/"], 42 | "extensions": [".ts", ".tsx"] 43 | } 44 | }, 45 | "react": { 46 | "version": "detect" 47 | } 48 | }, 49 | "overrides": [ 50 | { 51 | "files": ["src/**/*.test.tsx"], 52 | "extends": ["plugin:testing-library/react"], 53 | "rules": { 54 | "testing-library/prefer-user-event": "error" 55 | } 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Basic dependabot.yml file with 2 | # minimum configuration for two package managers 3 | 4 | version: 2 5 | updates: 6 | # Enable version updates for npm 7 | - package-ecosystem: "npm" 8 | # Look for `package.json` and `lock` files in the `root` directory 9 | directory: "/" 10 | # Check the npm registry for updates every day (weekdays) 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: Linters and tests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | run_tests: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - name: Setup Node.js 20 10 | uses: actions/setup-node@v4 11 | with: 12 | node-version: 20 13 | - name: Copy envs 14 | run: cp .env.dist .env 15 | - name: Install root dependencies 16 | uses: bahmutov/npm-install@v1 17 | - name: Lint 18 | run: npm run lint 19 | - name: Test & coverage 20 | run: npm run coverage 21 | env: 22 | CI: true 23 | -------------------------------------------------------------------------------- /.github/workflows/deploy-staging.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to staging 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | environment: staging 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Configure AWS credentials 12 | uses: aws-actions/configure-aws-credentials@v1 13 | with: 14 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 15 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 16 | aws-region: ${{ secrets.AWS_REGION }} 17 | - name: Setup Node.js 20 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | - name: Install dependencies 22 | run: npm ci 23 | - name: Build 24 | run: npm run build 25 | - name: Deploy 26 | run: aws s3 sync ./build s3://${{ secrets.S3_BUCKET }} --delete 27 | - name: Invalidate Cloudfront 28 | run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION }} --paths "/*" 29 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E 2 | on: pull_request 3 | 4 | # START PLAYWRIGHT SPECIFIC CONFIG 5 | jobs: 6 | prepare_app: 7 | runs-on: ubuntu-latest 8 | container: 9 | image: mcr.microsoft.com/playwright:v1.43.0-focal 10 | steps: 11 | - uses: actions/checkout@master 12 | - name: Copy envs 13 | run: cp .env.dist .env 14 | - name: Install root dependencies 15 | run: npm ci 16 | - name: Build ci 17 | run: npm run build:ci 18 | - name: Upload build 19 | uses: actions/upload-artifact@v4 20 | with: 21 | name: build 22 | path: build 23 | 24 | e2e_chrome: 25 | needs: prepare_app 26 | runs-on: ubuntu-latest 27 | container: 28 | image: mcr.microsoft.com/playwright:v1.43.0-focal 29 | defaults: 30 | run: 31 | working-directory: e2e 32 | steps: 33 | - uses: actions/checkout@master 34 | - name: Download build 35 | uses: actions/download-artifact@v4 36 | with: 37 | name: build 38 | path: build 39 | - name: Copy e2e envs 40 | run: cp .env.dist .env 41 | - name: Install e2e dependencies 42 | run: npm install 43 | - name: Run build and tests 44 | run: npm run test:chrome 45 | - name: Upload test results 46 | uses: actions/upload-artifact@v4 47 | if: always() 48 | with: 49 | name: e2e-chrome-artifacts 50 | path: | 51 | e2e/test-report/ 52 | e2e/test-results/ 53 | retention-days: 30 54 | 55 | e2e_firefox: 56 | needs: prepare_app 57 | runs-on: ubuntu-latest 58 | container: 59 | image: mcr.microsoft.com/playwright:v1.43.0-focal 60 | options: --user 1001 61 | defaults: 62 | run: 63 | working-directory: e2e 64 | steps: 65 | - uses: actions/checkout@master 66 | - name: Download build 67 | uses: actions/download-artifact@v4 68 | with: 69 | name: build 70 | path: build 71 | - name: Copy e2e envs 72 | run: cp .env.dist .env 73 | - name: Install e2e dependencies 74 | run: npm install 75 | - name: Run build and tests 76 | run: npm run test:firefox 77 | - name: Upload test results 78 | uses: actions/upload-artifact@v4 79 | if: always() 80 | with: 81 | name: e2e-firefox-artifacts 82 | path: | 83 | e2e/test-report/ 84 | e2e/test-results/ 85 | retention-days: 30 86 | 87 | e2e_safari: 88 | needs: prepare_app 89 | runs-on: ubuntu-latest 90 | container: 91 | image: mcr.microsoft.com/playwright:v1.43.0-focal 92 | defaults: 93 | run: 94 | working-directory: e2e 95 | steps: 96 | - uses: actions/checkout@master 97 | - name: Download build 98 | uses: actions/download-artifact@v4 99 | with: 100 | name: build 101 | path: build 102 | - name: Copy e2e envs 103 | run: cp .env.dist .env 104 | - name: Install e2e dependencies 105 | run: npm install 106 | - name: Run build and tests 107 | run: npm run test:safari 108 | - name: Upload test results 109 | uses: actions/upload-artifact@v4 110 | if: always() 111 | with: 112 | name: e2e-safari-artifacts 113 | path: | 114 | e2e/test-report/ 115 | e2e/test-results/ 116 | retention-days: 30 117 | # END PLAYWRIGHT SPECIFIC CONFIG 118 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node.js 20 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | 23 | - name: Install Dependencies 24 | run: npm ci 25 | 26 | - name: Create Release Pull Request 27 | uses: changesets/action@v1 28 | with: 29 | commit: 'ci: bump version' 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.babelsheet 17 | .env.local 18 | 19 | # editor files 20 | .idea/ 21 | .vscode/ 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | e2e/cypress/screenshots 27 | e2e/cypress/videos 28 | e2e/node_modules 29 | e2e/scripts/**/*.js 30 | e2e/testsResults 31 | 32 | .credentials.json 33 | stats.html -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # START PLAYWRIGHT SPECIFIC CONFIG 2 | default: 3 | image: mcr.microsoft.com/playwright:v1.43.0-focal 4 | 5 | stages: 6 | - build 7 | - test 8 | - deploy 9 | 10 | variables: 11 | npm_config_cache: "$CI_PROJECT_DIR/.npm" 12 | 13 | # cache using branch name 14 | # https://gitlab.com/help/ci/caching/index.md 15 | cache: 16 | key: ${CI_COMMIT_REF_SLUG} 17 | paths: 18 | - .npm 19 | - node_modules 20 | - e2e/node_modules 21 | 22 | spa_test: 23 | stage: test 24 | script: 25 | - cp .env.dist .env 26 | - npm install 27 | - npm run lint 28 | - npm run coverage 29 | 30 | e2e_build: 31 | stage: build 32 | script: 33 | - cp .env.dist .env 34 | - npm ci 35 | - npm run build:ci 36 | - cd e2e && npm install && cd .. 37 | artifacts: 38 | paths: 39 | - build/** 40 | - e2e/node_modules/** 41 | only: 42 | - merge_requests 43 | 44 | e2e_chrome: 45 | stage: test 46 | before_script: 47 | - cp ./e2e/.env.dist ./e2e/.env 48 | - cd e2e 49 | script: 50 | - npm run test:chrome 51 | artifacts: 52 | when: always 53 | paths: 54 | - e2e/test-report/ 55 | - e2e/test-results/ 56 | only: 57 | - merge_requests 58 | 59 | e2e_firefox: 60 | stage: test 61 | before_script: 62 | - cp ./e2e/.env.dist ./e2e/.env 63 | - cd e2e 64 | script: 65 | - npm run test:firefox 66 | artifacts: 67 | when: always 68 | paths: 69 | - e2e/test-report/ 70 | - e2e/test-results/ 71 | only: 72 | - merge_requests 73 | 74 | e2e_safari: 75 | stage: test 76 | before_script: 77 | - cp ./e2e/.env.dist ./e2e/.env 78 | - cd e2e 79 | script: 80 | - npm run test:safari 81 | artifacts: 82 | when: always 83 | paths: 84 | - e2e/test-report/ 85 | - e2e/test-results/ 86 | only: 87 | - merge_requests 88 | # END PLAYWRIGHT SPECIFIC CONFIG 89 | 90 | 91 | deploy_to_staging: 92 | stage: deploy 93 | environment: staging 94 | image: 95 | name: tshio/awscli-docker-compose-pipelines:0.0.7 96 | entrypoint: [''] 97 | script: 98 | - npm ci 99 | - npm run build 100 | - aws s3 sync ./build s3://"$S3_BUCKET" --delete 101 | - aws cloudfront create-invalidation --distribution-id "$CLOUDFRONT_DISTRIBUTION" --paths "/*" 102 | rules: 103 | - when: manual 104 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | 2 | npx --no-install commitlint --edit $1 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | 2 | npx validate-branch-name 3 | npx lint-staged 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | engine-strict=true 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.hbs 2 | *.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "proseWrap": "always", 6 | "editorconfig": true 7 | } 8 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-recommended" 3 | } 4 | -------------------------------------------------------------------------------- /.validate-branch-namerc.cjs: -------------------------------------------------------------------------------- 1 | const commitizenTypes = ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test']; 2 | const pattern = `^((${commitizenTypes.join('|')})(\/[a-zA-Z0-9_.-]+){1,2}|changeset-release/master)$`; 3 | 4 | module.exports = { 5 | pattern, 6 | errorMsg: `There is something wrong with your branch name. Branch names in this project must adhere to this contract: ${pattern}. Your commit will be rejected. You should rename your branch to a valid name and try again.`, 7 | }; 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-starter-boilerplate 2 | 3 | ## 0.2.0 4 | 5 | ### Minor Changes 6 | 7 | - a064e5e: add e2e playwright 8 | - a857de7: bump node version from 14 to 16 bump `tshio/awscli-docker-compose-pipelines` img version from `0.0.3` to 9 | `0.0.5` 10 | - 3e72dd6: setup eslint a11y plugin 11 | 12 | ### Patch Changes 13 | 14 | - dc8d506: Update React component plop command 15 | 16 | ## 0.1.1 17 | 18 | ### Patch Changes 19 | 20 | - b4197a9: add versioning and changelogs 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | 🎉 Thank you for considering contributing to the React Starter Boilerplate! 🎉 4 | 5 | Please read through the guideline below. 6 | 7 | ## Opening an Issue 8 | Something is not working as it should? 9 | 10 | Do you think you've found a bug in the starter, but you're not sure if you can fix it yourself? 11 | 12 | #### [Open a new issue!](https://github.com/TheSoftwareHouse/react-starter-boilerplate/issues/new) 🙂 13 | 14 | When you are opening a new issue in this repository, please remember about few things: 15 | - Describe as much as you can about the issue you are experiencing, 16 | - All kinds of screenshots and videos showing the issue, are also very welcome. 17 | 18 | After that, TSH team will take a look at your issue and try to figure it out. 19 | 20 | ## Making Pull Requests 21 | 22 | PR's to this repository are also very welcome. 23 | 24 | If you want to resolve one of the issues reported or add new functionality to the starter project, you need first to fork this repository. 25 | 26 | You need to open new branch where you will implement new functionality or add new improvement. 27 | Please note that we are validating branch names on git hooks. Branch names in this project must adhere following contract: 28 | 29 | ``` 30 | ^((build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\/[a-zA-Z0-9_.-]+){1,2}|changeset-release/master)$ 31 | ``` 32 | 33 | In addition to branch names, we also validate commit names. We use [conventional commits](https://www.conventionalcommits.org/) methodology for this purpose 34 | 35 | When your work will be done, you can make PR from your forked repository to the `master` branch of our repository. 36 | 37 | It will be also good if you can create an issue in this repository with proper description about the issue you want to fix or the improvement you want to add. 38 | 39 | Also, please select options to squash all the commits you made and remove source branch after accepting and merging your changes. 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 The Software House 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. -------------------------------------------------------------------------------- /PROJECT_README.md: -------------------------------------------------------------------------------- 1 | # [PROJECT_NAME] 2 | 3 | [PROJECT_DESCRIPTION] 4 | 5 | This project was bootstrapped with [Vite](https://github.com/vitejs/vite) and modified by TSH team. 6 | 7 | ## Quick Start 8 | 9 | To set up this project you need to follow this steps: 10 | 11 | 1. Install npm dependencies of the project: 12 | ```shell 13 | npm install 14 | ``` 15 | 16 | 2. Before your run your application locally, you need to set up environment variables: 17 | ```shell 18 | cp .env.dist .env 19 | ``` 20 | 21 | 3. Start your application locally: 22 | ```shell 23 | npm start 24 | ``` 25 | 26 | ## Scripts 27 | 28 | - `start` - Launches the app in development mode on [http://localhost:3000](http://localhost:3000) 29 | - `build` - Compiles and bundles the app for deployment* 30 | - `build:ci` - Build command optimized for CI/CD pipelines 31 | - `typecheck` - Validate the code using TypeScript compiler 32 | - `preview` - Boot up a local static web server that serves application build. It's an easy way to check if the production build looks OK on your local machine 33 | - `test` - Run unit tests with vitest 34 | - `coverage` - Run unit tests with code coverage calculation 35 | - `lint` - Validate the code using ESLint and Prettier 36 | - `lint:fix` - Validate and fix the code using ESLint and Prettier 37 | - `plop` - Run CLI with commands for code generation 38 | - `translations` - Run [Babelsheet](https://github.com/TheSoftwareHouse/babelsheet2) tool for fetch the latest translations 39 | - `serve:cypress` - Run Cypress E2E tests panel 40 | - `version` - Build CHANGELOG file base on git commits history 41 | - `e2e:open` - Run E2E tests panel 42 | - `e2e:ci:firefox`: Run E2E tests on Firefox browser in CI pipelines 43 | - `e2e:ci:chrome`: Run E2E tests on Chrome browser in CI pipelines 44 | 45 | ## Table of Contents 46 | 47 | 1. [Technology stack](/docs/01-technology-stack.md) 48 | 2. [Application structure](/docs/02-application-structure.md) 49 | 3. [React Query abstraction](/docs/03-react-query-abstraction.md) 50 | 4. [Using plop commands](/docs/04-using-plop-commands.md) 51 | 5. [E2E tests](/docs/05-e2e-tests.md) 52 | 53 | ## License 54 | 55 | Copyright © 2021-present The Software House. This source code is licensed under the MIT license found in the 56 | [LICENSE](LICENSE.md) file. 57 | 58 | --- 59 | 60 | Made with ♥ by The Software House (website, blog) 61 | 62 | 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # React Starter Boilerplate 6 | 7 | A highly scalable and focused on performance and best practices boilerplate code for TypeScript based React SPA applications. 8 | 9 | This project was bootstrapped with [Vite](https://github.com/vitejs/vite) and modified by TSH team. 10 | 11 | Start your new React application in seconds! 12 | 13 | ![GitHub stars](https://img.shields.io/github/stars/TheSoftwareHouse/react-starter-boilerplate?style=social) 14 | ![GitHub watchers](https://img.shields.io/github/watchers/TheSoftwareHouse/react-starter-boilerplate?style=social) 15 | ![GitHub followers](https://img.shields.io/github/followers/TheSoftwareHouse?style=social) 16 | 17 | ![Discord](https://img.shields.io/discord/955763210420649995) 18 | ![Version](https://img.shields.io/github/package-json/v/TheSoftwareHouse/react-starter-boilerplate) 19 | ![GitHub License](https://img.shields.io/github/license/TheSoftwareHouse/react-starter-boilerplate) 20 | 21 | ## Features 22 | 23 | ### Quick scaffolding 24 | 25 | Generate React code snippets from the CLI by using Plop micro-generator framework. 26 | 27 | ### TypeScript 28 | 29 | The best way to write modern frontend applications. Code is easier to understand. By using TypeScript it is more difficult to write invalid code as was the case in dynamically typed languages. 30 | 31 | ### Static code analysis 32 | 33 | Focus on writing code, not formatting it! Code formatter and linter keeps the code clean which makes work and communication with other developers more effective! 34 | 35 | ## How to bootstrap your React project 36 | 37 | To start your new React project based on the `react-starter-boilerplate` you need to follow this steps: 38 | 39 | 1. Clone this repository: 40 | 41 | ```shell 42 | git clone https://github.com/TheSoftwareHouse/react-starter-boilerplate.git 43 | ``` 44 | 45 | 2. Change the name of project directory to the name of your project. 46 | **Also don't forget to change the name of your application in `package.json` file.** 47 | 48 | 3. Restore git history of the project. To do that, run following commands: 49 | ```shell 50 | sudo rm -r .git 51 | git init 52 | git remote add origin 53 | git remote -v 54 | ``` 55 | 56 | 4. Replace this file with `PROJECT_README.md` and fill all the placeholders with data about your project: 57 | ```shell 58 | mv PROJECT_README.md README.md 59 | ``` 60 | 61 | 5. Add all files to git history and create initial commit: 62 | ```shell 63 | git add . 64 | git commit -m 'Initial commit' 65 | git push origin master 66 | ``` 67 | 68 | 6. Copy the `.env.dist` file to `.env.local` and populate the environment variables with the values used in the local environment 69 | 70 | ```shell 71 | cp .env.dist .env.local 72 | ``` 73 | 74 | Now, your project is bootstrapped successfully! 🎉 75 | 76 | You can install dependencies and start developing your React application 🚀 77 | 78 | ## Scripts 79 | 80 | ```shell 81 | npm run [command_name] 82 | ``` 83 | 84 | - `start` - Launches the app in development mode on [http://localhost:3000](http://localhost:3000) 85 | - `build` - Compiles and bundles the app for deployment* 86 | - `build:ci` - Build command optimized for CI/CD pipelines 87 | - `build:analyze` - Builds the app and opens the rollup-plugin-visualizer report in the browser 88 | - `typecheck` - Validate the code using TypeScript compiler 89 | - `preview` - Boot up a local static web server that serves application build. It's an easy way to check if the production build looks OK on your local machine 90 | - `test` - Run unit tests with vitest 91 | - `coverage` - Run unit tests with code coverage calculation 92 | - `lint` - Validate the code using ESLint and Prettier 93 | - `lint:fix` - Validate and fix the code using ESLint and Prettier 94 | - `plop` - Run CLI with commands for code generation 95 | - `translations` - Run [Babelsheet](https://github.com/TheSoftwareHouse/babelsheet2) tool for fetch the latest translations 96 | - `serve:cypress` - Run Cypress E2E tests panel 97 | - `version` - Build CHANGELOG file base on git commits history 98 | - `e2e:open` - Run E2E tests panel 99 | - `e2e:ci:firefox`: Run E2E tests on Firefox browser in CI pipelines 100 | - `e2e:ci:chrome`: Run E2E tests on Chrome browser in CI pipelines 101 | 102 | *See the section about [deployment](https://vitejs.dev/guide/static-deploy.html) for more information. 103 | 104 | ## Table of Contents 105 | 106 | 1. [Technology stack](/docs/01-technology-stack.md) 107 | 2. [Application structure](/docs/02-application-structure.md) 108 | 3. [React Query abstraction](/docs/03-react-query-abstraction.md) 109 | 4. [Using plop commands](/docs/04-using-plop-commands.md) 110 | 5. [E2E tests](/docs/05-e2e-tests.md) 111 | 112 | ## How to Contribute 113 | 114 | Anyone and everyone is welcome to contribute. Start by checking out the list of [open issues](https://github.com/TheSoftwareHouse/react-starter-boilerplate/issues). 115 | 116 | However, if you decide to get involved, please take a moment to review the [guidelines](CONTRIBUTING.md). 117 | 118 | ## License 119 | 120 | Copyright © 2021-present The Software House. This source code is licensed under the MIT license found in the 121 | [LICENSE](LICENSE.md) file. 122 | 123 | --- 124 | 125 | Made with ♥ by The Software House (website, blog) 126 | and contributors. 127 | 128 | -------------------------------------------------------------------------------- /babelsheet.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "cliVersion": "0.0.15", 3 | "spreadsheetId": "example spreadsheetId", 4 | "credentials": ".credentials.json", 5 | "userInput": { 6 | "credentials": ".credentials.json", 7 | "title": "example title Translations", 8 | "includeManual": true, 9 | "maxLevels": 5, 10 | "languages": "en, pl", 11 | "example": true, 12 | "email": "email@example.com", 13 | "scriptTemplate": "Flat JSON per language (recommended for SPA apps)", 14 | "outDir": "./src/i18n/data/", 15 | "scriptPath": "./scripts/fetch-translations.ts", 16 | "scriptName": "translations" 17 | } 18 | } -------------------------------------------------------------------------------- /bitbucket-pipelines.yml: -------------------------------------------------------------------------------- 1 | definitions: 2 | caches: 3 | npm: $HOME/.npm 4 | steps: 5 | - step: &deploy-s3 6 | image: tshio/awscli-docker-compose-pipelines:0.0.7 7 | script: 8 | - npm ci 9 | - npm run build 10 | - aws s3 sync ./build s3://$S3_BUCKET --delete 11 | - aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION --paths "/*" 12 | 13 | pipelines: 14 | default: 15 | - step: 16 | image: node:20 17 | caches: 18 | - node 19 | - npm 20 | name: Run linter and tests 21 | script: 22 | - cp .env.dist .env 23 | - npm ci 24 | - npm run lint 25 | - npm run coverage 26 | 27 | # START PLAYWRIGHT SPECIFIC CONFIG 28 | pull-requests: 29 | '**': 30 | - step: 31 | name: build and install 32 | image: mcr.microsoft.com/playwright:v1.43.0-focal 33 | caches: 34 | - node 35 | - npm 36 | script: 37 | - cp .env.dist .env 38 | - npm ci 39 | - npm run build:ci 40 | - cd e2e && npm install && cd .. 41 | artifacts: 42 | - build/** 43 | - e2e/node_modules/** 44 | - parallel: 45 | - step: 46 | name: e2e - chrome 47 | image: mcr.microsoft.com/playwright:v1.43.0-focal 48 | caches: 49 | - node 50 | script: 51 | - cp ./e2e/.env.dist ./e2e/.env 52 | - cd e2e && npm run test:chrome cd .. 53 | - step: 54 | name: e2e - firefox 55 | image: mcr.microsoft.com/playwright:v1.43.0-focal 56 | caches: 57 | - node 58 | script: 59 | - cp ./e2e/.env.dist ./e2e/.env 60 | - cd e2e && npm run test:firefox cd .. 61 | - step: 62 | name: e2e - safari 63 | image: mcr.microsoft.com/playwright:v1.43.0-focal 64 | caches: 65 | - node 66 | script: 67 | - cp ./e2e/.env.dist ./e2e/.env 68 | - cd e2e && npm run test:safari cd .. 69 | - step: 70 | name: deploy to Staging 71 | deployment: Staging 72 | trigger: manual 73 | <<: *deploy-s3 74 | # END PLAYWRIGHT SPECIFIC CONFIG 75 | 76 | custom: 77 | deploy-to-Staging: 78 | - step: 79 | name: deploy to Staging 80 | <<: *deploy-s3 81 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /docker/e2e-runner/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM cypress/included:10.11.0 2 | -------------------------------------------------------------------------------- /docs/01-technology-stack.md: -------------------------------------------------------------------------------- 1 | # Technology Stack 2 | 3 | React Starter Boilerplate application is based on [vite](https://github.com/vitejs/vite) development environment. 4 | It brings toolchain for bundling, serving and testing our frontend application. 5 | 6 | ## Packages installed 7 | 8 | | Library name | Description | 9 | |------------------|------------------------------------------------------------------------------| 10 | | React | Core library for web application user interface | 11 | | Tanstack Router | Library used for handling routing mechanism in React application | 12 | | React intl | Library used for handling internationalization in React application | 13 | | TypeScript | Library that adds static typing with optional type annotations to JavaScript | 14 | | Axios | Library for making HTTP calls to API | 15 | | TanStack Query | Library which provides hooks for managing communication with API | 16 | | Babelsheet | Library developed by TSH which provides tools for translations management | 17 | | msw | Local API mock server | 18 | | plop | Library which provide CLI interface with commands for rapid code creation | 19 | -------------------------------------------------------------------------------- /docs/02-application-structure.md: -------------------------------------------------------------------------------- 1 | # Application structure 2 | 3 | ## Root directory structure 4 | 5 | `├──`[`.changeset`](.changeset) — data about changesets
6 | `├──`[`.github`](.github) — GitHub configuration including CI/CD workflows
7 | `├──`[`.husky`](.husky) — Husky scripts for git hooks
8 | `├──`[`docker`](docker) — Docker related files
9 | `├──`[`docs`](docs) — Application documentation files
10 | `├──`[`e2e`](e2e) — Cypress E2E tests project
11 | `├──`[`e2e-playwright`](e2e-playwright) — Playwright E2E tests project
12 | `├──`[`plop-templates`](plop-templates) — Templates for plop commands
13 | `├──`[`public`](public) — React application public files
14 | `├──`[`scripts`](scripts) — Custom scripts (ex. fetching translations)
15 | `├──`[`src`](src) — React application source code
16 | 17 | ## Source code structure 18 | 19 | `├──`[`api`](src/api) — Configuration of API client and collection of API actions (queries and mutations) definition.
20 | `├──`[`routes`](src/routes) — React application Routing and features (view components/modules)
21 | `├──`[`assets`](src/assets) - React application public assets (images, icons, custom fonts etc.)
22 | `├──`[`context`](src/context) - Global contexts using across React application. Each context has its context and controller files
23 | `├──`[`hooks`](src/hooks) - Global hooks used across the application. The best approach is to keep flat structure of hooks in this directory
24 | `├──`[`i18n`](src/i18n) - Configuration of internationalization module in SPA application. It also contains JSON files with application translations managed with Babelsheet tool
25 | `├──`[`providers`](src/providers) - Configuration of providers tree in React application
26 | `├──`[`tests`](src/tests) - Configuration of React application unit tests
27 | `├──`[`types`](src/types) - Global types used across the application
28 | `├──`[`ui`](src/ui) - Base UI components used across the application. The best approach is to keep flat structure of UI components in this directory
29 | `├──`[`utils`](src/utils) - Base utility functions used across the application.
30 | -------------------------------------------------------------------------------- /docs/03-react-query-abstraction.md: -------------------------------------------------------------------------------- 1 | # React Query abstraction 2 | 3 | React Starter Boilerplate implements special abstraction for base hooks from Tanstack Query library: 4 | - `useQuery`, 5 | - `useMutation`, 6 | - `useInfiniteQuery`. 7 | 8 | This abstraction allows us to create API actions (queries and mutations) in a simple way in `src/api` directory. 9 | 10 | All you need is to create API functions in queries or mutation file and the name of API function you created will be automatically available to use in useQuery/useMutation hooks. 11 | 12 | To make it work properly you need to use `useQuery`, `useMutation` and `useInfiniteQuery` hooks from `src/hooks` directory, not from the TanStack Query library. 13 | 14 | ## Examples 15 | 16 | ### useQuery 17 | 18 | `src/api/auth.auth.queries.ts` 19 | ```ts 20 | export const authQueries = { 21 | getCurrentUser: (client: AxiosInstance) => async () => { 22 | return (await client.get('/me')).data; 23 | }, 24 | }; 25 | ``` 26 | 27 | Usage with `useQuery` hook: 28 | ```ts 29 | import { useQuery } from 'hooks/useQuery/useQuery'; 30 | 31 | const TestComponent = () => { 32 | const { data, isLoading } = useQuery('getCurrentUser'); 33 | 34 | ... 35 | } 36 | ``` 37 | 38 | ### useMutation 39 | 40 | `src/api/auth/auth.mutations.ts` 41 | ```ts 42 | export const authMutations = { 43 | login: (client: AxiosInstance) => async (body: LoginMutationArguments) => { 44 | return (await client.post('/authorize', body)).data; 45 | }, 46 | }; 47 | ``` 48 | 49 | Usage with `useMutation` hook: 50 | ```ts 51 | import { useMutation } from 'hooks/useMutation/useMutation'; 52 | 53 | const TestComponent = () => { 54 | const { mutateAsync, isLoading } = useMutation('login'); 55 | 56 | ... 57 | } 58 | ``` 59 | 60 | ### useInfiniteQuery 61 | 62 | `src/api/auth/auth.queries.ts` 63 | ```ts 64 | export const authQueries = { 65 | getUsersInfinite: 66 | (client: AxiosInstance) => 67 | async ({ pageParam = '0', count = '5' }: GetUsersInfiniteArgs) => { 68 | const queryParams = stringify({ page: pageParam, count: count }, { addQueryPrefix: true }); 69 | return (await client.get(`/users/${queryParams}`)).data; 70 | }, 71 | }; 72 | ``` 73 | 74 | Usage with `useInfiniteQuery` hook: 75 | 76 | ```ts 77 | import { useMutation } from 'hooks/useInfiniteQuery/useInfiniteQuery'; 78 | 79 | const TestComponent = () => { 80 | const { data, isFetching } = useInfiniteQuery('getUsersInfinite'); 81 | 82 | ... 83 | } 84 | 85 | ``` 86 | -------------------------------------------------------------------------------- /docs/04-using-plop-commands.md: -------------------------------------------------------------------------------- 1 | # Plop 2 | 3 | Plop is a tool which provide CLI interface with commands for rapid code creation. 4 | 5 | To access plop CLI you need to run following command: 6 | 7 | ```shell 8 | npm run plop 9 | ``` 10 | 11 | ## Available commands 12 | 13 | ### React app component 14 | 15 | This command creates a React component with a specific name inside `src/app` directory. 16 | 17 | Result of this command: 18 | - base React component file, 19 | - component types file, 20 | - component tests file. 21 | 22 | ### React app component with container 23 | 24 | This command creates React component with a specific name inside `src/app` directory with container component. 25 | 26 | Result of this command: 27 | - base React component file, 28 | - container component file, 29 | - components types file, 30 | - component tests file. 31 | 32 | ### React UI component 33 | 34 | This command creates base UI component with a specific name inside `src/ui` directory. 35 | 36 | Result of this command: 37 | - base React component file, 38 | - component types file, 39 | - component tests file, 40 | - add new hook to `hooks` index file. 41 | 42 | ### Custom hook 43 | 44 | This command creates custom React hook with a specific name inside `src/hooks` directory. 45 | 46 | Result of this command: 47 | - base hook file, 48 | - tests file, 49 | - add new hook to `hooks` index file. 50 | 51 | ### API actions collection 52 | 53 | This command creates new collection for API actions. 54 | 55 | Result of this command: 56 | - API actions collection mutations file, 57 | - API actions collection queries file, 58 | - API actions collection types file, 59 | - connect new API actions collection with API actions index file. 60 | 61 | ### API query 62 | 63 | This command creates new API query action inside specific collection. 64 | 65 | Result of this command: 66 | - new query inside specific API collection queries file, 67 | - new query action types inside specific API collection types file. 68 | 69 | ### API mutation 70 | 71 | This command creates new API mutation action inside specific collection. 72 | 73 | Result of this command: 74 | - new mutation inside specific API collection mutations file, 75 | - new mutation action types inside specific API collection types file. 76 | 77 | ### React Context 78 | 79 | This command creates global React context with a specific name inside `src/contexts` directory. 80 | 81 | Result of this command: 82 | - context file 83 | - context types file, 84 | - context controller file, 85 | - context controller types, 86 | - hook that retrieves data from the context, 87 | - hook tests file. 88 | -------------------------------------------------------------------------------- /docs/05-e2e-tests.md: -------------------------------------------------------------------------------- 1 | # E2E 2 | 3 | ## Setup 4 | 5 | In this project for E2E testing you can use one of two frameworks: 6 | [Cypress](https://www.cypress.io/) and [Playwright](https://playwright.dev/). 7 | 8 | You need to choose first which frameworks you use: 9 | 10 | *For using Cypress:* 11 | ```shell 12 | cd e2e 13 | npm install 14 | cd .. 15 | cp .env.dist .env 16 | cp .env.e2e.dist ./e2e/.env 17 | ``` 18 | 19 | *For using Playwright:* 20 | ```shell 21 | cd e2e-playwirght 22 | npm install 23 | cp .env.dist .env 24 | npm run test 25 | cd .. 26 | ``` 27 | 28 | Of course if you choose one of this tools you can remove the second one from your project. 29 | 30 | ## Details & Reasoning 31 | 32 | The configuration is mostly isolated to the e2e folder (and e2e-playwright), to allow for easy removal when not needed 33 | and to avoid conflicts with any other testing libraries, as they tend to pollute the global namespace. We believe that 34 | proper e2e testing is extremely valuable, but we also recognize that it's not for everyone and it will probably be one 35 | of the most removed or ignored features in the boilerplate versions. 36 | 37 | We also propose the second solution - e2e tests using Playwright, therefore the e2e-playwright folder has been created. 38 | When starting the project, if automatic tests will be written, you should stick to one solution (Cypress or Playwright) 39 | and remove redundant files and code. For example, if you choose Playwright, you should delete the e2e folder, rename 40 | e2e-playwright to e2e and remove all commented Cypress config code in CI configuration files. 41 | 42 | To get rid of e2e testing simply delete the e2e and e2e-playwright directory, the e2e.dist env file, anything beginning 43 | with "e2e" from package.json's scripts field and the step named "e2e" from the bitbucket pipelines configuration. 44 | -------------------------------------------------------------------------------- /e2e/.env.dist: -------------------------------------------------------------------------------- 1 | PLAYWRIGHT_HOST=http://localhost:1337 2 | PLAYWRIGHT_USER_LOGIN=login 3 | PLAYWRIGHT_USER_PASSWORD=password 4 | -------------------------------------------------------------------------------- /e2e/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["plugin:playwright/playwright-test", "../.eslintrc"] 4 | } 5 | -------------------------------------------------------------------------------- /e2e/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | /node_modules 3 | /test-results/ 4 | /test-report/ 5 | /.cache/ 6 | -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | ## Quick Start 2 | 3 | To start the development, run: 4 | 5 | ``` 6 | npm install 7 | npm run install:browsers (only for the first time) 8 | npm run test (or npm run test:chrome for browser specific) 9 | ``` 10 | 11 | ## E2E Playwright guide 12 | 13 | ### How to test locally? 14 | 15 | After installation, just run `npm run test` command (having a launched application on a given port is not required, 16 | playwright will run the application itself locally if it is not open). It will run test in headless mode. In CLI you 17 | will get listed results, also you can debug it by running command which CLI suggest: 18 | `npx playwright show-report test-report`. Report will be opened in browser, and you can see which test are 19 | passing/failing and on which browser. If you enter one specific test, you will see all test steps, and also TRACES which 20 | are very helpful to debug. If your test is failing, enter the trace and then you can debug step by step and see what 21 | automatic test did (what it see and what it clicked). 22 | 23 | ### How to debug test from CI? 24 | 25 | 1. Go to the **Pipelines** page of the PR 26 | 2. Downloads the artifacts 27 | 3. Extract the `test-report` folder locally in the repository in `e2e-playwright/test-report` folder 28 | 4. Run `npx playwright show-report test-report` and analyze the traces (check "Retry #1" tab to see traces) 29 | 5. There is second way to just see just a one specific test results: find the `trace.zip` file for the specific e2e test 30 | in question (in `test-results` folder) 31 | 6. Put in in repo in `e2e-playwright` folder (and remove it later). Run `npx playwright show-trace trace.zip` and 32 | analyze trace in specifing failing test. 33 | 34 | ## Available Scripts 35 | 36 | In the project directory, you can run: 37 | 38 | ### `npm run install:browsers` 39 | 40 | Will install default browsers dependencies to your computer. Helpful, when updating the playwright package and version 41 | of browsers. 42 | 43 | ### `npm run test` 44 | 45 | Will run e2e tests in headless mode on all browsers (listed in configuration file) 46 | 47 | ### `npm run test:chrome` or `npm run test:firefox` `npm run test:safari` 48 | 49 | Will run e2e tests in headless mode for the specific browser 50 | 51 | ### `npm run lint` and `npm run lint:fix` 52 | 53 | Runs the linter (and fixes fixable issues) 54 | 55 | ### `npm run clean` 56 | 57 | This is just a helper script to remove artifacts generated by e2e tests. This is not necessary to run this manually, 58 | because playwright will clear artifacts by its own just before tests starting. 59 | 60 | ### `npm run serve` 61 | 62 | This script will serve app build under given port (from env's). This script is used by playwright during tests in CI. 63 | App build is required. 64 | -------------------------------------------------------------------------------- /e2e/actions/homePage.ts: -------------------------------------------------------------------------------- 1 | import { Page, expect } from '@playwright/test'; 2 | 3 | const openHomePage = async (page: Page) => { 4 | await page.goto('/'); 5 | 6 | await expect(page).toHaveTitle(/React App/); 7 | await expect(page.locator('h2')).toContainText('Home'); 8 | }; 9 | 10 | export const homePageActions = { 11 | openHomePage, 12 | }; 13 | -------------------------------------------------------------------------------- /e2e/actions/navigation.ts: -------------------------------------------------------------------------------- 1 | import { Page, expect } from '@playwright/test'; 2 | 3 | const navigateToHomePage = async (page: Page) => { 4 | const link = page.getByRole('link', { name: 'Home' }); 5 | 6 | await expect(link).toHaveAttribute('href', '/'); 7 | await link.click(); 8 | 9 | await expect(page).toHaveURL(''); 10 | }; 11 | const navigateToAboutPage = async (page: Page) => { 12 | const link = page.getByRole('link', { name: 'About' }); 13 | 14 | await expect(link).toHaveAttribute('href', '/about'); 15 | await link.click(); 16 | 17 | await expect(page).toHaveURL(/about/); 18 | }; 19 | const navigateToHelpPage = async (page: Page) => { 20 | const link = page.getByRole('link', { name: 'Help' }); 21 | 22 | await expect(link).toHaveAttribute('href', '/help'); 23 | await link.click(); 24 | 25 | await expect(page).toHaveURL(/help/); 26 | }; 27 | 28 | export const navigationActions = { 29 | navigateToHomePage, 30 | navigateToAboutPage, 31 | navigateToHelpPage, 32 | }; 33 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e-playwright", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "install:browsers": "playwright install --with-deps", 7 | "test": "playwright test", 8 | "test:chrome": "playwright test --project=chromium", 9 | "test:firefox": "playwright test --project=firefox", 10 | "test:safari": "playwright test --project=webkit", 11 | "test:debug": "playwright test --debug", 12 | "lint": "eslint \"./**/*.ts\" && echo \"lint success\"", 13 | "lint:fix": "eslint --fix \"./**/*.ts\" && echo \"lint success\"", 14 | "clean": "rm -rf test-results && rm -rf test-report", 15 | "serve": "http-server --proxy $(grep PLAYWRIGHT_HOST .env | cut -d '=' -f2)? ../build --port 1337" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "dependencies": { 21 | "dotenv": "^16.4.5", 22 | "typescript": "5.4.3" 23 | }, 24 | "devDependencies": { 25 | "@playwright/test": "^1.41.1", 26 | "eslint-plugin-playwright": "^1.6.0", 27 | "http-server": "14.1.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /e2e/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | import { devices } from '@playwright/test'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config(); 6 | 7 | const BASE_URL = process.env.CI ? process.env.PLAYWRIGHT_HOST : 'http://localhost:3000'; 8 | 9 | const config: PlaywrightTestConfig = { 10 | testDir: 'tests', 11 | testMatch: '**/*.test.ts', 12 | timeout: 30000, 13 | fullyParallel: true, 14 | forbidOnly: !!process.env.CI, 15 | retries: process.env.CI ? 1 : 0, 16 | reporter: [['html', { outputFolder: 'test-report' }]], 17 | use: { 18 | baseURL: BASE_URL, 19 | trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure', 20 | }, 21 | projects: [ 22 | { 23 | name: 'chromium', 24 | use: { ...devices['Desktop Chrome'] }, 25 | }, 26 | { 27 | name: 'firefox', 28 | use: { ...devices['Desktop Firefox'] }, 29 | }, 30 | { 31 | name: 'webkit', 32 | use: { ...devices['Desktop Safari'] }, 33 | }, 34 | ], 35 | outputDir: 'test-results', // Folder for test artifacts such as screenshots, videos, traces, etc. 36 | webServer: { 37 | command: process.env.CI ? 'npm run serve' : `cd .. && npm run start`, 38 | url: BASE_URL, 39 | reuseExistingServer: !process.env.CI, 40 | }, 41 | }; 42 | 43 | // eslint-disable-next-line import/no-default-export 44 | export default config; 45 | -------------------------------------------------------------------------------- /e2e/tests/home.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@playwright/test'; 2 | 3 | import { homePageActions } from '../actions/homePage'; 4 | import { navigationActions } from '../actions/navigation'; 5 | 6 | test.describe('Home Page', () => { 7 | test('should navigate to the about page', async ({ page }) => { 8 | await homePageActions.openHomePage(page); 9 | await navigationActions.navigateToAboutPage(page); 10 | }); 11 | 12 | test('should navigate to the help page', async ({ page }) => { 13 | await homePageActions.openHomePage(page); 14 | await navigationActions.navigateToHelpPage(page); 15 | }); 16 | 17 | test('should navigate to the home page from other page', async ({ page }) => { 18 | await homePageActions.openHomePage(page); 19 | await navigationActions.navigateToAboutPage(page); 20 | await navigationActions.navigateToHomePage(page); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 31 | 34 | 35 | 36 | 37 |
38 |
39 | 40 |
41 | 42 | 43 | 44 |
45 |
46 |
47 | 48 | 49 | 50 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-starter-boilerplate", 3 | "version": "0.2.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=20" 7 | }, 8 | "type": "module", 9 | "scripts": { 10 | "start": "vite", 11 | "build": "tsc && vite build", 12 | "build:ci": "cross-env VITE_CI=1 vite build", 13 | "build:analyze": "cross-env ANALYZE=1 npm run build", 14 | "typecheck": "tsc --noEmit", 15 | "preview": "vite preview", 16 | "test": "vitest", 17 | "coverage": "vitest run --coverage", 18 | "lint": "eslint \"./src/**/*.{ts,tsx}\" && stylelint \"./src/**/*.{css,pcss,scss}\" && echo \"lint success\"", 19 | "lint:fix": "eslint --fix \"./src/**/*.{ts,tsx}\" && stylelint --fix \"./src/**/*.{css,pcss,scss}\" && echo \"lint success\"", 20 | "plop": "plop", 21 | "translations": "ts-node -T --project tsconfig.json ./scripts/fetch-translations.ts", 22 | "e2e": "cd e2e && npm run test && cd ..", 23 | "e2e:firefox": "cd e2e && npm run test:firefox && cd ..", 24 | "e2e:chrome": "cd e2e && npm run test:chrome && cd ..", 25 | "e2e:safari": "cd e2e && npm run test:safari && cd ..", 26 | "e2e:debug": "cd e2e && npm run test:debug && cd ..", 27 | "prepare": "husky" 28 | }, 29 | "lint-staged": { 30 | "src/**/*.{js,jsx,ts,tsx,md}": [ 31 | "eslint --fix" 32 | ], 33 | "src/**/*.json": [ 34 | "prettier --write" 35 | ], 36 | "src/**/*.{css,pcss,scss}": [ 37 | "stylelint --fix", 38 | "prettier --write" 39 | ] 40 | }, 41 | "browserslist": { 42 | "production": [ 43 | ">0.2%", 44 | "not dead", 45 | "not op_mini all" 46 | ], 47 | "development": [ 48 | "last 1 chrome version", 49 | "last 1 firefox version", 50 | "last 1 safari version" 51 | ] 52 | }, 53 | "dependencies": { 54 | "@sentry/browser": "8.8.0", 55 | "@tanstack/react-query": "5.45.1", 56 | "@tanstack/react-query-devtools": "5.45.1", 57 | "@tanstack/react-router": "1.29.2", 58 | "axios": "1.6.8", 59 | "clsx": "2.1.0", 60 | "jwt-decode": "4.0.0", 61 | "qs": "6.12.1", 62 | "react": "18.3.1", 63 | "react-dom": "18.2.0", 64 | "react-error-boundary": "4.0.13", 65 | "react-intl": "6.6.5", 66 | "typescript": "5.4.3", 67 | "zod": "3.23.0" 68 | }, 69 | "devDependencies": { 70 | "@changesets/cli": "2.27.1", 71 | "@commitlint/cli": "19.2.2", 72 | "@commitlint/config-conventional": "19.2.2", 73 | "@tanstack/router-devtools": "1.29.2", 74 | "@tanstack/router-vite-plugin": "1.30.0", 75 | "@testing-library/jest-dom": "6.4.2", 76 | "@testing-library/react": "15.0.2", 77 | "@testing-library/user-event": "14.5.2", 78 | "@types/flat": "5.0.5", 79 | "@types/node": "20.12.7", 80 | "@types/qs": "6.9.14", 81 | "@types/react": "18.3.1", 82 | "@types/react-dom": "18.2.25", 83 | "@typescript-eslint/eslint-plugin": "7.13.0", 84 | "@typescript-eslint/parser": "7.13.0", 85 | "@vitejs/plugin-react-swc": "3.6.0", 86 | "@vitest/coverage-v8": "1.5.0", 87 | "babelsheet2": "0.0.15", 88 | "babelsheet2-json-writer": "0.0.6", 89 | "babelsheet2-reader": "0.0.8", 90 | "cross-env": "7.0.3", 91 | "eslint": "8.57.0", 92 | "eslint-config-prettier": "9.1.0", 93 | "eslint-plugin-import": "2.29.1", 94 | "eslint-plugin-jest-dom": "5.2.0", 95 | "eslint-plugin-jsx-a11y": "6.8.0", 96 | "eslint-plugin-prettier": "5.1.3", 97 | "eslint-plugin-react": "7.34.1", 98 | "eslint-plugin-react-hooks": "4.6.0", 99 | "eslint-plugin-testing-library": "6.2.2", 100 | "eslint-plugin-ui-testing": "2.0.1", 101 | "eslint-plugin-vitest": "0.5.1", 102 | "husky": "9.0.11", 103 | "inquirer-directory": "2.2.0", 104 | "jsdom": "24.0.0", 105 | "lint-staged": "15.2.2", 106 | "msw": "2.2.13", 107 | "plop": "4.0.1", 108 | "prettier": "3.1.1", 109 | "rollup-plugin-visualizer": "5.12.0", 110 | "stylelint": "16.3.1", 111 | "stylelint-config-recommended": "14.0.0", 112 | "vite": "5.2.8", 113 | "vite-plugin-svgr": "4.2.0", 114 | "vite-tsconfig-paths": "4.3.2", 115 | "vitest": "1.5.0" 116 | }, 117 | "msw": { 118 | "workerDirectory": "public" 119 | }, 120 | "optionalDependencies": { 121 | "@rollup/rollup-linux-x64-gnu": "4.16.1" 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /plop/generators/apiActionsCollection.mjs: -------------------------------------------------------------------------------- 1 | import { getPlaceholderPattern } from '../utils.mjs'; 2 | 3 | export const apiActionsCollectionGeneratorDescription = 'API actions collection'; 4 | 5 | export const apiActionsCollectionGenerator = { 6 | description: apiActionsCollectionGeneratorDescription, 7 | prompts: [ 8 | { 9 | type: 'input', 10 | name: 'name', 11 | message: 'actions collection name', 12 | validate: input => input.length > 1 || 'Actions collection name cannot be empty!', 13 | }, 14 | ], 15 | actions: function() { 16 | return [ 17 | { 18 | type: 'add', 19 | path: 'src/api/actions/{{camelCase name}}/{{camelCase name}}.mutations.ts', 20 | templateFile: 'plop/templates/apiActions/apiActions.mutations.hbs', 21 | }, 22 | { 23 | type: 'add', 24 | path: 'src/api/actions/{{camelCase name}}/{{camelCase name}}.queries.ts', 25 | templateFile: 'plop/templates/apiActions/apiActions.queries.hbs', 26 | }, 27 | { 28 | type: 'add', 29 | path: 'src/api/actions/{{camelCase name}}/{{camelCase name}}.types.ts', 30 | templateFile: 'plop/templates/apiActions/apiActions.types.hbs', 31 | }, 32 | { 33 | type: 'modify', 34 | path: 'src/api/actions/index.ts', 35 | pattern: getPlaceholderPattern('API_COLLECTION_IMPORTS'), 36 | template: 37 | 'import { {{camelCase name}}Mutations } from \'./{{camelCase name}}/{{camelCase name}}.mutations\';\nimport { {{camelCase name}}Queries } from \'./{{camelCase name}}/{{camelCase name}}.queries\';\n$1', 38 | }, 39 | { 40 | type: 'modify', 41 | path: 'src/api/actions/index.ts', 42 | pattern: getPlaceholderPattern('API_COLLECTION_QUERIES'), 43 | template: '...{{camelCase name}}Queries,\n $1', 44 | }, 45 | { 46 | type: 'modify', 47 | path: 'src/api/actions/index.ts', 48 | pattern: getPlaceholderPattern('API_COLLECTION_MUTATIONS'), 49 | template: '...{{camelCase name}}Mutations,\n $1', 50 | }, 51 | ]; 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /plop/generators/apiMutation.mjs: -------------------------------------------------------------------------------- 1 | import { getDirectoriesList, getPlaceholderPattern } from '../utils.mjs'; 2 | 3 | const API_ACTIONS_COLLECTIONS_LIST = getDirectoriesList(`./src/api/actions`); 4 | 5 | export const apiMutationGeneratorDescription = 'API mutation'; 6 | 7 | export const apiMutationGenerator = (toKebabCase) => ({ 8 | description: apiMutationGeneratorDescription, 9 | prompts: [ 10 | { 11 | type: "list", 12 | name: "collection", 13 | message: "API actions collection name?", 14 | default: API_ACTIONS_COLLECTIONS_LIST[0], 15 | choices: API_ACTIONS_COLLECTIONS_LIST.map((collection) => ({ name: collection, value: collection })), 16 | }, 17 | { 18 | type: 'input', 19 | name: 'name', 20 | message: 'API mutation action name?', 21 | validate: input => input.length > 1 || 'API mutation action name cannot be empty!', 22 | }, 23 | { 24 | type: 'input', 25 | name: 'path', 26 | message: 'API mutation action path?', 27 | default: (answers) => `/${answers.collection}/${toKebabCase(answers.name)}`, 28 | validate: input => input.length > 1 || 'API mutation action path cannot be empty!', 29 | }, 30 | { 31 | type: "list", 32 | name: "method", 33 | message: "Mutation action method?", 34 | default: "post", 35 | choices: [ 36 | { name: "post", value: "post" }, 37 | { name: "delete", value: "delete" }, 38 | { name: "patch", value: "patch" }, 39 | { name: "put", value: "put" }, 40 | ], 41 | } 42 | ], 43 | actions: function() { 44 | return [ 45 | { 46 | type: 'modify', 47 | path: 'src/api/actions/{{collection}}/{{collection}}.types.ts', 48 | pattern: getPlaceholderPattern('API_ACTION_TYPES'), 49 | templateFile: 'plop/templates/apiMutation/apiMutation.types.hbs', 50 | }, 51 | { 52 | type: 'modify', 53 | path: 'src/api/actions/{{collection}}/{{collection}}.mutations.ts', 54 | pattern: getPlaceholderPattern('MUTATION_TYPE_IMPORTS'), 55 | template: '{{pascalCase name}}Payload,\n {{pascalCase name}}Response,\n $1', 56 | }, 57 | { 58 | type: 'modify', 59 | path: 'src/api/actions/{{collection}}/{{collection}}.mutations.ts', 60 | pattern: getPlaceholderPattern('MUTATION_FUNCTIONS_SETUP'), 61 | templateFile: 'plop/templates/apiMutation/apiMutation.hbs', 62 | } 63 | ] 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /plop/generators/apiQuery.mjs: -------------------------------------------------------------------------------- 1 | import { getDirectoriesList, getPlaceholderPattern } from '../utils.mjs'; 2 | 3 | const API_ACTIONS_COLLECTIONS_LIST = getDirectoriesList(`./src/api/actions`); 4 | 5 | export const apiQueryGeneratorDescription = 'API query'; 6 | 7 | export const apiQueryGenerator = (toKebabCase) => ({ 8 | description: apiQueryGeneratorDescription, 9 | prompts: [ 10 | { 11 | type: "list", 12 | name: "collection", 13 | message: "API actions collection name?", 14 | default: API_ACTIONS_COLLECTIONS_LIST[0], 15 | choices: API_ACTIONS_COLLECTIONS_LIST.map((collection) => ({ name: collection, value: collection })), 16 | }, 17 | { 18 | type: 'input', 19 | name: 'name', 20 | message: 'API query action name?', 21 | validate: input => input.length > 1 || 'API query action name cannot be empty!', 22 | }, 23 | { 24 | type: 'input', 25 | name: 'path', 26 | message: 'API query action path?', 27 | default: (answers) => `/${answers.collection}/${toKebabCase(answers.name)}`, 28 | validate: input => input.length > 1 || 'API query action path cannot be empty!', 29 | }, 30 | ], 31 | actions: function() { 32 | return [ 33 | { 34 | type: 'modify', 35 | path: 'src/api/actions/{{collection}}/{{collection}}.types.ts', 36 | pattern: getPlaceholderPattern('API_ACTION_TYPES'), 37 | templateFile: 'plop/templates/apiQuery/apiQuery.types.hbs', 38 | }, 39 | { 40 | type: 'modify', 41 | path: 'src/api/actions/{{collection}}/{{collection}}.queries.ts', 42 | pattern: getPlaceholderPattern('QUERY_TYPE_IMPORTS'), 43 | template: '{{pascalCase name}}Payload,\n {{pascalCase name}}Response,\n $1', 44 | }, 45 | { 46 | type: 'modify', 47 | path: 'src/api/actions/{{collection}}/{{collection}}.queries.ts', 48 | pattern: getPlaceholderPattern('QUERY_FUNCTIONS_SETUP'), 49 | templateFile: 'plop/templates/apiQuery/apiQuery.hbs', 50 | } 51 | ] 52 | } 53 | }); 54 | -------------------------------------------------------------------------------- /plop/generators/customHook.mjs: -------------------------------------------------------------------------------- 1 | export const customHookGeneratorDescription = 'Custom hook'; 2 | 3 | export const customHookGenerator = { 4 | description: customHookGeneratorDescription, 5 | prompts: [ 6 | { 7 | type: 'input', 8 | name: 'name', 9 | message: 'hook name', 10 | validate: input => input.length > 1 || 'Hook name cannot be empty!', 11 | }, 12 | ], 13 | actions: function() { 14 | return [ 15 | { 16 | type: 'add', 17 | path: 'src/hooks/{{camelCase name}}/{{camelCase name}}.tsx', 18 | templateFile: 'plop/templates/hook/hook.hbs', 19 | }, 20 | { 21 | type: 'add', 22 | path: 'src/hooks/{{camelCase name}}/{{camelCase name}}.test.tsx', 23 | templateFile: 'plop/templates/hook/hook.test.hbs', 24 | }, 25 | { 26 | type: 'modify', 27 | path: 'src/hooks/index.ts', 28 | pattern: 'export', 29 | templateFile: 'plop/templates/hook/hook.index.hbs', 30 | }, 31 | ]; 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /plop/generators/reactAppComponent.mjs: -------------------------------------------------------------------------------- 1 | export const reactAppComponentGeneratorDescription = 'React app component'; 2 | 3 | export const reactAppComponentGenerator = { 4 | description: reactAppComponentGeneratorDescription, 5 | prompts: [ 6 | { 7 | type: 'directory', 8 | name: 'directory', 9 | message: 'select directory', 10 | basePath: './src/routes', 11 | }, 12 | { 13 | type: 'input', 14 | name: 'name', 15 | message: 'component name', 16 | validate: (input) => input.length > 1 || 'Component name cannot be empty!', 17 | }, 18 | ], 19 | actions: function () { 20 | return [ 21 | { 22 | type: 'add', 23 | path: `src/routes/{{directory}}/{{camelCase name}}/{{pascalCase name}}.tsx`, 24 | templateFile: 'plop/templates/component/Component.hbs', 25 | }, 26 | { 27 | type: 'add', 28 | path: `src/routes/{{directory}}/{{camelCase name}}/{{pascalCase name}}.test.tsx`, 29 | templateFile: 'plop/templates/component/Component.test.hbs', 30 | }, 31 | { 32 | type: 'add', 33 | path: `src/routes/{{directory}}/{{camelCase name}}/{{pascalCase name}}.types.ts`, 34 | templateFile: 'plop/templates/component/Component.types.hbs', 35 | }, 36 | ]; 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /plop/generators/reactContainerComponent.mjs: -------------------------------------------------------------------------------- 1 | export const reactContainerComponentGeneratorDescription = 'React app component + container'; 2 | 3 | export const reactContainerComponentGenerator = { 4 | description: reactContainerComponentGeneratorDescription, 5 | prompts: [ 6 | { 7 | type: 'directory', 8 | name: 'directory', 9 | message: 'select directory', 10 | basePath: './src/routes', 11 | }, 12 | { 13 | type: 'input', 14 | name: 'name', 15 | message: 'component name', 16 | validate: (input) => input.length > 1 || 'Component name cannot be empty!', 17 | }, 18 | ], 19 | actions: function () { 20 | return [ 21 | { 22 | type: 'add', 23 | path: `src/routes/{{directory}}/{{camelCase name}}/{{pascalCase name}}.tsx`, 24 | templateFile: 'plop/templates/component/Component.hbs', 25 | }, 26 | { 27 | type: 'add', 28 | path: `src/routes/{{directory}}/{{camelCase name}}/{{pascalCase name}}.test.tsx`, 29 | templateFile: 'plop/templates/component/Component.test.hbs', 30 | }, 31 | { 32 | type: 'add', 33 | path: `src/routes/{{directory}}/{{camelCase name}}/{{pascalCase name}}Container.tsx`, 34 | templateFile: 'plop/templates/component/Container.hbs', 35 | }, 36 | { 37 | type: 'add', 38 | path: `src/routes/{{directory}}/{{camelCase name}}/{{pascalCase name}}.types.ts`, 39 | templateFile: 'plop/templates/component/ContainerComponent.types.hbs', 40 | }, 41 | ]; 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /plop/generators/reactContext.mjs: -------------------------------------------------------------------------------- 1 | export const reactContextGeneratorDescription = 'React Context'; 2 | 3 | export const reactContextGenerator = { 4 | description: reactContextGeneratorDescription, 5 | prompts: [ 6 | { 7 | type: 'input', 8 | name: 'name', 9 | message: 'context name', 10 | validate: input => input.length > 1 || 'Context name cannot be empty!', 11 | } 12 | ], 13 | actions: function() { 14 | return [ 15 | { 16 | type: 'add', 17 | path: 'src/context/{{camelCase name}}/{{camelCase name}}Context/{{pascalCase name}}Context.ts', 18 | templateFile: 'plop/templates/context/Context.hbs', 19 | }, 20 | { 21 | type: 'add', 22 | path: 'src/context/{{camelCase name}}/{{camelCase name}}Context/{{pascalCase name}}Context.types.ts', 23 | templateFile: 'plop/templates/context/Context.types.hbs', 24 | }, 25 | { 26 | type: 'add', 27 | path: 'src/context/{{camelCase name}}/{{camelCase name}}Context/{{pascalCase name}}Context.test.tsx', 28 | templateFile: 'plop/templates/context/Context.test.hbs', 29 | }, 30 | { 31 | type: 'add', 32 | path: 'src/context/{{camelCase name}}/{{camelCase name}}ContextController/{{pascalCase name}}ContextController.tsx', 33 | templateFile: 'plop/templates/context/ContextController.hbs', 34 | }, 35 | { 36 | type: 'add', 37 | path: 'src/context/{{camelCase name}}/{{camelCase name}}ContextController/{{pascalCase name}}ContextController.types.ts', 38 | templateFile: 'plop/templates/context/ContextController.types.hbs', 39 | }, 40 | { 41 | type: 'add', 42 | path: 'src/hooks/use{{pascalCase name}}/use{{pascalCase name}}.ts', 43 | templateFile: 'plop/templates/context/useContext.hbs', 44 | }, 45 | { 46 | type: 'add', 47 | path: 'src/hooks/use{{pascalCase name}}/use{{pascalCase name}}.test.tsx', 48 | templateFile: 'plop/templates/context/useContext.test.hbs', 49 | }, 50 | ]; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /plop/generators/reactUiComponent.mjs: -------------------------------------------------------------------------------- 1 | export const reactUiComponentGeneratorDescription = 'React UI component'; 2 | 3 | export const reactUiComponentGenerator = { 4 | description: reactUiComponentGeneratorDescription, 5 | prompts: [ 6 | { 7 | type: 'input', 8 | name: 'name', 9 | message: 'component name', 10 | validate: input => input.length > 1 || 'Component name cannot be empty!', 11 | }, 12 | ], 13 | actions: function() { 14 | return [ 15 | { 16 | type: 'add', 17 | path: `src/ui/{{camelCase name}}/{{pascalCase name}}.tsx`, 18 | templateFile: 'plop/templates/component/Component.hbs', 19 | }, 20 | { 21 | type: 'add', 22 | path: `src/ui/{{camelCase name}}/{{pascalCase name}}.test.tsx`, 23 | templateFile: 'plop/templates/component/Component.test.hbs', 24 | }, 25 | { 26 | type: 'add', 27 | path: `src/ui/{{camelCase name}}/{{pascalCase name}}.types.ts`, 28 | templateFile: 'plop/templates/component/Component.types.hbs', 29 | }, 30 | { 31 | type: 'modify', 32 | path: 'src/ui/index.ts', 33 | pattern: 'export', 34 | templateFile: 'plop/templates/component/Component.index.hbs', 35 | }, 36 | ]; 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /plop/templates/apiActions/apiActions.mutations.hbs: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios'; 2 | 3 | import { 4 | // MUTATION_TYPE_IMPORTS 5 | } from './{{camelCase name}}.types'; 6 | 7 | export const {{camelCase name}}Mutations = { 8 | // MUTATION_FUNCTIONS_SETUP 9 | }; 10 | -------------------------------------------------------------------------------- /plop/templates/apiActions/apiActions.queries.hbs: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios'; 2 | 3 | import { 4 | // QUERY_TYPE_IMPORTS 5 | } from './{{camelCase name}}.types'; 6 | 7 | export const {{camelCase name}}Queries = { 8 | // QUERY_FUNCTIONS_SETUP 9 | }; 10 | -------------------------------------------------------------------------------- /plop/templates/apiActions/apiActions.types.hbs: -------------------------------------------------------------------------------- 1 | // API_ACTION_TYPES 2 | -------------------------------------------------------------------------------- /plop/templates/apiMutation/apiMutation.hbs: -------------------------------------------------------------------------------- 1 | {{camelCase name}}: (client: AxiosInstance) => async (body: {{pascalCase name}}Payload) => { 2 | return (await client.{{method}}<{{pascalCase name}}Response>(`{{path}}`, body)).data; 3 | }, 4 | $1 -------------------------------------------------------------------------------- /plop/templates/apiMutation/apiMutation.types.hbs: -------------------------------------------------------------------------------- 1 | export type {{pascalCase name}}Payload = {}; 2 | 3 | export type {{pascalCase name}}Response = {}; 4 | 5 | $1 -------------------------------------------------------------------------------- /plop/templates/apiQuery/apiQuery.hbs: -------------------------------------------------------------------------------- 1 | {{camelCase name}}: (client: AxiosInstance) => async ({}: {{pascalCase name}}Payload) => { 2 | return (await client.get<{{pascalCase name}}Response>(`{{path}}`)).data; 3 | }, 4 | $1 -------------------------------------------------------------------------------- /plop/templates/apiQuery/apiQuery.types.hbs: -------------------------------------------------------------------------------- 1 | export type {{pascalCase name}}Payload = {}; 2 | 3 | export type {{pascalCase name}}Response = {}; 4 | 5 | $1 -------------------------------------------------------------------------------- /plop/templates/component/Component.hbs: -------------------------------------------------------------------------------- 1 | import { {{pascalCase name}}Props } from './{{pascalCase name}}.types'; 2 | 3 | export const {{pascalCase name}} = ({}: {{pascalCase name}}Props) => { 4 | return <>{{pascalCase name}}; 5 | }; 6 | -------------------------------------------------------------------------------- /plop/templates/component/Component.index.hbs: -------------------------------------------------------------------------------- 1 | export * from './{{camelCase name}}/{{pascalCase name}}'; -------------------------------------------------------------------------------- /plop/templates/component/Component.test.hbs: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'tests'; 2 | 3 | import { {{pascalCase name}} } from './{{pascalCase name}}'; 4 | 5 | describe('{{pascalCase name}}', () => { 6 | test('renders', () => { 7 | render(<{{pascalCase name}} />); 8 | 9 | const element = screen.getByText('{{pascalCase name}}'); 10 | 11 | expect(element).toBeInTheDocument(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /plop/templates/component/Component.types.hbs: -------------------------------------------------------------------------------- 1 | export type {{pascalCase name}}Props = {}; 2 | -------------------------------------------------------------------------------- /plop/templates/component/Container.hbs: -------------------------------------------------------------------------------- 1 | import { {{pascalCase name}}ContainerProps } from './{{pascalCase name}}.types'; 2 | import { {{pascalCase name}} } from './{{pascalCase name}}'; 3 | 4 | export const {{pascalCase name}}Container = ({}: {{pascalCase name}}ContainerProps) => { 5 | return <{{pascalCase name}} />; 6 | }; 7 | -------------------------------------------------------------------------------- /plop/templates/component/ContainerComponent.types.hbs: -------------------------------------------------------------------------------- 1 | export type {{pascalCase name}}ContainerProps = {}; 2 | 3 | export type {{pascalCase name}}Props = {}; 4 | -------------------------------------------------------------------------------- /plop/templates/context/Context.hbs: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import { {{pascalCase name}}ContextValue } from './{{pascalCase name}}Context.types'; 4 | 5 | export const {{pascalCase name}}Context = createContext<{{pascalCase name}}ContextValue | undefined>(undefined); 6 | -------------------------------------------------------------------------------- /plop/templates/context/Context.test.hbs: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'tests'; 2 | 3 | import { {{pascalCase name}}Context } from './{{pascalCase name}}Context'; 4 | 5 | describe('{{pascalCase name}}Context', () => { 6 | test('is undefined by default', () => { 7 | render( 8 | <{{pascalCase name}}Context.Consumer> 9 | {(context) =>
{typeof context}
} 10 | , 11 | { wrapper: ({ children }) => <>{children} }, 12 | ); 13 | 14 | expect(screen.getByTitle(/CONTEXT/)).toHaveTextContent('undefined'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /plop/templates/context/Context.types.hbs: -------------------------------------------------------------------------------- 1 | export type {{pascalCase name}}ContextValue = {}; 2 | -------------------------------------------------------------------------------- /plop/templates/context/ContextController.hbs: -------------------------------------------------------------------------------- 1 | import { {{pascalCase name}}ContextValue } from '../{{camelCase name}}Context/{{pascalCase name}}Context.types'; 2 | import { {{pascalCase name}}Context } from '../{{camelCase name}}Context/{{pascalCase name}}Context'; 3 | 4 | import { {{pascalCase name}}ContextControllerProps } from './{{pascalCase name}}ContextController.types'; 5 | 6 | export const {{pascalCase name}}ContextController = ({ children }: {{pascalCase name}}ContextControllerProps) => { 7 | const value: {{pascalCase name}}ContextValue = {}; 8 | 9 | return <{{pascalCase name}}Context.Provider value={value}>{children}; 10 | } 11 | -------------------------------------------------------------------------------- /plop/templates/context/ContextController.types.hbs: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type {{pascalCase name}}ContextControllerProps = { 4 | children: ReactNode; 5 | }; 6 | -------------------------------------------------------------------------------- /plop/templates/context/useContext.hbs: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { {{pascalCase name}}Context } from 'context/{{camelCase name}}/{{camelCase name}}Context/{{pascalCase name}}Context'; 4 | 5 | export const use{{pascalCase name}} = () => { 6 | const context = useContext({{pascalCase name}}Context); 7 | 8 | if (context === undefined) { 9 | throw new Error('{{pascalCase name}}Context must be within {{pascalCase name}}Provider'); 10 | } 11 | 12 | return context; 13 | }; 14 | -------------------------------------------------------------------------------- /plop/templates/context/useContext.test.hbs: -------------------------------------------------------------------------------- 1 | import { renderHook } from 'tests'; 2 | import { {{pascalCase name}}ContextController } from 'context/{{camelCase name}}/{{camelCase name}}ContextController/{{pascalCase name}}ContextController'; 3 | 4 | import { use{{pascalCase name}} } from './use{{pascalCase name}}'; 5 | 6 | describe('use{{pascalCase name}}', () => { 7 | test('throws error when context is unavailable', () => { 8 | vi.spyOn(console, 'error').mockImplementation(() => {}); 9 | 10 | const renderFn = () => renderHook(() => use{{pascalCase name}}()); 11 | 12 | expect(renderFn).toThrow('{{pascalCase name}}Context must be within {{pascalCase name}}Provider'); 13 | }); 14 | 15 | test('returns state when context is available', () => { 16 | const { result } = renderHook(() => use{{pascalCase name}}(), { 17 | wrapper: ({ children }) => ( 18 | <{{pascalCase name}}ContextController> 19 | <>{children} 20 | 21 | ), 22 | }); 23 | 24 | expect(result.current).toStrictEqual({}); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /plop/templates/hook/hook.hbs: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export const {{camelCase name}}: () => string = () => { 4 | const [state] = useState('1'); 5 | 6 | return state; 7 | }; 8 | -------------------------------------------------------------------------------- /plop/templates/hook/hook.index.hbs: -------------------------------------------------------------------------------- 1 | export * from './{{camelCase name}}/{{camelCase name}}'; -------------------------------------------------------------------------------- /plop/templates/hook/hook.test.hbs: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | 3 | import { {{camelCase name}} } from './{{camelCase name}}'; 4 | 5 | describe('{{camelCase name}}', () => { 6 | test('returns a value', async () => { 7 | const { result } = renderHook(() => {{camelCase name}}()); 8 | 9 | expect(result.current).toBe('1'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /plop/utils.mjs: -------------------------------------------------------------------------------- 1 | import {lstatSync, readdirSync} from "fs"; 2 | import path from "path"; 3 | 4 | const NAME_REGEX = /[^\/]+$/; 5 | 6 | const isDirectory = (source) => lstatSync(source).isDirectory(); 7 | 8 | export const getDirectoriesList = (source) => 9 | readdirSync(source) 10 | .map((name) => path.join(source, name)) 11 | .filter(isDirectory) 12 | .map((directoryName) => NAME_REGEX.exec(directoryName)[0].trimStart().trimEnd()); 13 | 14 | export const getPlaceholderPattern = (pattern) => new RegExp(`(\/\/ ${pattern})`, 's'); 15 | -------------------------------------------------------------------------------- /plopfile.mjs: -------------------------------------------------------------------------------- 1 | import promptDirectory from 'inquirer-directory'; 2 | 3 | import { customHookGeneratorDescription, customHookGenerator } from './plop/generators/customHook.mjs'; 4 | import { 5 | reactAppComponentGeneratorDescription, 6 | reactAppComponentGenerator, 7 | } from './plop/generators/reactAppComponent.mjs'; 8 | import { 9 | reactUiComponentGeneratorDescription, 10 | reactUiComponentGenerator, 11 | } from './plop/generators/reactUiComponent.mjs'; 12 | import { 13 | reactContainerComponentGeneratorDescription, 14 | reactContainerComponentGenerator, 15 | } from './plop/generators/reactContainerComponent.mjs'; 16 | import { 17 | apiActionsCollectionGeneratorDescription, 18 | apiActionsCollectionGenerator, 19 | } from './plop/generators/apiActionsCollection.mjs'; 20 | import { apiQueryGeneratorDescription, apiQueryGenerator } from './plop/generators/apiQuery.mjs'; 21 | import { apiMutationGeneratorDescription, apiMutationGenerator } from './plop/generators/apiMutation.mjs'; 22 | import { reactContextGeneratorDescription, reactContextGenerator } from './plop/generators/reactContext.mjs'; 23 | 24 | export default function (plop) { 25 | const toKebabCase = plop.getHelper('kebabCase'); 26 | 27 | plop.setPrompt('directory', promptDirectory); 28 | plop.setGenerator(reactAppComponentGeneratorDescription, reactAppComponentGenerator); 29 | plop.setGenerator(reactContainerComponentGeneratorDescription, reactContainerComponentGenerator); 30 | plop.setGenerator(reactUiComponentGeneratorDescription, reactUiComponentGenerator); 31 | plop.setGenerator(customHookGeneratorDescription, customHookGenerator); 32 | plop.setGenerator(apiActionsCollectionGeneratorDescription, apiActionsCollectionGenerator); 33 | plop.setGenerator(apiQueryGeneratorDescription, apiQueryGenerator(toKebabCase)); 34 | plop.setGenerator(apiMutationGeneratorDescription, apiMutationGenerator(toKebabCase)); 35 | plop.setGenerator(reactContextGeneratorDescription, reactContextGenerator); 36 | } 37 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheSoftwareHouse/react-starter-boilerplate/2d4aca7fd6f6a68e7268da29408da00a211b74c9/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheSoftwareHouse/react-starter-boilerplate/2d4aca7fd6f6a68e7268da29408da00a211b74c9/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheSoftwareHouse/react-starter-boilerplate/2d4aca7fd6f6a68e7268da29408da00a211b74c9/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker. 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | * - Please do NOT serve this file on production. 9 | */ 10 | 11 | const PACKAGE_VERSION = '2.2.13' 12 | const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' 13 | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') 14 | const activeClientIds = new Set() 15 | 16 | self.addEventListener('install', function () { 17 | self.skipWaiting() 18 | }) 19 | 20 | self.addEventListener('activate', function (event) { 21 | event.waitUntil(self.clients.claim()) 22 | }) 23 | 24 | self.addEventListener('message', async function (event) { 25 | const clientId = event.source.id 26 | 27 | if (!clientId || !self.clients) { 28 | return 29 | } 30 | 31 | const client = await self.clients.get(clientId) 32 | 33 | if (!client) { 34 | return 35 | } 36 | 37 | const allClients = await self.clients.matchAll({ 38 | type: 'window', 39 | }) 40 | 41 | switch (event.data) { 42 | case 'KEEPALIVE_REQUEST': { 43 | sendToClient(client, { 44 | type: 'KEEPALIVE_RESPONSE', 45 | }) 46 | break 47 | } 48 | 49 | case 'INTEGRITY_CHECK_REQUEST': { 50 | sendToClient(client, { 51 | type: 'INTEGRITY_CHECK_RESPONSE', 52 | payload: { 53 | packageVersion: PACKAGE_VERSION, 54 | checksum: INTEGRITY_CHECKSUM, 55 | }, 56 | }) 57 | break 58 | } 59 | 60 | case 'MOCK_ACTIVATE': { 61 | activeClientIds.add(clientId) 62 | 63 | sendToClient(client, { 64 | type: 'MOCKING_ENABLED', 65 | payload: true, 66 | }) 67 | break 68 | } 69 | 70 | case 'MOCK_DEACTIVATE': { 71 | activeClientIds.delete(clientId) 72 | break 73 | } 74 | 75 | case 'CLIENT_CLOSED': { 76 | activeClientIds.delete(clientId) 77 | 78 | const remainingClients = allClients.filter((client) => { 79 | return client.id !== clientId 80 | }) 81 | 82 | // Unregister itself when there are no more clients 83 | if (remainingClients.length === 0) { 84 | self.registration.unregister() 85 | } 86 | 87 | break 88 | } 89 | } 90 | }) 91 | 92 | self.addEventListener('fetch', function (event) { 93 | const { request } = event 94 | 95 | // Bypass navigation requests. 96 | if (request.mode === 'navigate') { 97 | return 98 | } 99 | 100 | // Opening the DevTools triggers the "only-if-cached" request 101 | // that cannot be handled by the worker. Bypass such requests. 102 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 103 | return 104 | } 105 | 106 | // Bypass all requests when there are no active clients. 107 | // Prevents the self-unregistered worked from handling requests 108 | // after it's been deleted (still remains active until the next reload). 109 | if (activeClientIds.size === 0) { 110 | return 111 | } 112 | 113 | // Generate unique request ID. 114 | const requestId = crypto.randomUUID() 115 | event.respondWith(handleRequest(event, requestId)) 116 | }) 117 | 118 | async function handleRequest(event, requestId) { 119 | const client = await resolveMainClient(event) 120 | const response = await getResponse(event, client, requestId) 121 | 122 | // Send back the response clone for the "response:*" life-cycle events. 123 | // Ensure MSW is active and ready to handle the message, otherwise 124 | // this message will pend indefinitely. 125 | if (client && activeClientIds.has(client.id)) { 126 | ;(async function () { 127 | const responseClone = response.clone() 128 | 129 | sendToClient( 130 | client, 131 | { 132 | type: 'RESPONSE', 133 | payload: { 134 | requestId, 135 | isMockedResponse: IS_MOCKED_RESPONSE in response, 136 | type: responseClone.type, 137 | status: responseClone.status, 138 | statusText: responseClone.statusText, 139 | body: responseClone.body, 140 | headers: Object.fromEntries(responseClone.headers.entries()), 141 | }, 142 | }, 143 | [responseClone.body], 144 | ) 145 | })() 146 | } 147 | 148 | return response 149 | } 150 | 151 | // Resolve the main client for the given event. 152 | // Client that issues a request doesn't necessarily equal the client 153 | // that registered the worker. It's with the latter the worker should 154 | // communicate with during the response resolving phase. 155 | async function resolveMainClient(event) { 156 | const client = await self.clients.get(event.clientId) 157 | 158 | if (client?.frameType === 'top-level') { 159 | return client 160 | } 161 | 162 | const allClients = await self.clients.matchAll({ 163 | type: 'window', 164 | }) 165 | 166 | return allClients 167 | .filter((client) => { 168 | // Get only those clients that are currently visible. 169 | return client.visibilityState === 'visible' 170 | }) 171 | .find((client) => { 172 | // Find the client ID that's recorded in the 173 | // set of clients that have registered the worker. 174 | return activeClientIds.has(client.id) 175 | }) 176 | } 177 | 178 | async function getResponse(event, client, requestId) { 179 | const { request } = event 180 | 181 | // Clone the request because it might've been already used 182 | // (i.e. its body has been read and sent to the client). 183 | const requestClone = request.clone() 184 | 185 | function passthrough() { 186 | const headers = Object.fromEntries(requestClone.headers.entries()) 187 | 188 | // Remove internal MSW request header so the passthrough request 189 | // complies with any potential CORS preflight checks on the server. 190 | // Some servers forbid unknown request headers. 191 | delete headers['x-msw-intention'] 192 | 193 | return fetch(requestClone, { headers }) 194 | } 195 | 196 | // Bypass mocking when the client is not active. 197 | if (!client) { 198 | return passthrough() 199 | } 200 | 201 | // Bypass initial page load requests (i.e. static assets). 202 | // The absence of the immediate/parent client in the map of the active clients 203 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 204 | // and is not ready to handle requests. 205 | if (!activeClientIds.has(client.id)) { 206 | return passthrough() 207 | } 208 | 209 | // Notify the client that a request has been intercepted. 210 | const requestBuffer = await request.arrayBuffer() 211 | const clientMessage = await sendToClient( 212 | client, 213 | { 214 | type: 'REQUEST', 215 | payload: { 216 | id: requestId, 217 | url: request.url, 218 | mode: request.mode, 219 | method: request.method, 220 | headers: Object.fromEntries(request.headers.entries()), 221 | cache: request.cache, 222 | credentials: request.credentials, 223 | destination: request.destination, 224 | integrity: request.integrity, 225 | redirect: request.redirect, 226 | referrer: request.referrer, 227 | referrerPolicy: request.referrerPolicy, 228 | body: requestBuffer, 229 | keepalive: request.keepalive, 230 | }, 231 | }, 232 | [requestBuffer], 233 | ) 234 | 235 | switch (clientMessage.type) { 236 | case 'MOCK_RESPONSE': { 237 | return respondWithMock(clientMessage.data) 238 | } 239 | 240 | case 'PASSTHROUGH': { 241 | return passthrough() 242 | } 243 | } 244 | 245 | return passthrough() 246 | } 247 | 248 | function sendToClient(client, message, transferrables = []) { 249 | return new Promise((resolve, reject) => { 250 | const channel = new MessageChannel() 251 | 252 | channel.port1.onmessage = (event) => { 253 | if (event.data && event.data.error) { 254 | return reject(event.data.error) 255 | } 256 | 257 | resolve(event.data) 258 | } 259 | 260 | client.postMessage( 261 | message, 262 | [channel.port2].concat(transferrables.filter(Boolean)), 263 | ) 264 | }) 265 | } 266 | 267 | async function respondWithMock(response) { 268 | // Setting response status code to 0 is a no-op. 269 | // However, when responding with a "Response.error()", the produced Response 270 | // instance will have status code set to 0. Since it's not possible to create 271 | // a Response instance with status code 0, handle that use-case separately. 272 | if (response.status === 0) { 273 | return Response.error() 274 | } 275 | 276 | const mockedResponse = new Response(response.body, response) 277 | 278 | Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { 279 | value: true, 280 | enumerable: true, 281 | }) 282 | 283 | return mockedResponse 284 | } 285 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /scripts/fetch-translations.ts: -------------------------------------------------------------------------------- 1 | import { fromBabelsheet } from 'babelsheet2-reader'; 2 | import { writeJSONFile } from 'babelsheet2-json-writer'; 3 | import { groupBy, mergeMap } from 'rxjs/operators'; 4 | import path from 'path'; 5 | 6 | const projectRoot = path.relative(__dirname, process.cwd()); 7 | const babelsheetConfig = require(path.join(projectRoot, './babelsheet.json')); 8 | 9 | fromBabelsheet({ 10 | spreadsheetId: babelsheetConfig.spreadsheetId, 11 | credentials: require(path.join(projectRoot, babelsheetConfig.credentials)), 12 | }).pipe( 13 | groupBy( 14 | ({ language }) => language, 15 | { element: ({ path, ...entry }) => ({ ...entry, path: path.join(".") }) } 16 | ), 17 | mergeMap(languageEntries$ => languageEntries$.pipe( 18 | writeJSONFile(`./src/i18n/data/${languageEntries$.key}.json`) 19 | )), 20 | ).subscribe( 21 | ({ filePath, entryCount }) => { 22 | console.log(`Wrote file: "${filePath}" with ${entryCount} entries`); 23 | } 24 | ); -------------------------------------------------------------------------------- /src/api/actions/auth/auth.mutations.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios'; 2 | 3 | import { BASE_URL } from 'api/axios'; 4 | 5 | import { 6 | LoginMutationArguments, 7 | LoginMutationResponse, 8 | // MUTATION_TYPE_IMPORTS 9 | } from './auth.types'; 10 | 11 | export const authMutations = { 12 | loginMutation: (client: AxiosInstance) => async (body: LoginMutationArguments) => { 13 | return (await client.post('/authorize', body)).data; 14 | }, 15 | // MUTATION_FUNCTIONS_SETUP 16 | }; 17 | 18 | export const refreshTokenUrl = `${BASE_URL}/users/refresh-token`; 19 | -------------------------------------------------------------------------------- /src/api/actions/auth/auth.queries.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios'; 2 | import { stringify } from 'qs'; 3 | 4 | import { queryFactoryOptions, infiniteQueryFactoryOptions } from '../../utils/queryFactoryOptions'; 5 | 6 | import { GetMeQueryResponse, GetUsersInfiniteArgs, GetUsersListArgs, GetUsersResponse } from './auth.types'; 7 | 8 | const getCurrentUser = (client: AxiosInstance) => async () => { 9 | return (await client.get('/me')).data; 10 | }; 11 | 12 | const getUsersInfinite = 13 | (client: AxiosInstance, { count = '5' }: GetUsersInfiniteArgs) => 14 | async ({ pageParam = '1' }) => { 15 | const queryParams = stringify({ page: pageParam, count }, { addQueryPrefix: true }); 16 | return (await client.get(`/users/${queryParams}`)).data; 17 | }; 18 | 19 | const getUsersList = 20 | (client: AxiosInstance, { page = '1' }: GetUsersListArgs) => 21 | async () => { 22 | const queryParams = stringify({ page, count: 5 }, { addQueryPrefix: true }); 23 | return (await client.get(`/users/${queryParams}`)).data; 24 | }; 25 | 26 | export const authQueries = { 27 | all: () => ['users'], 28 | me: () => 29 | queryFactoryOptions({ 30 | queryKey: [...authQueries.all(), 'me'], 31 | queryFn: getCurrentUser, 32 | }), 33 | lists: () => [...authQueries.all(), 'list'], 34 | list: (params: GetUsersListArgs) => 35 | queryFactoryOptions({ 36 | queryKey: [...authQueries.lists(), params], 37 | queryFn: (client) => getUsersList(client, params), 38 | }), 39 | listsInfinite: () => [...authQueries.lists(), 'infinite'], 40 | listInfinite: (params: GetUsersInfiniteArgs) => 41 | infiniteQueryFactoryOptions({ 42 | queryKey: [...authQueries.listsInfinite(), params], 43 | queryFn: (client) => getUsersInfinite(client, params), 44 | initialPageParam: '1', 45 | getNextPageParam: ({ nextPage }) => nextPage?.toString(), 46 | }), 47 | }; 48 | -------------------------------------------------------------------------------- /src/api/actions/auth/auth.types.ts: -------------------------------------------------------------------------------- 1 | export type LoginMutationArguments = { 2 | username: string; 3 | password: string; 4 | }; 5 | 6 | export type LoginMutationResponse = { 7 | accessToken: string; 8 | tokenType: string; 9 | expires: number; 10 | refreshToken: string; 11 | }; 12 | 13 | export type GetMeQueryResponse = { 14 | firstName: string; 15 | lastName: string; 16 | username: string; 17 | }; 18 | 19 | export type User = { 20 | id: string; 21 | name: string; 22 | }; 23 | 24 | export type GetUsersResponse = { 25 | users: User[]; 26 | nextPage?: number | null; 27 | }; 28 | 29 | export type GetUsersInfiniteArgs = { 30 | count?: string; 31 | }; 32 | 33 | export type GetUsersListArgs = { 34 | page?: string; 35 | }; 36 | 37 | export type RefreshTokenMutationResponse = { 38 | accessToken: string; 39 | refreshToken: string; 40 | }; 41 | 42 | // API_ACTION_TYPES 43 | -------------------------------------------------------------------------------- /src/api/actions/index.ts: -------------------------------------------------------------------------------- 1 | import { authMutations } from './auth/auth.mutations'; 2 | 3 | export const mutations = { 4 | ...authMutations, 5 | // API_COLLECTION_MUTATIONS 6 | } as const; 7 | 8 | export type AxiosMutationsType = typeof mutations; 9 | -------------------------------------------------------------------------------- /src/api/axios/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { requestSuccessInterceptor } from 'context/apiClient/apiClientContextController/interceptors/requestInterceptors'; 4 | import { 5 | responseFailureInterceptor, 6 | responseSuccessInterceptor, 7 | } from 'context/apiClient/apiClientContextController/interceptors/responseInterceptors'; 8 | 9 | export const BASE_URL = import.meta.env.VITE_API_URL; 10 | 11 | const axiosClient = axios.create({ 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | }, 15 | baseURL: BASE_URL, 16 | }); 17 | 18 | axiosClient.interceptors.request.use(requestSuccessInterceptor); 19 | axiosClient.interceptors.response.use(responseSuccessInterceptor, responseFailureInterceptor); 20 | 21 | // eslint-disable-next-line import/no-default-export 22 | export default axiosClient; 23 | -------------------------------------------------------------------------------- /src/api/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { DefaultBodyType, HttpResponse, PathParams } from 'msw'; 2 | 3 | import { 4 | GetMeQueryResponse, 5 | GetUsersResponse, 6 | LoginMutationArguments, 7 | LoginMutationResponse, 8 | } from 'api/actions/auth/auth.types'; 9 | 10 | import { http } from './http'; 11 | 12 | const authorizeHandler = http.post('/authorize', async () => 13 | HttpResponse.json( 14 | { 15 | accessToken: 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3', 16 | tokenType: 'bearer', 17 | expires: 123, 18 | refreshToken: 'IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk', 19 | }, 20 | { status: 200 }, 21 | ), 22 | ); 23 | const meHandler = http.get('/me', async () => 24 | HttpResponse.json( 25 | { 26 | firstName: 'Mike', 27 | lastName: 'Tyson', 28 | username: 'mike', 29 | }, 30 | { status: 200 }, 31 | ), 32 | ); 33 | 34 | const createUsers = (numUsers = 40) => { 35 | return Array.from({ length: numUsers }, (el, index) => ({ id: `${index}`, name: `User ${index + 1}` })); 36 | }; 37 | 38 | const usersHandler = http.get('/users', ({ request }) => { 39 | const url = new URL(request.url); 40 | 41 | const pageParam = url.searchParams.get('page'); 42 | const countParam = url.searchParams.get('count'); 43 | const page = pageParam ? parseInt(pageParam) : null; 44 | const count = countParam ? parseInt(countParam) : null; 45 | const allUsers = createUsers(); 46 | 47 | if (page === null || count === null) { 48 | return HttpResponse.json({ users: allUsers }, { status: 200 }); 49 | } 50 | 51 | const start = (page - 1) * count; 52 | const end = start + count; 53 | const nextPageCursor = end >= allUsers.length ? null : page + 1; 54 | const paginatedUsers = allUsers.slice(start, end); 55 | 56 | return HttpResponse.json({ users: paginatedUsers, nextPage: nextPageCursor }, { status: 200 }); 57 | }); 58 | 59 | export const handlers = [authorizeHandler, meHandler, usersHandler]; 60 | -------------------------------------------------------------------------------- /src/api/mocks/http.ts: -------------------------------------------------------------------------------- 1 | import { http as baseHttp } from 'msw'; 2 | 3 | const BASE_URL = import.meta.env.VITE_API_URL; 4 | 5 | const createRestHandler = ( 6 | method: MethodType, 7 | ): (typeof baseHttp)[MethodType] => { 8 | const wrapperFn = ((...params: Parameters<(typeof baseHttp)[MethodType]>) => { 9 | const [path, resolver] = params; 10 | 11 | const url = new RegExp('^(?:[a-z+]+:)?//', 'i').test(path.toString()) ? path : `${BASE_URL}${path}`; 12 | 13 | return baseHttp[method](url, resolver); 14 | }) as (typeof baseHttp)[MethodType]; 15 | 16 | return wrapperFn; 17 | }; 18 | 19 | export const http = { 20 | head: createRestHandler('head'), 21 | get: createRestHandler('get'), 22 | post: createRestHandler('post'), 23 | put: createRestHandler('put'), 24 | delete: createRestHandler('delete'), 25 | patch: createRestHandler('patch'), 26 | options: createRestHandler('options'), 27 | }; 28 | -------------------------------------------------------------------------------- /src/api/mocks/mock-worker.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw/browser'; 2 | 3 | import { handlers } from './handlers'; 4 | 5 | export const worker = setupWorker(...handlers); 6 | -------------------------------------------------------------------------------- /src/api/types/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { QueryMeta } from '@tanstack/react-query'; 3 | import { AxiosRequestConfig } from 'axios'; 4 | 5 | export type MutationHTTPMethod = 'DELETE' | 'POST' | 'PUT' | 'PATCH'; 6 | 7 | export type Unwrap = T extends PromiseLike ? U : T; 8 | 9 | export type ExtendedQueryMeta = QueryMeta & { 10 | error: { excludedCodes: number[]; showGlobalError: boolean }; 11 | }; 12 | 13 | export type ExtendedAxiosRequestConfig = AxiosRequestConfig & { 14 | _retry?: boolean; 15 | }; 16 | -------------------------------------------------------------------------------- /src/api/utils/queryFactoryOptions.ts: -------------------------------------------------------------------------------- 1 | import { StandardizedApiError } from 'context/apiClient/apiClientContextController/apiError/apiError.types'; 2 | import { UseInfiniteQueryOptions } from 'hooks/useInfiniteQuery/useInfiniteQuery.types'; 3 | import { UseQueryOptions } from 'hooks/useQuery/useQuery.types'; 4 | 5 | export const queryFactoryOptions = ( 6 | options: UseQueryOptions, 7 | ) => options; 8 | 9 | export const infiniteQueryFactoryOptions = < 10 | TQueryFnData = unknown, 11 | TPageParam = unknown, 12 | TError = StandardizedApiError, 13 | >( 14 | options: UseInfiniteQueryOptions, 15 | ) => options; 16 | -------------------------------------------------------------------------------- /src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/vite-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/assets/images/vitest-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/assets/styles/loader.css: -------------------------------------------------------------------------------- 1 | /* non minified version of the CSS embeded in public/index.html */ 2 | .gooey { 3 | position: absolute; 4 | top: 50%; 5 | left: 50%; 6 | width: 142px; 7 | height: 40px; 8 | margin: -20px 0 0 -71px; 9 | background: #fff; 10 | opacity: 1; 11 | animation: gooey 2s; 12 | filter: contrast(20); 13 | } 14 | .gooey .dot { 15 | position: absolute; 16 | width: 16px; 17 | height: 16px; 18 | top: 12px; 19 | left: 15px; 20 | filter: blur(4px); 21 | background: #000; 22 | border-radius: 50%; 23 | transform: translateX(0); 24 | animation: dot 2.8s infinite; 25 | } 26 | .gooey .dots { 27 | transform: translateX(0); 28 | margin-top: 12px; 29 | margin-left: 31px; 30 | animation: dots 2.8s infinite; 31 | } 32 | .gooey .dots span { 33 | display: block; 34 | float: left; 35 | width: 16px; 36 | height: 16px; 37 | margin-left: 16px; 38 | filter: blur(4px); 39 | background: #000; 40 | border-radius: 50%; 41 | } 42 | 43 | @keyframes gooey { 44 | 0% { 45 | opacity: 0; 46 | } 47 | 40% { 48 | opacity: 0; 49 | } 50 | 100% { 51 | opacity: 1; 52 | } 53 | } 54 | 55 | @keyframes dot { 56 | 50% { 57 | transform: translateX(96px); 58 | } 59 | } 60 | 61 | @keyframes dots { 62 | 50% { 63 | transform: translateX(-31px); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/assets/styles/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 4 | 'Droid Sans', 'Helvetica Neue', sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | } 8 | 9 | code { 10 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 11 | } 12 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContext/ApiClientContext.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'tests'; 2 | 3 | import { ApiClientContext } from './ApiClientContext'; 4 | 5 | describe('ApiClientContext', () => { 6 | test('correctly receive strategy', () => { 7 | render( 8 | {(context) =>
{typeof context}
}
, 9 | { 10 | wrapper: ({ children }) => <>{children}, 11 | }, 12 | ); 13 | 14 | expect(screen.getByTitle(/CONTEXT/)).toBeInTheDocument(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContext/ApiClientContext.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { createContext } from 'react'; 3 | 4 | import { ApiClientContextValue } from './ApiClientContext.types'; 5 | 6 | export const ApiClientContext = createContext(undefined); 7 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContext/ApiClientContext.types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios'; 2 | 3 | export type ApiResponse = { 4 | data: TData; 5 | config: TConfig | null; 6 | }; 7 | 8 | export type ApiClientContextValue = { 9 | client: AxiosInstance; 10 | }; 11 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContextController/ApiClientContextController.test.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { render, screen } from 'tests'; 4 | 5 | import { ApiClientContextController } from './ApiClientContextController'; 6 | 7 | describe('ApiClientContextController', () => { 8 | const wrapper = ({ children }: { children?: ReactNode }) => <>{children}; 9 | 10 | test('renders its children', () => { 11 | render( 12 | 13 | TEST 14 | , 15 | { wrapper }, 16 | ); 17 | 18 | expect(screen.getByText(/TEST/)).toBeInTheDocument(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContextController/ApiClientContextController.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 | 4 | import { ApiClientContext } from 'context/apiClient/apiClientContext/ApiClientContext'; 5 | import { ApiClientContextValue } from 'context/apiClient/apiClientContext/ApiClientContext.types'; 6 | import axiosClient from 'api/axios'; 7 | import { useHandleQueryErrors } from 'hooks/useHandleQueryErrors/useHandleQueryErrors'; 8 | import { ExtendedQueryMeta } from 'api/types/types'; 9 | 10 | import { ApiClientControllerProps } from './ApiClientContextController.types'; 11 | import { StandardizedApiError } from './apiError/apiError.types'; 12 | 13 | const metaErrorConfig = { error: { showGlobalError: true, excludedCodes: [] } }; 14 | 15 | export const ApiClientContextController = ({ children }: ApiClientControllerProps) => { 16 | const { handleErrors, shouldHandleGlobalError } = useHandleQueryErrors(); 17 | 18 | const mutationCache = new MutationCache({ 19 | onError: (err, variables, context, mutation) => { 20 | const error = err as StandardizedApiError; 21 | shouldHandleGlobalError((mutation.meta as ExtendedQueryMeta)?.error, error?.statusCode) && handleErrors(error); 22 | }, 23 | }); 24 | 25 | const queryCache = new QueryCache({ 26 | onError: (err, query) => { 27 | const error = err as StandardizedApiError; 28 | 29 | shouldHandleGlobalError((query.meta as ExtendedQueryMeta)?.error, error?.statusCode) && handleErrors(error); 30 | }, 31 | }); 32 | 33 | const queryClient = useMemo( 34 | () => 35 | new QueryClient({ 36 | defaultOptions: { queries: { refetchOnWindowFocus: false, meta: metaErrorConfig } }, 37 | mutationCache, 38 | queryCache, 39 | }), 40 | // eslint-disable-next-line react-hooks/exhaustive-deps 41 | [], 42 | ); 43 | 44 | const ctx: ApiClientContextValue = { client: axiosClient }; 45 | 46 | return ( 47 | 48 | {children} 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContextController/ApiClientContextController.types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type ApiClientControllerProps = { 4 | children: ReactNode; 5 | }; 6 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContextController/apiError/apiError.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosResponse } from 'axios'; 2 | 3 | import { getStandardizedApiError } from './apiError'; 4 | 5 | const getMockAxiosError = (data: unknown) => { 6 | return new AxiosError('mockError', 'ERR', undefined, undefined, { 7 | status: 400, 8 | data, 9 | } as AxiosResponse); 10 | }; 11 | 12 | describe('getStandardizedApiError', () => { 13 | it('returns basic ApiError', () => { 14 | const errorData = { 15 | error: { 16 | code: 'MOCK_ERROR', 17 | message: 'Mock error', 18 | }, 19 | }; 20 | const mockAxiosError = getMockAxiosError(errorData); 21 | 22 | expect(getStandardizedApiError(mockAxiosError)).toMatchObject({ 23 | type: 'basic', 24 | statusCode: 400, 25 | data: errorData, 26 | originalError: mockAxiosError, 27 | }); 28 | }); 29 | 30 | it('returns form ApiError', () => { 31 | const errorData = { 32 | errors: { 33 | name: ['MOCK_ERROR'], 34 | }, 35 | }; 36 | const mockAxiosError = getMockAxiosError(errorData); 37 | 38 | expect(getStandardizedApiError(mockAxiosError)).toMatchObject({ 39 | type: 'form', 40 | statusCode: 400, 41 | data: errorData, 42 | originalError: mockAxiosError, 43 | }); 44 | }); 45 | 46 | it('returns unknown ApiError', () => { 47 | const errorData = { 48 | data: { 49 | error: 'some unknown error shape', 50 | }, 51 | }; 52 | const mockAxiosError = getMockAxiosError(errorData); 53 | 54 | expect(getStandardizedApiError(mockAxiosError)).toMatchObject({ 55 | statusCode: 400, 56 | type: 'unknown', 57 | data: errorData, 58 | originalError: mockAxiosError, 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContextController/apiError/apiError.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | import zod from 'zod'; 3 | 4 | import { BasicApiError, BasicErrorData, FormApiError, FormErrorData, UnknownApiError } from './apiError.types'; 5 | 6 | export class ApiError extends Error { 7 | readonly originalError; 8 | readonly statusCode; 9 | readonly type; 10 | readonly data; 11 | 12 | constructor(data: T, message?: string) { 13 | super(message); 14 | this.name = 'ApiError'; 15 | this.originalError = data.originalError; 16 | this.type = data.type; 17 | this.statusCode = data.statusCode; 18 | this.data = data.data; 19 | } 20 | } 21 | 22 | export const getStandardizedApiError = ( 23 | error: AxiosError, 24 | ): ApiError | ApiError | ApiError => { 25 | const errorData = error.response?.data; 26 | const standarizedError = { 27 | type: 'unknown', 28 | statusCode: error.response?.status, 29 | originalError: error, 30 | data: errorData, 31 | } satisfies UnknownApiError; 32 | 33 | if (isBasicErrorData(errorData)) { 34 | return new ApiError({ 35 | ...standarizedError, 36 | type: 'basic', 37 | } as BasicApiError); 38 | } 39 | if (isFormErrorData(errorData)) { 40 | return new ApiError({ 41 | ...standarizedError, 42 | type: 'form', 43 | } as FormApiError); 44 | } 45 | 46 | return new ApiError(standarizedError); 47 | }; 48 | 49 | export const basicErrorDataSchema = zod.object({ 50 | error: zod.object({ 51 | code: zod.string(), 52 | message: zod.string().optional(), 53 | }), 54 | }); 55 | 56 | const isBasicErrorData = (error: unknown): error is BasicErrorData => { 57 | const { success } = basicErrorDataSchema.safeParse(error); 58 | return success; 59 | }; 60 | 61 | export const formErrorDataSchema = zod.object({ 62 | errors: zod.record(zod.string(), zod.array(zod.string())), 63 | }); 64 | 65 | const isFormErrorData = (error: unknown): error is FormErrorData => { 66 | const { success } = formErrorDataSchema.safeParse(error); 67 | return success; 68 | }; 69 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContextController/apiError/apiError.types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | import zod from 'zod'; 3 | 4 | import { ApiError } from './apiError'; 5 | import { formErrorDataSchema, basicErrorDataSchema } from './apiError'; 6 | 7 | export type FormErrorData = zod.infer; 8 | 9 | export type BasicErrorData = zod.infer; 10 | 11 | type BaseApiError = { 12 | statusCode: number | undefined; 13 | data: TData; 14 | originalError: AxiosError; 15 | }; 16 | 17 | export type BasicApiError = { type: 'basic' } & BaseApiError; 18 | 19 | export type FormApiError = { type: 'form' } & BaseApiError; 20 | 21 | export type UnknownApiError = { type: 'unknown' } & BaseApiError; 22 | 23 | export type StandardizedApiError = ApiError | ApiError | ApiError; 24 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContextController/interceptors/requestInterceptors.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestHeaders, InternalAxiosRequestConfig } from 'axios'; 2 | import { jwtDecode } from 'jwt-decode'; 3 | 4 | import { RefreshTokenMutationResponse } from 'api/actions/auth/auth.types'; 5 | import { authStorage } from 'context/auth/authStorage/AuthStorage'; 6 | import { refreshTokenUrl } from 'api/actions/auth/auth.mutations'; 7 | 8 | export const requestSuccessInterceptor = async ( 9 | config: InternalAxiosRequestConfig, 10 | ): Promise => { 11 | if (!authStorage.accessToken || authStorage.expires === null) { 12 | return config; 13 | } 14 | 15 | const secondsSinceEpoch = Math.round(new Date().getTime() / 1000); 16 | const isTokenExpired = secondsSinceEpoch >= authStorage.expires; 17 | 18 | if (isTokenExpired) { 19 | try { 20 | const { data } = await axios.post(refreshTokenUrl, { 21 | accessToken: authStorage.accessToken, 22 | refreshToken: authStorage.refreshToken, 23 | }); 24 | 25 | const { exp } = jwtDecode<{ exp: number }>(data.accessToken); 26 | 27 | authStorage.accessToken = data.accessToken; 28 | authStorage.expires = exp; 29 | authStorage.refreshToken = data.refreshToken; 30 | } catch (e) { 31 | authStorage.accessToken = null; 32 | authStorage.expires = null; 33 | authStorage.refreshToken = null; 34 | } 35 | 36 | return { 37 | ...config, 38 | withCredentials: false, 39 | headers: { 40 | ...config.headers, 41 | Authorization: `Bearer ${authStorage.accessToken}`, 42 | } as AxiosRequestHeaders, 43 | }; 44 | } 45 | 46 | return config; 47 | }; 48 | -------------------------------------------------------------------------------- /src/context/apiClient/apiClientContextController/interceptors/responseInterceptors.ts: -------------------------------------------------------------------------------- 1 | import axios, { type AxiosError, AxiosResponse } from 'axios'; 2 | import { jwtDecode } from 'jwt-decode'; 3 | 4 | import { authStorage } from 'context/auth/authStorage/AuthStorage'; 5 | import { getStandardizedApiError } from 'context/apiClient/apiClientContextController/apiError/apiError'; 6 | import { ExtendedAxiosRequestConfig } from 'api/types/types'; 7 | import { RefreshTokenMutationResponse } from 'api/actions/auth/auth.types'; 8 | import { refreshTokenUrl } from 'api/actions/auth/auth.mutations'; 9 | 10 | export const responseSuccessInterceptor = (response: AxiosResponse) => response; 11 | 12 | export const responseFailureInterceptor = async (error: AxiosError) => { 13 | const standarizedError = getStandardizedApiError(error); 14 | 15 | const originalRequest = error.config as ExtendedAxiosRequestConfig; 16 | 17 | if (standarizedError.statusCode === 401 && originalRequest?._retry) { 18 | authStorage.accessToken = null; 19 | authStorage.expires = null; 20 | authStorage.refreshToken = null; 21 | 22 | window.location.replace('/login'); 23 | 24 | return Promise.reject(standarizedError); 25 | } 26 | 27 | if (standarizedError.statusCode === 401 && originalRequest) { 28 | originalRequest._retry = true; 29 | 30 | try { 31 | const { data } = await axios.post(refreshTokenUrl, { 32 | accessToken: authStorage.accessToken, 33 | refreshToken: authStorage.refreshToken, 34 | }); 35 | const { exp } = jwtDecode<{ exp: number }>(data.accessToken); 36 | 37 | authStorage.accessToken = data.accessToken; 38 | authStorage.expires = exp; 39 | authStorage.refreshToken = data.refreshToken; 40 | 41 | return axios(originalRequest); 42 | } catch { 43 | authStorage.accessToken = null; 44 | authStorage.expires = null; 45 | authStorage.refreshToken = null; 46 | window.location.replace('/login'); 47 | 48 | return Promise.reject(standarizedError); 49 | } 50 | } 51 | 52 | return Promise.reject(standarizedError); 53 | }; 54 | -------------------------------------------------------------------------------- /src/context/auth/authActionCreators/authActionCreators.ts: -------------------------------------------------------------------------------- 1 | import { AuthActionType, ResetTokensAction, SetTokensAction, SetTokensPayload } from './authActionCreators.types'; 2 | 3 | export const setTokens = (payload: SetTokensPayload): SetTokensAction => ({ 4 | type: AuthActionType.setTokens, 5 | payload, 6 | }); 7 | 8 | export const resetTokens = (): ResetTokensAction => ({ 9 | type: AuthActionType.resetTokens, 10 | }); 11 | -------------------------------------------------------------------------------- /src/context/auth/authActionCreators/authActionCreators.types.ts: -------------------------------------------------------------------------------- 1 | export enum AuthActionType { 2 | setTokens = 'set-tokens', 3 | resetTokens = 'reset-tokens', 4 | setIsAuthenticated = 'set-is-authenticated', 5 | } 6 | 7 | export type SetTokensPayload = { 8 | accessToken: string; 9 | refreshToken: string; 10 | expires: number; 11 | }; 12 | 13 | export type SetTokensAction = { 14 | type: AuthActionType.setTokens; 15 | payload: SetTokensPayload; 16 | }; 17 | 18 | export type ResetTokensAction = { 19 | type: AuthActionType.resetTokens; 20 | }; 21 | 22 | export type AuthAction = SetTokensAction | ResetTokensAction; 23 | -------------------------------------------------------------------------------- /src/context/auth/authContext/AuthContext.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'tests'; 2 | 3 | import { AuthContext } from './AuthContext'; 4 | 5 | describe('AuthContext', () => { 6 | test('is undefined by default', () => { 7 | render({(context) =>
{typeof context}
}
, { 8 | wrapper: ({ children }) => <>{children}, 9 | }); 10 | 11 | expect(screen.getByTitle(/CONTEXT/)).toHaveTextContent('undefined'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/context/auth/authContext/AuthContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import { AuthContextValue } from './AuthContext.types'; 4 | 5 | export const AuthContext = createContext(undefined); 6 | -------------------------------------------------------------------------------- /src/context/auth/authContext/AuthContext.types.ts: -------------------------------------------------------------------------------- 1 | import { GetMeQueryResponse, LoginMutationArguments } from 'api/actions/auth/auth.types'; 2 | import { AuthState } from '../authReducer/authReducer.types'; 3 | 4 | export type AuthContextValue = AuthState & { 5 | isAuthenticated: boolean; 6 | isAuthenticating: boolean; 7 | login: ({ password, username }: LoginMutationArguments) => void; 8 | logout: VoidFunction; 9 | user: GetMeQueryResponse | undefined; 10 | }; 11 | -------------------------------------------------------------------------------- /src/context/auth/authContextController/AuthContextController.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useReducer } from 'react'; 2 | 3 | import { useMutation } from 'hooks/useMutation/useMutation'; 4 | import { useUser } from '../../../hooks/useUser/useUser'; 5 | import { resetTokens, setTokens } from '../authActionCreators/authActionCreators'; 6 | import { AuthContext } from '../authContext/AuthContext'; 7 | import { AuthContextValue } from '../authContext/AuthContext.types'; 8 | import { authReducer } from '../authReducer/authReducer'; 9 | import { authStorage } from '../authStorage/AuthStorage'; 10 | 11 | import { AuthContextControllerProps } from './AuthContextController.types'; 12 | 13 | export const AuthContextController = ({ children }: AuthContextControllerProps) => { 14 | const [state, dispatch] = useReducer(authReducer, { 15 | accessToken: authStorage.accessToken, 16 | refreshToken: authStorage.refreshToken, 17 | expires: authStorage.expires, 18 | }); 19 | 20 | const { 21 | data: user, 22 | isLoadingAndEnabled, 23 | isSuccess: isUserSuccess, 24 | isError, 25 | resetUser, 26 | } = useUser({ 27 | enabled: !!state.accessToken, 28 | }); 29 | 30 | const { mutateAsync: login, isPending: isAuthenticating } = useMutation('loginMutation', { 31 | onSuccess: (res) => { 32 | dispatch( 33 | setTokens({ 34 | accessToken: res.accessToken, 35 | refreshToken: res.refreshToken, 36 | expires: res.expires, 37 | }), 38 | ); 39 | }, 40 | onError: () => { 41 | dispatch(resetTokens()); 42 | resetUser(); 43 | }, 44 | }); 45 | 46 | const logout = useCallback(() => { 47 | resetUser(); 48 | dispatch(resetTokens()); 49 | }, [resetUser]); 50 | 51 | useEffect(() => { 52 | if (isError) { 53 | dispatch(resetTokens()); 54 | } 55 | }, [isError]); 56 | 57 | useEffect(() => { 58 | authStorage.accessToken = state.accessToken; 59 | authStorage.expires = state.expires; 60 | authStorage.refreshToken = state.refreshToken; 61 | }, [state]); 62 | 63 | const value: AuthContextValue = useMemo( 64 | () => ({ 65 | ...state, 66 | isAuthenticating: isAuthenticating || isLoadingAndEnabled, 67 | isAuthenticated: isUserSuccess, 68 | login, 69 | logout, 70 | user, 71 | }), 72 | [state, isAuthenticating, isUserSuccess, isLoadingAndEnabled, login, logout, user], 73 | ); 74 | 75 | return {children}; 76 | }; 77 | -------------------------------------------------------------------------------- /src/context/auth/authContextController/AuthContextController.types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type AuthContextControllerProps = { 4 | children: ReactNode; 5 | }; 6 | -------------------------------------------------------------------------------- /src/context/auth/authReducer/authReducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'react'; 2 | 3 | import { AuthAction, AuthActionType } from '../authActionCreators/authActionCreators.types'; 4 | 5 | import { AuthState } from './authReducer.types'; 6 | 7 | export const authReducer: Reducer = (prevState, action) => { 8 | switch (action.type) { 9 | case AuthActionType.setTokens: 10 | return { 11 | ...prevState, 12 | ...action.payload, 13 | }; 14 | case AuthActionType.resetTokens: 15 | return { 16 | ...prevState, 17 | accessToken: null, 18 | refreshToken: null, 19 | expires: null, 20 | }; 21 | default: 22 | return prevState; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/context/auth/authReducer/authReducer.types.ts: -------------------------------------------------------------------------------- 1 | export type AuthState = { 2 | accessToken: string | null; 3 | refreshToken: string | null; 4 | expires: number | null; 5 | }; 6 | -------------------------------------------------------------------------------- /src/context/auth/authStorage/AuthStorage.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from './AuthStorage.types'; 2 | 3 | const ACCESS_TOKEN_KEY = 'accessToken'; 4 | const REFRESH_TOKEN_KEY = 'refreshToken'; 5 | const EXPIRES_KEY = 'expires'; 6 | 7 | class AuthStorage { 8 | private _accessToken: string | null = null; 9 | private _refreshToken: string | null = null; 10 | private _expires: number | null = null; 11 | private _storage: Storage | null = null; 12 | 13 | constructor(_storage: Storage) { 14 | try { 15 | this._storage = _storage; 16 | this.accessToken = _storage.getItem(ACCESS_TOKEN_KEY); 17 | this.refreshToken = _storage.getItem(REFRESH_TOKEN_KEY); 18 | this.expires = Number(_storage.getItem(EXPIRES_KEY)); 19 | } catch (error) { 20 | this._storage = null; 21 | this.accessToken = null; 22 | this.refreshToken = null; 23 | this.expires = null; 24 | } 25 | } 26 | 27 | get accessToken(): string | null { 28 | return this._accessToken; 29 | } 30 | 31 | set accessToken(value: string | null) { 32 | this._accessToken = value; 33 | 34 | try { 35 | if (typeof value === 'string') { 36 | this._storage?.setItem(ACCESS_TOKEN_KEY, value); 37 | } else { 38 | this._storage?.removeItem(ACCESS_TOKEN_KEY); 39 | } 40 | } catch (error) { 41 | this._storage?.onError(error); 42 | } 43 | } 44 | 45 | get refreshToken(): string | null { 46 | return this._refreshToken; 47 | } 48 | 49 | set refreshToken(value: string | null) { 50 | this._refreshToken = value; 51 | 52 | try { 53 | if (typeof value === 'string') { 54 | this._storage?.setItem(REFRESH_TOKEN_KEY, value); 55 | } else { 56 | this._storage?.removeItem(REFRESH_TOKEN_KEY); 57 | } 58 | } catch (error) { 59 | this._storage?.onError(error); 60 | } 61 | } 62 | 63 | get expires(): number | null { 64 | return this._expires; 65 | } 66 | 67 | set expires(value: number | null) { 68 | this._expires = value; 69 | 70 | try { 71 | if (typeof value === 'number') { 72 | this._storage?.setItem(EXPIRES_KEY, value.toString()); 73 | } else { 74 | this._storage?.removeItem(EXPIRES_KEY); 75 | } 76 | } catch (error) { 77 | this._storage?.onError(error); 78 | } 79 | } 80 | } 81 | 82 | const storage: Storage = { 83 | getItem: (key: string) => sessionStorage.getItem(key), 84 | setItem: (key: string, value: string) => sessionStorage.setItem(key, value), 85 | removeItem: (key: string) => sessionStorage.removeItem(key), 86 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 87 | onError: (error: unknown) => { 88 | // handle errors here 89 | }, 90 | }; 91 | 92 | export const authStorage = new AuthStorage(storage); 93 | -------------------------------------------------------------------------------- /src/context/auth/authStorage/AuthStorage.types.ts: -------------------------------------------------------------------------------- 1 | export interface Storage { 2 | getItem: (key: string) => TItem; 3 | setItem: (key: string, value: string) => void; 4 | removeItem: (key: string) => void; 5 | onError: (error: unknown) => void; 6 | } 7 | -------------------------------------------------------------------------------- /src/context/locale/AppLocale.enum.ts: -------------------------------------------------------------------------------- 1 | export enum AppLocale { 2 | en = 'en', 3 | pl = 'pl', 4 | } 5 | -------------------------------------------------------------------------------- /src/context/locale/defaultLocale.ts: -------------------------------------------------------------------------------- 1 | // see https://vitejs.dev/guide/env-and-mode.html 2 | import { AppLocale } from './AppLocale.enum'; 3 | 4 | export const defaultLocale: AppLocale = import.meta.env.VITE_DEFAULT_LOCALE as AppLocale; 5 | -------------------------------------------------------------------------------- /src/context/locale/localeContext/LocaleContext.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'tests'; 2 | 3 | import { LocaleContext } from './LocaleContext'; 4 | 5 | describe('LocaleContext', () => { 6 | test('is undefined by default', () => { 7 | render( 8 | {(context) =>
{typeof context}
}
, 9 | { 10 | wrapper: ({ children }) => <>{children}, 11 | }, 12 | ); 13 | 14 | expect(screen.getByTitle(/CONTEXT/)).toHaveTextContent('undefined'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/context/locale/localeContext/LocaleContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import { LocaleContextValueType } from './LocaleContext.types'; 4 | 5 | export const LocaleContext = createContext(undefined); 6 | -------------------------------------------------------------------------------- /src/context/locale/localeContext/LocaleContext.types.ts: -------------------------------------------------------------------------------- 1 | import { AppLocale } from '../AppLocale.enum'; 2 | 3 | export type LocaleContextValueType = { 4 | defaultLocale: AppLocale; 5 | locale: AppLocale; 6 | setLocale: (locale: AppLocale) => void; 7 | }; 8 | -------------------------------------------------------------------------------- /src/context/locale/localeContextController/LocaleContextController.test.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useContext } from 'react'; 2 | 3 | import { act, render, screen } from 'tests'; 4 | import { AppLocale } from '../AppLocale.enum'; 5 | import { defaultLocale } from '../defaultLocale'; 6 | import { LocaleContext } from '../localeContext/LocaleContext'; 7 | 8 | import { LocaleContextController } from './LocaleContextController'; 9 | 10 | describe('LocaleContextController', () => { 11 | const wrapper = ({ children }: { children?: ReactNode }) => <>{children}; 12 | 13 | const TestComponent = () => { 14 | const context = useContext(LocaleContext); 15 | 16 | return ( 17 | <> 18 | 19 |
{JSON.stringify(context)}
20 | 21 | ); 22 | }; 23 | 24 | test('renders its children', () => { 25 | render( 26 | 27 | TEST 28 | , 29 | { wrapper }, 30 | ); 31 | 32 | expect(screen.getByText(/TEST/)).toBeInTheDocument(); 33 | }); 34 | 35 | test('provides functioning locale context', () => { 36 | render( 37 | 38 | 39 | , 40 | { wrapper }, 41 | ); 42 | 43 | expect(screen.getByTitle(/CONTEXT/)).toHaveTextContent( 44 | JSON.stringify({ 45 | defaultLocale, 46 | locale: defaultLocale, 47 | }), 48 | ); 49 | 50 | act(() => screen.getByText(/SET LOCALE/).click()); 51 | 52 | expect(screen.getByTitle(/CONTEXT/)).toHaveTextContent( 53 | JSON.stringify({ 54 | defaultLocale, 55 | locale: AppLocale.pl, 56 | }), 57 | ); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/context/locale/localeContextController/LocaleContextController.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { IntlProvider } from 'react-intl'; 3 | 4 | import { AppLocale } from '../AppLocale.enum'; 5 | import { defaultLocale } from '../defaultLocale'; 6 | import { translations } from 'i18n/messages'; 7 | import { LocaleContext } from '../localeContext/LocaleContext'; 8 | 9 | import { LocaleContextControllerProps } from './LocaleContextController.types'; 10 | 11 | export const LocaleContextController = ({ children }: LocaleContextControllerProps) => { 12 | const [locale, setLocale] = useState(defaultLocale); 13 | 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/context/locale/localeContextController/LocaleContextController.types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type LocaleContextControllerProps = { 4 | children: ReactNode; 5 | }; 6 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMetaEnv { 2 | readonly VITE_DEFAULT_LOCALE: string; 3 | readonly VITE_API_URL: string; 4 | readonly VITE_SENTRY_DSN: string; 5 | readonly VITE_CI: string; 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv; 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useApiClient/useApiClient'; 2 | export * from './useAuth/useAuth'; 3 | export * from './useInfiniteQuery/useInfiniteQuery'; 4 | export * from './useLocale/useLocale'; 5 | export * from './useMutation/useMutation'; 6 | export * from './useQuery/useQuery'; 7 | -------------------------------------------------------------------------------- /src/hooks/useApiClient/useApiClient.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { ApiClientContext } from '../../context/apiClient/apiClientContext/ApiClientContext'; 4 | 5 | export const useApiClient = () => { 6 | const ctx = useContext(ApiClientContext); 7 | 8 | if (typeof ctx === 'undefined') { 9 | throw new Error('useApiClient hook is not wrapped by ApiClient provider'); 10 | } 11 | 12 | return ctx; 13 | }; 14 | -------------------------------------------------------------------------------- /src/hooks/useAuth/useAuth.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from 'tests'; 2 | import { AppProviders } from 'providers/AppProviders'; 3 | import axiosClient from 'api/axios'; 4 | 5 | import { useAuth } from './useAuth'; 6 | 7 | const mockedTokens = { 8 | accessToken: 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3', 9 | }; 10 | 11 | describe('useAuth', () => { 12 | test('removes token from session storage', async () => { 13 | const { result } = renderHook(() => useAuth(), { 14 | wrapper: ({ children }) => ( 15 | 16 | <>{children}, 17 | 18 | ), 19 | }); 20 | 21 | act(() => global.sessionStorage.setItem('accessToken', mockedTokens.accessToken)); 22 | act(() => result.current.logout()); 23 | expect(global.sessionStorage.getItem('accessToken')).toBeNull(); 24 | }); 25 | 26 | test('adds token to session storage', async () => { 27 | const response = { status: 200, data: mockedTokens }; 28 | vitest.spyOn(axiosClient, 'post').mockResolvedValue(response); 29 | 30 | const { result } = renderHook(() => useAuth(), { 31 | wrapper: ({ children }) => ( 32 | 33 | <>{children}, 34 | 35 | ), 36 | }); 37 | 38 | await act(() => result.current?.login({ password: 'foo', username: 'bar' })); 39 | expect(global.sessionStorage.getItem('accessToken')).toBe('MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/hooks/useAuth/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { AuthContext } from 'context/auth/authContext/AuthContext'; 4 | 5 | export const useAuth = () => { 6 | const context = useContext(AuthContext); 7 | if (context === undefined) { 8 | throw new Error('AuthContext must be within AuthProvider'); 9 | } 10 | 11 | return context; 12 | }; 13 | -------------------------------------------------------------------------------- /src/hooks/useHandleQueryErrors/useHandleQueryErrors.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | 3 | import { useHandleQueryErrors } from './useHandleQueryErrors'; 4 | 5 | describe('useHandleQueryErrors', () => { 6 | describe('shouldHandleGlobalError', () => { 7 | it('should return false when no metaError & errorCode provided', () => { 8 | const { result } = renderHook(useHandleQueryErrors); 9 | 10 | expect(result.current.shouldHandleGlobalError()).toEqual(false); 11 | }); 12 | 13 | it('should return false when no metaError provided', () => { 14 | const { result } = renderHook(useHandleQueryErrors); 15 | 16 | expect(result.current.shouldHandleGlobalError(undefined, 400)).toEqual(false); 17 | }); 18 | 19 | it('should return false when no errorCode provided', () => { 20 | const { result } = renderHook(useHandleQueryErrors); 21 | 22 | expect(result.current.shouldHandleGlobalError({ excludedCodes: [400], showGlobalError: true })).toEqual(false); 23 | }); 24 | 25 | it('should return false when errorCode is included in excldudedCodes', () => { 26 | const { result } = renderHook(useHandleQueryErrors); 27 | 28 | expect(result.current.shouldHandleGlobalError({ excludedCodes: [400], showGlobalError: true }, 400)).toEqual( 29 | false, 30 | ); 31 | }); 32 | 33 | it('should return false when showGlobalError equals false', () => { 34 | const { result } = renderHook(useHandleQueryErrors); 35 | 36 | expect(result.current.shouldHandleGlobalError({ excludedCodes: [400], showGlobalError: false }, 400)).toEqual( 37 | false, 38 | ); 39 | }); 40 | 41 | it('should return true when errorCode is not included in excludedCodes', () => { 42 | const { result } = renderHook(useHandleQueryErrors); 43 | 44 | expect(result.current.shouldHandleGlobalError({ excludedCodes: [400], showGlobalError: true }, 500)).toEqual( 45 | true, 46 | ); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/hooks/useHandleQueryErrors/useHandleQueryErrors.ts: -------------------------------------------------------------------------------- 1 | import { isClientError, isServerError } from 'utils/apiErrorStatuses'; 2 | import { ExtendedQueryMeta } from 'api/types/types'; 3 | import { logger } from 'integrations/logger'; 4 | import { StandardizedApiError } from 'context/apiClient/apiClientContextController/apiError/apiError.types'; 5 | 6 | export const useHandleQueryErrors = () => { 7 | const handleErrors = (error: StandardizedApiError) => { 8 | if (isServerError(error.statusCode)) { 9 | // show translated error message in toast/snackbar 10 | logger.error(error.originalError.message); 11 | } 12 | 13 | if (isClientError(error.statusCode)) { 14 | // show translated error message in toast/snackbar 15 | logger.error(error.originalError.message); 16 | } 17 | }; 18 | 19 | const shouldHandleGlobalError = (metaError?: ExtendedQueryMeta['error'], errorCode?: number) => { 20 | if (!errorCode || !metaError) { 21 | return false; 22 | } 23 | 24 | return metaError.showGlobalError && !metaError.excludedCodes.includes(errorCode); 25 | }; 26 | 27 | return { handleErrors, shouldHandleGlobalError }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/hooks/useInfiniteQuery/useInfiniteQuery.ts: -------------------------------------------------------------------------------- 1 | import { useInfiniteQuery as useRQInfiniteQuery } from '@tanstack/react-query'; 2 | 3 | import { useApiClient } from 'hooks/useApiClient/useApiClient'; 4 | import { StandardizedApiError } from 'context/apiClient/apiClientContextController/apiError/apiError.types'; 5 | 6 | import { UseInfiniteQueryOptions } from './useInfiniteQuery.types'; 7 | 8 | /** 9 | * Fetching data using this hook doesn't require specifying query function like it's required in react-query 10 | * @see https://react-query.tanstack.com/guides/query-functions 11 | * This hook uses proper querying strategy provided via ApiClientContext 12 | * @see ApiClientContextController.ts 13 | * */ 14 | export const useInfiniteQuery = ( 15 | params: UseInfiniteQueryOptions, 16 | ) => { 17 | const { client } = useApiClient(); 18 | const { queryFn, ...options } = params; 19 | 20 | return useRQInfiniteQuery({ 21 | ...options, 22 | queryFn: queryFn(client), 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /src/hooks/useInfiniteQuery/useInfiniteQuery.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UseInfiniteQueryOptions as UseInfiniteRQQueryOptions, 3 | InfiniteData, 4 | QueryFunction, 5 | QueryKey, 6 | } from '@tanstack/react-query'; 7 | import { AxiosInstance } from 'axios'; 8 | 9 | import { ExtendedQueryMeta } from 'api/types/types'; 10 | import { StandardizedApiError } from 'context/apiClient/apiClientContextController/apiError/apiError.types'; 11 | 12 | export type UseInfiniteQueryOptions = Omit< 13 | UseInfiniteRQQueryOptions, TQueryFnData, QueryKey, TPageParam>, 14 | 'queryFn' 15 | > & { 16 | meta?: Partial; 17 | queryFn: (client: AxiosInstance) => QueryFunction; 18 | }; 19 | 20 | export type GenericInfiniteQueryOptions = Omit< 21 | UseInfiniteQueryOptions, 22 | 'queryKey' | 'queryFn' 23 | >; 24 | -------------------------------------------------------------------------------- /src/hooks/useLocale/useLocale.test.tsx: -------------------------------------------------------------------------------- 1 | import { IntlProvider } from 'react-intl'; 2 | 3 | import { renderHook } from 'tests'; 4 | 5 | import { useLocale } from './useLocale'; 6 | 7 | describe('useLocale', () => { 8 | test('throws when locale context is unavailable', () => { 9 | vi.spyOn(console, 'error').mockImplementation(() => {}); 10 | 11 | const renderFn = () => 12 | renderHook(() => useLocale(), { 13 | wrapper: ({ children }) => ( 14 | {}} locale=""> 15 | {children} 16 | 17 | ), 18 | }); 19 | expect(renderFn).toThrow('LocaleContext is unavailable, make sure you are using LocaleContextController'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/hooks/useLocale/useLocale.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useCallback, useMemo } from 'react'; 2 | import { useIntl } from 'react-intl'; 3 | 4 | import type { TranslateFn } from 'i18n/messages'; 5 | import { LocaleContext } from 'context/locale/localeContext/LocaleContext'; 6 | 7 | import { UseLocaleReturnType } from './useLocale.types'; 8 | 9 | export const useLocale: UseLocaleReturnType = () => { 10 | const intl = useIntl(); 11 | const localeContext = useContext(LocaleContext); 12 | 13 | if (localeContext === undefined) { 14 | throw new Error('LocaleContext is unavailable, make sure you are using LocaleContextController'); 15 | } 16 | 17 | const t: TranslateFn = useCallback((id, value?) => intl.formatMessage({ id }, value), [intl]); 18 | 19 | return useMemo( 20 | () => ({ 21 | ...intl, 22 | ...localeContext, 23 | t, 24 | }), 25 | [intl, localeContext, t], 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/hooks/useLocale/useLocale.types.ts: -------------------------------------------------------------------------------- 1 | import type { IntlShape } from 'react-intl'; 2 | 3 | import type { TranslateFn } from 'i18n/messages'; 4 | import type { LocaleContextValueType } from 'context/locale/localeContext/LocaleContext.types'; 5 | 6 | export type WithTranslateFn = { 7 | t: TranslateFn; 8 | }; 9 | 10 | export type UseLocaleReturnType = () => IntlShape & LocaleContextValueType & WithTranslateFn; 11 | -------------------------------------------------------------------------------- /src/hooks/useMutation/useMutation.test.tsx: -------------------------------------------------------------------------------- 1 | import axiosClient from 'api/axios'; 2 | import { AppProviders } from 'providers/AppProviders'; 3 | import { act, renderHook, waitFor } from 'tests'; 4 | 5 | import { useMutation } from './useMutation'; 6 | 7 | const mockMutationResponse = { 8 | token: '87sa6dsa7dfsa8d87', 9 | }; 10 | 11 | describe('useMutation', () => { 12 | test('returns the data fetched from api on mutation', async () => { 13 | const response = { status: 200, data: mockMutationResponse }; 14 | vitest.spyOn(axiosClient, 'post').mockResolvedValue(response); 15 | 16 | const { result } = renderHook(() => useMutation('loginMutation', {}), { 17 | wrapper: ({ children }) => ( 18 | 19 | <>{children}, 20 | 21 | ), 22 | }); 23 | 24 | expect(result.current.data).toBeUndefined(); 25 | act(() => result.current?.mutate({ password: 'foo', username: 'bar' })); 26 | await waitFor(() => { 27 | expect(result.current.data).toBe(mockMutationResponse); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/hooks/useMutation/useMutation.ts: -------------------------------------------------------------------------------- 1 | import { useMutation as useRQMutation, UseMutationOptions, MutationKey } from '@tanstack/react-query'; 2 | 3 | import { useApiClient } from 'hooks/useApiClient/useApiClient'; 4 | import { AxiosMutationsType, mutations } from 'api/actions'; 5 | import { StandardizedApiError } from 'context/apiClient/apiClientContextController/apiError/apiError.types'; 6 | import { ExtendedQueryMeta } from 'api/types/types'; 7 | 8 | import { DataForMutation, GetMutationParams } from './useMutation.types'; 9 | 10 | /** 11 | * Mutating data using this hook doesn't require specifying mutation function like it is required in react-query 12 | * @see https://react-query.tanstack.com/guides/mutations 13 | * This hook uses proper mutating strategy provided via ApiClientContext 14 | * @see ApiClientContextController.ts 15 | * */ 16 | 17 | export const useMutation = ( 18 | mutation: Key, 19 | options?: Omit< 20 | UseMutationOptions, TError, GetMutationParams>, 21 | 'mutationKey' | 'mutationFn' 22 | > & { 23 | meta?: Partial; 24 | }, 25 | ) => { 26 | const { client } = useApiClient(); 27 | const mutationFn = mutations[mutation](client); 28 | const mutationKey: MutationKey = [mutation]; 29 | 30 | return useRQMutation({ 31 | mutationKey, 32 | mutationFn: async (args) => (await mutationFn(args)) as DataForMutation, 33 | ...options, 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/hooks/useMutation/useMutation.types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { AxiosMutationsType } from 'api/actions'; 4 | import { Unwrap } from 'api/types/types'; 5 | 6 | export type DataForMutation = Unwrap< 7 | ReturnType> 8 | >; 9 | 10 | export type GetMutationParams = ReturnType extends ( 11 | value: infer Params, 12 | ) => any 13 | ? Params extends Parameters>[0] 14 | ? Params 15 | : any 16 | : never; 17 | -------------------------------------------------------------------------------- /src/hooks/useQuery/useQuery.test.tsx: -------------------------------------------------------------------------------- 1 | import axiosClient from 'api/axios'; 2 | import { AppProviders } from 'providers/AppProviders'; 3 | import { renderHook, waitFor } from 'tests'; 4 | import { authQueries } from 'api/actions/auth/auth.queries'; 5 | 6 | import { useQuery } from './useQuery'; 7 | 8 | const mockCurrentUser = { 9 | firstName: 'Test', 10 | lastName: 'User', 11 | username: 'testUser', 12 | }; 13 | 14 | const mockApiResponse = (data: unknown, method: 'get' | 'post') => { 15 | const response = { status: 200, data: data }; 16 | vitest.spyOn(axiosClient, method).mockResolvedValue(response); 17 | }; 18 | 19 | describe('useQuery', () => { 20 | test('returns the data fetched from api', async () => { 21 | mockApiResponse(mockCurrentUser, 'get'); 22 | 23 | const { result } = renderHook(() => useQuery(authQueries.me()), { 24 | wrapper: ({ children }) => ( 25 | 26 | <>{children}, 27 | 28 | ), 29 | }); 30 | 31 | expect(result.current.data).toBeUndefined(); 32 | await waitFor(() => { 33 | expect(result.current.data).toBe(mockCurrentUser); 34 | }); 35 | }); 36 | 37 | test('returns proper loading state', async () => { 38 | mockApiResponse(mockCurrentUser, 'get'); 39 | 40 | const { result } = renderHook(() => useQuery(authQueries.me()), { 41 | wrapper: ({ children }) => ( 42 | 43 | <>{children}, 44 | 45 | ), 46 | }); 47 | 48 | expect(result.current.isLoading).toBe(true); 49 | await waitFor(() => { 50 | expect(result.current.isLoading).toBe(false); 51 | }); 52 | }); 53 | 54 | test('returns error status properly', async () => { 55 | const response = { status: 401 }; 56 | vitest.spyOn(axiosClient, 'get').mockRejectedValue(response); 57 | 58 | const { result } = renderHook(() => useQuery({ ...authQueries.me(), retry: false }), { 59 | wrapper: ({ children }) => ( 60 | 61 | <>{children}, 62 | 63 | ), 64 | }); 65 | 66 | expect(result.current.status).toBe('pending'); 67 | await waitFor(() => { 68 | expect(result.current.status).toBe('error'); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/hooks/useQuery/useQuery.ts: -------------------------------------------------------------------------------- 1 | import { useQuery as useRQQuery } from '@tanstack/react-query'; 2 | 3 | import { useApiClient } from '../useApiClient/useApiClient'; 4 | import { StandardizedApiError } from 'context/apiClient/apiClientContextController/apiError/apiError.types'; 5 | import { UseQueryOptions } from 'hooks/useQuery/useQuery.types'; 6 | 7 | export const useQuery = ( 8 | params: UseQueryOptions, 9 | ) => { 10 | const { client } = useApiClient(); 11 | const { queryFn, ...options } = params; 12 | 13 | const result = useRQQuery({ 14 | queryFn: (args) => queryFn(client)(args), 15 | ...options, 16 | }); 17 | 18 | return { ...result, isLoadingAndEnabled: result.isPending && result.fetchStatus !== 'idle' }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/hooks/useQuery/useQuery.types.ts: -------------------------------------------------------------------------------- 1 | import { UseQueryOptions as UseRQQueryOptions, QueryFunction, QueryKey } from '@tanstack/react-query'; 2 | import { AxiosInstance } from 'axios'; 3 | 4 | import { ExtendedQueryMeta } from 'api/types/types'; 5 | import { StandardizedApiError } from 'context/apiClient/apiClientContextController/apiError/apiError.types'; 6 | 7 | export type UseQueryOptions = Omit< 8 | UseRQQueryOptions, 9 | 'queryFn' 10 | > & { 11 | meta?: Partial; 12 | queryFn: (client: AxiosInstance) => QueryFunction; 13 | }; 14 | 15 | export type GenericQueryOptions = Omit< 16 | UseQueryOptions, 17 | 'queryKey' | 'queryFn' 18 | >; 19 | -------------------------------------------------------------------------------- /src/hooks/useUser/useUser.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from '@tanstack/react-query'; 2 | 3 | import { GenericQueryOptions } from 'hooks/useQuery/useQuery.types'; 4 | import { GetMeQueryResponse } from 'api/actions/auth/auth.types'; 5 | import { useQuery } from '../useQuery/useQuery'; 6 | import { authQueries } from 'api/actions/auth/auth.queries'; 7 | 8 | export const useUser = (options?: GenericQueryOptions) => { 9 | const queryClient = useQueryClient(); 10 | 11 | const resetUser = () => queryClient.removeQueries({ queryKey: authQueries.me().queryKey }); 12 | 13 | const query = useQuery({ ...authQueries.me(), ...options }); 14 | return { ...query, resetUser }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/hooks/useUsers/useUsers.ts: -------------------------------------------------------------------------------- 1 | import { authQueries } from 'api/actions/auth/auth.queries'; 2 | import { useInfiniteQuery } from '../useInfiniteQuery/useInfiniteQuery'; 3 | 4 | export const useUsers = () => useInfiniteQuery(authQueries.listInfinite({})); 5 | -------------------------------------------------------------------------------- /src/i18n/data/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "home.helloWorld": "Hello World" 3 | } -------------------------------------------------------------------------------- /src/i18n/data/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "home.helloWorld": "Witaj świecie" 3 | } -------------------------------------------------------------------------------- /src/i18n/messages.test.ts: -------------------------------------------------------------------------------- 1 | import { AppLocale } from 'context/locale/AppLocale.enum'; 2 | 3 | import { translations } from './messages'; 4 | 5 | test('has object entries for all locales', () => { 6 | const value = Object.fromEntries(Object.entries(translations).map((entry) => [entry[0], typeof entry[1]])); 7 | const expectedValue: Record = { 8 | [AppLocale.en]: 'object', 9 | [AppLocale.pl]: 'object', 10 | }; 11 | 12 | expect(value).toEqual(expectedValue); 13 | }); 14 | -------------------------------------------------------------------------------- /src/i18n/messages.ts: -------------------------------------------------------------------------------- 1 | import type { PrimitiveType } from 'intl-messageformat/src/formatters'; 2 | 3 | import { AppLocale } from 'context/locale/AppLocale.enum'; 4 | 5 | import enMessages from './data/en.json'; 6 | import plMessages from './data/pl.json'; 7 | 8 | type KeyAsValue = { [P in keyof T]: P }; 9 | 10 | const keysToValues = >(source: T): KeyAsValue => 11 | (Object.keys(source) as Array).reduce( 12 | (accumulated, current) => { 13 | accumulated[current] = current; 14 | return accumulated; 15 | }, 16 | {} as KeyAsValue, 17 | ); 18 | 19 | export const AppMessages = { 20 | ...keysToValues(enMessages), 21 | ...keysToValues(plMessages), 22 | }; 23 | 24 | export type Translation = keyof typeof AppMessages; 25 | 26 | export type TranslateFn = (id: Translation, value?: Record) => string; 27 | 28 | export const translations: Record> = { 29 | [AppLocale.en]: enMessages, 30 | [AppLocale.pl]: plMessages, 31 | }; 32 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 3 | import { RouterProvider, createRouter } from '@tanstack/react-router'; 4 | 5 | import 'assets/styles/main.css'; 6 | 7 | import { AppProviders } from 'providers/AppProviders'; 8 | import { enableMocking } from 'setupMSW'; 9 | import { logger } from 'integrations/logger'; 10 | 11 | import { routeTree } from './routeTree.gen'; 12 | const openReactQueryDevtools = import.meta.env.DEV; 13 | 14 | if (import.meta.env.VITE_SENTRY_DSN) { 15 | logger.init(); 16 | } 17 | 18 | const container = document.getElementById('root'); 19 | const root = createRoot(container as Element); 20 | 21 | export const router = createRouter({ routeTree }); 22 | 23 | declare module '@tanstack/react-router' { 24 | interface Register { 25 | router: typeof router; 26 | } 27 | } 28 | 29 | enableMocking().then(() => 30 | root.render( 31 | 32 | 33 | {openReactQueryDevtools && } 34 | , 35 | ), 36 | ); 37 | -------------------------------------------------------------------------------- /src/integrations/logger.ts: -------------------------------------------------------------------------------- 1 | import { 2 | init, 3 | captureException, 4 | captureMessage, 5 | browserTracingIntegration, 6 | httpClientIntegration, 7 | captureConsoleIntegration, 8 | } from '@sentry/browser'; 9 | 10 | type LogLevel = 'error' | 'info' | 'warning'; 11 | type Logger = Record void> & Record; 12 | 13 | const initLogger = () => 14 | init({ 15 | dsn: import.meta.env.VITE_SENTRY_DSN, 16 | integrations: [ 17 | httpClientIntegration({ 18 | failedRequestStatusCodes: [[400, 599]], 19 | failedRequestTargets: [/.*/], 20 | }), 21 | captureConsoleIntegration(), 22 | browserTracingIntegration(), 23 | ], 24 | tracesSampleRate: 1.0, 25 | }); 26 | 27 | const sendLog = (level: LogLevel, message: string | Error) => { 28 | if (typeof message === 'string') { 29 | captureMessage(message, { level }); 30 | } 31 | captureException(message, { level }); 32 | }; 33 | 34 | export const logger = { 35 | init: initLogger, 36 | error: (message: string | Error) => sendLog('error', message), 37 | warning: (message: string | Error) => sendLog('warning', message), 38 | info: (message: string | Error) => sendLog('info', message), 39 | } satisfies Logger; 40 | -------------------------------------------------------------------------------- /src/providers/AppProviders.tsx: -------------------------------------------------------------------------------- 1 | import { LocaleContextController } from 'context/locale/localeContextController/LocaleContextController'; 2 | import { AuthContextController } from 'context/auth/authContextController/AuthContextController'; 3 | import { ApiClientContextController } from '../context/apiClient/apiClientContextController/ApiClientContextController'; 4 | 5 | import { AppProvidersProps } from './AppProviders.types'; 6 | 7 | export const AppProviders = ({ children }: AppProvidersProps) => ( 8 | 9 | 10 | {children} 11 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /src/providers/AppProviders.types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type AppProvidersProps = { 4 | children: ReactNode; 5 | }; 6 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare namespace NodeJS { 3 | interface ProcessEnv { 4 | NODE_ENV: 'development' | 'production' | 'test'; 5 | VITE_DEFAULT_LOCALE: string; 6 | VITE_API_URL: string; 7 | VITE_CI: number; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* prettier-ignore-start */ 2 | 3 | /* eslint-disable */ 4 | 5 | // @ts-nocheck 6 | 7 | // noinspection JSUnusedGlobalSymbols 8 | 9 | // This file is auto-generated by TanStack Router 10 | 11 | import { createFileRoute } from '@tanstack/react-router' 12 | 13 | // Import Routes 14 | 15 | import { Route as rootRoute } from './routes/__root' 16 | import { Route as IndexImport } from './routes/index' 17 | import { Route as UsersIndexImport } from './routes/users/index' 18 | import { Route as AboutIndexImport } from './routes/about/index' 19 | import { Route as UsersIdIndexImport } from './routes/users/$id/index' 20 | 21 | // Create Virtual Routes 22 | 23 | const HelpIndexLazyImport = createFileRoute('/help/')() 24 | 25 | // Create/Update Routes 26 | 27 | const IndexRoute = IndexImport.update({ 28 | path: '/', 29 | getParentRoute: () => rootRoute, 30 | } as any) 31 | 32 | const HelpIndexLazyRoute = HelpIndexLazyImport.update({ 33 | path: '/help/', 34 | getParentRoute: () => rootRoute, 35 | } as any).lazy(() => import('./routes/help/index.lazy').then((d) => d.Route)) 36 | 37 | const UsersIndexRoute = UsersIndexImport.update({ 38 | path: '/users/', 39 | getParentRoute: () => rootRoute, 40 | } as any) 41 | 42 | const AboutIndexRoute = AboutIndexImport.update({ 43 | path: '/about/', 44 | getParentRoute: () => rootRoute, 45 | } as any) 46 | 47 | const UsersIdIndexRoute = UsersIdIndexImport.update({ 48 | path: '/users/$id/', 49 | getParentRoute: () => rootRoute, 50 | } as any) 51 | 52 | // Populate the FileRoutesByPath interface 53 | 54 | declare module '@tanstack/react-router' { 55 | interface FileRoutesByPath { 56 | '/': { 57 | preLoaderRoute: typeof IndexImport 58 | parentRoute: typeof rootRoute 59 | } 60 | '/about/': { 61 | preLoaderRoute: typeof AboutIndexImport 62 | parentRoute: typeof rootRoute 63 | } 64 | '/users/': { 65 | preLoaderRoute: typeof UsersIndexImport 66 | parentRoute: typeof rootRoute 67 | } 68 | '/help/': { 69 | preLoaderRoute: typeof HelpIndexLazyImport 70 | parentRoute: typeof rootRoute 71 | } 72 | '/users/$id/': { 73 | preLoaderRoute: typeof UsersIdIndexImport 74 | parentRoute: typeof rootRoute 75 | } 76 | } 77 | } 78 | 79 | // Create and export the route tree 80 | 81 | export const routeTree = rootRoute.addChildren([ 82 | IndexRoute, 83 | AboutIndexRoute, 84 | UsersIndexRoute, 85 | HelpIndexLazyRoute, 86 | UsersIdIndexRoute, 87 | ]) 88 | 89 | /* prettier-ignore-end */ 90 | -------------------------------------------------------------------------------- /src/routes/-components/Home.test.tsx: -------------------------------------------------------------------------------- 1 | import axiosClient from 'api/axios'; 2 | import { LocaleContext } from 'context/locale/localeContext/LocaleContext'; 3 | import { render, act, screen } from 'tests'; 4 | 5 | import { Home } from './Home'; 6 | 7 | const response = { status: 200, data: {} }; 8 | vitest.spyOn(axiosClient, 'get').mockResolvedValue(response); 9 | 10 | describe('Home', () => { 11 | test('renders heading', async () => { 12 | render(); 13 | const element = await screen.findByText(/Home/); 14 | expect(element).toBeInTheDocument(); 15 | }); 16 | 17 | test('changes locale when "here" button is clicked', async () => { 18 | render( 19 | 20 | {(value) => ( 21 | <> 22 | LOCALE: {value?.locale} 23 | 24 | 25 | )} 26 | , 27 | ); 28 | 29 | const initialText = (await screen.findByText(/LOCALE/)).textContent as string; 30 | 31 | act(() => screen.getByText(/here/).click()); 32 | 33 | expect(await screen.findByText(/LOCALE/)).not.toHaveTextContent(initialText); 34 | 35 | act(() => screen.getByText(/here/).click()); 36 | 37 | expect(await screen.findByText(/LOCALE/)).toHaveTextContent(initialText); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/routes/-components/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react'; 2 | import { useNavigate } from '@tanstack/react-router'; 3 | 4 | import { AppLocale } from 'context/locale/AppLocale.enum'; 5 | import { useLocale } from 'hooks/useLocale/useLocale'; 6 | import { useAuth } from 'hooks/useAuth/useAuth'; 7 | import { useUsers } from 'hooks/useUsers/useUsers'; 8 | import { Translation } from 'ui/translation/Translation'; 9 | import { LocationInfo } from 'ui/locationInfo/LocationInfo'; 10 | 11 | export const Home = () => { 12 | const { locale, setLocale } = useLocale(); 13 | const { user, login, logout, isAuthenticated, isAuthenticating } = useAuth(); 14 | 15 | const { 16 | data: usersResponse, 17 | isFetching: isFetchingUsers, 18 | isFetched: areUsersFetched, 19 | hasNextPage: hasMoreUsers, 20 | fetchNextPage: loadMoreUsers, 21 | isFetchingNextPage, 22 | } = useUsers(); 23 | 24 | const navigate = useNavigate(); 25 | 26 | return ( 27 | <> 28 |

Home

29 |

30 | 31 | 32 | 33 | This text is translated using Translation component. 34 | 35 | Click 36 | {' '} 42 | to change language. 43 |

44 |

This is a starter project for TSH React application. Click on navigation links above to learn more.

45 |
46 | 47 |
48 |
49 |

User information 🧑

50 |
51 | 57 | 60 |
61 | {isAuthenticating &&

Loading data about you...

} 62 | {isAuthenticated && ( 63 | {JSON.stringify(user, null, 2)} 64 | )} 65 |
66 |
67 |

List of users 🧑

68 |
69 |
    70 | {areUsersFetched && 71 | usersResponse?.pages?.map((page, index) => ( 72 | 73 | {page.users?.map((user) => ( 74 |
  • 75 | 82 |
  • 83 | ))} 84 |
    85 | ))} 86 |
87 | {isFetchingNextPage &&

Loading more users...

} 88 | 91 |
92 |
93 | 94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/routes/-layout/Layout.css: -------------------------------------------------------------------------------- 1 | .app__logo { 2 | height: 5rem; 3 | pointer-events: none; 4 | margin: 1rem 0; 5 | } 6 | 7 | .app__header { 8 | text-align: center; 9 | background-color: #282c34; 10 | min-height: 20vh; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | justify-content: center; 15 | font-size: 0.75rem; 16 | color: white; 17 | padding: 2rem 1rem; 18 | } 19 | 20 | .app__link { 21 | color: #61dafb; 22 | } 23 | 24 | .app__navigation { 25 | background-color: #004ae0; 26 | padding: 0.5rem 1rem; 27 | } 28 | 29 | .app__menu { 30 | margin: 0; 31 | padding: 0; 32 | display: flex; 33 | align-items: center; 34 | justify-content: center; 35 | list-style: none; 36 | } 37 | 38 | .app__menu-link { 39 | display: block; 40 | padding: 0.5rem 1rem; 41 | color: #ffffff; 42 | text-decoration: none; 43 | font-weight: bold; 44 | } 45 | 46 | .app__menu-link:focus { 47 | outline-color: #9ebeff; 48 | } 49 | 50 | .app__menu-link:hover { 51 | color: #9ebeff; 52 | } 53 | 54 | .app__main { 55 | padding: 2rem 1rem; 56 | } 57 | -------------------------------------------------------------------------------- /src/routes/-layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Outlet } from '@tanstack/react-router'; 2 | import ViteLogo from 'assets/images/vite-logo.svg?react'; 3 | import VitestLogo from 'assets/images/vitest-logo.svg?react'; 4 | 5 | import logo from 'assets/images/logo.svg'; 6 | import './Layout.css'; 7 | 8 | export const Layout = () => { 9 | return ( 10 |
11 |
12 | logo 13 |

14 | Edit src/layout/Layout.tsx and save to reload. 15 |

16 | 17 | Learn React 18 | 19 |

20 | 21 | 22 | 23 | 24 | 25 | 26 |

27 |
28 | 52 |
53 | 54 |
55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { createRootRoute, ScrollRestoration } from '@tanstack/react-router'; 2 | import { TanStackRouterDevtools } from '@tanstack/router-devtools'; 3 | const enableTanstackRouterDevtools = import.meta.env.DEV; 4 | 5 | import { Layout } from 'routes/-layout/Layout'; 6 | 7 | export const Route = createRootRoute({ 8 | component: () => ( 9 | <> 10 | 11 | 12 | {enableTanstackRouterDevtools && } 13 | 14 | ), 15 | }); 16 | -------------------------------------------------------------------------------- /src/routes/about/-components/About.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'tests'; 2 | 3 | import { About } from './About'; 4 | 5 | describe('About', () => { 6 | test('renders heading', async () => { 7 | render(); 8 | const element = await screen.findByText(/About/); 9 | expect(element).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/routes/about/-components/About.tsx: -------------------------------------------------------------------------------- 1 | import { LocationInfo } from 'ui/locationInfo/LocationInfo'; 2 | 3 | export const About = () => { 4 | return ( 5 | <> 6 |

About

7 |

This project includes and demonstrates usage of recommended packages and tools:

8 |
    9 |
  • 10 | TypeScript - Typed superset of JavaScript that compiles to plain 11 | JavaScript 12 |
  • 13 |
  • 14 | Vitest - Next generation testing framework powered by Vite. 15 |
  • 16 |
  • 17 | React Testing Library - Simple and 18 | complete React DOM testing utilities that encourage good testing practices 19 |
  • 20 |
  • 21 | TanStack Router - A fully type-safe React router with 22 | built-in data fetching, stale-while revalidate caching and first-class search-param APIs. 23 |
  • 24 |
  • 25 | React Intl - React components and an API to format dates, 26 | numbers, and strings, including pluralization and handling translations 27 |
  • 28 |
29 |
30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/routes/about/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router'; 2 | 3 | import { About } from 'routes/about/-components/About'; 4 | 5 | export const Route = createFileRoute('/about/')({ 6 | component: () => , 7 | }); 8 | -------------------------------------------------------------------------------- /src/routes/help/-components/Help.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'tests'; 2 | 3 | import { Help } from './Help'; 4 | 5 | describe('Help', () => { 6 | test('renders heading', async () => { 7 | render(); 8 | 9 | const element = await screen.findByText(/Help/); 10 | expect(element).toBeInTheDocument(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/routes/help/-components/Help.tsx: -------------------------------------------------------------------------------- 1 | import { LocationInfo } from 'ui/locationInfo/LocationInfo'; 2 | 3 | export const Help = () => { 4 | return ( 5 | <> 6 |

Help

7 |

8 | This project was bootstrapped with Vite and modified by TSH team. 9 |

10 |

Available Scripts

11 |

In the project directory, you can run:

12 |

13 | npm start 14 |

15 |

16 | Runs the app in the development mode. 17 |
18 | Open http://localhost:3000 to view it in the browser. 19 |

20 |

21 | The page will reload if you make edits. 22 |
23 | You will also see any lint errors in the console. 24 |

25 |

26 | npm test 27 |

28 |

29 | Launches the test runner in the interactive watch mode. 30 |
31 | See the section about running tests for more information. 32 |

33 |

34 | npm run coverage 35 |

36 |

37 | Launches the test runner in the coverage report generation mode. 38 |
39 | See this section for more information. 40 |

41 |

42 | npm run build 43 |

44 |

45 | Builds the app for production to the build folder. 46 |
47 | It correctly bundles React in production mode and optimizes the build for the best performance. 48 |

49 |

50 | The build is minified and the filenames include the hashes. 51 |
52 | Your app is ready to be deployed! 53 |

54 |

55 | See the section about deployment for more information. 56 |

57 |

Learn More

58 |

59 | You can learn more in the Vite documentation. 60 |

61 |

62 | To learn React, check out the React documentation. 63 |

64 |
65 | 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/routes/help/index.lazy.tsx: -------------------------------------------------------------------------------- 1 | import { createLazyFileRoute } from '@tanstack/react-router'; 2 | 3 | import { Help } from 'routes/help/-components/Help'; 4 | 5 | export const Route = createLazyFileRoute('/help/')({ 6 | component: () => , 7 | }); 8 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router'; 2 | 3 | import { Home } from 'routes/-components/Home'; 4 | 5 | export const Route = createFileRoute('/')({ 6 | component: () => , 7 | }); 8 | -------------------------------------------------------------------------------- /src/routes/users/$id/-components/User.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'tests'; 2 | 3 | import { User } from './User'; 4 | 5 | describe('User', () => { 6 | test('renders params', async () => { 7 | render(, { routerConfig: { routerPath: '/users/$id/', currentPath: '/users/1' } }); 8 | const element = await screen.findByText(/"id": "1"/); 9 | expect(element).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/routes/users/$id/-components/User.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from '@tanstack/react-router'; 2 | 3 | import { CodeBlock } from 'ui/codeBlock/CodeBlock'; 4 | 5 | export const User = () => { 6 | const params = useParams({ from: '/users/$id/' }); 7 | 8 | return ( 9 | <> 10 |

User

11 | Params extracted from url: {JSON.stringify(params, null, 4)} 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/routes/users/$id/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router'; 2 | 3 | import { User } from 'routes/users/$id/-components/User'; 4 | 5 | export const Route = createFileRoute('/users/$id/')({ 6 | component: () => , 7 | validateSearch: {}, 8 | }); 9 | -------------------------------------------------------------------------------- /src/routes/users/-components/UsersList.tsx: -------------------------------------------------------------------------------- 1 | import { useSearch, useNavigate } from '@tanstack/react-router'; 2 | 3 | import { CodeBlock } from 'ui/codeBlock/CodeBlock'; 4 | import { useQuery } from 'hooks/useQuery/useQuery'; 5 | import { UserSortType } from 'routes/users'; 6 | import { authQueries } from 'api/actions/auth/auth.queries'; 7 | 8 | export const UsersList = () => { 9 | const { sort, page } = useSearch({ from: '/users/' }); 10 | const navigate = useNavigate(); 11 | 12 | const { data: usersResponse, isFetched: areUsersFetched } = useQuery({ 13 | ...authQueries.list({ page: page.toString() }), 14 | select: (data) => { 15 | return { ...data, users: data.users.sort((a, b) => (sort === 'desc' ? +b.id - +a.id : +a.id - +b.id)) }; 16 | }, 17 | }); 18 | 19 | const sortUsers = (type: UserSortType) => { 20 | navigate({ 21 | search: { 22 | sort: type, 23 | }, 24 | }); 25 | }; 26 | 27 | const goToNextPage = () => { 28 | navigate({ 29 | search: (prev) => ({ 30 | ...prev, 31 | page: page + 1, 32 | }), 33 | }); 34 | }; 35 | 36 | const goToPrevPage = () => { 37 | const newPage = page <= 1 ? undefined : page - 1; 38 | 39 | navigate({ 40 | search: (prev) => ({ 41 | ...prev, 42 | page: newPage, 43 | }), 44 | }); 45 | }; 46 | 47 | return ( 48 | <> 49 |

Users

50 |
This is an example how to use useSearch() from tanstack-router
51 |
52 | Current searchParams (provided by{' '} 53 | 58 | useSearch() 59 | {' '} 60 | hook) 61 | {JSON.stringify({ sort, page })} 62 |
63 |
64 | 65 | 69 |
    {areUsersFetched && usersResponse?.users.map((u) =>
  • {u.name}
  • )}
70 | 73 | 76 |
77 | 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/routes/users/index.tsx: -------------------------------------------------------------------------------- 1 | import { SearchSchemaInput, createFileRoute } from '@tanstack/react-router'; 2 | import { z } from 'zod'; 3 | 4 | import { UsersList } from 'routes/users/-components/UsersList'; 5 | 6 | const userSearchSchema = z.object({ 7 | sort: z.enum(['asc', 'desc']).catch('asc'), 8 | page: z.number().positive().catch(1), 9 | }); 10 | 11 | export type UserSearch = z.infer; 12 | export type UserSortType = UserSearch['sort']; 13 | 14 | export const Route = createFileRoute('/users/')({ 15 | component: () => , 16 | validateSearch: (search: { sort?: string; page?: number } & SearchSchemaInput) => userSearchSchema.parse(search), 17 | }); 18 | -------------------------------------------------------------------------------- /src/setupMSW.ts: -------------------------------------------------------------------------------- 1 | export async function enableMocking() { 2 | if (!import.meta.env.DEV) { 3 | return; 4 | } 5 | // static import will cause msw to be bundled into production code and significantly increase bundle size 6 | const { worker } = await import('api/mocks/mock-worker'); 7 | return worker.start({ onUnhandledRequest: 'bypass' }); 8 | } 9 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | 7 | declare global { 8 | // eslint-disable-next-line @typescript-eslint/no-namespace 9 | namespace NodeJS { 10 | interface Global { 11 | MutationObserver: typeof MutationObserver; 12 | } 13 | } 14 | } 15 | 16 | global.MutationObserver = class { 17 | disconnect(): void {} 18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 19 | observe(target: Node, options?: MutationObserverInit): void {} 20 | takeRecords(): MutationRecord[] { 21 | return []; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/tests/index.tsx: -------------------------------------------------------------------------------- 1 | // see https://testing-library.com/docs/react-testing-library/setup#custom-render 2 | import { Queries } from '@testing-library/dom'; 3 | import { render, RenderOptions, RenderResult } from '@testing-library/react'; 4 | import { useState } from 'react'; 5 | import { IntlProvider } from 'react-intl'; 6 | import { 7 | RouterProvider, 8 | createRootRoute, 9 | Outlet, 10 | createRoute, 11 | createRouter, 12 | createMemoryHistory, 13 | } from '@tanstack/react-router'; 14 | 15 | import { ApiClientContextController } from 'context/apiClient/apiClientContextController/ApiClientContextController'; 16 | import { AuthContext } from 'context/auth/authContext/AuthContext'; 17 | import { AppLocale } from 'context/locale/AppLocale.enum'; 18 | import { defaultLocale } from 'context/locale/defaultLocale'; 19 | import { LocaleContext } from 'context/locale/localeContext/LocaleContext'; 20 | 21 | import { ExtraRenderOptions, WrapperProps } from './types'; 22 | 23 | // @TODO: https://bitbucket.org/thesoftwarehouse/react-starter-boilerplate/pull-requests/5/rss-9-add-login-page/diff#comment-132626297 24 | const _Wrapper = ({ children, routerConfig = {} }: WrapperProps) => { 25 | const [locale, setLocale] = useState(defaultLocale); 26 | const { routerPath = '/', currentPath = routerPath } = routerConfig; 27 | 28 | const rootRoute = createRootRoute({ component: () => }); 29 | 30 | const componentRoute = createRoute({ 31 | path: routerPath, 32 | getParentRoute: () => rootRoute, 33 | component: () => children, 34 | }); 35 | const router = createRouter({ 36 | history: createMemoryHistory({ 37 | initialEntries: [currentPath], 38 | }), 39 | routeTree: rootRoute.addChildren([componentRoute]), 40 | }); 41 | 42 | return ( 43 | 44 | 56 | {}} defaultLocale={defaultLocale} locale={locale}> 57 | 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | 66 | function customRender( 67 | ui: React.ReactElement, 68 | options?: Omit & ExtraRenderOptions, 69 | ): RenderResult; 70 | function customRender( 71 | ui: React.ReactElement, 72 | options: RenderOptions & ExtraRenderOptions, 73 | ): RenderResult; 74 | function customRender( 75 | ui: React.ReactElement, 76 | options?: (RenderOptions | Omit) & ExtraRenderOptions, 77 | ): RenderResult | RenderResult { 78 | const Wrapper = ({ children }: Pick) => ( 79 | <_Wrapper routerConfig={options?.routerConfig}>{children} 80 | ); 81 | 82 | return render(ui, { wrapper: options?.wrapper ?? Wrapper, ...options }); 83 | } 84 | 85 | // re-export everything 86 | export * from '@testing-library/react'; 87 | // override render method 88 | export { customRender as render }; 89 | -------------------------------------------------------------------------------- /src/tests/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type RouterConfig = { 4 | routerPath?: string; 5 | currentPath?: string; 6 | }; 7 | 8 | export type ExtraRenderOptions = { 9 | routerConfig?: RouterConfig; 10 | }; 11 | 12 | export type WrapperProps = { 13 | children: ReactNode; 14 | routerConfig?: RouterConfig; 15 | }; 16 | -------------------------------------------------------------------------------- /src/types/simplify.ts: -------------------------------------------------------------------------------- 1 | export type Simplify = T extends object ? { [K in keyof T]: Simplify } : T; 2 | -------------------------------------------------------------------------------- /src/types/split.ts: -------------------------------------------------------------------------------- 1 | export type Split< 2 | S extends string, 3 | SplitBy extends string, 4 | Accumulator extends string = '', 5 | > = S extends `${infer Head}${infer Rest}` 6 | ? Head extends SplitBy 7 | ? Accumulator | Split 8 | : Split 9 | : Accumulator; 10 | -------------------------------------------------------------------------------- /src/ui/codeBlock/CodeBlock.css: -------------------------------------------------------------------------------- 1 | .code-block { 2 | background-color: #9ebeff; 3 | padding: 1rem 0.5rem; 4 | overflow: auto; 5 | } 6 | -------------------------------------------------------------------------------- /src/ui/codeBlock/CodeBlock.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'tests'; 2 | 3 | import { CodeBlock } from './CodeBlock'; 4 | 5 | describe('CodeBlock', () => { 6 | test('renders its children', async () => { 7 | render(TEST); 8 | const element = await screen.findByText(/TEST/); 9 | expect(element).toBeInTheDocument(); 10 | }); 11 | 12 | test('passes down given className to the paragraph', async () => { 13 | render(TEST); 14 | const element = await screen.findByText(/TEST/); 15 | const parent = element.parentElement; 16 | expect(parent).toHaveClass('foo'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/ui/codeBlock/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'clsx'; 2 | 3 | import { CodeBlockProps } from './CodeBlock.types'; 4 | 5 | import './CodeBlock.css'; 6 | 7 | export const CodeBlock = ({ className, children }: CodeBlockProps) => ( 8 |

9 | {children} 10 |

11 | ); 12 | -------------------------------------------------------------------------------- /src/ui/codeBlock/CodeBlock.types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export type CodeBlockProps = { 4 | className?: string; 5 | children?: ReactNode; 6 | }; 7 | -------------------------------------------------------------------------------- /src/ui/errorBoundary/ErrorBoundary.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | 3 | import { logger } from 'integrations/logger'; 4 | 5 | import { ErrorBoundary } from './ErrorBoundary'; 6 | 7 | const logErrorSpy = vitest.spyOn(logger, 'error'); 8 | 9 | const ErrorComponent = ({ shouldError = true }: { shouldError?: boolean }) => { 10 | if (shouldError) { 11 | throw new Error('error'); 12 | } 13 | return
no error
; 14 | }; 15 | 16 | describe('ErrorBoundary', () => { 17 | it('should show fallback component and log error via logger', () => { 18 | render( 19 | error}> 20 | 21 | , 22 | ); 23 | 24 | expect(screen.getByText('error')).toBeInTheDocument(); 25 | expect(logErrorSpy).toHaveBeenCalledTimes(1); 26 | }); 27 | 28 | it('should not log error when shouldLog = false', () => { 29 | render( 30 | error}> 31 | 32 | , 33 | ); 34 | 35 | expect(screen.getByText('error')).toBeInTheDocument(); 36 | expect(logErrorSpy).not.toBeCalled(); 37 | }); 38 | 39 | it('should show children content when there is no error', () => { 40 | render( 41 | error}> 42 | 43 | , 44 | ); 45 | 46 | expect(screen.getByText('no error')).toBeInTheDocument(); 47 | expect(logErrorSpy).not.toBeCalled(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/ui/errorBoundary/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; 2 | import { ErrorInfo } from 'react'; 3 | 4 | import { logger } from 'integrations/logger'; 5 | 6 | import { ErrorBoundaryProps } from './ErrorBoundary.types'; 7 | 8 | export const ErrorBoundary = ({ shouldLog = true, onError, ...props }: ErrorBoundaryProps) => { 9 | const handleError = (error: Error, errorInfo: ErrorInfo) => { 10 | if (shouldLog) { 11 | logger.error(error); 12 | } 13 | onError?.(error, errorInfo); 14 | }; 15 | 16 | // eslint-disable-next-line react/jsx-props-no-spreading 17 | return ; 18 | }; 19 | -------------------------------------------------------------------------------- /src/ui/errorBoundary/ErrorBoundary.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ErrorBoundaryProps as ReactErrorBoundaryProps, 3 | FallbackProps as ErrorBoundaryFallbackProps, 4 | } from 'react-error-boundary'; 5 | 6 | export type ErrorBoundaryProps = ReactErrorBoundaryProps & { 7 | shouldLog?: boolean; 8 | }; 9 | 10 | export type FallbackProps = ErrorBoundaryFallbackProps; 11 | -------------------------------------------------------------------------------- /src/ui/loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | export const Loader = () => { 2 | return
Loading...
; 3 | }; 4 | -------------------------------------------------------------------------------- /src/ui/locationInfo/LocationInfo.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from 'tests'; 2 | 3 | import { LocationInfo } from './LocationInfo'; 4 | 5 | describe('Location info', () => { 6 | test('renders current location data', async () => { 7 | render(, { 8 | routerConfig: { routerPath: '/foo' }, 9 | }); 10 | const element = await screen.findByText(/\/foo/); 11 | expect(element).toBeInTheDocument(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/ui/locationInfo/LocationInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useRouterState } from '@tanstack/react-router'; 2 | 3 | import { CodeBlock } from 'ui/codeBlock/CodeBlock'; 4 | 5 | export const LocationInfo = () => { 6 | const location = useRouterState({ select: (state) => state.location }); 7 | 8 | return ( 9 |
10 |

11 | Current location (provided by{' '} 12 | 13 | useLocation 14 | {' '} 15 | hook from react-router): 16 |

17 | {JSON.stringify(location)} 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/ui/translation/Translation.tsx: -------------------------------------------------------------------------------- 1 | import { useLocale } from 'hooks/useLocale/useLocale'; 2 | import { AppMessages } from 'i18n/messages'; 3 | 4 | import { TranslationProps } from './Translation.types'; 5 | 6 | export const Translation = ({ id, values }: TranslationProps) => { 7 | const { formatMessage } = useLocale(); 8 | 9 | return <>{formatMessage({ id: AppMessages[id] }, values)}; 10 | }; 11 | -------------------------------------------------------------------------------- /src/ui/translation/Translation.types.ts: -------------------------------------------------------------------------------- 1 | import { PrimitiveType } from 'react-intl'; 2 | 3 | import { Translation } from 'i18n/messages'; 4 | 5 | export type TranslationProps = { 6 | id: Translation; 7 | values?: Record; 8 | }; 9 | -------------------------------------------------------------------------------- /src/utils/apiErrorStatuses.ts: -------------------------------------------------------------------------------- 1 | import { startsWith } from 'utils/startsWith'; 2 | 3 | export const isServerError = (statusCode?: number) => { 4 | return startsWith(5, statusCode); 5 | }; 6 | 7 | export const isClientError = (statusCode?: number) => { 8 | return startsWith(4, statusCode); 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/startsWith.ts: -------------------------------------------------------------------------------- 1 | export const startsWith = (startingChar: string | number, dataToCheck?: string | number) => { 2 | if (!dataToCheck) { 3 | return false; 4 | } 5 | 6 | return dataToCheck.toString().charAt(0) === startingChar.toString(); 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "target": "ESNext", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "types": ["vite/client", "vite-plugin-svgr/client", "@testing-library/jest-dom", "vitest/globals"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "ts-node": { 22 | "compilerOptions": { 23 | "module": "NodeNext", 24 | "moduleResolution": "NodeNext", 25 | } 26 | }, 27 | "include": ["src", "vite.config.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, type PluginOption } from 'vite'; 2 | import react from '@vitejs/plugin-react-swc'; 3 | import viteTsconfigPaths from 'vite-tsconfig-paths'; 4 | import svgrPlugin from 'vite-plugin-svgr'; 5 | import { configDefaults } from 'vitest/config'; 6 | import { TanStackRouterVite } from '@tanstack/router-vite-plugin'; 7 | import { visualizer } from 'rollup-plugin-visualizer'; 8 | 9 | const manualChunks = (id: string) => { 10 | if (id.includes('@sentry')) { 11 | return 'sentry'; 12 | } 13 | }; 14 | 15 | /* eslint-disable import/no-default-export */ 16 | export default defineConfig({ 17 | plugins: [ 18 | react(), 19 | viteTsconfigPaths(), 20 | svgrPlugin(), 21 | TanStackRouterVite(), 22 | process.env.ANALYZE ? (visualizer({ open: true, gzipSize: true }) as PluginOption) : null, 23 | ], 24 | server: { 25 | open: true, 26 | port: 3000, 27 | }, 28 | build: { 29 | outDir: 'build', 30 | rollupOptions: { 31 | output: { 32 | manualChunks, 33 | }, 34 | }, 35 | }, 36 | test: { 37 | globals: true, 38 | environment: 'jsdom', 39 | setupFiles: 'src/setupTests.ts', 40 | clearMocks: true, 41 | exclude: [...configDefaults.exclude, 'e2e/**/*', 'e2e-playwright/**/*'], 42 | coverage: { 43 | reporter: ['text', 'json', 'html'], 44 | }, 45 | }, 46 | }); 47 | --------------------------------------------------------------------------------