From d5e6151fde540f68d0588a8b319854e155f75486 Mon Sep 17 00:00:00 2001 From: Ashok-1256 Date: Fri, 13 Jan 2023 00:30:41 +0530 Subject: [PATCH] Added more things in template from CRA-boiler-plate --- .editorconfig | 24 +++ .github/PULL_REQUEST_TEMPLATE.md | 25 +++ .github/workflows/build.yaml | 69 +++++++ .github/workflows/code_validation.yml | 25 --- .github/workflows/commitlint.yaml | 31 +++ .github/workflows/lint.yml | 11 + .github/workflows/release.yml | 31 +++ .github/workflows/test.yaml | 31 +++ .husky/commit-msg | 6 + .husky/pre-commit | 8 +- .husky/prepare-commit-msg | 4 + .prettierrc.json | 6 +- .versionrc.js | 35 ++++ .vscode/extensions.json | 7 + CHANGELOG.md | 195 ++++++++++++++++++ RELEASE_PROCESS.md | 18 ++ commitlint.config.js | 10 + docs/building-blocks/README.md | 92 +++++++++ docs/building-blocks/async-components.md | 34 +++ docs/building-blocks/css.md | 73 +++++++ docs/building-blocks/i18n.md | 104 ++++++++++ docs/building-blocks/routing.md | 65 ++++++ docs/building-blocks/slice/README.md | 26 +++ docs/building-blocks/slice/redux-injectors.md | 33 +++ docs/building-blocks/slice/redux-saga.md | 60 ++++++ docs/building-blocks/slice/redux-toolkit.md | 137 ++++++++++++ docs/building-blocks/slice/reselect.md | 59 ++++++ docs/building-blocks/testing.md | 181 ++++++++++++++++ docs/deployment/aws.md | 50 +++++ .../__tests__/extractingMessages.test.ts | 79 +++++++ .../extractMessages/i18next-scanner.config.js | 62 ++++++ internals/extractMessages/jest.config.js | 9 + .../extractMessages/stringfyTranslations.js | 68 ++++++ internals/scripts/create-changelog.script.ts | 24 +++ internals/scripts/utils.ts | 26 +++ src/IDBStore/Config.js | 12 ++ src/IDBStore/IDBObjectStore.js | 53 +++++ src/IDBStore/IDBStore.js | 158 ++++++++++++++ src/IDBStore/__tests__/IDBObjectStore.test.js | 40 ++++ src/IDBStore/__tests__/IDBStore.test.js | 64 ++++++ src/IDBStore/utils.js | 25 +++ src/assets/styles/index.css | 13 -- src/locales/__tests__/i18n.test.ts | 16 ++ src/locales/de/translation.json | 19 ++ src/locales/en/translation.json | 19 ++ src/locales/i18n.ts | 38 ++++ src/locales/translations.ts | 35 ++++ src/locales/types.ts | 18 ++ src/log-layer/Config.js | 55 +++++ src/log-layer/LogController.js | 53 +++++ src/log-layer/LogLayer.js | 99 +++++++++ src/log-layer/Middleware/ErrorResponse.js | 57 +++++ src/log-layer/Middleware/EventResponse.js | 50 +++++ src/log-layer/Middleware/Response.js | 75 +++++++ src/log-layer/__tests__/LogController.test.js | 113 ++++++++++ src/log-layer/__tests__/LogReporter.test.js | 136 ++++++++++++ .../__tests__/Middleware/Response.test.js | 53 +++++ src/log-layer/__tests__/constants.js | 93 +++++++++ src/log-layer/__tests__/logService.test.js | 87 ++++++++ src/log-layer/__tests__/testUtils.js | 8 + src/log-layer/__tests__/utils.test.js | 64 ++++++ src/log-layer/log-layer.md | 33 +++ src/log-layer/logReporter.js | 139 +++++++++++++ src/log-layer/logService.js | 67 ++++++ src/log-layer/utils.js | 95 +++++++++ src/setupTests.ts | 11 + src/utils/@reduxjs/toolkit.tsx | 19 ++ src/utils/api/request.ts | 54 +++++ src/utils/config.ts | 3 - src/utils/const.ts | 3 - src/utils/helpers.ts | 3 - src/utils/reactLazy/loadable.tsx | 30 +++ src/utils/translations/messages.ts | 13 ++ src/utils/types/injector-typings.ts | 22 ++ 74 files changed, 3612 insertions(+), 51 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/build.yaml delete mode 100644 .github/workflows/code_validation.yml create mode 100644 .github/workflows/commitlint.yaml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yaml create mode 100755 .husky/commit-msg create mode 100755 .husky/prepare-commit-msg create mode 100644 .versionrc.js create mode 100644 .vscode/extensions.json create mode 100644 CHANGELOG.md create mode 100644 RELEASE_PROCESS.md create mode 100644 commitlint.config.js create mode 100644 docs/building-blocks/README.md create mode 100644 docs/building-blocks/async-components.md create mode 100644 docs/building-blocks/css.md create mode 100644 docs/building-blocks/i18n.md create mode 100644 docs/building-blocks/routing.md create mode 100644 docs/building-blocks/slice/README.md create mode 100644 docs/building-blocks/slice/redux-injectors.md create mode 100644 docs/building-blocks/slice/redux-saga.md create mode 100644 docs/building-blocks/slice/redux-toolkit.md create mode 100644 docs/building-blocks/slice/reselect.md create mode 100644 docs/building-blocks/testing.md create mode 100644 docs/deployment/aws.md create mode 100644 internals/extractMessages/__tests__/extractingMessages.test.ts create mode 100644 internals/extractMessages/i18next-scanner.config.js create mode 100644 internals/extractMessages/jest.config.js create mode 100644 internals/extractMessages/stringfyTranslations.js create mode 100644 internals/scripts/create-changelog.script.ts create mode 100644 internals/scripts/utils.ts create mode 100644 src/IDBStore/Config.js create mode 100644 src/IDBStore/IDBObjectStore.js create mode 100644 src/IDBStore/IDBStore.js create mode 100644 src/IDBStore/__tests__/IDBObjectStore.test.js create mode 100644 src/IDBStore/__tests__/IDBStore.test.js create mode 100644 src/IDBStore/utils.js delete mode 100644 src/assets/styles/index.css create mode 100644 src/locales/__tests__/i18n.test.ts create mode 100644 src/locales/de/translation.json create mode 100644 src/locales/en/translation.json create mode 100644 src/locales/i18n.ts create mode 100644 src/locales/translations.ts create mode 100644 src/locales/types.ts create mode 100644 src/log-layer/Config.js create mode 100644 src/log-layer/LogController.js create mode 100644 src/log-layer/LogLayer.js create mode 100644 src/log-layer/Middleware/ErrorResponse.js create mode 100644 src/log-layer/Middleware/EventResponse.js create mode 100644 src/log-layer/Middleware/Response.js create mode 100644 src/log-layer/__tests__/LogController.test.js create mode 100644 src/log-layer/__tests__/LogReporter.test.js create mode 100644 src/log-layer/__tests__/Middleware/Response.test.js create mode 100644 src/log-layer/__tests__/constants.js create mode 100644 src/log-layer/__tests__/logService.test.js create mode 100644 src/log-layer/__tests__/testUtils.js create mode 100644 src/log-layer/__tests__/utils.test.js create mode 100644 src/log-layer/log-layer.md create mode 100644 src/log-layer/logReporter.js create mode 100644 src/log-layer/logService.js create mode 100644 src/log-layer/utils.js create mode 100755 src/setupTests.ts create mode 100644 src/utils/@reduxjs/toolkit.tsx create mode 100644 src/utils/api/request.ts delete mode 100644 src/utils/config.ts delete mode 100644 src/utils/const.ts delete mode 100644 src/utils/helpers.ts create mode 100644 src/utils/reactLazy/loadable.tsx create mode 100644 src/utils/translations/messages.ts create mode 100644 src/utils/types/injector-typings.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2e1ce17 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[{package,bower}.json] +indent_style = space +indent_size = 2 + +[{.eslintrc,.scss-lint.yml}] +indent_style = space +indent_size = 2 + +[*.{scss,sass}] +indent_style = space +indent_size = 2 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1cf4c6c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ +## React Boilerplate CRA Template + +### ⚠️ Clear this template before you submit (after you read the things below) + +Thank you for contributing! Please take a moment to review our [**contributing guidelines**](https://github.com/react-boilerplate/react-boilerplate/blob/master/CONTRIBUTING.md) +to make the process easy and effective for everyone involved. + +**Please open an issue** before embarking on any significant pull request, especially those that +add a new library or change existing tests, otherwise you risk spending a lot of time working +on something that might not end up being merged into the project. + +Before opening a pull request, please ensure: + +- [ ] You have followed our [**contributing guidelines**](https://github.com/react-boilerplate/react-boilerplate/blob/master/CONTRIBUTING.md) +- [ ] Double-check your branch is based on `dev` and targets `dev` +- [ ] Pull request has tests (we are going for 100% coverage!) +- [ ] Code is well-commented, linted and follows project conventions +- [ ] Documentation is updated (if necessary) +- [ ] Internal code generators and templates are updated (if necessary) +- [ ] Description explains the issue/use-case resolved and auto-closes related issues + +Be kind to code reviewers, please try to keep pull requests as small and focused as possible :) + +**IMPORTANT**: By submitting a patch, you agree to allow the project +owners to license your work under the terms of the [MIT License](https://github.com/react-boilerplate/react-boilerplate/blob/master/LICENSE.md). diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..0e51122 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,69 @@ +name: build + +on: + - push + - pull_request + - workflow_dispatch + +jobs: + createNpmPackage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 16.x + uses: actions/setup-node@v3 + with: + node-version: 16.x + - run: yarn add shelljs + - run: yarn run create:npm-package + - name: Upload template + uses: actions/upload-artifact@v1 + with: + name: cra-template-rb + path: .cra-template-rb + + createAndTestCRA: + needs: createNpmPackage + runs-on: ubuntu-latest + steps: + - name: Use Node.js 16.x + uses: actions/setup-node@v3 + with: + node-version: 16.x + - name: Download template + uses: actions/download-artifact@v1 + with: + name: cra-template-rb + path: ../cra-template-rb # Put into the upper folder. create-react-app wants the current directory empty + - name: Create CRA from downloaded template + run: yarn create react-app --template file:../cra-template-rb . + - run: yarn run build + - run: yarn run test:generators + - run: yarn run lint + - run: yarn run checkTs + - run: yarn run test + - run: yarn run cleanAndSetup + - run: yarn run build + - run: yarn run test:generators + - run: yarn run lint + - run: yarn run checkTs + createCRAWithMultipleNodeVersions: + needs: createNpmPackage + strategy: + matrix: + node-version: [14.x, 18.x] + runs-on: ubuntu-latest + steps: + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - name: Download template + uses: actions/download-artifact@v1 + with: + name: cra-template-rb + path: ../cra-template-rb # Put into the upper folder. create-react-app wants the current directory empty + - name: Create CRA from downloaded template + run: yarn create react-app --template file:../cra-template-rb . + - run: yarn run build + - run: yarn run test diff --git a/.github/workflows/code_validation.yml b/.github/workflows/code_validation.yml deleted file mode 100644 index 2b1e510..0000000 --- a/.github/workflows/code_validation.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Code Validation - -on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] - -jobs: - eslint: - name: Run eslint and prettier scanning - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@v3 - - name: Use Node.js 17 - uses: actions/setup-node@v3.3.0 - with: - node-version: 17 - - uses: bahmutov/npm-install@v1 - - run: npm run lint:check - - run: npm run prettier:check diff --git a/.github/workflows/commitlint.yaml b/.github/workflows/commitlint.yaml new file mode 100644 index 0000000..eb9ed5f --- /dev/null +++ b/.github/workflows/commitlint.yaml @@ -0,0 +1,31 @@ +# Run commitlint on Pull Requests and commits +name: commitlint +on: + pull_request: + types: ['opened', 'edited', 'reopened', 'synchronize'] + push: + branches: + - master + - dev + +jobs: + lint-pull-request-name: + # Only on pull requests + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - run: yarn add @commitlint/config-conventional + - uses: JulienKode/pull-request-name-linter-action@v0.1.2 + lint-commits: + # Only if we are pushing or merging PR to the master + if: (github.event_name == 'pull_request' && github.base_ref == 'refs/heads/master') || github.event_name == 'push' + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 30 # Its fine to lint last 30 commits only + - run: yarn add @commitlint/config-conventional + - uses: wagoid/commitlint-github-action@v1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..fdd5f3a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,11 @@ +name: lint +on: + - push + - pull_request +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: yarn --frozen-lockfile + - run: yarn run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bfcc838 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: release + +on: + release: + types: + - created + workflow_dispatch: + +jobs: + createAndTestCRAFromNpm: + strategy: + matrix: + node-version: [16.x, 18.x] + + runs-on: ubuntu-latest + steps: + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - name: Create CRA from npm template + run: yarn create react-app --template cra-template-rb . + - run: yarn run build + - run: yarn run test:generators + - run: yarn run lint + - run: yarn run checkTs + - run: yarn run cleanAndSetup + - run: yarn run build + - run: yarn run test:generators + - run: yarn run lint + - run: yarn run checkTs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..37ce229 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,31 @@ +name: test +on: + - push + - pull_request + - workflow_dispatch + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 16.x + uses: actions/setup-node@v3 + with: + node-version: 16.x + - run: yarn --frozen-lockfile + - run: yarn run test:coverage + - name: Upload to Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + testInternals: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 16.x + uses: actions/setup-node@v3 + with: + node-version: 16.x + - run: yarn --frozen-lockfile + - run: yarn run test:internals diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..f2e79f5 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,6 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +if yarn git-branch-is dev; +then yarn commitlint --edit $1; +fi diff --git a/.husky/pre-commit b/.husky/pre-commit index d24fdfc..ddb7a69 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,6 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" -npx lint-staged +yarn checkTs +yarn lint-staged +yarn verify-startingTemplate-changes diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100755 index 0000000..c827af9 --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn devmoji -e \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json index b0a179d..b88daf7 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,5 +1,9 @@ { + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, "singleQuote": true, - "trailingComma": "none", + "trailingComma": "all", "arrowParens": "avoid" } diff --git a/.versionrc.js b/.versionrc.js new file mode 100644 index 0000000..cfe095f --- /dev/null +++ b/.versionrc.js @@ -0,0 +1,35 @@ +const internalSection = `Internals`; +/* + * Used for creating CHANGELOG.md automatically. + * Anything under the internalSection should be boilerplate internals + * and shouldn't interest the end users, meaning that the template shouldn't be effected. + */ + +// Check the descriptions of the types -> https://github.com/commitizen/conventional-commit-types/blob/master/index.json +module.exports = { + types: [ + { type: 'feat', section: 'Features', hidden: false }, + { type: 'fix', section: 'Bug Fixes', hidden: false }, + { type: 'docs', section: 'Documentation', hidden: false }, + { type: 'perf', section: 'Performance Updates', hidden: false }, + + // Other changes that don't modify src or test files + { type: 'chore', section: internalSection, hidden: false }, + + // Adding missing tests or correcting existing tests + { type: 'test', section: internalSection, hidden: false }, + + // Changes to our CI configuration files and scripts + { type: 'ci', section: internalSection, hidden: false }, + + // A code change that neither fixes a bug nor adds a feature + { type: 'refactor', section: internalSection, hidden: false }, + + // Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) + { type: 'style', section: internalSection, hidden: false }, + ], + skip: { + changelog: true, + }, + commitAll: true, +}; diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..ea892e0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "orta.vscode-jest" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..64fbff1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,195 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +## [1.2.6](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v1.2.5...v1.2.6) (2022-10-23) + +- 🔧 maintenance(multiple) ([59b46a4](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/59b46a483ea67adc1747f8852165e68fa476b6df)) +- **deps:** updated practically all dependencies ([#198](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/198)) ([8c2d9d1](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/8c2d9d14e65dc016f46453dbfa00153c052e9e2c)) + +## [1.2.5](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v1.2.4...v1.2.5) (2022-06-20) + +### Internals + +- 🔧 bump react to 18.1 ([#170](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/170)) ([7ef3155](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/7ef31555a29c273ffd02b55f30f913218d13eb1f)) + +## [1.2.4](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v1.2.3...v1.2.4) (2022-04-12) + +### Bug Fixes + +- 🐛 quick fix for breaking changes of @types/react 18 ([69a5bb7](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/69a5bb7eb671db16a070950816117f3d94c9d9e0)) + +## [1.2.3](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v1.2.2...v1.2.3) (2022-01-19) + +### Internals + +- 🔧 maintenance(CRA v5) ([41e775f](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/41e775f4f3d003dcb5f6ccec6c5be0566c951fb8)) + +## [1.2.2](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v1.2.1...v1.2.2) (2021-07-20) + +### Bug Fixes + +- 🐛 downgrade inquirer ([ffc735e](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/ffc735ed55b66b68301399dbdc1f33dc8b4fd9a5)), closes [#136](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/136) + +## [1.2.1](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v1.2.0...v1.2.1) (2021-07-13) + +### Documentation + +- 📚️ fixed typo [#129](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/129) ([e7794c8](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/e7794c87bd6e13a0ed377f1e0d18640954dabeee)) +- 📚️ fixing typo for navigationBarReducer ([#114](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/114)) ([d04de39](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/d04de392090129c299828948bb7343dd25d6b016)) +- 📚️added missing selector function in async components ([#133](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/133)) ([2bb4bc7](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/2bb4bc7ae17c62d56d98efcab19e672bc84de44d)) + +### Internals + +- 🔧 maintenance ([2e1b814](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/2e1b814b2e005edbe79602d9d45dd2b56cee733e)) + +## [1.2.0](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v1.1.1...v1.2.0) (2021-01-22) + +### Features + +- ✨revised folder structure & generators ([#107](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/107)) ([b1e9d69](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/b1e9d696c027eedd19594a071d72ceec2e832ef8)) + +### Bug Fixes + +- 🐛 added missing web-vitals to startingTemplate ([4fc4bc3](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/4fc4bc3a03d28e781c177f10501d8bf88458806e)) +- 🐛 dynamic messages loading ([02d9a1d](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/02d9a1da3d868c0a6cde7cfbede8889210b37482)), closes [#103](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/103) +- 🐛 message extraction script [#102](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/102) ([4b8788c](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/4b8788c9b9d9e3004feebf8e04fdb96e16a3a2d7)) +- 🐛 removed offline-first paradigm ([a27ceca](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/a27ceca7620c5133a70fe21cad83a391a38b8fa5)) + +### Documentation + +- 📚️ removed how-to repo ([324d3d3](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/324d3d3a10cd56acdd1a7be0dee31b28c718ef3d)) + +### Internals + +**This section only concerns the contributors of this project. You can ignore these changes since they DO NOT create changes in the CRA Template** + +
Click to see the internal changes + +- 👷 added manual triggers ([a514701](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/a514701081fe65ea6099be810ed14c1e1ca80a7d)) +- 🔧 added i18n mock to generators ([#106](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/106)) ([2440250](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/2440250e9a69bff9216ba73aad83168f28985ca3)) +- 🔧 bumped typescript version ([386de98](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/386de985a40a2fe1e75e4873af2d94044516964a)) +- 🚨 fixed minor type error ([976d19d](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/976d19ddab033548f89637de8acd58c86663792a)) + +
+ +## [1.1.1](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v1.0.2...v1.1.0) (2020-12-02) + +### Features + +- ✨ added translation JSON files extraction ([#65](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/65)) ([59d5cc4](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/59d5cc4c332a17c8070ef83fd3c7e2b1d10d7bbb)) +- ✨ added .editorconfig ([0423d7c](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/0423d7c13b8802cd1435cff941fe4eeb727a0a49)) +- ✨ added web-vitals from CRA 4.0 ([17c4f97](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/17c4f97f7edc4c64f385962fbe4aea8e07950312)) +- ✨ fast refresh with CRA 4.0 ([bc7ea9c](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/bc7ea9c0bbad5cbe075c5648ad987fad06961ee9)) + +### Bug Fixes + +- 🐛 included .npmrc file in the template which was ignored ([53b28fd](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/53b28fd0a428ca6d53b77e5a44b3d0c73369a4fc)) +- 🐛 setting html lang tag to selected language ([c2e61a2](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/c2e61a2ba49cf6558eb36188d3807a051b312492)) + +### Internals + +**This section only concerns the contributors of this project. You can ignore these changes since they DO NOT create changes in the CRA Template** + +
Click to see the internal changes + +- **chore:** 📚️ 🔧 review & update ([45c604c](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/45c604c1e5ed7e29dfd0351b3b9c7eaf1cc01a05)) +- 📚️ added release process steps ([f3eb490](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/f3eb490bf9c993b8276e0b7688b8c887b09c2e3e)) +- ♻️ fixing typos, settings and concistency ([c32691c](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/c32691c2dc6819e02d8f43d9054ec50375e7199c)) +- 🔧 maintenance ([#66](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/66)) ([432f449](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/432f4492aa23056e63c721629f274fc8392fd4ba)) +- 🔧 added component folder selection to generators ([#76](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/76)) ([de8e6fd](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/de8e6fd7b8ca4520f2b64c46d4ebd19daf004925)) +- 🔧 switched to yarn ([#89](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/89)) ([2a90e24](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/2a90e24b8eaf8adcfb6008f20a2fc4a8f83bfa33)) +
+ +## [1.0.2](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v1.0.1...v1.0.2) (2020-10-27) + +Quick patch for cra v4 bug. No changes + +### Bug Fixes + +- CRA v4 bug fix ([#79](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/79)) ([2cae593](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/2cae593fbd53ee1e6e4a7f31cf50781c1b1ab6b9)) + +## [1.0.1](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v1.0.0...v1.0.1) (2020-07-03) + +### Bug Fixes + +- 🐛 switched to plain objects in i18n helper function ([de76cf6](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/de76cf66da852a786822109e04b49aa62b5b0511)) + +### Documentation + +- 📚️ added react-router hooks ([3927a1b](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/3927a1b513035c6a19d0dab532f76655418fa002)) + +### Internals + +**This section only concerns the contributors of this project. You can ignore these changes since they DO NOT create changes in the CRA Template** + +
Click to see the internal changes + +- 📚 fix redux-toolkit docs ([#35](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/35)) ([30732a8](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/30732a8f68766e1a5a0685dfe5f5e8d1260f30c2)) +- 📚️ fixed docs issues ([97d67f0](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/97d67f0b1f53af6922017b83f3568710a7dda50a)) +- 📚️ fix redux url ([#42](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/42)) ([a491728](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/a49172853e87caa720d0af99341a932e98f3f537)) +- 🐛 removing redundant "history" ([#31](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/31)) ([0793d31](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/0793d314439afd434e6ea7a07d9fef15cd47e30b)) +- ♻️ fixing variable name in redux-toolkit docs ([#37](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/37)) ([3968ade](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/3968aded3182ab16a35f5596ef2f53e05109d296)) +- ♻️ update redux-toolkit docs ([#33](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/33)) ([8dd5931](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/8dd5931b0bd62416446dfb0b2fa761ee77eab852)) +- 🎨 added og meta tags ([43657d6](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/43657d634e28d7a1fa46779657f86e46586c5ac2)) +- 🔧 merge dev for the release ([#48](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/48)) ([043c524](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/043c52477b0c15360a9682d2e0e928dd4b72fbdb)) +- **deps:** 🔗 bump websocket-extensions from 0.1.3 to 0.1.4 ([#39](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/39)) ([36e1f9e](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/36e1f9eb4c5439cc4e6ec9ed71a535d52de3ecd8)) +- 🔧 removed theme from startingTemplate ([02d1e62](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/02d1e627ea8b3918f26efc461db3faafaa86a278)) + +
+ +## [1.0.0](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v0.1.3...v1.0.0) (2020-05-18) + +### Features + +- ✨ added media query utility ([c776cdd](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/c776cdd7e55295d304268cc0821779c9720f0fdd)) + +### Bug Fixes + +- 🐛 removed `connected-react-router` ([#28](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/28)) ([f6a0350](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/f6a0350dc5c6203a1b1c47d2b420245b7251bd05)) +- 🐛 supporting css prop in styled-components ([57895a3](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/57895a3ca97acc7e6dda2a14b093d669d3be4e9e)), closes [#27](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/27) + +### Documentation + +- 📚️ added example repo to readme ([5f5e413](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/5f5e4133b85f5a7c6bbbbae24fd0c6361ad9e151)) +- 📚️ added FAQ ([47d81b3](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/47d81b311e7c7d2a1f68e55af6d7e10e37526759)) +- 📚add netlify deployment ([#23](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/23)) ([fb9860d](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/fb9860defd9704d2941a2ef7bdb9c13ed462786b)) + +### Internals + +**This section only concerns the contributors of this project. You can ignore these changes since they DO NOT create changes in the CRA Template** + +
Click to see the internal changes + +- ♻️ fix typo ([a4a4f50](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/a4a4f5076abc31d9cad612c0c4daab7d37b753b4)), closes [#25](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/25) +- ♻️ fix typo in toolkit.tsx comment ([#18](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/18)) ([1867a5b](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/1867a5b48fcd3b8d54ddd3a07cddf5ececc36c91)) +- ♻️ updated clean script name ([3cedb94](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/3cedb9494d242cc48fc605c2002d1cf173c14c55)) +- ♻️ updated readme ([765a897](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/765a8972733a556bd39b414cbbe1ff9458864f6a)) +- 🔧 added commit hook verify startingTemplate changes ([e0240c8](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/e0240c83f1f5d0a7fd370759c6114b25bdb5044c)) +- 🔧 added script for creating changelog ([4ed9ed5](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/4ed9ed555e6d3c5363819af3f5eebf7800ec6046)) +- 🔧 improved cleaning script ([a3d05f8](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/a3d05f8faa49cffbdb3e82fe53a9024db8f2170b)), closes [#29](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/29) +- 🔧 moved creation of the test CRA to a script to avoid husky bug ([e6f8054](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/e6f805435b332587875f045f327e97c43b0b49bf)) +- 🚨 added media utility tests ([3f2d9c9](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/3f2d9c991e8af4914dcd899d6a346dceae0a9463)) + +
+ +## [0.1.3](https://github.com/react-boilerplate/react-boilerplate-cra-template/compare/v0.1.2...v0.1.3) (2020-05-05) + +### Bug Fixes + +- 🐛 moving typescript check to pre-commit from lint-staged ([6aac0d3](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/6aac0d302bcea714dd8a1ad49b3c77b91204d0b2)) + +### Internals + +**This section only concerns the contributors of this project. You can ignore these changes since they DO NOT create changes in the CRA Template** + +
Click to see the internal changes + +- ✨ redux dev tools enabled on github page ([aa890c5](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/aa890c50bcd130788b0b4736efd51b71ae9c057c)) +- 👷 added job to test the released version ([a328db6](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/a328db6f64b9baffbc2bd04f1e84809f3a9e8364)) +- 🔧 added npm test to CI ([1fbf852](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/1fbf85269e499c280c0cbe15194f882344b3e9ec)) +- 🔧 adding commitlint to workflows ([#13](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/13)) ([f049526](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/f04952662f818ea5fab6d895770e3570748b4313)) +- 🔧Fix typo in README ([#9](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/9)) ([8680c10](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/8680c10d2d96e8ad2cae86a40f3c0a86ba76a513)) +- 🔧switched to standard-version ([#15](https://github.com/react-boilerplate/react-boilerplate-cra-template/issues/15)) ([ce497b5](https://github.com/react-boilerplate/react-boilerplate-cra-template/commit/ce497b533a2aa81d1a5a08d487534e60b4189b32)) + +
diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md new file mode 100644 index 0000000..aeef2fd --- /dev/null +++ b/RELEASE_PROCESS.md @@ -0,0 +1,18 @@ +# RELEASE PROCESS + +The release process is **semi-automated**. The generated changelog requires editing to keep it visually appealing and clear for everyone. + +## Step by step + +1. All the development goes into `dev` branch. There are only squash merges allowed there so that its not flooded with everyones commits. +2. Make a PR to `master` from `dev` and if all checks are good then merge with the title `chore: 🔧 releasing x.x.x`. +3. Generate the changelog + - `yarn run changelog` +4. Take a look at the previous changelogs and modify the generated changelog accordingly. Delete and organize the commits and move them under internals section if needed. +5. Create the release + - `yarn run release` +6. Publish to npm + - `yarn run publish:npm` +7. Push the changes to git. +8. Create release in github by copy pasting the related section from the CHANGELOG.md +9. There is a `release CI workflow`. Wait for it to be succeeded to see if there any problems with the released version. diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..67712cf --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,10 @@ +// Use types from .versionrc.js so that when generating CHANGELOG there are no inconsistencies +const standardVersionTypes = require('./.versionrc').types; +const typeEnums = standardVersionTypes.map(t => t.type); + +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [2, 'always', typeEnums], + }, +}; diff --git a/docs/building-blocks/README.md b/docs/building-blocks/README.md new file mode 100644 index 0000000..9cf45e7 --- /dev/null +++ b/docs/building-blocks/README.md @@ -0,0 +1,92 @@ +# Building Blocks + +In this section, we will explain the **building blocks** of the boilerplate in detail. + +First we have to look at what is happening when react starts its life with `index.tsx` file. + +### `src/index.tsx`: + +It is one of the most important files of the boilerplate. It contains all the global setup to make sure your app runs smoothly. Let's break its contents down: + +- `react-app-polyfill` is imported to enable compatibility with many browsers and cool stuff like generator functions, Promises, etc. +- A Redux `store` is instantiated. +- `createRoot().render()` not only renders the [root React component](https://github.com/react-boilerplate/react-boilerplate-cra-template/blob/master/src/app/index.tsx), called ``, of your application, but it renders it with ``. +- Hot module replacement via [Webpack HMR](https://webpack.js.org/guides/hot-module-replacement/) makes the i18n translations hot re-loadable. +- i18next internationalization support setup. +- `` connects your app with the Redux `store`. + +Again, `src/index.tsx` handles all the bootstrapping and setup of the features we are using in the boilerplate. Now, let's review a summary of the **building blocks**. + +{% hint style="info" %} + +**🧙Tips:** Following chapters reveal more details and tutorials on how to use the building blocks. + +{% endhint %} + +### Redux + +Redux is likely to play a significant role in your application. If you're new to Redux, we'd strongly suggest that you complete this checklist and then come back: + +- Understand the motivation behind Redux. +- Understand the three principles of Redux. +- Implement Redux in a small React app of yours. + +The Redux `store` is the heart of your application. Check out `src/store/configureStore.ts` to see how we have configured the store. + +The `createStore()` factory creates the Redux store and accepts three parameters. + +1. **Root reducer:** A master reducer combining all your reducers. +2. **Initial state:** The initial state of your app as determined by your reducers. +3. **Middleware/enhancers:** Middlewares are third party libraries which intercept each Redux action dispatched to the Redux store and then... do stuff. For example, if you install the [`redux-logger`](https://github.com/evgenyrodionov/redux-logger) middleware, it will listen to all the actions being dispatched to the store and print the previous and next state in the browser console. It's helpful to track what happens in your app. + +In our application, we are using a single middleware. + +1. **`redux-saga`:** Used for managing _side-effects_ such as dispatching actions asynchronously or accessing browser data. + +### Redux-Toolkit + +> The official, opinionated, batteries-included toolset for efficient Redux development. + +This is the latest and best way of using Redux. It handles lots of the things you would need to do to get Redux working. + +We will leave you alone with their [documentation](https://redux-toolkit.js.org) at this point. This boilerplate uses Redux heavily, so you must understand it. + +### Reselect + +Reselect is a library used for slicing your Redux state and providing only the relevant sub-tree to a React component. It has three key features: + +1. Computational power. +2. Memoization. +3. Composability. + +Imagine an application that shows a list of users. Its Redux state tree stores an array of usernames with signatures: + +`{ id: number, username: string, gender: string, age: number }`. + +Let's see how the three features of reselect help. + +- **Computation:** While performing a search operation, reselect will filter the original array and return only matching usernames. Redux state does not have to store a separate array of filtered usernames. +- **Memoization:** A selector will not compute a new result unless one of its arguments change. That means, if you are repeating the same search once again, reselect will not filter the array over and over. It will just return the previously computed and, subsequently, cached result. Reselect compares the old and the new arguments and then decides whether to compute again or return the cached result. +- **Composability:** You can combine multiple selectors. For example, one selector can filter usernames according to a search key, and another selector can filter the already filtered array according to gender. One more selector can further filter according to age. You combine these selectors by using `createSelector()`. + +### Redux-Saga + +If your application interacts with some back-end API for data, we recommend using `redux-saga` for side-effect management. Too much jargon? Let's simplify. + +Imagine that your application is fetching data in JSON format from a back-end. For every API call, ideally, you should define at least three kinds of [action creators](http://redux.js.org/docs/basics/Actions.html): + +1. `API_REQUEST`: Upon dispatching this, your application should show a spinner to let the user know that something's happening. +2. `API_SUCCESS`: Upon dispatching this, your application should show the data to the user. +3. `API_FAILURE`: Upon dispatching this, your application should show an error message to the user. + +And this is only for **_one_** API call. In a real-world scenario, one page of your application could be making tens of API calls. How do we manage all of them effectively? It essentially boils down to controlling the flow of your application. What if there was a background process that handled multiple actions simultaneously and communicated with the Redux store and React components at the same time? Here is where `redux-saga` enters the picture. + +For a mental model, consider a saga like a separate thread in your application that's solely responsible for side-effects. Then `redux-saga` is a Redux middleware, which means this thread can be started, paused, and canceled from the main application with standard Redux actions. It has access to the full Redux application state, and it can dispatch Redux actions as well. + +### Linting + +This boilerplate includes a complete static code analysis setup. It's composed of [ESLint](http://eslint.org/), [stylelint](https://stylelint.io/), and [Prettier](https://prettier.io/). + +We recommend that you install the relevant IDE extensions for each one of these tools. Once you do, every time you press [save], all your code will automatically be formatted and reviewed for quality. + +The boilerplate provides a pre-commit git hook to analyze and fix linting errors automatically before committing your code. If you'd like to disable it or modify its behavior, take a look at the `lint-staged` section in `package.json`. diff --git a/docs/building-blocks/async-components.md b/docs/building-blocks/async-components.md new file mode 100644 index 0000000..0e0651e --- /dev/null +++ b/docs/building-blocks/async-components.md @@ -0,0 +1,34 @@ +# Async Components + +To load a component asynchronously, create a `Loadable` file by hand or via component generator with the 'Do you want to load resources asynchronously?' option activated. + +This is the content of the file by default: + +#### `Loadable.tsx` + +```ts +import { lazyLoad } from 'utils/loadable'; + +export const HomePage = lazyLoad( + () => import('./index'), + module => module.HomePage, // Select your exported HomePage function for lazy loading +); +``` + +In this case, the app won't show anything while loading your component. You can, however, make it display a custom loader with: + +```ts +import React from 'react'; +import { lazyLoad } from 'utils/loadable'; + +export const HomePage = lazyLoad( + () => import('./index'), + module => module.HomePage, + { + fallback:
Loading...
, + } +); +``` + +Make sure to rename your `Loadable.ts` file to `Loadable.tsx`. +This feature is built into the boilerplate using React's `lazy` and `Suspense` features. diff --git a/docs/building-blocks/css.md b/docs/building-blocks/css.md new file mode 100644 index 0000000..8eb495c --- /dev/null +++ b/docs/building-blocks/css.md @@ -0,0 +1,73 @@ +# Styling (CSS) + +## Next Generation CSS + +This boilerplate uses [`styled-components`](https://github.com/styled-components/styled-components) for styling React components. `styled-components` allows you to write actual CSS inside your JavaScript, enabling you to use the [full power of CSS](https://github.com/styled-components/styled-components/blob/master/docs/css-we-support.md) 💪 without mapping between styles and components. There are many ways to style React applications, but many developers find `styled-components` to be a more natural approach to styling components. + +### Linting + +To complement `styled-components`, this boilerplate also has a CSS linting setup. It uses `stylelint` which will help you stay consistent with modern CSS standards. Read about it [here](linting.md). + +### `sanitize.css` + +This boilerplate also uses [`sanitize.css`](https://github.com/jonathantneal/sanitize.css) to make browsers render all elements more consistently and in line with modern standards, it's a modern alternative to CSS resets. More info available on the [`sanitize.css` page](sanitize.md). + +## styled-components + +The example below creates two styled React components (`` and `<Wrapper>`) and renders them as children of the `<Header>` component: + +```ts +import * as React from 'react'; +import styled from 'styled-components/macro'; + +// Create a <Title> React component that renders an <h1> which is +// centered, palevioletred and sized at 1.5em +const Title = styled.h1` + font-size: 1.5em; + text-align: center; + color: palevioletred; +`; + +// Create a <Wrapper> React component that renders a <section> with +// some padding and a papayawhip background +const Wrapper = styled.section` + padding: 4em; + background: papayawhip; +`; + +// Use them like any other React component – except they're styled! +function Button() { + return ( + <Wrapper> + <Title>Hello, here is your first styled component! + ... + + ); +} +``` + +_(The CSS rules are automatically vendor-prefixed, so you don't have to think about it!)_ + +{% hint style="info" %} + +🧙**Tips:** Importing from `styled-components/macro` will enable some features you can [see here](https://styled-components.com/docs/tooling#babel-macro). + +{% endhint %} + +## Media queries + +Type-safe media queries can be complicated if you haven't mastered TypeScript. Therefore we include a [media utility file](../../src/styles/media.ts) to make things easier for you. + +### Example Usage + +```ts +import { media } from 'styles/media'; + +const SomeDiv = styled.div` + display: flex; + .... ${media.medium} { + display: block; + } +`; +``; +``` diff --git a/docs/building-blocks/i18n.md b/docs/building-blocks/i18n.md new file mode 100644 index 0000000..1ee9230 --- /dev/null +++ b/docs/building-blocks/i18n.md @@ -0,0 +1,104 @@ +# `i18n` + +[react-i18next](https://react.i18next.com/) is a powerful internationalization framework for React / React Native which is based on `i18next`. It is a library to manage internationalization and pluralization support for your React application. This involves multi-language support for both the static text but also things like variable numbers, words, or names that change with the application state. + +## Usage + +The setup and translations are in the **`locales/`** folder. You can add more language to subfolder `de`, `en`, `fr`, and so on. + +**`i18n.ts`** is the setup file. It initiates `i18next` with the translations. We also include a helper function here to help use your translations with intellisense support in your project, rather than having to rely on a `something.otherthing.title` kind of string-based format which is error-prone and **not** refactorable. It maps your JSON translation file to JavaScript objects so that you can call them like, surprise, regular objects. + +{% hint style="info" %} + +🧙**Tips:** Check the example application of this boilerplate to see how you can separate your translations into logical groups and make everything intellisense-supported 💪 + +{% endhint %} + +### Using translations with hooks + +Let's say your translation JSON is this: + +```json +{ + "HomePage": { + "Features": { + "someItem": "Some text in English" + } + } +} +``` + +Now you can get the **`someItem`** translation very easily and safely with intellisense support. + +```ts +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { translations } from 'locales/translations'; + +export function MyComponent() { + const { t, i18n } = useTranslation(); + const changLanguageButtonClicked = evt => { + const language = event.target.value; + i18n.changeLanguage(language); + }; + // The nested objects are intellisense supported ✅ + return
{t(translations.HomePage.Features.someItem)}
; +} +``` + +Check the [react-i18next](https://react.i18next.com/) docs for other usage types. Its very flexible and well-featured. + +## Extracting JSON Files + +You don't have to add or delete each entry in `translation.json` manually. Using [i18next-scanner](https://github.com/i18next/i18next-scanner) its fairly straight-forward to extract all the translations into JSON files. It scans your code and whenever it sees something like `t('a.b.c')` it adds `{a: {b : { c: ""}}}` into the JSON files. + +Simply, run this script + +```shell +yarn run extract-messages +``` + +{% hint style="warning" %} + +**WARNING:** The rest below only applies if you want to use `translations` object and want to extract messages later on. If you are going with the default `t('a.b')` approach or if you don't want to extract messages you don't need the `messages.ts` below + +{% endhint %} + +However, there is a catch here. As mentioned above, we provide **helper object** for translations so that they are type-safe and intellisense-supported. This ruins the scanning ability of `i18next-scanner`. In order to overcome this, we need to define our translated messages in a file + +#### `messages.ts` + +```ts +import { translations } from 'locales/translations'; +import { _t } from 'utils/messages'; + +export const messages = { + someItem: () => _t(translations.HomePage.Features.someItem, 'default value'), + // ... +}; +``` + +then we use `messages` in our react component + +```ts +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { messages } from './messages'; + +export function MyComponent() { + const { t } = useTranslation(); + return
{t(...messages.someItem()}
; +} +``` + +The reason behind this is, we have to convert this + +`t(translations.Homepage.Features.someItem)` + +to + +`t('Homeage.Features.someItem')` + +before `i18next-scanner` parses the file. To do that there is custom [function](../../internals/extractMessages/stringfyTranslations.js) running before the parsing happens. This function looks at `_t(...)`'s and converts them to strings. Then, scanner carries out its duty... + +The example application includes this usage and you can take a look at there for a working example. diff --git a/docs/building-blocks/routing.md b/docs/building-blocks/routing.md new file mode 100644 index 0000000..81bf337 --- /dev/null +++ b/docs/building-blocks/routing.md @@ -0,0 +1,65 @@ +# Routing + +`react-router` is the de-facto standard routing solution for React applications. + +## Why not use [connected-react-router](https://github.com/supasate/connected-react-router)? + +There is a detailed explanation for this decision [here](https://reacttraining.com/react-router/web/guides/deep-redux-integration). In short, the recommendation is to forego keeping routes in the Redux store, simply because it shouldn't be needed. There are other ways of navigating, as explained there. + +## Usage + +To add a new route, simply import the `Route` component and use it standalone or inside the `Routes` component (all part of [RR6 API](https://reactrouter.com/docs/en/v6/getting-started/overview)): + +```ts +} /> +``` + +Top level routes are located in `src/app/index.tsx`. + +If you want your route component (or any component for that matter) to be loaded asynchronously, use the component generator with 'Do you want to load resources asynchronously?' option activated. + +## Child Routes + +For example, if you have a route called `about` at `/about`, and want to make a child route called `team` at `/about/our-team`, follow the example in `src/app/index.tsx` to create a `Routes` within the parent component. + +#### `AboutPage/index.tsx` + +```ts +import { Routes, Route } from 'react-router-dom'; + +export function AboutPage() { + return ( + + + + ); +} +``` + +## Routing programmatically + +You can use the `react-router hooks`, such as [useNavigate](https://reactrouter.com/docs/en/v6/hooks/use-navigate) or [useParams](https://reactrouter.com/docs/en/v6/hooks/use-params), to change the route, get params, and more. + +```ts +import { useNavigate } from 'react-router-dom'; + +function HomeButton() { + let navigate = useNavigate(); + + function handleClick() { + navigate('/home'); + } + + return ( + + ); +} +``` + +{% hint style="info" %} + +You can read more in [`react-router`'s documentation](https://reactrouter.com/docs/en/v6). + +{% endhint %} diff --git a/docs/building-blocks/slice/README.md b/docs/building-blocks/slice/README.md new file mode 100644 index 0000000..62ed480 --- /dev/null +++ b/docs/building-blocks/slice/README.md @@ -0,0 +1,26 @@ +# What is a Slice? + +If you have read the redux-toolkit documentation you are familiar with the `slice` concept now. Here, we are taking it another step further by enriching it with `reselect` and `redux-saga`. + +Slice manages, encapsulates, and operates a `portion` of your application's data. For example, if you have a page that displays a user list, then you can have a slice called 'UsersPageSlice' that contains all the users in its state, also the functions to read it from the store and the functions to update the users in the list. So, in short, a slice is a redux-toolkit slice also containing the relative `reselect` and `redux-saga` operations within its folder. After all, they are all related to managing the same portion of the data. + +A `slice` is independent of the UI component. It can contain any kind of logic and it can be located in any folder. To follow the `folder-by-feature` pattern it is recommended to keep your `slices` closer to your component using it. But, this doesn't mean that it only belongs to that component. You can import and use that slice in whichever component you want. + +The next steps in the documentation describes how to use the slices with some examples. + +Example folder view: + +``` +project +| +├── app +│ └── src +│ ├── app +│ │ ├── Homepage +│ │ │ ├── index.tsx +│ │ │ ├── slice => Contains the relevant stuff for Homepage data +│ │ │ │ ├── index.ts +│ │ │ │ ├── saga.ts +│ │ │ │ ├── selectors.ts +│ │ │ │ └── types.ts +``` diff --git a/docs/building-blocks/slice/redux-injectors.md b/docs/building-blocks/slice/redux-injectors.md new file mode 100644 index 0000000..084c5d1 --- /dev/null +++ b/docs/building-blocks/slice/redux-injectors.md @@ -0,0 +1,33 @@ +# Redux Injectors + +[`redux-injectors`](https://github.com/react-boilerplate/redux-injectors) is an official `react-boilerplate` companion library. We built it so that it can be used and maintained independently from `react-boilerplate`. It allows you to dynamically load reducers and sagas as needed, instead of loading them all upfront. This has some nice benefits, such as avoiding having to manage a big global list of reducers and sagas. It also facilitates more effective use of [code-splitting](https://webpack.js.org/guides/code-splitting/). + +You can find the main repo for the library [here](https://github.com/react-boilerplate/redux-injectors) and read the docs [here](https://github.com/react-boilerplate/redux-injectors/blob/master/docs/api.md). + +## Usage + +```ts +import { + useInjectSaga, + useInjectReducer, + SagaInjectionModes, +} from 'utils/redux-injectors'; +import { saga } from './saga'; +import { reducer } from '.'; + +export function SomeComponent() { + useInjectReducer({ key: 'SomeComponent', reducer }); + useInjectSaga({ + key: 'SomeComponent', + saga, + mode: SagaInjectionModes.DAEMON, + }); + // ... +} +``` + +{% hint style="info" %} + +**Note:** Importing `redux-injectors` from `utils/redux-injectors` will add extra type-safety. + +{% endhint %} diff --git a/docs/building-blocks/slice/redux-saga.md b/docs/building-blocks/slice/redux-saga.md new file mode 100644 index 0000000..5c027b6 --- /dev/null +++ b/docs/building-blocks/slice/redux-saga.md @@ -0,0 +1,60 @@ +# Redux-Saga + +`redux-saga` is a library to manage side-effects in your application. It works beautifully for data fetching, concurrent computations and a lot more. [Sebastien Lorber](https://twitter.com/sebastienlorber) put it best: + +> Imagine there is widget1 and widget2. When some button on widget1 is clicked, then it should have an effect on widget2. Instead of coupling the 2 widgets together (i.e. widget1 dispatches an action that targets widget2), widget1 only dispatches that its button was clicked. Then the saga listens for this button click and updates widget2 by dispatching a new event that widget2 is aware of. +> +> This adds a level of indirection that is unnecessary for simple apps, but makes it easier to scale complex applications. You can now publish widget1 and widget2 to different npm repositories so that they never have to know about each other, without having them share a global registry of actions. The 2 widgets are now bounded by contexts that can live separately. They don't need each other to be consistent and can be reused in other apps as well. **The saga is the coupling point between the two widgets that coordinate them in a meaningful way for your business.** + +_Note: It is well worth reading the [source](https://stackoverflow.com/questions/34570758/why-do-we-need-middleware-for-async-flow-in-redux/34623840#34623840) of this quote in its entirety!_ + +To learn more about this amazing way to handle concurrent flows, start with the [official documentation](https://redux-saga.github.io/redux-saga) and explore some examples! (Read [this comparison](https://stackoverflow.com/questions/34930735/pros-cons-of-using-redux-saga-with-es6-generators-vs-redux-thunk-with-es7-async/34933395) if you're used to `redux-thunk`.) + +## Usage + +Sagas are associated with a slice. If your slice already has a `saga.ts` file, simply add your saga to that. If your slice does not yet have a `saga.ts` file, add one with this boilerplate structure: + +#### `.../slice/saga.ts` + +```ts +import { takeLatest, call, put, select } from 'redux-saga/effects'; +import { homepageActions } from '.'; + +// Root saga +export default function* homepageSaga() { + // if necessary, start multiple sagas at once with `all` + yield [ + takeLatest(actions.someAction.type, getSomething), + takeLatest(actions.someOtherAction.type, getOtherThing), + ]; +} +``` + +### Using your saga in components + +Once you have a saga in your slice, [`useInjectSaga`](https://github.com/react-boilerplate/redux-injectors/blob/master/docs/api.md#useinjectsaga) from `redux-injectors` will inject the root saga. + +#### `.../slice/index.ts` + +```ts +// ... code from above + +export const useHomepageSlice = () => { + useInjectReducer({ key: slice.name, reducer: slice.reducer }); + useInjectSaga({ key: sliceKey, saga: homepageSaga }); + return { actions: slice.actions }; +}; +``` + +A `mode` argument can be one of three constants (import the enum `SagaInjectionModes` from `redux-injectors`): + +- `DAEMON` (default value) — starts a saga when a component is being mounted and never cancels it or starts again; +- `RESTART_ON_REMOUNT` — starts a saga when a component is being mounted + and cancels with `task.cancel()` on component un-mount for improved performance; +- `ONCE_TILL_UNMOUNT` — behaves like `RESTART_ON_REMOUNT` but never runs the saga again. + +{% hint style="info" %} + +🎉 **Good News:** You don't need to write this boilerplate code by hand, the `slice` generator will generate it for you. ✓ + +{% endhint %} diff --git a/docs/building-blocks/slice/redux-toolkit.md b/docs/building-blocks/slice/redux-toolkit.md new file mode 100644 index 0000000..610921b --- /dev/null +++ b/docs/building-blocks/slice/redux-toolkit.md @@ -0,0 +1,137 @@ +# Redux-Toolkit + +If you haven't worked with Redux, it's highly recommended (possibly indispensable!) to read through the (amazing) [official documentation](http://redux.js.org) and/or watch this [free video tutorial series](https://egghead.io/series/getting-started-with-redux). + +As minimal as Redux is, the challenge it addresses - app state management - is a complex topic that is too involved to properly discuss here. + +## Usage + +### 1) Creating a dedicated slice folder + +Let's start creating a slice to manage our Homepage data and call it `HomepageSlice`. + +An empty folder `.../Homepage/slice/` + +### 2) Declaring your state + +Redux manages your **state** so we have to declare our state first. We can create a `types.ts` file in our slice. Types are crucial for efficient and safe development. Your compiler and code completion will understand the shape of your state and help you code the rest of your project faster and safer. + +#### `.../Homepage/slice/types.ts` + +```ts +/* --- STATE --- */ +export interface HomepageState { + username: string; + // declare what you want in your Homepage state +} +``` + +### 3) Updating your Redux State + +Now that you are adding another `slice` to your state you also need to declare this in your `types/RootState.ts` file. Since we are adding Redux slices **asynchronously** with [Redux-injectors](redux-injectors.md), the compiler cannot tell what the Redux State is during the build time. So, we explicitly declare them `types/RootState.ts` file: + +#### `types/RootState.ts` + +```ts +import { HomepageState } from 'app/.../Homepage/slice/types'; + +// Properties are optional because they are injected when the components are mounted sometime in your application's life. So, not available always +export interface RootState { + homepage?: HomepageState; +} +``` + +### 4) Creating your slice + +Fortunately, [Redux Toolkit](https://redux-toolkit.js.org) handles most of the work for us. To create our slice, we create a `index.ts` file in our folder as well. This will be responsible for: + +- Our slice's **initial state** +- **Actions** we can trigger +- **Reducers** that decide how the state will change, given the action received + +#### `.../Homepage/slice/index.ts` + +```ts +import { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from 'utils/@reduxjs/toolkit'; // Importing from `utils` makes them more type-safe ✅ +import { HomepageState } from './types'; + +// The initial state of the Homepage +export const initialState: HomepageState = { + username: 'Initial username for my state', +}; + +const slice = createSlice({ + name: 'homepage', + initialState, + reducers: { + changeUsername(state, action: PayloadAction) { + // Here we say lets change the username in my homepage state when changeUsername actions fires + // Type-safe: It will expect `string` when firing the action. ✅ + state.username = action.payload; + }, + }, +}); + +/** + * `actions` will be used to trigger change in the state from where ever you want + */ +export const { actions: homepageActions } = slice; +``` + +### 5) Adding the slice to your Redux Store + +Let's add our slice to the redux state. We can write a simple 'hook' and use it in our component(whichever you want) + +#### `.../Homepage/slice/index.ts` + +```ts +// ... code from above + +/** + * Let's turn this into a hook style usage. This will inject the slice to redux store and return actions in case you want to use in the component + */ +export const useHomepageSlice = () => { + useInjectReducer({ key: slice.name, reducer: slice.reducer }); + return { actions: slice.actions }; +}; +``` + +### 5) Using the slice in your component + +Let's use the hook we created above in our component + +#### `.../Homepage/index.tsx` + +```ts +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useHomepageSlice } from './slice'; +import { selectUsername } from './slice/selectors'; + +export function HomePage() { + // Use the slice we created + const { actions } = useHomepageSlice(); + + // Used to dispatch slice actions + const dispatch = useDispatch(); + + // `selectors` are used to read the state. Explained in other chapter + // Will be inferred as `string` type ✅ + const username = useSelector(selectUsername); + + const textInputChanged = evt => { + // Trigger the action to change the state. It accepts `string` as we declared in `slice.ts`. Fully type-safe ✅ + dispatch(actions.changeUsername(evt.target.value)); + }; + // ... +} +``` + +{% hint style="info" %} + +🎉 **Good News:** You don't need to write this boilerplate code by hand, the `slice` generator will generate it for you. ✓ + +`yarn generate slice` + +{% endhint %} diff --git a/docs/building-blocks/slice/reselect.md b/docs/building-blocks/slice/reselect.md new file mode 100644 index 0000000..b9af3c8 --- /dev/null +++ b/docs/building-blocks/slice/reselect.md @@ -0,0 +1,59 @@ +# Reselect + +`reselect` memoizes ("caches") previous state trees and calculations based upon the said tree. This means repeated changes and calculations are fast and efficient, providing us with a performance boost over standard `useSelector` implementations. + +The [official documentation](https://github.com/reactjs/reselect) offers a good starting point! + +## Usage + +There are two different kinds of selectors, simple and complex ones. + +### Simple selectors + +Simple selectors are just that: they take the application state and select a part of it. + +```ts +export const mySelector = (state: MyRootState) => state.someState; +``` + +### Complex selectors + +If we need to, we can combine simple selectors to build more complex ones which get nested state parts with `reselect`'s `createSelector` function. We import other selectors and pass them to the `createSelector` call: + +#### `.../slice/selectors.ts` + +```ts +import { createSelector } from '@reduxjs/toolkit'; + +export const mySelector = (state: MyRootState) => state.someState; + +// Here type of `someState` will be inferred ✅ +const myComplexSelector = createSelector( + mySelector, + someState => someState.someNestedState, +); + +export { myComplexSelector }; +``` + +### Using your selectors in components + +#### `index.tsx` + +```ts +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { selectUsername } from './slice/selectors'; + +export function HomePage() { + // Type of the `username` will be inferred ✅ + const username = useSelector(selectUsername); + // ... +} +``` + +{% hint style="info" %} + +🎉 **Good News:** You don't need to write this boilerplate code by hand, the `slice` generator will generate it for you. ✓ + +{% endhint %} diff --git a/docs/building-blocks/testing.md b/docs/building-blocks/testing.md new file mode 100644 index 0000000..c2f71c2 --- /dev/null +++ b/docs/building-blocks/testing.md @@ -0,0 +1,181 @@ +# Component testing + + + +- [Component testing](#component-testing) + - [Shallow rendering](#shallow-rendering) + - [`button.tsx`](#buttontsx) + - [`Homepage.tsx`](#homepagetsx) + - [react-testing-library](#react-testing-library) - [`button.test.tsx`](#buttontesttsx) + - [Snapshot testing](#snapshot-testing) + - [Behavior testing](#behavior-testing) - [`button.test.tsx`](#buttontesttsx-1) - [`button.test.tsx`](#buttontesttsx-2) + + +## Shallow rendering + +React provides us with a nice add-on called the Shallow Renderer. This renderer will render a React component **one level deep**. Let's explore what that means with a simple ` + ); +} + +export default Button; +``` + +_Note: This is a [state**less** (aka "dumb") component](../understanding-react-boilerplate.md#src-app)_ + +It might be used in another component like this: + +#### `Homepage.tsx` + +```tsx +import Button from './Button'; + +function HomePage() { + return ; +} +``` + +_Note: This is a [state**ful** (or "smart") component](../understanding-react-boilerplate.md#src-app)_ + +When rendered normally with the standard `ReactDOMClient.createRoot().render()` function, this will be the HTML output +(_Comments added in parallel to compare structures in HTML from JSX source_): + +```html + + +``` + +Conversely, when rendered with the shallow renderer, we'll get a String containing this "HTML": + +```html + + +``` + +If we test our `Button` with the normal renderer and there's a problem with the `CheckmarkIcon`, then the test for the `Button` will fail as well. This makes it harder to find the culprit. Using the _shallow_ renderer, we isolate the problem's cause since we don't render any components other than the one we're testing! + +Note that when using the shallow renderer, all assertions have to be done manually, and you cannot test anything that needs the DOM. + +## react-testing-library + +To write more maintainable tests that more closely resemble the way our component is used in real life, we have included [react-testing-library](https://github.com/testing-library/react-testing-library). This library renders our component within a simulated DOM and provides utilities for querying it. + +Let's give it a go with our `); + + expect(container.firstChild).toMatchSnapshot(); +}); +``` + +`render` returns an object that has a property `container` and yes, this is the container our `); + + fireEvent.click(getByText(text)); + expect(onClickSpy).toHaveBeenCalledTimes(1); +}); +``` + +Our finished test file looks like this: + +#### `button.test.tsx` + +```ts +import * as React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Button from '../Button'; + +describe('); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('handles clicks', () => { + const onClickSpy = jest.fn(); + const text = 'Click me!'; + const { getByText } = render(); + + fireEvent.click(getByText(text)); + expect(onClickSpy).toHaveBeenCalledTimes(1); + }); +}); +``` + +And that's how you unit-test your components and make sure they work correctly! + +Be sure to have a look at our example application. It deliberately shows some variations of test implementations with `react-testing-library`. + +> For more robust user interaction tests, see [@testing-library/user-event](https://github.com/testing-library/user-event). `fireEvent` dispatches DOM events, but `user-event` should be used to simulate full interactions, which may fire multiple events and do additional checks along the way. diff --git a/docs/deployment/aws.md b/docs/deployment/aws.md new file mode 100644 index 0000000..0eff219 --- /dev/null +++ b/docs/deployment/aws.md @@ -0,0 +1,50 @@ +# Deploying to AWS S3 - Cloudfront + +### Deploying to S3 in 7 steps + +_Step 1:_ Run `yarn install` to install dependencies, then `yarn build` to create the `./build` folder. + +_Step 2:_ Navigate to [AWS S3](https://aws.amazon.com/s3) and login (or sign up if you don't have an account). Click on `Services` followed by `S3` in the dropdown. + +_Step 3:_ Click on `Create Bucket` and fill out both your `Bucket Name` and `Region` (for the USA, we recommend `US Standard`). Click `Next` until the `Set Permissions` section appears, and remove the tick from `Block all public access` (to make objects public). Click `Create` to create your bucket. + +_Step 4:_ To make the bucket objects publicly viewable, go into the bucket, then `Permissions` (on the top bar) -> `Bucket Policy`. Copy-paste this, replacing`[BUCKET_NAME]` with your bucket name). + +``` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AddPerm", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::[BUCKET_NAME]/*" + } + ] +} +``` + +_Step 5:_ Go to `Properties`. Click on the `Static Website Hosting` accordion where you should see the URL (or _endpoint_) of your website (i.e., `example.s3-website-us-east-1.amazonaws.com`). Click `Enable website hosting` and fill in both the `Index document` and `Error document` input fields with `index.html`. Click `Save`. + +_Step 6:_ Click on your new S3 bucket on the left to open the bucket. Click `Upload` and select all the files within your `./build` folder. Click `Start Upload`. You can easily automate the deployment with a single [helper script](https://gist.github.com/Can-Sahin/d7de7e2ff5c1a39b82ced2d9bd7c60ae) that uses `aws-cli`. Running the shell script with necessary permissions on `AWS` will take care of all the issues mentioned in the warning below. + +_Step 7:_ Click on the `Properties` tab, open `Static Website Hosting`, and click on the _Endpoint_ link. The app should be running on that URL. + +{% hint style="danger" %} + +IMPORTANT: S3 objects' `Cache-Control` metadata can cause problems on deployments. For example, not serving the new `index.html` file but returning the cached one, possibly resulting in users not getting the recently deployed web app. Since the `index.html` and `sw.js` files are the files loaded initially, and all the js bundles and chunks come later depending on these two files, we suggest adjusting the `Cache-Control` metadata for them after deployments. To do so, go to the file, then `Properties` -> `Metadata`. Add `max-age=0,no-cache,no-store,must-revalidate` to the key `Cache-Control`. + +{% endhint %} + +### Cloudfront for HTTPS and GZIP + +_HTTPS_: `S3` serves only `HTTP`, so for `HTTPS`, you can use `Cloudfront`. Setting up `Cloudfront` is a bit longer than `S3 Static Website Hosting`. Therefore follow [AWS Instructions](https://aws.amazon.com/premiumsupport/knowledge-center/cloudfront-serve-static-website/) - follow the second configuration steps (Using a website endpoint as the origin with anonymous (public) access allowed) - to set a `Cloudfront` distribution using your `S3 Website`. + +{% hint style="info" %} + +Note: SPA applications handle routing inside the client, so pages like `/about` are unknown to the `Cloudfront`; it's configured to return the `index.html` file in the `S3 Bucket`). To prevent `404 Not Found` responses, after setting up your Cloudfront Distribution, go to the `Error Pages` then `Create Custom Error Response` to map `404` code to `200`. + +{% endhint %} + +_GZIP Compression_: Enabling gzip can reduce chunk sizes and improve loading performance dramatically. To enable gzip with `Cloudfront`, navigate to your distribution, then `Behaviors` -> `Edit` -> `Compress Objects Automatically`. Mark `Yes`. This alone **isn't enough**. You must update your `S3 Bucket CORS Configuration` to send `Content-Length` header by adding `Content-Length` in a [CORSRule](https://docs.aws.amazon.com/AmazonS3/latest/dev/cors.html). diff --git a/internals/extractMessages/__tests__/extractingMessages.test.ts b/internals/extractMessages/__tests__/extractingMessages.test.ts new file mode 100644 index 0000000..b2f5773 --- /dev/null +++ b/internals/extractMessages/__tests__/extractingMessages.test.ts @@ -0,0 +1,79 @@ +import scannerConfig from '../i18next-scanner.config'; +import i18nextScanner from 'i18next-scanner'; + +describe('extracting messages', () => { + const options = scannerConfig.options; + options.defaultNs = 'translation_test'; + const ns = options.defaultNs; + let parser = new i18nextScanner.Parser(options); + + beforeEach(() => { + parser = new i18nextScanner.Parser(options); + }); + + it('extract object with default values', () => { + const content = ` + import { translations } from 'locales/translations'; + export const messages = { + x: () => _t(translations.a), + x: () => _t(translations.a.k1), + x: () => _t(translations.a.k2, "v1"), + x: () => _t(translations.a.k3, "v1", {a: "b"}), + }; + `; + scannerConfig.parseContent(content, parser); + const result = parser.get({ sort: true }); + expect(result.en[ns].a.k1).toBe(''); + expect(result.en[ns].a.k2).toBe('v1'); + expect(result.en[ns].a.k3).toBe('v1'); + }); + + it('extract strings with default values', () => { + const content = ` + import { translations } from 'locales/translations'; + export const messages = { + x: () => _t("a"), + x: () => _t("a.k1"), + x: () => _t("a.k2", "v1"), + x: () => _t("a.k3", "v1", {a: "b"}), + }; + `; + scannerConfig.parseContent(content, parser); + const result = parser.get({ sort: true }); + expect(result.en[ns].a.k1).toBe(''); + expect(result.en[ns].a.k2).toBe('v1'); + expect(result.en[ns].a.k3).toBe('v1'); + }); + + it('extract nested objects', () => { + const content = ` + import { translations } from 'locales/translations'; + const m = translations.a; + export const messages = { + x: () => _t(m.k1), + x: () => _t(m.k2, "v1"), + }; + `; + scannerConfig.parseContent(content, parser); + const result = parser.get({ sort: true }); + expect(result.en[ns].a.k1).toBe(''); + expect(result.en[ns].a.k2).toBe('v1'); + }); + + it('extract strings in react components', () => { + const content = ` + export function HomePage() { + return ( +
+ {t('a.k1')} + {t('a.k2', 'v1')} +
+ ); + } + `; + scannerConfig.parseContent(content, parser); + const result = parser.get({ sort: true }); + expect(result.en[ns].a.k1).toBe(''); + expect(result.en[ns].a.k2).toBe('v1'); + }); +}); diff --git a/internals/extractMessages/i18next-scanner.config.js b/internals/extractMessages/i18next-scanner.config.js new file mode 100644 index 0000000..e900799 --- /dev/null +++ b/internals/extractMessages/i18next-scanner.config.js @@ -0,0 +1,62 @@ +var fs = require('fs'); +const path = require('path'); +const typescript = require('typescript'); +const compilerOptions = require('../../tsconfig.json').compilerOptions; + +const stringfyTranslationObjects = require('./stringfyTranslations.js'); + +module.exports = { + input: [ + 'src/app/**/**.{ts,tsx}', + '!**/node_modules/**', + '!src/app/**/*.test.{ts,tsx}', + ], + output: './', + options: { + debug: false, + removeUnusedKeys: false, + func: { + list: ['t'], + extensions: [''], // We dont want this extension because we manually check on transform function below + }, + lngs: ['en', 'de'], + defaultLng: 'en', + defaultNs: 'translation', + resource: { + loadPath: 'src/locales/{{lng}}/{{ns}}.json', + savePath: 'src/locales/{{lng}}/{{ns}}.json', + jsonIndent: 2, + lineEnding: '\n', + }, + keySeparator: '.', // char to separate keys + nsSeparator: ':', // char to split namespace from key + interpolation: { + prefix: '{{', + suffix: '}}', + }, + }, + transform: function transform(file, enc, done) { + const extensions = ['.ts', '.tsx']; + + const { base, ext } = path.parse(file.path); + if (extensions.includes(ext) && !base.includes('.d.ts')) { + const content = fs.readFileSync(file.path, enc); + const shouldStringfyObjects = base === 'messages.ts'; + parseContent(content, this.parser, shouldStringfyObjects); + } + + done(); + }, +}; +function parseContent(content, parser, shouldStringfyObjects = true) { + const { outputText } = typescript.transpileModule(content, { + compilerOptions: compilerOptions, + }); + let cleanedContent = outputText; + if (shouldStringfyObjects) { + cleanedContent = stringfyTranslationObjects(outputText); + } + parser.parseFuncFromString(cleanedContent); +} + +module.exports.parseContent = parseContent; diff --git a/internals/extractMessages/jest.config.js b/internals/extractMessages/jest.config.js new file mode 100644 index 0000000..ba6c0f7 --- /dev/null +++ b/internals/extractMessages/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + testMatch: [ + '**/__tests__/**/*.+(ts|tsx|js)', + '**/?(*.)+(spec|test).+(ts|tsx|js)', + ], + transform: { + '^.+\\.(ts|tsx)$': 'ts-jest', + }, +}; diff --git a/internals/extractMessages/stringfyTranslations.js b/internals/extractMessages/stringfyTranslations.js new file mode 100644 index 0000000..47e201d --- /dev/null +++ b/internals/extractMessages/stringfyTranslations.js @@ -0,0 +1,68 @@ +/** + * This is custom intermediate function to convert translations objects to i18next resource strings + * `i18next-scanner expects strings such like: + * + * {t('a.b')} + * + * but translations object enables us to write the same thing as: + * + * {t(translations.a.b)} + * + * So, this function converts them into strings like the first one so that scanner recognizes + */ + +function stringfyTranslationObjects(content) { + let contentWithObjectsStringified = content; + const pattern = /_t\((.+?)[),]/gim; + const matches = content.matchAll(pattern); + for (const match of matches) { + if (match.length < 1) { + continue; + } + const key = match[1]; + let keyAsStringValue = ''; + if (["'", '"', '`'].some(x => key.includes(x))) { + keyAsStringValue = key; + } else { + keyAsStringValue = stringifyRecursively(content, key); + keyAsStringValue = `'${keyAsStringValue}'`; + } + contentWithObjectsStringified = replaceTranslationObjectWithString( + contentWithObjectsStringified, + key, + keyAsStringValue, + ); + } + return contentWithObjectsStringified; +} + +// Recursively concatenate all the `variables` until we hit the imported translations object +function stringifyRecursively(content, key) { + let [root, ...rest] = key.split('.'); + const pattern = `${root} =(.+?);`; + const regex = RegExp(pattern, 'gim'); + let match = regex.exec(content); + if (match && match.length > 1) { + const key = match[1].trim(); + root = stringifyRecursively(content, key); + } else if (isImportedTranslationObject(content, root)) { + root = null; + } + + if (root != null) { + return [root, ...rest].join('.'); + } else { + return [...rest].join('.'); + } +} + +function isImportedTranslationObject(content, key) { + const pattern = `import {.*?${key}.*?} from.+locales/translations.*`; + return RegExp(pattern, 'gim').test(content); +} + +function replaceTranslationObjectWithString(content, key, keyAsStringValue) { + return content.replace(`_t(${key}`, `t(${keyAsStringValue}`); +} + +module.exports = stringfyTranslationObjects; diff --git a/internals/scripts/create-changelog.script.ts b/internals/scripts/create-changelog.script.ts new file mode 100644 index 0000000..8cd81cf --- /dev/null +++ b/internals/scripts/create-changelog.script.ts @@ -0,0 +1,24 @@ +import shell from 'shelljs'; + +interface Options {} + +export function createChangeLog(opts: Options = {}) { + const changes1 = shell.exec(`git diff package.json`, { silent: true }); + const changes2 = shell.exec(`git diff yarn.lock`, { silent: true }); + if (changes1.stdout.length > 0 || changes2.stdout.length > 0) { + console.error('Error: Unstaged files'); + process.exit(1); + } + shell.exec( + `yarn standard-version --skip.commit --skip.tag --skip.changelog=0`, + { + silent: false, + }, + ); + + // Revert the bumbped version + shell.exec(`git checkout -- yarn.lock`, { silent: true }); + shell.exec(`git checkout -- package.json`, { silent: true }); +} + +createChangeLog(); diff --git a/internals/scripts/utils.ts b/internals/scripts/utils.ts new file mode 100644 index 0000000..6c86722 --- /dev/null +++ b/internals/scripts/utils.ts @@ -0,0 +1,26 @@ +import shell from 'shelljs'; + +export function shellEnableAbortOnFail() { + if (!shell.config.fatal) { + shell.config.fatal = true; + return true; + } + return false; +} + +export function shellDisableAbortOnFail() { + if (shell.config.fatal) { + shell.config.fatal = false; + } +} + +export function parseArgv(argv: string[], key: string, existsOnly?: boolean) { + const index = argv.indexOf(`--${key}`); + if (index > 0) { + if (existsOnly) { + return true; + } + return argv[index + 1]; + } + return undefined; +} diff --git a/src/IDBStore/Config.js b/src/IDBStore/Config.js new file mode 100644 index 0000000..b55b56a --- /dev/null +++ b/src/IDBStore/Config.js @@ -0,0 +1,12 @@ +export const DB_Name = 'Calendar'; +export const CURRENT_IDB_VERSION = 1; + +/** + * @type { Object. } + */ +export const objectStores = { + Logs: { + tableName: 'Logs', + startVersion: 1, + }, +}; diff --git a/src/IDBStore/IDBObjectStore.js b/src/IDBStore/IDBObjectStore.js new file mode 100644 index 0000000..0ba0b28 --- /dev/null +++ b/src/IDBStore/IDBObjectStore.js @@ -0,0 +1,53 @@ +import IdbStore from './IDBStore'; + +class IDBObjectStore { + /** + * @constructor + * @param {String} objectStoreName + */ + constructor(objectStoreName) { + this.objectStoreName = objectStoreName; + } + + /** + * @param {...any} args + * @returns Get the value + */ + get = async (...args) => { + return IdbStore.get(this.objectStoreName, ...args); + }; + + /** + * @param {...any} args + * @returns Get All the values + */ + getAll = async (...args) => { + return IdbStore.getAll(this.objectStoreName, ...args); + }; + + /** + * @param {...any} args + * @returns Set the value + */ + set = async (...args) => { + return IdbStore.set(this.objectStoreName, ...args); + }; + + /** + * @param {...any} args + * @returns Delete the value + */ + delete = async (...args) => { + return IdbStore.delete(this.objectStoreName, ...args); + }; + + /** + * @param {...any} args + * @returns Clears the object store + */ + clear = async (...args) => { + return IdbStore.clear(this.objectStoreName, ...args); + }; +} + +export default IDBObjectStore; diff --git a/src/IDBStore/IDBStore.js b/src/IDBStore/IDBStore.js new file mode 100644 index 0000000..c4886a7 --- /dev/null +++ b/src/IDBStore/IDBStore.js @@ -0,0 +1,158 @@ +import { openDB } from 'idb'; +import { CURRENT_IDB_VERSION, DB_Name, objectStores } from './Config'; +import { createObjectStore, deleteObjectStore } from './utils'; + +const objectStoreNames = Object.keys(objectStores); + +/** + * @description - If we want to have logs only in console not in IDB. Then we need to use actual console method so that it only prints in console. + * We want to have that functionality here because if some method of IDBStore gets errored and we try to log that error then it will again trigger IDB method and it will go infinite. + * @returns Returns object containing actual console methods. + */ +const getNativeConsole = () => { + if (console.native) return console.native; + return console; +}; + +class IDBStore { + dbPromise = null; + initialized = false; + + /** + * @returns {Promise} Db promise + */ + init = async () => { + this.dbPromise = openDB(DB_Name, CURRENT_IDB_VERSION, { + upgrade: this.upgradeNeeded, + blocked: this.onBlocked, + blocking: this.onBlocking, + }); + this.initialized = true; + return this.dbPromise; + }; + + getIDB = async () => { + if (this.initialized) { + return await this.dbPromise; + } else { + return await this.init(); + } + }; + + upgradeNeeded = (db, oldVersion, newVersion) => { + this.removeOlderObjectStores(db, newVersion); + this.addLatestObjectStores(db, oldVersion); + }; + + /** + * @param {*} db + * @param {Number} newVersion + * @description - Removes older object stores which are no longer valid for newer DB. + */ + removeOlderObjectStores = (db, newVersion) => { + objectStoreNames.forEach(objectStoreName => { + const objectStore = objectStores[objectStoreName]; + if (objectStore.endVersion && objectStore.endVersion < newVersion) { + deleteObjectStore(db, objectStoreName); + } + }); + }; + + /** + * @param {*} db + * @param {Number} oldVersion + * @description - Adds newer object stores which were not present on previous DB. + */ + addLatestObjectStores = (db, oldVersion) => { + objectStoreNames.forEach(objectStoreName => { + const objectStore = objectStores[objectStoreName]; + if (objectStore.startVersion && objectStore.startVersion > oldVersion) { + createObjectStore(db, objectStoreName); + } + }); + }; + + onBlocked = () => { + getNativeConsole().error( + 'Cannot open DB as another older DB is open on the same origin' + ); + }; + + // Same as onVersionChange event in plain IDB + onBlocking = () => { + getNativeConsole().error( + 'This connection is blocking a future version of the database from opening.' + ); + }; + + /** + * @param {string} objectStoreName + * @param {string} key + * @returns {Promise} Value of item with key in objectStore + */ + get = async (objectStoreName, key) => { + try { + return (await this.getIDB()).get(objectStoreName, key); + } catch (e) { + getNativeConsole().error('Error ocurred in IDB method get', e); + throw e; + } + }; + + /** + * @param {string} objectStoreName + * @returns {Promise} All the values in object store + */ + getAll = async objectStoreName => { + try { + return (await this.getIDB()).getAll(objectStoreName); + } catch (e) { + getNativeConsole().error('Error ocurred in IDB method getAll'); + throw e; + } + }; + + /** + * @param {String} objectStoreName + * @param {String} value + * @param {String} key + * @returns Sets the value against key in object store + */ + set = async (objectStoreName, value, key) => { + try { + return (await this.getIDB()).put(objectStoreName, value, key); + } catch (e) { + getNativeConsole().error('Error ocurred in IDB method set', e); + throw e; + } + }; + + /** + * @param {String} objectStoreName + * @param {String} key + * @returns Deletes the item with key in object store + */ + delete = async (objectStoreName, key) => { + try { + return (await this.getIDB()).delete(objectStoreName, key); + } catch (e) { + getNativeConsole().error('Error ocurred in IDB method delete', e); + throw e; + } + }; + + /** + * @param {String} objectStoreName + * @description Clears the object store + */ + clear = async objectStoreName => { + try { + return (await this.getIDB()).clear(objectStoreName); + } catch (e) { + getNativeConsole().error('Error ocurred in IDB method clear', e); + throw e; + } + }; +} + +export default new IDBStore(); diff --git a/src/IDBStore/__tests__/IDBObjectStore.test.js b/src/IDBStore/__tests__/IDBObjectStore.test.js new file mode 100644 index 0000000..cd48b23 --- /dev/null +++ b/src/IDBStore/__tests__/IDBObjectStore.test.js @@ -0,0 +1,40 @@ +import IDBObjectStore from '../IDBObjectStore'; + +const objectStore = 'Logs'; +const logStore = new IDBObjectStore(objectStore); + +beforeAll(async () => { + return logStore.clear(objectStore); +}); + +describe('Perform actions on IndexedDB', () => { + const key = 'testKey'; + const value = 'testValue'; + const invalidKey = 'invalidKey'; + + test('set a key value pair', async () => { + const keyFromIdb = await logStore.set(value, key); + const valueFromIdb = await logStore.get(key); + expect(valueFromIdb).toBe(value); + expect(keyFromIdb).toBe(key); + }); + + test('get a value using valid key', async () => { + const valueFromIdb = await logStore.get(key); + expect(valueFromIdb).toBe(value); + }); + + test('get a value using invalid key', async () => { + const valueFromIdb = await logStore.get(invalidKey); + expect(valueFromIdb).toBeUndefined(); + }); + + test('delete a value using valid key', async () => { + await logStore.delete(key); + expect(logStore.get(key)).resolves.toBeUndefined(); + }); + + test('delete a value using invalid key', async () => { + await logStore.delete(invalidKey); + }); +}); diff --git a/src/IDBStore/__tests__/IDBStore.test.js b/src/IDBStore/__tests__/IDBStore.test.js new file mode 100644 index 0000000..e387196 --- /dev/null +++ b/src/IDBStore/__tests__/IDBStore.test.js @@ -0,0 +1,64 @@ +import IdbStore from '../IDBStore'; + +describe('Existence of IndexedDB', () => { + test('IndexedDB is defined on window object', () => { + expect(window.indexedDB).toBeDefined(); + }); + + test('IndexedDB is defined on IdbStore obj', async () => { + const idb = await IdbStore.getIDB(); + expect(idb).toBeDefined(); + }); +}); + +describe('Perform actions on IndexedDB', () => { + const key = 'testKey'; + const value = 'testValue'; + const objectStore = 'Logs'; + const invalidObjectStore = 'invalid'; + + test('set a value on valid object store', async () => { + const keyFromIdb = await IdbStore.set(objectStore, value, key); + const valueFromIdb = await IdbStore.get(objectStore, key); + expect(valueFromIdb).toBe(value); + expect(keyFromIdb).toBe(key); + }); + + test('set a value on invalid object store', async () => { + try { + await IdbStore.set(invalidObjectStore, value, key); + } catch (e) { + expect(e).toBeInstanceOf(Error); + } + expect.assertions(1); + }); + + test('get a value on valid object store', async () => { + const valueFromIdb = await IdbStore.get(objectStore, key); + expect(valueFromIdb).toBe(value); + }); + + test('get a value on invalid object store', async () => { + try { + await IdbStore.get(invalidObjectStore, key); + } catch (e) { + expect(e).toBeInstanceOf(Error); + } + expect.assertions(1); + }); + + test('delete a value on valid object store', async () => { + await IdbStore.delete(objectStore, key); + const valueFromIdb = await IdbStore.get(objectStore, key); + expect(valueFromIdb).toBeUndefined(); + }); + + test('delete a value on invalid object store', async () => { + try { + await IdbStore.delete(invalidObjectStore, key); + } catch (e) { + expect(e).toBeInstanceOf(Error); + } + expect.assertions(1); + }); +}); diff --git a/src/IDBStore/utils.js b/src/IDBStore/utils.js new file mode 100644 index 0000000..49645e7 --- /dev/null +++ b/src/IDBStore/utils.js @@ -0,0 +1,25 @@ +/** + * @param {Any} db + * @param {String} name + * @description Creates object store with name provided in argument. Logs error if something goes wrong + */ +export const createObjectStore = (db, name) => { + try { + db.createObjectStore(name); + } catch (e) { + console.error('cannot create object store', name, e); + } +}; + +/** + * @param {Any} db + * @param {String} name + * @description Deletes object store with name provided in argument. Logs error if something goes wrong + */ +export const deleteObjectStore = (db, name) => { + try { + db.deleteObjectStore(name); + } catch (e) { + console.error('cannot delete object store', name, e); + } +}; diff --git a/src/assets/styles/index.css b/src/assets/styles/index.css deleted file mode 100644 index ec2585e..0000000 --- a/src/assets/styles/index.css +++ /dev/null @@ -1,13 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} diff --git a/src/locales/__tests__/i18n.test.ts b/src/locales/__tests__/i18n.test.ts new file mode 100644 index 0000000..0fb08c5 --- /dev/null +++ b/src/locales/__tests__/i18n.test.ts @@ -0,0 +1,16 @@ +import { translations } from 'locales/translations'; +import { i18n } from '../i18n'; + +describe('i18n', () => { + it('should initiate i18n', async () => { + const t = await i18n; + expect(t).toBeDefined(); + }); + + it('should initiate i18n with translations', async () => { + const t = await i18n; + expect(t(translations.feedbackFeature.description).length).toBeGreaterThan( + 0, + ); + }); +}); diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json new file mode 100644 index 0000000..bb1b0ab --- /dev/null +++ b/src/locales/de/translation.json @@ -0,0 +1,19 @@ +{ + "routingFeature": { + "title": "Standard Routing", + "description": "Routing macht es möglich Seiten (z.B. '/about') Ihrer Anwendung hinzuzufügen." + }, + "i18nFeature": { + "title": "i18n Internationalisierung und Pluralisierung", + "selectLanguage": "Sprache auswählen", + "description": "Das Internet ist global. Mehrsprachige- und Pluralisierungsunterstützung ist entscheidend für große Web-Anwendungen. Sie können die Sprache unten verändern, ohne die Seite aktualisieren zu müssen." + }, + "feedbackFeature": { + "title": "Sofortiges Feedback", + "description": "Genießen Sie die beste Entwicklungserfahrung und programmieren Sie Ihre App so schnell wie noch nie! Ihre Änderungen an dem CSS und JavaScript sind sofort reflektiert, ohne die Seite aktualisieren zu müssen." + }, + "scaffoldingFeature": { + "title": "Schnelles Scaffolding", + "description": "Automatisieren Sie die Kreation von Komponenten, Containern, Routen, Selektoren und Sagas – und ihre Tests – direkt von dem Terminal!" + } +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json new file mode 100644 index 0000000..f76a230 --- /dev/null +++ b/src/locales/en/translation.json @@ -0,0 +1,19 @@ +{ + "routingFeature": { + "title": "Industry-standard Routing", + "description": "It's natural to want to add pages (e.g. `/about`) to your application, and routing makes this possible." + }, + "i18nFeature": { + "title": "i18n Internationalization & Pluralization", + "selectLanguage": "Select Language", + "description": "Scalable apps need to support multiple languages, easily add and support multiple languages. Change the language below to see how instantly it updates the page without refreshing." + }, + "feedbackFeature": { + "title": "Instant Feedback", + "description": "Enjoy the best DX and code your app at the speed of thought! Your saved changes to the CSS and JS are reflected instantaneously without refreshing the page." + }, + "scaffoldingFeature": { + "title": "Quick Scaffolding", + "description": "Automate the creation of components, features, routes, selectors and sagas - and their tests - right from the CLI! Avoid fighting the glue of your code and focus on your app!" + } +} diff --git a/src/locales/i18n.ts b/src/locales/i18n.ts new file mode 100644 index 0000000..8c25bea --- /dev/null +++ b/src/locales/i18n.ts @@ -0,0 +1,38 @@ +import i18next from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import en from './en/translation.json'; +import de from './de/translation.json'; +import { convertLanguageJsonToObject } from './translations'; + +export const translationsJson = { + en: { + translation: en, + }, + de: { + translation: de, + }, +}; + +// Create the 'translations' object to provide full intellisense support for the static json files. +convertLanguageJsonToObject(en); + +export const i18n = i18next + // pass the i18n instance to react-i18next. + .use(initReactI18next) + // detect user language + // learn more: https://github.com/i18next/i18next-browser-languageDetector + .use(LanguageDetector) + // init i18next + // for all options read: https://www.i18next.com/overview/configuration-options + .init({ + resources: translationsJson, + fallbackLng: 'en', + debug: + process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test', + + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + }); diff --git a/src/locales/translations.ts b/src/locales/translations.ts new file mode 100644 index 0000000..7035221 --- /dev/null +++ b/src/locales/translations.ts @@ -0,0 +1,35 @@ +import { ConvertedToObjectType, TranslationJsonType } from './types'; + +/** + * This file is seperate from the './i18n.ts' simply to make the Hot Module Replacement work seamlessly. + * Your components can import this file in 'messages.ts' files which would ruin the HMR if this isn't a separate module + */ + +export const translations: ConvertedToObjectType = + {} as any; + +/* + * Converts the static JSON file into an object where keys are identical + * but values are strings concatenated according to syntax. + * This is helpful when using the JSON file keys and still having the intellisense support + * along with type-safety + */ +export const convertLanguageJsonToObject = ( + json: any, + objToConvertTo = translations, + current?: string, +) => { + Object.keys(json).forEach(key => { + const currentLookupKey = current ? `${current}.${key}` : key; + if (typeof json[key] === 'object') { + objToConvertTo[key] = {}; + convertLanguageJsonToObject( + json[key], + objToConvertTo[key], + currentLookupKey, + ); + } else { + objToConvertTo[key] = currentLookupKey; + } + }); +}; diff --git a/src/locales/types.ts b/src/locales/types.ts new file mode 100644 index 0000000..bab1667 --- /dev/null +++ b/src/locales/types.ts @@ -0,0 +1,18 @@ +export type ConvertedToObjectType = { + [P in keyof T]: T[P] extends string ? string : ConvertedToObjectType; +}; + +/** + +If you don't want non-existing keys to throw ts error you can simply do(also keeping the intellisense) + +export type ConvertedToObjectType = { + [P in keyof T]: T[P] extends string ? string : ConvertedToObjectType; +} & { + [P: string]: any; +}; + +*/ + +// Selecting the json file that our intellisense would pick from +export type TranslationJsonType = typeof import('./en/translation.json'); diff --git a/src/log-layer/Config.js b/src/log-layer/Config.js new file mode 100644 index 0000000..1eb300a --- /dev/null +++ b/src/log-layer/Config.js @@ -0,0 +1,55 @@ +import response from './Middleware/Response'; +import eventResponse from './Middleware/EventResponse'; +import errorResponse from './Middleware/ErrorResponse'; + +export const allLogLevels = ['error', 'warn', 'info', 'log', 'debug']; +export const allLogTypes = ['error', 'warn', 'info', 'log', 'debug']; +export const allowedLogLevels = ['error']; +export const ERROR = 'error'; + +export const CLIENT_RELOAD_SEPARATOR_TEMPLATE = `################### Client reloaded at {ISODate} ##########################`; +export const DATE_SEPARATOR_TEMPLATE = `######################## {Date} ##############################`; + +/** + * @type {Object.} + */ +export const namespaces = { + Axios: { + prefix: 'Axios::', + enabled: true, + }, + App: { + prefix: 'App::', + enabled: true, + }, + Event: { + prefix: 'Event::', + enabled: true, + }, + ServiceWorker: { + prefix: 'ServiceWorker::', + enabled: true, + }, + EventService: { + prefix: 'Event Service::', + enabled: true, + }, + Delta: { + prefix: 'Delta::', + enabled: true, + }, +}; + +/** + * @type {{filter: Function }[]} + */ +export const middlewareFilters = [response, eventResponse, errorResponse]; + +/** + * @type {Object.} + */ +export const typeWrappers = { + Response: response.Response, + EventResponse: eventResponse.EventResponse, + ErrorResponse: errorResponse.ErrorResponse, +}; diff --git a/src/log-layer/LogController.js b/src/log-layer/LogController.js new file mode 100644 index 0000000..b733e22 --- /dev/null +++ b/src/log-layer/LogController.js @@ -0,0 +1,53 @@ +import logLayer from './LogLayer'; +import { allLogTypes, middlewareFilters } from './Config'; + +class LogController { + init = () => { + this.overrideDefaultConsoleMethods(); + window.console.logLayer = logLayer; + }; + + overrideDefaultConsoleMethods = () => { + const originalConsole = window.console; + allLogTypes.forEach(logType => { + originalConsole[logType] = this.consoleLog.bind(this, logType); + }); + }; + + consoleLog = (type, ...args) => { + const logArr = this.sanitize(args); + const ts = Date.now(); + logLayer.log(type, logArr, ts); + }; + + log = (type, ...args) => { + const logArr = this.sanitize(args); + const shouldLog = this.shouldLog(args); + if (shouldLog) { + const ts = Date.now(); + logLayer.log(type, logArr, ts, { persist: true }); + } + }; + + sanitize = args => { + const _args = [...args]; + return middlewareFilters.reduce((acc, { filter }) => { + return filter(acc); + }, _args); + }; + + shouldLog = args => { + let result = true; + middlewareFilters.forEach(({ shouldLog }) => { + if (shouldLog && !shouldLog(args)) result = false; + }); + return result; + }; + + nativeLog = (type, ...args) => { + const nativeConsole = logLayer.getNative(); + nativeConsole[type](...args); + }; +} + +export default new LogController(); diff --git a/src/log-layer/LogLayer.js b/src/log-layer/LogLayer.js new file mode 100644 index 0000000..c5615d9 --- /dev/null +++ b/src/log-layer/LogLayer.js @@ -0,0 +1,99 @@ +import logReporter from './logReporter'; +import { allLogLevels, allowedLogLevels, ERROR } from './Config'; +import { convertToArray, isProdEnv } from './utils'; + +class LogLayer { + enabledLogLevels = {}; + consoleObj = {}; + + constructor() { + this.constructConsoleObj(); + this.setInitialEnabledLogLevels(); + console.native = this.consoleObj; + } + + /** + * @description - Creates consoleObj as class property. This will have all(log, error, warn, info, debug) native console methods. + */ + constructConsoleObj = () => { + allLogLevels.forEach(logType => { + this.consoleObj[logType] = console[logType]; + }); + }; + + setInitialEnabledLogLevels = () => { + if (isProdEnv) { + this.enable([ERROR]); + } else { + this.enableAll(); + } + }; + + getNative = () => { + return this.consoleObj; + }; + + /** + * @param {String} type + * @param {Array} logArgs + * @param {number} ts + * @param {{ persist?: Boolean }} options + */ + log = (type, logArgs, ts, options = {}) => { + if (this.enabledLogLevels[type]) { + const nativeConsole = this.getNative(); + nativeConsole[type](...logArgs); + } + if (this.shouldAddInIDB(type, options)) { + this.addLogInIDB(type, logArgs, ts); + } + }; + + shouldAddInIDB = (type, options) => { + if (allowedLogLevels.includes(type)) { + return true; + } + return options?.persist; + }; + + /** + * @param {String} type + * @param {Array} logArgs + * @param {Number} ts + * @description - Creates log string by concatenating Json string of every arg. + */ + addLogInIDB = (type, logArgs, ts) => { + const logContent = this.getLogContent(logArgs); + logReporter.addLog(type, logContent, ts); + }; + + getLogContent = args => { + return args.reduce((content, arg) => { + return `${content}${JSON.stringify(arg)} `; + }, ''); + }; + + enable = logLevels => { + logLevels = convertToArray(logLevels); + logLevels.forEach(level => { + this.enabledLogLevels[level] = true; + }); + }; + + disable = logLevels => { + logLevels = convertToArray(logLevels); + logLevels.forEach(level => { + this.enabledLogLevels[level] = false; + }); + }; + + enableAll = () => { + this.enable(allLogLevels); + }; + + disableAll = () => { + this.disable(allLogLevels); + }; +} + +export default new LogLayer(); diff --git a/src/log-layer/Middleware/ErrorResponse.js b/src/log-layer/Middleware/ErrorResponse.js new file mode 100644 index 0000000..a7657a1 --- /dev/null +++ b/src/log-layer/Middleware/ErrorResponse.js @@ -0,0 +1,57 @@ +import { selectPropsFromTemplate } from '../utils'; + +const TEMPLATE_OBJ = { + config: { + params: '', + data: '', + baseURL: '', + url: '', + }, + response: { + status: '', + data: { + detail: '', + error: '', + parameter: '', + }, + }, +}; + +class ErrorResponse { + constructor(responseObj) { + this.type = 'ErrorResponse'; + this.data = responseObj; + } +} + +class ErrorResponseMiddleware { + ErrorResponse = ErrorResponse; + + isErrorResponseObj = obj => { + return obj instanceof ErrorResponse; + }; + + filter = logArgs => { + return logArgs.map(arg => { + const filteredArg = this.filterArg(arg); + return filteredArg; + }); + }; + + filterArg = arg => { + if (this.isErrorResponseObj(arg)) { + const actualArg = { ...arg.data }; + const reducers = [this.stripSensitiveProps]; + return reducers.reduce((acc, reducer) => reducer(acc), actualArg); + } + return arg; + }; + + stripSensitiveProps = errorResponse => { + return selectPropsFromTemplate(errorResponse, TEMPLATE_OBJ); + }; +} + +const errorResponse = new ErrorResponseMiddleware(); + +export default errorResponse; diff --git a/src/log-layer/Middleware/EventResponse.js b/src/log-layer/Middleware/EventResponse.js new file mode 100644 index 0000000..474ad38 --- /dev/null +++ b/src/log-layer/Middleware/EventResponse.js @@ -0,0 +1,50 @@ +class EventResponse { + constructor(responseObj) { + this.type = 'Event Response'; + this.data = responseObj; + } +} + +class EventResponseMiddleware { + EventResponse = EventResponse; + + isEventResponseObj = obj => { + return obj instanceof EventResponse; + }; + + filter = logArgs => { + return logArgs.map(arg => { + const filteredArg = this.filterArg(arg); + return filteredArg; + }); + }; + + filterArg = arg => { + if (this.isEventResponseObj(arg)) { + const eventsArr = arg.data.data?.events || []; + const reducers = [this.stripSensitivePropsFromEvent]; + return eventsArr.map(event => + reducers.reduce((acc, reducer) => reducer(acc), event) + ); + } + return arg; + }; + + stripSensitivePropsFromEvent = e => { + e = e || {}; + const { event = {} } = e; + const formattedEvent = this.formatEvent(event); + return { + ...e, + event: formattedEvent, + }; + }; + + formatEvent = event => { + return { ...event, title: event?.eventInfo?.title, eventInfo: '' }; + }; +} + +const eventResponse = new EventResponseMiddleware(); + +export default eventResponse; diff --git a/src/log-layer/Middleware/Response.js b/src/log-layer/Middleware/Response.js new file mode 100644 index 0000000..ee014b8 --- /dev/null +++ b/src/log-layer/Middleware/Response.js @@ -0,0 +1,75 @@ +const TEMPLATE_OBJ = { + config: { + baseURL: '', + url: '', + params: '', + data: '', + }, + status: '', +}; + +class Response { + constructor(responseObj) { + this.type = 'Response'; + this.data = responseObj; + } +} + +const ignoreUrls = ['transactions/getDelta']; + +class ResponseMiddleware { + Response = Response; + + isResponseObj = obj => { + return obj instanceof Response; + }; + + shouldLog = logArgs => { + let result = true; + logArgs.forEach(arg => { + if (this.isResponseObj(arg)) { + const { data: { config: { url } = {} } = {} } = arg || {}; + if (ignoreUrls.includes(url)) { + result = false; + } + } + }); + return result; + }; + + filter = logArgs => { + return logArgs.map(arg => { + const filteredArg = this.filterArg(arg); + return filteredArg; + }); + }; + + filterArg = arg => { + if (this.isResponseObj(arg)) { + const actualArg = { ...arg.data }; + const reducers = [this.stripSensitiveProps]; + return reducers.reduce((acc, reducer) => reducer(acc), actualArg); + } + return arg; + }; + + stripSensitiveProps = response => { + return this.selectPropsFromTemplate(response, TEMPLATE_OBJ); + }; + + selectPropsFromTemplate = (target, template) => { + if (target && target instanceof Object && !Array.isArray(target)) { + const result = {}; + Object.keys(template).forEach(key => { + const value = target[key]; + result[key] = this.selectPropsFromTemplate(value, template[key]); + }); + return result; + } + return target; + }; +} + +const response = new ResponseMiddleware(); + +export default response; diff --git a/src/log-layer/__tests__/LogController.test.js b/src/log-layer/__tests__/LogController.test.js new file mode 100644 index 0000000..f71258e --- /dev/null +++ b/src/log-layer/__tests__/LogController.test.js @@ -0,0 +1,113 @@ +import logController from '../LogController'; +import logLayer from '../LogLayer'; +import IDBObjectStore from 'IDBStore/IDBObjectStore'; +import { getLastLog } from './testUtils'; + +const logStore = new IDBObjectStore('Logs'); +const nativeConsole = logLayer.getNative(); +const logTypes = ['log', 'error', 'warn', 'info', 'debug']; + +const mockConsoleMethods = () => { + logTypes.forEach(type => { + nativeConsole[type] = jest.fn(); + }); +}; + +beforeAll(async () => { + logController.init(); + await logStore.clear(); + mockConsoleMethods(); +}); + +describe('Console log methods when all log levels are enabled', () => { + beforeAll(() => { + logLayer.enableAll(); + }); + + beforeEach(() => { + logTypes.forEach(type => { + nativeConsole[type].mockReset(); + }); + }); + + logTypes.forEach(type => { + test(`Test console method ${type}`, async () => { + const logStatement = 'Hello, I am here'; + console[type](logStatement); + expect(nativeConsole[type]).toHaveBeenCalledWith(logStatement); + expect.assertions(1); + }); + }); +}); + +describe('Console log methods when all log levels are disabled', () => { + beforeAll(() => { + logLayer.disableAll(); + }); + beforeEach(() => { + logTypes.forEach(type => { + nativeConsole[type].mockReset(); + }); + }); + logTypes.forEach(type => { + test(`Test console method ${type}`, async () => { + const logStatement = 'Hello, I am here'; + console[type](logStatement); + expect(nativeConsole[type]).not.toHaveBeenCalled(); + expect.assertions(1); + }); + }); +}); + +describe('Console log methods when some log levels are enabled', () => { + const enabledLogLevels = ['error', 'warn']; + beforeAll(() => { + logLayer.enable(enabledLogLevels); + }); + beforeEach(() => { + logTypes.forEach(type => { + nativeConsole[type].mockReset(); + }); + }); + logTypes.forEach(type => { + test(`Test console method ${type}`, async () => { + const logStatement = 'Hello, I am here'; + console[type](logStatement); + if (enabledLogLevels.includes(type)) { + expect(nativeConsole[type]).toHaveBeenCalledWith(logStatement); + } else { + expect(nativeConsole[type]).not.toHaveBeenCalled(); + } + expect.assertions(1); + }); + }); +}); + +describe('Log persistance in IDB', () => { + const persistanceAllowedTypes = ['error']; + beforeAll(async () => { + logLayer.enableAll(); + logStore.clear(); + }); + + beforeEach(async () => { + return logStore.clear(); + }); + + logTypes.forEach(type => { + test(`Test console method ${type}`, async () => { + const logStatement = `${type} Hello, I am here`; + // We cannot be sure that after doing console[method] log will be available immediately in logs DB. But since mock implementation of DB is in memory it will be available right after inserting in it. + console[type](logStatement); + const logEntryInIDB = await getLastLog(logStore); + if (persistanceAllowedTypes.includes(type)) { + expect(logEntryInIDB).toMatch(logStatement); + expect(logEntryInIDB).toMatch(type); + expect.assertions(2); + } else { + expect(logEntryInIDB).toBeUndefined(); + expect.assertions(1); + } + }); + }); +}); diff --git a/src/log-layer/__tests__/LogReporter.test.js b/src/log-layer/__tests__/LogReporter.test.js new file mode 100644 index 0000000..b39407e --- /dev/null +++ b/src/log-layer/__tests__/LogReporter.test.js @@ -0,0 +1,136 @@ +import logReporter from '../logReporter'; +import IDBObjectStore from 'IDBStore/IDBObjectStore'; +import { getOnlyDate } from '../utils'; +import { getLastLog } from './testUtils'; + +const logStore = new IDBObjectStore('Logs'); +const MINUTE = 60 * 10000; +const DAY = 24 * 60 * MINUTE; + +// Adding custom matcher to jest +expect.extend({ + toContainValues(received, expected) { + let pass = true; + const receivedString = received.reduce((acc, log) => acc + log + ' ', ''); + pass = expected.reduce( + (pass, expectedLog) => pass && receivedString.includes(expectedLog), + pass + ); + return { pass, message: () => 'passed ' + pass }; + }, +}); + +describe('Test initialization ', () => { + test('Client reload separator should be present on initialization', async () => { + const lastLogEntry = await getLastLog(logStore); + expect(lastLogEntry).toMatch('Client reloaded at'); + expect.assertions(1); + }); +}); + +describe('Test date separator', () => { + const referenceTime = Date.now(); + beforeAll(async () => { + return logStore.clear(); + }); + + test('Add a log', async () => { + const content = 'Hello, I am here'; + const ts = referenceTime + 100; + logReporter.addLog('log', content, ts); + const lastLogEntry = await getLastLog(logStore); + expect(lastLogEntry).toMatch(content); + expect(lastLogEntry).toMatch('log'); + expect.assertions(2); + }); + + test('Add a log with same date', async () => { + const content = 'Hello, I am here'; + const ts = referenceTime + 500; + logReporter.addLog('error', content, ts); + const lastLogEntry = await getLastLog(logStore); + expect(lastLogEntry).toMatch(content); + expect(lastLogEntry).toMatch('error'); + expect.assertions(2); + }); + + test('Add a log with different date', async () => { + const content = 'Hello, I am here'; + const ts = referenceTime + DAY; + logReporter.addLog('error', content, ts); + const [dateSeparator, lastLogEntry] = await getLastLog(logStore, 2); + expect(lastLogEntry).toMatch(content); + expect(lastLogEntry).toMatch('error'); + expect(dateSeparator).toMatch(getOnlyDate(ts)); + expect.assertions(3); + }); +}); + +describe('Remove logs older than longevity', () => { + const referenceTime = Date.now(); + const logsBefore30Minutes = ['testBefore1', 'testBefore2']; + const logsCurrent = ['testCurrent1', 'testCurrent2']; + const logsFuture30Minute = ['testFuture1', 'testFuture2']; + beforeAll(async () => { + await logStore.clear(); + logsBefore30Minutes.forEach(log => { + logReporter.addLog('error', log, referenceTime - 30 * MINUTE); + }); + logsCurrent.forEach(log => { + logReporter.addLog('log', log, referenceTime); + }); + logsFuture30Minute.forEach(log => { + logReporter.addLog('info', log, referenceTime + 30 * MINUTE); + }); + }); + + test('Remove logs older than 50 minutes', async () => { + logReporter.longevityTime = 50 * MINUTE; + const logsBeforeCleanup = await logStore.getAll(); + logReporter.cleanup(); + const logsAfterCleanup = await logStore.getAll(); + expect(logsBeforeCleanup).toEqual(logsAfterCleanup); + expect.assertions(1); + }); + + test('Remove logs older than 30 minutes', async () => { + logReporter.longevityTime = 30 * MINUTE - 1; + const logsBeforeCleanup = await logStore.getAll(); + logReporter.cleanup(); + const logsAfterCleanup = await logStore.getAll(); + expect(logsBeforeCleanup).toContainValues([ + ...logsBefore30Minutes, + ...logsCurrent, + ...logsFuture30Minute, + ]); + expect(logsAfterCleanup).toContainValues([ + ...logsCurrent, + ...logsFuture30Minute, + ]); + expect(logsAfterCleanup).not.toContainValues([...logsBefore30Minutes]); + expect.assertions(3); + }); + + test('Remove logs older than current time', async () => { + logReporter.longevityTime = 0; + const logsBeforeCleanup = await logStore.getAll(); + logReporter.cleanup(); + const logsAfterCleanup = await logStore.getAll(); + expect(logsBeforeCleanup).toContainValues([ + ...logsCurrent, + ...logsFuture30Minute, + ]); + expect(logsAfterCleanup).toContainValues([...logsFuture30Minute]); + expect(logsAfterCleanup).not.toContainValues([...logsCurrent]); + expect.assertions(3); + }); +}); + +describe('Get log file', () => { + test('Get all logs as file', async () => { + const logFile = await logReporter.getLogFile(); + expect(logFile).toBeInstanceOf(Blob); + expect(logFile.type).toBe('text/json'); + expect.assertions(2); + }); +}); diff --git a/src/log-layer/__tests__/Middleware/Response.test.js b/src/log-layer/__tests__/Middleware/Response.test.js new file mode 100644 index 0000000..d6fe747 --- /dev/null +++ b/src/log-layer/__tests__/Middleware/Response.test.js @@ -0,0 +1,53 @@ +import logService from '../../logService'; +import logLayer from '../../LogLayer'; +import IDBObjectStore from 'IDBStore/IDBObjectStore'; +import { + deltaPollResponse, + otherResponseObj, + expectedResponseObj, +} from '../constants'; +import { getLastLog } from '../testUtils'; + +const { + typeWrappers: { Response }, +} = logService; + +const logStore = new IDBObjectStore('Logs'); +const logTypes = ['log', 'error', 'warn', 'info', 'debug']; +const nativeConsole = logLayer.getNative(); + +const mockConsoleMethods = () => { + logTypes.forEach(type => { + nativeConsole[type] = jest.fn(); + }); +}; + +beforeAll(() => { + mockConsoleMethods(); +}); + +// Response of url type Transaction/delta will not be logged. Because they are polling calls. +describe('Log response of delta poll requests', () => { + logTypes.forEach(logType => { + test(`Log delta poll response using ${logType} method`, async () => { + const logServiceMethod = logService[logType]; + const beforeLog = await getLastLog(logStore); + logServiceMethod(new Response(deltaPollResponse)); + const afterLog = await getLastLog(logStore); + expect(nativeConsole[logType]).not.toHaveBeenCalled(); + expect(beforeLog).toBe(afterLog); + }); + }); +}); + +describe('Log response of other requests', () => { + logTypes.forEach(logType => { + test(`Log other responses using ${logType} method`, async () => { + const logServiceMethod = logService[logType]; + logServiceMethod(new Response(otherResponseObj)); + const afterLog = await getLastLog(logStore); + expect(nativeConsole[logType]).toHaveBeenCalledWith(expectedResponseObj); + expect(afterLog).toMatch(JSON.stringify(expectedResponseObj)); + }); + }); +}); diff --git a/src/log-layer/__tests__/constants.js b/src/log-layer/__tests__/constants.js new file mode 100644 index 0000000..b7ce828 --- /dev/null +++ b/src/log-layer/__tests__/constants.js @@ -0,0 +1,93 @@ +export const deltaPollResponse = { + data: { + transactionId: '6811246869007598810_6853635982348025965', + }, + status: 200, + statusText: '', + headers: { + 'content-type': 'application/json;charset=utf-8', + }, + config: { + url: 'transactions/getDelta', + method: 'get', + headers: { + Accept: 'application/json, text/plain, */*', + 'X-Session-Token': '1:ToUJWe82TWjGsdCZKRXTFcV7w666v25C', + 'X-API-Endpoint': 'https://flockmail-backend.flock-staging.com/s/1/3598/', + }, + params: { + transactionId: '6811246869007598810_6853635982348025965', + onlySilent: false, + api: 'https://flockmail-backend.flock-staging.com/s/1/3598/', + token: '1:ToUJWe82TWjGsdCZKRXTFcV7w666v25C', + }, + baseURL: 'https://flockmail-backend.flock-staging.com/kairos/', + transformRequest: [null], + transformResponse: [null], + timeout: 0, + responseType: 'json', + xsrfCookieName: 'XSRF-TOKEN', + xsrfHeaderName: 'X-XSRF-TOKEN', + maxContentLength: -1, + maxBodyLength: -1, + transitional: { + silentJSONParsing: true, + forcedJSONParsing: true, + clarifyTimeoutError: false, + }, + }, + request: {}, +}; + +export const otherResponseObj = { + data: { + events: [], + syncToken: + 'xo7ffbf6605548ba4e2a5980788c89df14b7ff0281a259e2aa29dc8b426ae855c3591aef1e489ae70f86863a299b62b87dd4d5e0254a03d0b241a3a66803c632aeba008c9104afb90f75906dbcddb9966bf6d91032475c2521f5268158cb348d2c69d5', + }, + status: 200, + statusText: '', + headers: { + 'content-type': 'application/json;charset=utf-8', + }, + config: { + url: 'calendars/6815948711536920398/events/fetch', + method: 'post', + data: '{"timeZone":"Asia/Kolkata","startTime":"2021-09-22T00:00:00+05:30","endTime":"2022-01-08T00:00:00+05:30","timeFormat":"iso"}', + headers: { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + 'X-Session-Token': '1:ToUJWe82TWjGsdCZKRXTFcV7w666v25C', + 'X-API-Endpoint': 'https://flockmail-backend.flock-staging.com/s/1/3598/', + }, + params: { + api: 'https://flockmail-backend.flock-staging.com/s/1/3598/', + token: '1:ToUJWe82TWjGsdCZKRXTFcV7w666v25C', + }, + baseURL: 'https://flockmail-backend.flock-staging.com/kairos/', + transformRequest: [null], + transformResponse: [null], + timeout: 0, + responseType: 'json', + xsrfCookieName: 'XSRF-TOKEN', + xsrfHeaderName: 'X-XSRF-TOKEN', + maxContentLength: -1, + maxBodyLength: -1, + transitional: { + silentJSONParsing: true, + forcedJSONParsing: true, + clarifyTimeoutError: false, + }, + }, + request: {}, +}; + +export const expectedResponseObj = { + config: { + baseURL: 'https://flockmail-backend.flock-staging.com/kairos/', + method: 'post', + url: 'calendars/6815948711536920398/events/fetch', + params: { api: 'https://flockmail-backend.flock-staging.com/s/1/3598/' }, + }, + status: 200, +}; diff --git a/src/log-layer/__tests__/logService.test.js b/src/log-layer/__tests__/logService.test.js new file mode 100644 index 0000000..87566ef --- /dev/null +++ b/src/log-layer/__tests__/logService.test.js @@ -0,0 +1,87 @@ +import logService from '../logService'; +import logLayer from '../LogLayer'; +import IDBObjectStore from 'IDBStore/IDBObjectStore'; +import { getLastLog } from './testUtils'; +import { namespaces } from '../Config'; + +const logStore = new IDBObjectStore('Logs'); +const logTypes = ['log', 'error', 'warn', 'info', 'debug']; +const nativeConsole = logLayer.getNative(); + +const mockConsoleMethods = () => { + logTypes.forEach(type => { + nativeConsole[type] = jest.fn(); + }); +}; + +beforeAll(() => { + mockConsoleMethods(); +}); + +// Without namespace +describe('Use log service without namespace', () => { + logTypes.forEach(logType => { + test(`Test log service method ${logType}`, async () => { + const logContent = 'Hello, I am here for testing'; + const testArray = ['test1', 'test2']; + const logServiceMethod = logService[logType]; + logServiceMethod(logContent, testArray); + expect(nativeConsole[logType]).toHaveBeenCalledWith( + logContent, + testArray + ); + const lastLogEntry = await getLastLog(logStore); + expect(lastLogEntry).toMatch(logContent); + expect(lastLogEntry).toMatch(JSON.stringify(testArray)); + expect(lastLogEntry).toMatch(logType); + expect.assertions(4); + }); + }); +}); + +// With enabled namespace +describe('Use log service with enabled namespace', () => { + const AxiosLogService = logService.Axios; + const { + Axios: { prefix: axiosPrefix }, + } = namespaces; + + logTypes.forEach(logType => { + test(`Test log service method ${logType}`, async () => { + const logContent = 'Hello, I am here for testing'; + const testArray = ['test1', 'test2']; + const logServiceMethod = AxiosLogService[logType]; + logServiceMethod(logContent, testArray); + expect(nativeConsole[logType]).toHaveBeenCalledWith( + axiosPrefix, + logContent, + testArray + ); + const lastLogEntry = await getLastLog(logStore); + expect(lastLogEntry).toMatch(logContent); + expect(lastLogEntry).toMatch(JSON.stringify(testArray)); + expect(lastLogEntry).toMatch(logType); + expect(lastLogEntry).toMatch(axiosPrefix); + expect.assertions(5); + }); + }); +}); + +// With disabled namespace or undefined namespace +describe('Use log service with disabled or undefined namespace', () => { + const UndefinedLogService = logService.Undefined; + + logTypes.forEach(logType => { + test(`Test log service method ${logType}`, async () => { + const logContent = 'Hello, I am here for testing'; + const testArray = ['test1', 'test2']; + const logServiceMethod = UndefinedLogService[logType]; + const beforeLastLogEntry = await getLastLog(logStore); + logServiceMethod(logContent, testArray); + expect(nativeConsole[logType]).not.toHaveBeenCalled(); + const lastLogEntry = await getLastLog(logStore); + expect(beforeLastLogEntry).toBe(lastLogEntry); + expect.assertions(2); + }); + }); +}); diff --git a/src/log-layer/__tests__/testUtils.js b/src/log-layer/__tests__/testUtils.js new file mode 100644 index 0000000..59576af --- /dev/null +++ b/src/log-layer/__tests__/testUtils.js @@ -0,0 +1,8 @@ +export const getLastLog = async (logStore, count = 1) => { + const allLogs = await logStore.getAll(); + if (count === 1) { + return allLogs.length ? allLogs[allLogs.length - 1] : undefined; + } + if (!allLogs.length) return []; + return allLogs.slice(Math.max(0, allLogs.length - count)); +}; diff --git a/src/log-layer/__tests__/utils.test.js b/src/log-layer/__tests__/utils.test.js new file mode 100644 index 0000000..7a2b556 --- /dev/null +++ b/src/log-layer/__tests__/utils.test.js @@ -0,0 +1,64 @@ +import { selectPropsFromTemplate } from '../utils'; + +describe('Test selectPropsFromTemplate method', () => { + const targetObj = { + a: { + b: { c: 'c', d: 'd' }, + e: 'e', + }, + f: ['f', 'ff', 'fff'], + g: 50, + h: null, + i: undefined, + j: [{ k: 'k', l: 'l' }], + }; + + test('Template obj is empty', () => { + const templateObj = {}; + const expected = {}; + const filteredObj = selectPropsFromTemplate(targetObj, templateObj); + expect(filteredObj).toEqual(expected); + }); + + test('Template obj is selecting null and undefined from target', () => { + const templateObj = { h: '', i: '' }; + const expected = { h: targetObj.h, i: targetObj.i }; + const filteredObj = selectPropsFromTemplate(targetObj, templateObj); + expect(filteredObj).toEqual(expected); + }); + + test('Template obj is selecting array from target', () => { + const templateObj = { f: '' }; + const expected = { f: targetObj.f }; + const filteredObj = selectPropsFromTemplate(targetObj, templateObj); + expect(filteredObj).toEqual(expected); + }); + + test('Template obj is selecting array of objects from target', () => { + const templateObj = { j: '' }; + const expected = { j: targetObj.j }; + const filteredObj = selectPropsFromTemplate(targetObj, templateObj); + expect(filteredObj).toEqual(expected); + }); + + test('Template obj is selecting some nested obj property from target', () => { + const templateObj = { a: { b: { c: '', d: '' } } }; + const expected = { a: { b: { c: targetObj.a.b.c, d: targetObj.a.b.d } } }; + const filteredObj = selectPropsFromTemplate(targetObj, templateObj); + expect(filteredObj).toEqual(expected); + }); + + test('Template obj is selecting some nested obj property from target', () => { + const templateObj = { a: { b: '' } }; + const expected = { a: { b: { c: targetObj.a.b.c, d: targetObj.a.b.d } } }; + const filteredObj = selectPropsFromTemplate(targetObj, templateObj); + expect(filteredObj).toEqual(expected); + }); + + test('Template obj is selecting whole target object', () => { + const templateObj = targetObj; + const expected = targetObj; + const filteredObj = selectPropsFromTemplate(targetObj, templateObj); + expect(filteredObj).toEqual(expected); + }); +}); diff --git a/src/log-layer/log-layer.md b/src/log-layer/log-layer.md new file mode 100644 index 0000000..611d98f --- /dev/null +++ b/src/log-layer/log-layer.md @@ -0,0 +1,33 @@ +Log-layer + +1. Log-layer.js + 1. This module manages enabled log levels. Only enabled log-levels will be logged to the console. Enabled log levels for dev environments will be all log types and for prod it will be only error. + 1. This component also checks if log should be added in IDB or not. If it is then use logReporter to add log in IDB. By default console.error logs will only be added to IDB. If it specifically receives options of persisting in IDB then it will not check the log type. + 1. This module will be a single instance of class. + 1. Instance will be available in window.console.logLayer +1. Log controller + 1. This module controls the log-layer and starting point of initialization. + 1. This is also a single instance of class. + 1. This overrides default console methods and assigns custom methods. + 1. It also sanitizes sensitive data from logs. For that it uses middleware. If you want to sanitize some other data then write your own middleware and add it to the list of middlewares. Check the middleware section to check how to write middleware. + 1. It also has a separate method to log the data which will always persist in IDB. But do not use that method directly instead use log service for tasks. +1. Log service + 1. This service should be used when you want to specifically add some permanent logs in IDB. + 1. This module also supports namespace. + 1. This module will be a proxy object. + 1. You can import logService like this. Then you can use logService.log, logService.error etc methods. If you want to use namespace then use it like logService.NameSpace.log, logService.NameSpace.error. + 1. But make sure the namespace you provide should be present in config. Otherwise it won’t get persisted. From namespace config you can disable specific namespace and all the logs of that namespace will not be logged or persisted. +1. Log reporter + 1. This module manages storing and retrieving logs from IDB. + 1. This is a single instance of class. + 1. ` `It also removes logs older than their longevity time. + 1. It has a method to retrieve all logs in file. + 1. It also appends proper date and client reload separators in IDB. +1. Middleware + 1. This should be a single instance of class. + 1. Two methods should be present there. + 1. Should log (logArgs) + 1. Checks that this log should be logged or not. + 1. Filter(logArgs) + 1. Returns filtered and sanitized log args which can be then logged. + diff --git a/src/log-layer/logReporter.js b/src/log-layer/logReporter.js new file mode 100644 index 0000000..a5994c3 --- /dev/null +++ b/src/log-layer/logReporter.js @@ -0,0 +1,139 @@ +import IDBObjectStore from 'IDBStore/IDBObjectStore'; +import { CalendarStore } from 'store/applicationStore'; +import { getCalendarConfig, getFcgConfig } from 'helpers/localStorage'; +import { + getPrettyDateWithTz, + getDateSeparator, + getClientReloadSeparator, + getOnlyDate, +} from './utils'; + +const LOG_OBJECT_STORE = 'Logs'; +const HALF_DAY = 12 * 60 * 60 * 1000; +const CLEANUP_INTERVAL = 4 * 60 * 60 * 1000; +const SEVEN_DAYS = 3 * 2 * HALF_DAY; + +class LogReporter { + constructor() { + this.logStore = new IDBObjectStore(LOG_OBJECT_STORE); + this.counter = 0; + this.lastTs = null; + this.cleanupInterval = CLEANUP_INTERVAL; + this.longevityTime = SEVEN_DAYS; + this.cleanup(); + this.addLogsOnClientReload(); + setInterval(this.cleanup, this.cleanupInterval); + } + + getKey = ts => { + return `${ts}_${this.counter++}`; + }; + + set = (log, ts = Date.now()) => { + const key = this.getKey(ts); + this.logStore.set(log, key); + }; + + /** + * @param {String} type + * @param {String} logString + * @param {Number} ts + * @description - Adds log in indexed db. Before adding log checks if date of last log is same as of new log if not then appends date separator. + */ + addLog = (type, logString, ts) => { + const prettyDateWithTz = getPrettyDateWithTz(ts); + const log = `${type} ${prettyDateWithTz} ${logString}`; + this.appendDateSeparator(ts); + this.set(log, ts); + this.lastTs = ts; + }; + + addLogsOnClientReload = () => { + this.appendClientReloadSeparator(); + this.appendLocalStorageData(); + }; + + /** + * @description Cleans logs older than seven days. + */ + cleanup = () => { + const lastInvalidKey = `${Date.now() - this.longevityTime}`; + const keyRangeToBeRemoved = IDBKeyRange.upperBound(lastInvalidKey); + this.logStore.delete(keyRangeToBeRemoved); + }; + + /** + * @description Append this separator on client reload. + */ + appendClientReloadSeparator = () => { + const ts = Date.now(); + const clientReloadSeparator = getClientReloadSeparator(ts); + this.set(clientReloadSeparator, ts); + }; + + /** + * @description Append this separator on date change. + */ + appendDateSeparator = ts => { + if (this.shouldAddDateSeparator(ts)) { + const dateSeparator = getDateSeparator(ts); + this.set(dateSeparator, ts); + } + }; + + /** + * @description Append local storage data on client reload + */ + appendLocalStorageData = () => { + const fcgConfig = getFcgConfig(); + const calendarConfig = getCalendarConfig(); + let localStorageData = 'Local storage data: '; + try { + const stringifiedFcgConfig = JSON.stringify(fcgConfig); + const stringifiedCalendarConfig = JSON.stringify(calendarConfig); + localStorageData += `${stringifiedFcgConfig} \n ${stringifiedCalendarConfig}`; + } catch (e) {} + this.set(localStorageData); + }; + + shouldAddDateSeparator = ts => { + if (!this.lastTs) return true; + const lastDate = getOnlyDate(this.lastTs); + const currentDate = getOnlyDate(ts); + return lastDate !== currentDate; + }; + + getMetaInformation = () => { + const userAgent = window.navigator.userAgent; + const clientVersion = CalendarStore.appVersion; + return `${userAgent} \n Version ${clientVersion}`; + }; + + /** + * @async + * @returns {String} Returns log string by concatenating logs array. + */ + getLogs = async () => { + const logsArr = await this.logStore.getAll(); + const metaInformation = this.getMetaInformation(); + const logString = `${metaInformation} \n ${logsArr.join('\n')}`; + return logString; + }; + + /** + * @async + * @returns {File} Returns log file created from available logs. + */ + getLogFile = async () => { + const logData = await this.getLogs(); + var blob = new Blob([logData], { type: 'text/json' }); + const fileName = `logFile_${Date.now()}`; + const file = new File([blob], fileName, { type: 'text/json' }); + return file; + }; +} + +const logReporter = new LogReporter(); +window.getLogFile = logReporter.getLogFile; + +export default logReporter; diff --git a/src/log-layer/logService.js b/src/log-layer/logService.js new file mode 100644 index 0000000..b61af2d --- /dev/null +++ b/src/log-layer/logService.js @@ -0,0 +1,67 @@ +import logController from './LogController'; +import { allLogTypes, typeWrappers } from './Config'; +import { isNameSpaceEnabled, getPrefixForNameSpace } from './utils'; + +class LogService { + /** + * @param {string} namespace - namespace for log service + */ + constructor(namespace) { + this.namespace = namespace; + this.isNameSpaceEnabled = isNameSpaceEnabled(this.namespace); + this.namespacePrefix = getPrefixForNameSpace(this.namespace); + this.nativeConsole = {}; + this.typeWrappers = typeWrappers; + this.setupLogMethods(); + this.setupNativeLogMethods(); + } + + getPrefixes = () => { + if (this.namespacePrefix) return [this.namespacePrefix]; + return []; + }; + + setupLogMethods = () => { + allLogTypes.forEach(logType => { + this[logType] = this._log.bind(this, logType); + }); + }; + + _log = (type, ...args) => { + if (this.isNameSpaceEnabled) { + const prefixes = this.getPrefixes(); + logController.log(type, ...[...prefixes, ...args]); + } + }; + + setupNativeLogMethods = () => { + allLogTypes.forEach(logType => { + this.nativeConsole[logType] = this._nativeLog.bind(this, logType); + }); + }; + + _nativeLog = (type, ...args) => { + const prefixes = this.getPrefixes() || []; + logController.nativeLog(type, ...[...prefixes, ...args]); + }; +} + +const withNameSpaces = () => { + const logService = new LogService(); + const proxyHandlers = { + get(target, key) { + if (!target[key]) { + target[key] = new LogService(key); + } + return target[key]; + }, + }; + return new Proxy(logService, proxyHandlers); +}; + +/** + * @type {{log: Function, error: Function, warn: Function, info: Function, debug: Function, nativeConsole: Object}} + */ +const logServiceWithNameSpace = withNameSpaces(); + +export default logServiceWithNameSpace; diff --git a/src/log-layer/utils.js b/src/log-layer/utils.js new file mode 100644 index 0000000..0a703c8 --- /dev/null +++ b/src/log-layer/utils.js @@ -0,0 +1,95 @@ +import moment from 'moment-timezone'; +import { + namespaces, + DATE_SEPARATOR_TEMPLATE, + CLIENT_RELOAD_SEPARATOR_TEMPLATE, +} from './Config'; + +export const isProdEnv = (() => { + return process.env.NODE_ENV === 'production'; +})(); + +/** + * @param {String} namespace + * @returns {Boolean} Returns true if namespace is enabled. + */ +export const isNameSpaceEnabled = namespace => { + if (!namespace) return true; + const namespaceObj = namespaces[namespace]; + return namespaceObj && namespaceObj.enabled; +}; + +/** + * @param {String} namespace + * @returns {String} Extract namespace prefix from namespace obj + */ +export const getPrefixForNameSpace = namespace => { + const namespaceObj = namespaces[namespace]; + return namespaceObj?.prefix || namespace; +}; + +/** + * @param {Number} ts + * @returns {String} - Date in YYYY-MM-DDTHH:MM:SSZ format + */ +export const getPrettyDateWithTz = ts => { + return moment(ts).format(); +}; + +/** + * @param {Number} ts + * @returns {String} - Date in DD/MM/YYYY format + */ +export const getOnlyDate = ts => { + return moment(ts).format('DD/MM/YYYY'); +}; + +/** + * @param {Number} ts + * @returns {String} - Returns date separator string with timestamp converted to date. + */ +export const getDateSeparator = ts => { + const prettyDate = getOnlyDate(ts); + return DATE_SEPARATOR_TEMPLATE.replace('{Date}', prettyDate); +}; + +/** + * @param {Number} ts + * @returns {String} - Returns client reload separator string with timestamp converted to pretty date. + */ +export const getClientReloadSeparator = ts => { + const prettyDateWithTz = getPrettyDateWithTz(ts); + return CLIENT_RELOAD_SEPARATOR_TEMPLATE.replace( + '{ISODate}', + prettyDateWithTz + ); +}; + +export const convertToArray = arg => { + if (!Array.isArray(arg)) { + return [arg]; + } + return arg; +}; + +/** + * Selects Props from target object based on template obj + * @param {Object} target + * @param {Object} template + * @returns {Object} + */ +export const selectPropsFromTemplate = (target, template) => { + if (target && _isObjectType(target)) { + const result = !_isObjectType(template) ? target : {}; + Object.keys(template).forEach(key => { + const value = target[key]; + result[key] = selectPropsFromTemplate(value, template[key]); + }); + return result; + } + return target; +}; + +const _isObjectType = value => { + return value instanceof Object && !Array.isArray(value); +}; diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100755 index 0000000..6f2d6ac --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,11 @@ +// react-testing-library renders your components to document.body, +// this adds jest-dom's custom assertions +import '@testing-library/jest-dom/extend-expect'; + +import 'react-app-polyfill/ie11'; +import 'react-app-polyfill/stable'; + +import 'jest-styled-components'; + +// Init i18n for the tests needing it +import 'locales/i18n'; diff --git a/src/utils/@reduxjs/toolkit.tsx b/src/utils/@reduxjs/toolkit.tsx new file mode 100644 index 0000000..c7d647b --- /dev/null +++ b/src/utils/@reduxjs/toolkit.tsx @@ -0,0 +1,19 @@ +import { RootStateKeyType } from '../types/injector-typings'; +import { + createSlice as createSliceOriginal, + SliceCaseReducers, + CreateSliceOptions, +} from '@reduxjs/toolkit'; + +/* Wrap createSlice with stricter Name options */ + +/* istanbul ignore next */ +export const createSlice = < + State, + CaseReducers extends SliceCaseReducers, + Name extends RootStateKeyType, +>( + options: CreateSliceOptions, +) => { + return createSliceOriginal(options); +}; diff --git a/src/utils/api/request.ts b/src/utils/api/request.ts new file mode 100644 index 0000000..330aadc --- /dev/null +++ b/src/utils/api/request.ts @@ -0,0 +1,54 @@ +export class ResponseError extends Error { + public response: Response; + + constructor(response: Response) { + super(response.statusText); + this.response = response; + } +} +/** + * Parses the JSON returned by a network request + * + * @param {object} response A response from a network request + * + * @return {object} The parsed JSON from the request + */ +function parseJSON(response: Response) { + if (response.status === 204 || response.status === 205) { + return null; + } + return response.json(); +} + +/** + * Checks if a network request came back fine, and throws an error if not + * + * @param {object} response A response from a network request + * + * @return {object|undefined} Returns either the response, or throws an error + */ +function checkStatus(response: Response) { + if (response.status >= 200 && response.status < 300) { + return response; + } + const error = new ResponseError(response); + error.response = response; + throw error; +} + +/** + * Requests a URL, returning a promise + * + * @param {string} url The URL we want to request + * @param {object} [options] The options we want to pass to "fetch" + * + * @return {object} The response data + */ +export async function request( + url: string, + options?: RequestInit, +): Promise<{} | { err: ResponseError }> { + const fetchResponse = await fetch(url, options); + const response = checkStatus(fetchResponse); + return parseJSON(response); +} diff --git a/src/utils/config.ts b/src/utils/config.ts deleted file mode 100644 index bf7629d..0000000 --- a/src/utils/config.ts +++ /dev/null @@ -1,3 +0,0 @@ -// any config related setup should go under this file - -export {}; diff --git a/src/utils/const.ts b/src/utils/const.ts deleted file mode 100644 index 599ec83..0000000 --- a/src/utils/const.ts +++ /dev/null @@ -1,3 +0,0 @@ -// place for application constants - -export {}; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts deleted file mode 100644 index 916587c..0000000 --- a/src/utils/helpers.ts +++ /dev/null @@ -1,3 +0,0 @@ -// define your helper functions here - -export {}; diff --git a/src/utils/reactLazy/loadable.tsx b/src/utils/reactLazy/loadable.tsx new file mode 100644 index 0000000..1c1f846 --- /dev/null +++ b/src/utils/reactLazy/loadable.tsx @@ -0,0 +1,30 @@ +import React, { lazy, Suspense } from 'react'; + +interface Opts { + fallback: React.ReactNode; +} +type Unpromisify = T extends Promise ? P : never; + +export const lazyLoad = < + T extends Promise, + U extends React.ComponentType, +>( + importFunc: () => T, + selectorFunc?: (s: Unpromisify) => U, + opts: Opts = { fallback: null }, +) => { + let lazyFactory: () => Promise<{ default: U }> = importFunc; + + if (selectorFunc) { + lazyFactory = () => + importFunc().then(module => ({ default: selectorFunc(module) })); + } + + const LazyComponent = lazy(lazyFactory); + + return (props: React.ComponentProps): JSX.Element => ( + + + + ); +}; diff --git a/src/utils/translations/messages.ts b/src/utils/translations/messages.ts new file mode 100644 index 0000000..81ad3b6 --- /dev/null +++ b/src/utils/translations/messages.ts @@ -0,0 +1,13 @@ +/** + * This function has two roles: + * 1) If the `id` is empty it assings something so does i18next doesn't throw error. Typescript should prevent this anyway + * 2) It has a hand-picked name `_t` (to be short) and should only be used while using objects instead of strings for translation keys + * `internals/extractMessages/stringfyTranslations.js` script converts this to `t('a.b.c')` style before `i18next-scanner` scans the file contents + * so that our json objects can also be recognized by the scanner. + */ +export const _t = (id: string, ...rest: any[]): [string, ...any[]] => { + if (!id) { + id = '_NOT_TRANSLATED_'; + } + return [id, ...rest]; +}; diff --git a/src/utils/types/injector-typings.ts b/src/utils/types/injector-typings.ts new file mode 100644 index 0000000..54105c6 --- /dev/null +++ b/src/utils/types/injector-typings.ts @@ -0,0 +1,22 @@ +import { RootState } from 'types'; +import { Saga } from 'redux-saga'; +import { SagaInjectionModes } from 'redux-injectors'; +import { Reducer, AnyAction } from '@reduxjs/toolkit'; + +type RequiredRootState = Required; + +export type RootStateKeyType = keyof RootState; + +export type InjectedReducersType = { + [P in RootStateKeyType]?: Reducer; +}; +export interface InjectReducerParams { + key: Key; + reducer: Reducer; +} + +export interface InjectSagaParams { + key: RootStateKeyType | string; + saga: Saga; + mode?: SagaInjectionModes; +}