├── renovate.json
├── src
├── react-app-env.d.ts
├── App
│ ├── App.module.css
│ └── App.tsx
├── index.tsx
├── index.css
└── logo.svg
├── example-app
├── src
│ ├── react-app-env.d.ts
│ ├── declaration.d.ts
│ ├── components
│ │ ├── NavList
│ │ │ ├── NavList.module.scss
│ │ │ └── NavList.tsx
│ │ ├── AppContainer
│ │ │ └── AppContainer.tsx
│ │ ├── ErrorBoundary
│ │ │ └── ErrorBoundary.tsx
│ │ ├── Loader
│ │ │ ├── Loader.tsx
│ │ │ └── Loader.module.scss
│ │ ├── CustomMenu
│ │ │ └── CustomMenu.tsx
│ │ ├── Header
│ │ │ └── Header.tsx
│ │ ├── LibrarySearchingContent
│ │ │ └── LibrarySearchingContent.tsx
│ │ ├── PatentItem
│ │ │ └── PatentItem.tsx
│ │ └── App
│ │ │ └── App.tsx
│ ├── index.scss
│ ├── constants
│ │ ├── routes.ts
│ │ ├── index.ts
│ │ └── actionNames.ts
│ ├── sagas
│ │ ├── index.ts
│ │ └── astronomyPictureSagas.ts
│ ├── index.tsx
│ ├── theme.ts
│ ├── pages
│ │ ├── HomePage.tsx
│ │ ├── ErrorPage.tsx
│ │ ├── PatentsPage.tsx
│ │ ├── AstronomyPicturePage.tsx
│ │ ├── MarsRoverPhotosPage.tsx
│ │ └── LibraryPage.tsx
│ ├── types.d.ts
│ ├── reducers
│ │ ├── types.d.ts
│ │ ├── rootReducer.ts
│ │ ├── libraryReducer.ts
│ │ ├── patentsReducer.ts
│ │ ├── astronomyPictureReducer.ts
│ │ ├── marsRoverPhotosReducer.ts
│ │ ├── testHelpers.ts
│ │ └── selector.spec.ts
│ ├── store.ts
│ ├── actions
│ │ ├── types.d.ts
│ │ └── index.ts
│ └── services
│ │ └── NasaService.ts
├── public
│ ├── favicon.ico
│ └── index.html
├── README.md
├── .prettierrc.json
├── tsconfig.json
├── package.json
└── .eslintrc.js
├── public
├── robots.txt
├── favicon.ico
├── logo192.png
├── logo512.png
├── manifest.json
└── index.html
├── .huskyrc.json
├── .lintstagedrc.json
├── .github
├── workflows
│ ├── README.md
│ └── default.yaml
└── ISSUE_TEMPLATE
│ ├── ---question.md
│ ├── ---feature.md
│ └── ---bug-report.md
├── lib
├── src
│ ├── helpers
│ │ ├── utils.ts
│ │ ├── const.ts
│ │ └── nanoid.utils.ts
│ ├── publicApi.ts
│ ├── store
│ │ ├── actions.ts
│ │ ├── selector.ts
│ │ └── reducer.ts
│ ├── middlewares
│ │ ├── toolkit.middleware.ts
│ │ ├── ignoreActionTypes.middleware.ts
│ │ ├── saga.middleware.ts
│ │ └── promise.middleware.ts
│ ├── typings.d.ts
│ └── configurePendingEffects.ts
├── .eslintrc.js
├── tsconfig.json
├── package.json
└── README.md
├── .prettierrc.json
├── tsconfig.json
├── .gitignore
├── .eslintrc.js
├── package.json
├── CODE_OF_CONDUCT.md
└── README.md
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["config:base"]
3 | }
4 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/App/App.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | text-align: center;
3 | }
4 |
--------------------------------------------------------------------------------
/example-app/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 |
--------------------------------------------------------------------------------
/.huskyrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "hooks": {
3 | "pre-commit": "lint-staged"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.lintstagedrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "src/**/*.{js,jsx,ts,tsx}": [
3 | "prettier --write"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/example-app/src/declaration.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.scss';
2 | declare module 'redux-saga-tester';
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kioviensis/redux-pending-effects/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kioviensis/redux-pending-effects/HEAD/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kioviensis/redux-pending-effects/HEAD/public/logo512.png
--------------------------------------------------------------------------------
/.github/workflows/README.md:
--------------------------------------------------------------------------------
1 | https://medium.com/@cakeinpanic/github-actions-%D0%B1%D0%B0%D0%B7%D0%B0-2501445e7392
2 |
--------------------------------------------------------------------------------
/example-app/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kioviensis/redux-pending-effects/HEAD/example-app/public/favicon.ico
--------------------------------------------------------------------------------
/example-app/src/components/NavList/NavList.module.scss:
--------------------------------------------------------------------------------
1 | .navListItemActiveLink span {
2 | text-decoration: underline;
3 | }
4 |
--------------------------------------------------------------------------------
/lib/src/helpers/utils.ts:
--------------------------------------------------------------------------------
1 | export function isPromise(value: null | Promise): boolean {
2 | return value instanceof Promise;
3 | }
4 |
--------------------------------------------------------------------------------
/example-app/README.md:
--------------------------------------------------------------------------------
1 | ## Example Application
2 |
3 | This is the place where experiments conducted and shows how to use this library in a real-world application.
4 |
--------------------------------------------------------------------------------
/example-app/src/index.scss:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | background-color: #2a2a2a;
4 | }
5 |
6 | a {
7 | text-decoration: none;
8 | }
9 |
10 | ul {
11 | list-style: none;
12 | }
13 |
--------------------------------------------------------------------------------
/lib/src/publicApi.ts:
--------------------------------------------------------------------------------
1 | export { selectIsPending } from './store/selector';
2 | export { includePendingReducer } from './store/reducer';
3 | export { configurePendingEffects } from './configurePendingEffects';
4 |
--------------------------------------------------------------------------------
/example-app/src/constants/routes.ts:
--------------------------------------------------------------------------------
1 | export const routes = {
2 | HOME: '/',
3 | PATENTS: 'patents',
4 | LIBRARY: 'library',
5 | ASTRONOMY_PICTURE: 'apod',
6 | MARS_ROVER_PHOTOS: 'mars_rover_photos'
7 | };
8 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import { App } from './App/App';
5 | import './index.css';
6 |
7 | ReactDOM.render(, document.getElementById('root'));
8 |
--------------------------------------------------------------------------------
/src/App/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styles from './App.module.css';
4 |
5 | export const App: React.FC = () => (
6 |
9 | );
10 |
--------------------------------------------------------------------------------
/lib/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | ecmaVersion: 6,
5 | sourceType: 'module',
6 | ecmaFeatures: {
7 | modules: true
8 | }
9 | }
10 | };
11 |
--------------------------------------------------------------------------------
/example-app/src/sagas/index.ts:
--------------------------------------------------------------------------------
1 | import { all } from '@redux-saga/core/effects';
2 |
3 | import { astronomyPictureWatcher } from './astronomyPictureSagas';
4 |
5 | export function* rootSaga() {
6 | yield all([astronomyPictureWatcher()]);
7 | }
8 |
--------------------------------------------------------------------------------
/example-app/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'react-dom';
3 |
4 | import { AppContainer } from './components/AppContainer/AppContainer';
5 | import './index.scss';
6 |
7 | render(, document.getElementById('root'));
8 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/---question.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: '❔ Question'
3 | about: Ask a question about redux-pending-effects
4 | title: ''
5 | labels: question
6 | assignees: ''
7 | ---
8 |
9 | Provide your question, code sample, or other information that could help
10 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "bracketSpacing": true,
5 | "semi": true,
6 | "singleQuote": true,
7 | "jsxSingleQuote": true,
8 | "endOfLine": "lf",
9 | "trailingComma": "none",
10 | "arrowParens": "avoid"
11 | }
12 |
--------------------------------------------------------------------------------
/example-app/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "bracketSpacing": true,
5 | "semi": true,
6 | "singleQuote": true,
7 | "jsxSingleQuote": true,
8 | "endOfLine": "lf",
9 | "trailingComma": "none",
10 | "arrowParens": "avoid"
11 | }
12 |
--------------------------------------------------------------------------------
/lib/src/store/actions.ts:
--------------------------------------------------------------------------------
1 | import { REDUX_PENDING_EFFECTS_PATCH_EFFECT } from '../helpers/const';
2 |
3 | export const patchEffect = (
4 | payload: RPE.PatchEffectPayload
5 | ): { type: string; payload: RPE.PatchEffectPayload } => ({
6 | type: REDUX_PENDING_EFFECTS_PATCH_EFFECT,
7 | payload
8 | });
9 |
--------------------------------------------------------------------------------
/example-app/src/theme.ts:
--------------------------------------------------------------------------------
1 | import { dark } from 'grommet';
2 | import { deepMerge } from 'grommet/utils';
3 |
4 | export const theme = deepMerge(dark, {
5 | global: {
6 | colors: {
7 | background: '#2a2a2a'
8 | }
9 | },
10 | formField: {
11 | focus: {
12 | border: {
13 | color: 'status-ok'
14 | }
15 | }
16 | }
17 | });
18 |
--------------------------------------------------------------------------------
/lib/src/store/selector.ts:
--------------------------------------------------------------------------------
1 | import { REDUX_PENDING_EFFECTS } from '../helpers/const';
2 |
3 | export const selectIsPending = (state: TState): boolean => {
4 | const pendingState = (state as any)[REDUX_PENDING_EFFECTS] as RPE.State;
5 | const { effectsEntity } = pendingState;
6 | const { length: size } = Object.keys(effectsEntity);
7 | return size > 0;
8 | };
9 |
--------------------------------------------------------------------------------
/example-app/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | patentsActionsNames,
3 | libraryActionNames,
4 | astronomyPictureActionNames,
5 | marsRoverPhotosActionNames
6 | } from './actionNames';
7 | import { routes } from './routes';
8 |
9 | export {
10 | patentsActionsNames,
11 | libraryActionNames,
12 | astronomyPictureActionNames,
13 | marsRoverPhotosActionNames,
14 | routes
15 | };
16 |
--------------------------------------------------------------------------------
/example-app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Redux pending effects example app
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/lib/src/helpers/const.ts:
--------------------------------------------------------------------------------
1 | export const REDUX_PENDING_EFFECTS = '@@REDUX_PENDING_EFFECTS';
2 | export const REDUX_PENDING_EFFECTS_PATCH_EFFECT =
3 | '@@REDUX_PENDING_EFFECTS/PATCH_EFFECT';
4 | export const REDUX_PENDING_EFFECTS_IGNORED_ACTION_TYPES =
5 | '@@REDUX_PENDING_EFFECTS/IGNORED_ACTION_TYPES';
6 | export const effectTypes = {
7 | saga: 'redux-saga',
8 | promise: 'redux-pending-middleware',
9 | toolkit: 'redux-toolkit'
10 | };
11 |
--------------------------------------------------------------------------------
/example-app/src/pages/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Heading, Paragraph } from 'grommet';
3 |
4 | export const HomePage: React.FC = () => (
5 |
6 |
7 | Example Application
8 |
9 |
10 | This is the place where experiments conducted and shows how to use this
11 | library in a real-world application.
12 |
13 |
14 | );
15 |
--------------------------------------------------------------------------------
/.github/workflows/default.yaml:
--------------------------------------------------------------------------------
1 | name: 'default'
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | readiness:
11 | runs-on: macos-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 | - uses: actions/setup-node@v3
15 | name: 'Setup node'
16 | with:
17 | node-version: '16.x'
18 |
19 | - name: 'Install npm dependencies'
20 | run: yarn install
21 |
22 | - name: 'Test'
23 | run: yarn test --ci
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/---feature.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F339 Feature"
3 | about: Submit a feature request or share an interesting idea
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 | ---
8 |
9 | ### What is this feature?
10 |
11 | Feature details
12 |
13 | ### How the feature should work?
14 |
15 | A clear and concise description of what you want to happen.
16 |
17 | ### Is your feature request related to a problem?
18 |
19 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
20 |
--------------------------------------------------------------------------------
/example-app/src/components/AppContainer/AppContainer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BrowserRouter } from 'react-router-dom';
3 | import { Provider } from 'react-redux';
4 |
5 | import { store } from '../../store';
6 | import { App } from '../App/App';
7 | import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary';
8 |
9 | export const AppContainer = () => (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/example-app/src/types.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Global {
2 | type PatentDataShape = {
3 | id?: string;
4 | title: string;
5 | description: string;
6 | imageUrl: string;
7 | };
8 |
9 | type LibraryContentDataShape = {
10 | id: string;
11 | title: string;
12 | link: string;
13 | };
14 |
15 | type AstronomyPictureDataShape = {
16 | title: string;
17 | imageUrl: string;
18 | description: string;
19 | };
20 |
21 | type MarsRoverPhotoDataShape = {
22 | id: number;
23 | imageUrl: string;
24 | };
25 | }
26 |
--------------------------------------------------------------------------------
/example-app/src/components/ErrorBoundary/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { ErrorPage } from '../../pages/ErrorPage';
4 |
5 | export class ErrorBoundary extends React.Component<
6 | Record,
7 | { hasError: boolean }
8 | > {
9 | state = {
10 | hasError: false
11 | };
12 |
13 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
14 | this.setState({ hasError: true });
15 | }
16 |
17 | render() {
18 | return this.state.hasError ? : this.props.children;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/example-app/src/reducers/types.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Reducers {
2 | interface PatentsReducerState {
3 | patentsData: Global.PatentDataShape[];
4 | error: null | string;
5 | shouldPatentsUpdate: boolean;
6 | }
7 |
8 | interface LibraryReducerState {
9 | libraryData: Global.LibraryContentDataShape[];
10 | error: null | string;
11 | }
12 |
13 | interface AstronomyPictureReducerState {
14 | astronomyPictureData: Global.AstronomyPictureDataShape | null;
15 | error: null | string;
16 | shouldAstronomyPictureDataUpdate: boolean;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/example-app/src/components/Loader/Loader.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 |
3 | import styles from './Loader.module.scss';
4 |
5 | export const Loader = () => {
6 | const [spinnerInnerMarkup] = useState(
7 | Array.from(Array(7), (_, i) => )
8 | );
9 |
10 | return (
11 |
12 |
13 |
14 |
15 |
{spinnerInnerMarkup}
16 |
17 |
loading
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/example-app/src/pages/ErrorPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Paragraph } from 'grommet';
3 |
4 | type ErrorPageProps = {
5 | optionalMessage?: string;
6 | };
7 |
8 | export const ErrorPage: React.FC = ({ optionalMessage }) => (
9 |
10 |
11 | Ups... Something went wrong. Please, try again later.
12 |
13 | {optionalMessage ? (
14 |
15 | {optionalMessage}
16 |
17 | ) : null}
18 |
19 | );
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src",
4 | "target": "es5",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "declaration": false,
7 | "allowJs": true,
8 | "skipLibCheck": true,
9 | "esModuleInterop": true,
10 | "allowSyntheticDefaultImports": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react",
19 | "typeRoots": ["node_modules/@types"]
20 | },
21 | "include": ["src", "**/*.d.ts"]
22 | }
23 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/---bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F41B Bug Report"
3 | about: Report us an issue you found or something that you think is not working properly
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 | ---
8 |
9 | ### Description of the problem
10 |
11 | A clear and concise description of what the bug is.
12 |
13 | ### Steps To Reproduce
14 |
15 | Steps to reproduce the behavior
16 |
17 | ### Expected behavior
18 |
19 | A clear and concise description of what you expected to happen.
20 |
21 | ### Screenshots
22 |
23 | If applicable, add screenshots to help explain your problem.
24 |
25 | ### Versions:
26 |
27 | - OS:
28 | - Browser:
29 | - Monoreact:
30 |
--------------------------------------------------------------------------------
/example-app/src/reducers/rootReducer.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { includePendingReducer } from 'redux-pending-effects';
3 |
4 | import { patentsReducer } from './patentsReducer';
5 | import { libraryReducer } from './libraryReducer';
6 | import { astronomyPictureReducer } from './astronomyPictureReducer';
7 | import { marsRoverPhotosReducer } from './marsRoverPhotosReducer';
8 |
9 | export const rootReducer = combineReducers(
10 | includePendingReducer({
11 | patentsReducer,
12 | libraryReducer,
13 | astronomyPictureReducer,
14 | marsRoverPhotosReducer
15 | })
16 | );
17 |
18 | export type RootState = ReturnType;
19 |
--------------------------------------------------------------------------------
/lib/src/middlewares/toolkit.middleware.ts:
--------------------------------------------------------------------------------
1 | import { AnyAction, Dispatch, MiddlewareAPI } from 'redux';
2 |
3 | import { patchEffect } from '../store/actions';
4 | import { effectTypes } from '../helpers/const';
5 |
6 | export const pendingToolkitMiddleware =
7 | ({ dispatch }: MiddlewareAPI) =>
8 | (next: Dispatch) =>
9 | (action: AnyAction): AnyAction => {
10 | const requestId = action?.meta?.requestId;
11 |
12 | if (requestId !== undefined) {
13 | dispatch(
14 | patchEffect({
15 | effectId: requestId,
16 | effectType: effectTypes.toolkit,
17 | actionType: action.type
18 | })
19 | );
20 | }
21 |
22 | return next(action);
23 | };
24 |
--------------------------------------------------------------------------------
/lib/src/typings.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace RPE {
2 | interface State {
3 | effectsEntity: Record;
4 | ignoredActionTypes: null | string[];
5 | }
6 |
7 | interface PayloadAction {
8 | type: T;
9 | payload: P;
10 | }
11 |
12 | type PatchEffectPayload = {
13 | effectId: string;
14 | effectType: string;
15 | actionType: string;
16 | };
17 |
18 | interface ConfigureOptions {
19 | promise: boolean;
20 | toolkit: boolean;
21 | saga: boolean;
22 | ignoredActionTypes: string[];
23 | }
24 |
25 | interface ConfigureOutput {
26 | middlewares: T[];
27 | sagaOptions: {
28 | effectMiddlewares: K[];
29 | };
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/example-app/src/reducers/libraryReducer.ts:
--------------------------------------------------------------------------------
1 | import { createReducer } from '@reduxjs/toolkit';
2 |
3 | import { libraryActionNames } from '../constants';
4 |
5 | const defaultState: Reducers.LibraryReducerState = {
6 | libraryData: [],
7 | error: null
8 | };
9 |
10 | export const libraryReducer = createReducer(defaultState, {
11 | [libraryActionNames.FULFILLED]: (
12 | state,
13 | { payload }: { payload: Global.LibraryContentDataShape[] }
14 | ) => ({
15 | libraryData: payload,
16 | error: null
17 | }),
18 | [libraryActionNames.REJECTED]: (
19 | state,
20 | { error }: { error: { message: string } }
21 | ) => ({
22 | libraryData: [],
23 | error: error.message
24 | })
25 | });
26 |
--------------------------------------------------------------------------------
/example-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src",
4 | "target": "es5",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "declaration": false,
7 | "allowJs": true,
8 | "skipLibCheck": true,
9 | "esModuleInterop": true,
10 | "allowSyntheticDefaultImports": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 | "typeRoots": ["node_modules/@types"],
20 | "noFallthroughCasesInSwitch": true
21 | },
22 | "include": ["src", "**/*.d.ts"]
23 | }
24 |
--------------------------------------------------------------------------------
/lib/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "rootDir": "./src",
4 | "sourceMap": true,
5 | "declaration": true,
6 | "jsx": "react",
7 | "target": "es5",
8 | "lib": ["dom", "dom.iterable", "esnext"],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "types": ["jest", "monoreact"],
21 | "typeRoots": ["node_modules/@types"]
22 | },
23 | "include": ["src"]
24 | }
25 |
--------------------------------------------------------------------------------
/example-app/src/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
2 | import { configurePendingEffects } from 'redux-pending-effects';
3 | import createSagaMiddleware from '@redux-saga/core';
4 |
5 | import { rootReducer as reducer } from './reducers/rootReducer';
6 | import { rootSaga } from './sagas';
7 |
8 | export const { middlewares, sagaOptions } = configurePendingEffects({
9 | promise: true,
10 | toolkit: true,
11 | saga: true
12 | });
13 | export const sagaMiddleware = createSagaMiddleware(sagaOptions);
14 | export const defaultMiddlewares = getDefaultMiddleware({
15 | serializableCheck: false
16 | });
17 | export const middleware = [
18 | ...middlewares,
19 | sagaMiddleware,
20 | ...defaultMiddlewares
21 | ];
22 |
23 | export const store = configureStore({ reducer, middleware });
24 |
25 | sagaMiddleware.run(rootSaga);
26 |
--------------------------------------------------------------------------------
/lib/src/middlewares/ignoreActionTypes.middleware.ts:
--------------------------------------------------------------------------------
1 | import { AnyAction, Dispatch, MiddlewareAPI } from 'redux';
2 | import {
3 | REDUX_PENDING_EFFECTS,
4 | REDUX_PENDING_EFFECTS_IGNORED_ACTION_TYPES
5 | } from '../helpers/const';
6 |
7 | export const getIgnoreActionTypesMiddleware =
8 | (ignoredActionTypes: string[]) =>
9 | ({ getState, dispatch }: MiddlewareAPI) =>
10 | (next: Dispatch) =>
11 | (action: AnyAction): AnyAction => {
12 | const state = getState();
13 | const rpeState: RPE.State = state[REDUX_PENDING_EFFECTS];
14 |
15 | if (
16 | rpeState.ignoredActionTypes === null &&
17 | action.type !== REDUX_PENDING_EFFECTS_IGNORED_ACTION_TYPES
18 | ) {
19 | dispatch({
20 | type: REDUX_PENDING_EFFECTS_IGNORED_ACTION_TYPES,
21 | payload: ignoredActionTypes
22 | });
23 | }
24 |
25 | return next(action);
26 | };
27 |
--------------------------------------------------------------------------------
/example-app/src/constants/actionNames.ts:
--------------------------------------------------------------------------------
1 | export const patentsActionsNames = {
2 | GET: 'GET_PATENTS',
3 | PENDING: 'GET_PATENTS_PENDING',
4 | FULFILLED: 'GET_PATENTS_FULFILLED',
5 | REJECTED: 'GET_PATENTS_REJECTED'
6 | };
7 |
8 | export const libraryActionNames = {
9 | GET: 'GET_LIBRARY_CONTENT',
10 | PENDING: 'GET_LIBRARY_CONTENT/pending',
11 | FULFILLED: 'GET_LIBRARY_CONTENT/fulfilled',
12 | REJECTED: 'GET_LIBRARY_CONTENT/rejected'
13 | };
14 |
15 | export const astronomyPictureActionNames = {
16 | GET: 'GET_APOD',
17 | PENDING: 'GET_APOD_PENDING',
18 | FULFILLED: 'GET_APOD_FULFILLED',
19 | REJECTED: 'GET_APOD_REJECTED'
20 | };
21 |
22 | export const marsRoverPhotosActionNames = {
23 | GET: 'GET_MARS_ROVER_PHOTOS',
24 | PENDING: 'GET_MARS_ROVER_PHOTOS_PENDING',
25 | FULFILLED: 'GET_MARS_ROVER_PHOTOS_FULFILLED',
26 | REJECTED: 'GET_MARS_ROVER_PHOTOS_REJECTED'
27 | };
28 |
--------------------------------------------------------------------------------
/example-app/src/sagas/astronomyPictureSagas.ts:
--------------------------------------------------------------------------------
1 | import { call, put, takeEvery } from '@redux-saga/core/effects';
2 |
3 | import { astronomyPictureActionNames } from '../constants';
4 | import { nasaService } from '../services/NasaService';
5 | import {
6 | getAstronomyPictureDataLoaded,
7 | getAstronomyPictureDataRejected
8 | } from '../actions';
9 |
10 | export function* astronomyPictureWatcher() {
11 | yield takeEvery(astronomyPictureActionNames.GET, astronomyPictureWorker);
12 | }
13 |
14 | export function* astronomyPictureWorker() {
15 | try {
16 | const astronomyPictureData: Global.AstronomyPictureDataShape = yield call(
17 | nasaService.getAstronomyPictureData
18 | );
19 |
20 | yield put(getAstronomyPictureDataLoaded(astronomyPictureData));
21 | } catch (e) {
22 | yield put(
23 | getAstronomyPictureDataRejected(
24 | (e as Error).message || (e as Response).statusText
25 | )
26 | );
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/example-app/src/reducers/patentsReducer.ts:
--------------------------------------------------------------------------------
1 | import { AnyAction } from 'redux';
2 |
3 | import { patentsActionsNames } from '../constants';
4 |
5 | const defaultState: Reducers.PatentsReducerState = {
6 | patentsData: [],
7 | error: null,
8 | shouldPatentsUpdate: true
9 | };
10 |
11 | export const patentsReducer = (
12 | state = defaultState,
13 | action: Actions.PatentsTypes | AnyAction
14 | ) => {
15 | switch (action.type) {
16 | case patentsActionsNames.FULFILLED:
17 | return {
18 | patentsData: (action as Actions.GetPatentsFulFilled).payload,
19 | error: null,
20 | shouldPatentsUpdate: false
21 | };
22 | case patentsActionsNames.REJECTED:
23 | return {
24 | patentsData: [],
25 | error: `Error: ${
26 | (action as Actions.GetPatentsRejected).payload.statusText
27 | }`,
28 | shouldPatentsUpdate: false
29 | };
30 | default:
31 | return state;
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | **/node_modules/
3 | **/.docz
4 | **/dist
5 | **/build
6 | **/.cache
7 | **/.eslintcache
8 |
9 | # reporters
10 | /report
11 |
12 | # IDEs and editors
13 | /.idea
14 | .project
15 | .classpath
16 | .c9/
17 | *.launch
18 | .settings/
19 | *.sublime-workspace
20 |
21 | # IDE - VSCode
22 | .vscode/*
23 | !.vscode/settings.json
24 | !.vscode/tasks.json
25 | !.vscode/launch.json
26 | !.vscode/extensions.json
27 |
28 | # misc
29 | /.sass-cache
30 | /connect.lock
31 | /coverage
32 | /libpeerconnection.log
33 | npm-debug.log
34 | yarn-error.log
35 | testem.log
36 | /typings
37 |
38 | # System Files
39 | .DS_Store
40 | Thumbs.db
41 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
42 |
43 | # dependencies
44 | /.pnp
45 | .pnp.js
46 |
47 | # testing
48 | /coverage
49 |
50 | # misc
51 | .DS_Store
52 | .env.local
53 | .env.development.local
54 | .env.test.local
55 | .env.production.local
56 |
57 | npm-debug.log*
58 | yarn-debug.log*
59 | yarn-error.log*
60 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ['@typescript-eslint', 'sonarjs'],
3 | extends: [
4 | 'eslint:recommended',
5 | 'plugin:prettier/recommended',
6 | 'plugin:sonarjs/recommended',
7 | 'plugin:@typescript-eslint/recommended'
8 | ],
9 | rules: {
10 | semi: [2, 'always'],
11 | quotes: 0,
12 | 'consistent-return': 0,
13 | 'no-useless-catch': 0,
14 | 'sonarjs/no-useless-catch': 0,
15 | 'sonarjs/cognitive-complexity': 0,
16 | 'sonarjs/no-duplicated-branches': 0,
17 | 'spaced-comment': 0,
18 | 'no-empty-function': 2,
19 | 'no-useless-constructor': 0,
20 | 'lines-between-class-members': 2,
21 | '@typescript-eslint/explicit-function-return-type': 0,
22 | '@typescript-eslint/no-redeclare': 0,
23 | '@typescript-eslint/no-explicit-any': 0,
24 | '@typescript-eslint/no-namespace': 0,
25 | '@typescript-eslint/no-unused-expressions': 0
26 | },
27 | env: {
28 | browser: true,
29 | node: true,
30 | jest: true
31 | }
32 | };
33 |
--------------------------------------------------------------------------------
/example-app/src/components/CustomMenu/CustomMenu.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Close, Menu as HamburgerMenuIcon } from 'grommet-icons';
3 | import { Box, ButtonType, Menu, MenuProps } from 'grommet';
4 | import { Omit } from 'grommet/utils';
5 |
6 | type MenuState = {
7 | drop: boolean;
8 | hover: boolean;
9 | };
10 |
11 | type CustomMenuProps = MenuProps &
12 | Omit & {
13 | iconColorOnHover: string;
14 | };
15 |
16 | export const CustomMenu: React.FC = ({
17 | iconColorOnHover,
18 | ...menuProps
19 | }) => (
20 |
36 | );
37 |
--------------------------------------------------------------------------------
/lib/src/helpers/nanoid.utils.ts:
--------------------------------------------------------------------------------
1 | // Borrowed from https://github.com/ai/nanoid/tree/master/non-secure and @reduxjs/toolkit
2 | // This alphabet uses a-z A-Z 0-9 _- symbols.
3 | // Symbols are generated for smaller size.
4 | // -_zyxwvutsrqponmlkjihgfedcba9876543210ZYXWVUTSRQPONMLKJIHGFEDCBA
5 | let url = '-_';
6 | // Loop from 36 to 0 (from z to a and 9 to 0 in Base36).
7 | let i = 36;
8 | while (i--) {
9 | // 36 is radix. Number.prototype.toString(36) returns number
10 | // in Base36 representation. Base36 is like hex, but it uses 0–9 and a-z.
11 | url += i.toString(36);
12 | }
13 | // Loop from 36 to 10 (from Z to A in Base36).
14 | i = 36;
15 | while (i-- - 10) {
16 | url += i.toString(36).toUpperCase();
17 | }
18 |
19 | export const nanoid = (size = 21): string => {
20 | let id = '';
21 | // Compact alternative for `for (var i = 0; i < size; i++)`
22 | while (size--) {
23 | // `| 0` is compact and faster alternative for `Math.floor()`
24 | id += url[(Math.random() * 64) | 0];
25 | }
26 | return id;
27 | };
28 |
--------------------------------------------------------------------------------
/example-app/src/pages/PatentsPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 |
4 | import { getPatents } from '../actions';
5 | import { RootState } from '../reducers/rootReducer';
6 | import { PatentItem } from '../components/PatentItem/PatentItem';
7 | import { ErrorPage } from './ErrorPage';
8 |
9 | export const PatentsPage: React.FC = () => {
10 | const dispatch = useDispatch();
11 | const { shouldPatentsUpdate, patentsData, error } = useSelector<
12 | RootState,
13 | Reducers.PatentsReducerState
14 | >(state => state.patentsReducer);
15 |
16 | useEffect(() => {
17 | if (shouldPatentsUpdate) {
18 | dispatch(getPatents());
19 | }
20 | }, [dispatch, shouldPatentsUpdate]);
21 |
22 | return error ? (
23 |
24 | ) : (
25 |
26 | {patentsData.map(({ id, ...rest }: Global.PatentDataShape) => (
27 |
28 | ))}
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/example-app/src/components/Header/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { Box, Heading, Anchor } from 'grommet';
4 |
5 | import { NavList } from '../NavList/NavList';
6 | import { CustomMenu } from '../CustomMenu/CustomMenu';
7 | import { routes } from '../../constants';
8 |
9 | export const Header: React.FC = () => (
10 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | }]}
30 | />
31 |
32 |
33 | );
34 |
--------------------------------------------------------------------------------
/example-app/src/components/LibrarySearchingContent/LibrarySearchingContent.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Paragraph } from 'grommet';
3 |
4 | type LibrarySearchingContentType = {
5 | content: Global.LibraryContentDataShape[];
6 | };
7 |
8 | export const LibrarySearchingContent: React.FC = ({
9 | content
10 | }) =>
11 | content.length ? (
12 |
20 | {content.map(({ id, title, link }: Global.LibraryContentDataShape) => (
21 |
33 | ))}
34 |
35 | ) : (
36 |
37 | No matching found
38 |
39 | );
40 |
--------------------------------------------------------------------------------
/example-app/src/reducers/astronomyPictureReducer.ts:
--------------------------------------------------------------------------------
1 | import { AnyAction } from 'redux';
2 |
3 | import { astronomyPictureActionNames } from '../constants';
4 |
5 | const defaultState: Reducers.AstronomyPictureReducerState = {
6 | astronomyPictureData: null,
7 | error: null,
8 | shouldAstronomyPictureDataUpdate: true
9 | };
10 |
11 | export const astronomyPictureReducer = (
12 | state = defaultState,
13 | action: Actions.AstronomyPictureTypes | AnyAction
14 | ) => {
15 | switch (action.type) {
16 | case astronomyPictureActionNames.FULFILLED:
17 | return {
18 | astronomyPictureData: (action as Actions.getAstronomyPictureFulFilled)
19 | .payload,
20 | error: null,
21 | shouldAstronomyPictureDataUpdate: false
22 | };
23 | case astronomyPictureActionNames.REJECTED:
24 | return {
25 | astronomyPictureData: null,
26 | error: (action as Actions.getAstronomyPictureFulRejected).payload
27 | .errorMessage,
28 | shouldAstronomyPictureDataUpdate: false
29 | };
30 | default:
31 | return state;
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/example-app/src/reducers/marsRoverPhotosReducer.ts:
--------------------------------------------------------------------------------
1 | import { AnyAction } from 'redux';
2 |
3 | import { marsRoverPhotosActionNames } from '../constants';
4 |
5 | export type MarsRoverPhotosReducerState = {
6 | photosData: any;
7 | error: null | string;
8 | loading: boolean;
9 | shouldPhotosDataUpdate: boolean;
10 | };
11 |
12 | const defaultState: MarsRoverPhotosReducerState = {
13 | photosData: [],
14 | error: null,
15 | loading: false,
16 | shouldPhotosDataUpdate: true
17 | };
18 |
19 | export const marsRoverPhotosReducer = (
20 | state = defaultState,
21 | action: Actions.MarsRoverPhotosTypes | AnyAction
22 | ) => {
23 | switch (action.type) {
24 | case marsRoverPhotosActionNames.PENDING:
25 | return {
26 | ...state,
27 | loading: true,
28 | shouldPhotosDataUpdate: false
29 | };
30 | case marsRoverPhotosActionNames.FULFILLED:
31 | return {
32 | ...state,
33 | loading: false,
34 | photosData: (action as Actions.MarsRoverPhotosFulFilled).payload
35 | };
36 | case marsRoverPhotosActionNames.REJECTED:
37 | return {
38 | ...state,
39 | loading: false,
40 | error: (action as Actions.MarsRoverPhotosRejected).payload
41 | };
42 | default:
43 | return state;
44 | }
45 | };
46 |
--------------------------------------------------------------------------------
/example-app/src/components/PatentItem/PatentItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Grid, Heading, Paragraph } from 'grommet';
3 | import HtmlParser from 'react-html-parser';
4 |
5 | export const PatentItem: React.FC = ({
6 | title,
7 | description,
8 | imageUrl
9 | }) => (
10 |
22 |
28 |
29 | {HtmlParser(title)}
30 |
31 |
32 |
44 |
45 | {HtmlParser(description)}
46 |
47 |
48 | );
49 |
--------------------------------------------------------------------------------
/lib/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-pending-effects",
3 | "description": "🦋 A redux toolkit that tracks your asynchronous redux actions (effects) and informs about the pending state using the selector function",
4 | "version": "1.0.5",
5 | "private": false,
6 | "license": "MIT",
7 | "author": {
8 | "email": "tarsis.maksym@gmail.com",
9 | "name": "Max Tarsis",
10 | "url": "https://github.com/tarsinzer"
11 | },
12 | "main": "dist/bundle.cjs.js",
13 | "module": "dist/bundle.js",
14 | "source": "src/publicApi.ts",
15 | "typings": "dist/publicApi.d.ts",
16 | "scripts": {
17 | "build": "monoreact build",
18 | "start": "monoreact watch",
19 | "test": "monoreact test --passWithNoTests",
20 | "lint": "monoreact lint",
21 | "prepublishOnly": "yarn build"
22 | },
23 | "peerDependencies": {
24 | "redux": "^4.1.0"
25 | },
26 | "optionalDependencies": {
27 | "redux-saga": "^1.1.3"
28 | },
29 | "keywords": [
30 | "redux",
31 | "pending",
32 | "async",
33 | "action",
34 | "middleware",
35 | "promise",
36 | "effect",
37 | "side-effect",
38 | "redux-promise",
39 | "redux-pending",
40 | "redux-middleware",
41 | "redux-effects",
42 | "redux-toolkit",
43 | "redux-saga",
44 | "redux-thunk",
45 | "redux-promise-middleware"
46 | ],
47 | "publishConfig": {
48 | "access": "public"
49 | },
50 | "workspace": true
51 | }
52 |
--------------------------------------------------------------------------------
/example-app/src/pages/AstronomyPicturePage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { Box, Heading, Paragraph, Image } from 'grommet';
3 | import { useDispatch, useSelector } from 'react-redux';
4 |
5 | import { RootState } from '../reducers/rootReducer';
6 | import { ErrorPage } from './ErrorPage';
7 | import { getAstronomyPictureData } from '../actions';
8 |
9 | export const AstronomyPicturePage = () => {
10 | const dispatch = useDispatch();
11 | const { astronomyPictureData, error, shouldAstronomyPictureDataUpdate } =
12 | useSelector(
13 | state => state.astronomyPictureReducer
14 | );
15 |
16 | useEffect(() => {
17 | if (shouldAstronomyPictureDataUpdate) {
18 | dispatch(getAstronomyPictureData);
19 | }
20 | }, [dispatch, shouldAstronomyPictureDataUpdate]);
21 |
22 | return error ? (
23 |
24 | ) : (
25 |
26 |
27 | Astronomy Picture of the Day
28 |
29 | {astronomyPictureData?.title}
30 |
31 |
32 |
33 |
34 | {astronomyPictureData?.description}
35 |
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/lib/src/configurePendingEffects.ts:
--------------------------------------------------------------------------------
1 | import { Middleware } from 'redux';
2 | import { EffectMiddleware } from '@redux-saga/core';
3 |
4 | import { getIgnoreActionTypesMiddleware } from './middlewares/ignoreActionTypes.middleware';
5 | import { pendingPromiseMiddleware } from './middlewares/promise.middleware';
6 | import { pendingToolkitMiddleware } from './middlewares/toolkit.middleware';
7 | import { pendingSagaMiddleware } from './middlewares/saga.middleware';
8 |
9 | const defaultConfigureOptions = {
10 | promise: false,
11 | toolkit: false,
12 | saga: false,
13 | ignoredActionTypes: []
14 | };
15 |
16 | export const configurePendingEffects = (
17 | configureOptions: RPE.ConfigureOptions = defaultConfigureOptions
18 | ): RPE.ConfigureOutput => {
19 | const { promise, toolkit, saga, ignoredActionTypes } = configureOptions;
20 | const middlewares = [];
21 | const sagaOptions: { effectMiddlewares: EffectMiddleware[] } = {
22 | effectMiddlewares: []
23 | };
24 |
25 | if (ignoredActionTypes?.length) {
26 | middlewares.push(getIgnoreActionTypesMiddleware(ignoredActionTypes));
27 | }
28 |
29 | if (promise) {
30 | middlewares.push(pendingPromiseMiddleware);
31 | }
32 |
33 | if (toolkit) {
34 | middlewares.push(pendingToolkitMiddleware);
35 | }
36 |
37 | if (saga) {
38 | sagaOptions.effectMiddlewares.push(pendingSagaMiddleware);
39 | }
40 |
41 | return { middlewares, sagaOptions };
42 | };
43 |
--------------------------------------------------------------------------------
/lib/src/middlewares/saga.middleware.ts:
--------------------------------------------------------------------------------
1 | import { AnyAction } from 'redux';
2 | import { EffectMiddleware } from '@redux-saga/core';
3 | import { put, effectTypes as sagaEffectTypes } from '@redux-saga/core/effects';
4 |
5 | import { patchEffect } from '../store/actions';
6 | import { nanoid } from '../helpers/nanoid.utils';
7 | import { effectTypes } from '../helpers/const';
8 |
9 | const trackWorker = (
10 | worker: (action: AnyAction) => any
11 | ): ((action: AnyAction) => Generator) =>
12 | function* wrapper(action: AnyAction) {
13 | const effectId = nanoid();
14 | const { type: actionType } = action;
15 | const effectType = effectTypes.saga;
16 | const patchEffectPayload = { effectId, effectType, actionType };
17 |
18 | if (!put) {
19 | throw new Error('trackWorker expects installed redux-saga lib.');
20 | }
21 |
22 | try {
23 | yield put(patchEffect(patchEffectPayload));
24 | yield* worker(action);
25 | yield put(patchEffect(patchEffectPayload));
26 | } catch (e) {
27 | yield put(patchEffect(patchEffectPayload));
28 | }
29 | };
30 |
31 | export const pendingSagaMiddleware: EffectMiddleware = next => effect => {
32 | if (effect.type === sagaEffectTypes.FORK) {
33 | const effectArgs = effect.payload.args;
34 | const sagaWorkerArgIndex = 1;
35 |
36 | effectArgs[sagaWorkerArgIndex] = trackWorker(
37 | effectArgs[sagaWorkerArgIndex]
38 | );
39 | }
40 |
41 | return next(effect);
42 | };
43 |
--------------------------------------------------------------------------------
/example-app/src/components/NavList/NavList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { NavLink } from 'react-router-dom';
3 | import { Anchor, Box } from 'grommet';
4 |
5 | import styles from './NavList.module.scss';
6 | import { routes } from '../../constants';
7 |
8 | type NavLinkProps = {
9 | to: string;
10 | label: string;
11 | end?: boolean;
12 | };
13 |
14 | const navLinkProps: NavLinkProps[] = [
15 | {
16 | to: routes.HOME,
17 | label: 'Home',
18 | end: true
19 | },
20 | {
21 | to: routes.PATENTS,
22 | label: 'NASA Patents'
23 | },
24 | {
25 | to: routes.LIBRARY,
26 | label: 'NASA Library'
27 | },
28 | {
29 | to: routes.ASTRONOMY_PICTURE,
30 | label: 'APOD'
31 | },
32 | {
33 | to: routes.MARS_ROVER_PHOTOS,
34 | label: 'Mars Rover Photos'
35 | }
36 | ];
37 |
38 | export const NavList: React.FC = () => {
39 | const handleNavLinkClassName: (props: { isActive: boolean }) => string =
40 | React.useCallback(
41 | ({ isActive }) => (isActive ? styles.navListItemActiveLink : ''),
42 | []
43 | );
44 |
45 | return (
46 |
47 | {navLinkProps.map(({ label, ...rest }) => (
48 |
49 |
50 |
51 |
52 |
53 | ))}
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/example-app/src/pages/MarsRoverPhotosPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { Box } from 'grommet';
3 | import { useDispatch, useSelector } from 'react-redux';
4 |
5 | import { nasaService } from '../services/NasaService';
6 | import { getMarsRoverPhotos } from '../actions';
7 | import { RootState } from '../reducers/rootReducer';
8 | import { MarsRoverPhotosReducerState } from '../reducers/marsRoverPhotosReducer';
9 | import { Loader } from '../components/Loader/Loader';
10 | import { ErrorPage } from './ErrorPage';
11 |
12 | export const MarsRoverPhotosPage = () => {
13 | const dispatch = useDispatch();
14 | const { shouldPhotosDataUpdate, photosData, error, loading } = useSelector<
15 | RootState,
16 | MarsRoverPhotosReducerState
17 | >(state => state.marsRoverPhotosReducer);
18 |
19 | useEffect(() => {
20 | if (shouldPhotosDataUpdate) {
21 | dispatch(getMarsRoverPhotos(nasaService));
22 | }
23 | }, [dispatch, shouldPhotosDataUpdate]);
24 |
25 | if (loading) {
26 | return ;
27 | }
28 |
29 | return error ? (
30 |
31 | ) : (
32 |
40 | {photosData.map(({ id, imageUrl }: Global.MarsRoverPhotoDataShape) => (
41 |
52 | ))}
53 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/example-app/src/components/App/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { selectIsPending } from 'redux-pending-effects';
4 | import { Routes, Route } from 'react-router-dom';
5 | import { Grommet, Box } from 'grommet';
6 |
7 | import { Header } from '../Header/Header';
8 | import { HomePage } from '../../pages/HomePage';
9 | import { PatentsPage } from '../../pages/PatentsPage';
10 | import { LibraryPage } from '../../pages/LibraryPage';
11 | import { AstronomyPicturePage } from '../../pages/AstronomyPicturePage';
12 | import { theme } from '../../theme';
13 | import { Loader } from '../Loader/Loader';
14 | import { routes } from '../../constants';
15 | import { MarsRoverPhotosPage } from '../../pages/MarsRoverPhotosPage';
16 |
17 | export const App: React.FC = () => {
18 | const isPending = useSelector(selectIsPending);
19 |
20 | return (
21 |
22 |
23 | {isPending ? (
24 |
25 | ) : (
26 |
34 |
35 | } path={routes.HOME} />
36 | } path={routes.PATENTS} />
37 | } path={routes.LIBRARY} />
38 | }
40 | path={routes.ASTRONOMY_PICTURE}
41 | />
42 | }
44 | path={routes.MARS_ROVER_PHOTOS}
45 | />
46 |
47 |
48 | )}
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/example-app/src/actions/types.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Actions {
2 | enum PATENTS_ACTION_TYPES {
3 | GET = 'GET_PATENTS',
4 | PENDING = 'GET_PATENTS_PENDING',
5 | FULFILLED = 'GET_PATENTS_FULFILLED',
6 | REJECTED = 'GET_PATENTS_REJECTED'
7 | }
8 |
9 | interface GetPatents {
10 | type: ReturnType;
11 | payload: Promise;
12 | }
13 |
14 | interface GetPatentsPending {
15 | type: ReturnType;
16 | }
17 |
18 | interface GetPatentsFulFilled {
19 | type: ReturnType;
20 | payload: Global.PatentDataShape[];
21 | }
22 |
23 | interface GetPatentsRejected {
24 | type: ReturnType;
25 | payload: { statusText: string };
26 | }
27 |
28 | type PatentsTypes =
29 | | GetPatents
30 | | GetPatentsPending
31 | | GetPatentsFulFilled
32 | | GetPatentsRejected;
33 |
34 | interface getAstronomyPicture {
35 | type: string;
36 | }
37 |
38 | interface getAstronomyPictureFulFilled {
39 | type: string;
40 | payload: Global.AstronomyPictureDataShape;
41 | }
42 |
43 | interface getAstronomyPictureFulRejected {
44 | type: string;
45 | payload: {
46 | errorMessage: string;
47 | };
48 | }
49 |
50 | type AstronomyPictureTypes =
51 | | getAstronomyPicture
52 | | getAstronomyPictureFulFilled
53 | | getAstronomyPictureFulRejected;
54 |
55 | interface MarsRoverPhotosPending {
56 | type: string;
57 | }
58 |
59 | interface MarsRoverPhotosFulFilled {
60 | type: string;
61 | payload: Global.MarsRoverPhotoDataShape[];
62 | }
63 |
64 | interface MarsRoverPhotosRejected {
65 | type: string;
66 | payload: string;
67 | }
68 |
69 | type MarsRoverPhotosTypes =
70 | | MarsRoverPhotosPending
71 | | MarsRoverPhotosFulFilled
72 | | MarsRoverPhotosRejected;
73 | }
74 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-pending-effects-workspace",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "author": {
7 | "name": "Max Tarsis",
8 | "email": "tarsis.maksym@gmail.com",
9 | "url": "https://github.com/tarsinzer"
10 | },
11 | "workspaces": [
12 | "lib"
13 | ],
14 | "scripts": {
15 | "build": "cd lib && yarn build",
16 | "start": "cd lib && yarn start",
17 | "lint": "cd lib && yarn lint",
18 | "test": "cd example-app && yarn test",
19 | "postinstall": "yarn example:install && yarn build",
20 | "example:install": "cd example-app && yarn install"
21 | },
22 | "devDependencies": {
23 | "@typescript-eslint/eslint-plugin": "5.41.0",
24 | "@typescript-eslint/parser": "5.41.0",
25 | "eslint": "8.16.0",
26 | "eslint-config-prettier": "8.5.0",
27 | "eslint-config-react-app": "6.0.0",
28 | "eslint-plugin-compat": "4.0.2",
29 | "eslint-plugin-flowtype": "8.0.3",
30 | "eslint-plugin-import": "2.26.0",
31 | "eslint-plugin-jsx-a11y": "6.5.1",
32 | "eslint-plugin-prettier": "4.0.0",
33 | "eslint-plugin-react": "7.33.2",
34 | "eslint-plugin-react-hooks": "4.5.0",
35 | "eslint-plugin-sonarjs": "0.13.0",
36 | "husky": "4.3.8",
37 | "lint-staged": "12.5.0",
38 | "monoreact": "0.34.0",
39 | "prettier": "2.6.2",
40 | "redux-pending-effects": "*",
41 | "webpack": "5.76.0",
42 | "yarn": "1.22.19"
43 | },
44 | "browserslist": {
45 | "production": [
46 | ">0.2%",
47 | "not dead",
48 | "not op_mini all"
49 | ],
50 | "development": [
51 | "last 1 chrome version",
52 | "last 1 firefox version",
53 | "last 1 safari version"
54 | ]
55 | },
56 | "keywords": [
57 | "redux",
58 | "pending",
59 | "async",
60 | "action",
61 | "middleware",
62 | "promise",
63 | "effect",
64 | "side-effect",
65 | "redux-promise",
66 | "redux-pending",
67 | "redux-middleware",
68 | "redux-effects",
69 | "redux-toolkit",
70 | "redux-saga",
71 | "redux-thunk",
72 | "redux-promise-middleware"
73 | ],
74 | "dependencies": {}
75 | }
76 |
--------------------------------------------------------------------------------
/lib/src/store/reducer.ts:
--------------------------------------------------------------------------------
1 | import { Action, AnyAction, Reducer, ReducersMapObject } from 'redux';
2 |
3 | import {
4 | REDUX_PENDING_EFFECTS,
5 | REDUX_PENDING_EFFECTS_IGNORED_ACTION_TYPES,
6 | REDUX_PENDING_EFFECTS_PATCH_EFFECT
7 | } from '../helpers/const';
8 |
9 | type PatchEffectPayloadAction = RPE.PayloadAction<
10 | typeof REDUX_PENDING_EFFECTS_PATCH_EFFECT,
11 | RPE.PatchEffectPayload
12 | >;
13 | type IgnoredActionTypesPayloadAction = RPE.PayloadAction<
14 | typeof REDUX_PENDING_EFFECTS_IGNORED_ACTION_TYPES,
15 | string[]
16 | >;
17 | type PayloadAction = PatchEffectPayloadAction | IgnoredActionTypesPayloadAction;
18 |
19 | const defaultState: RPE.State = {
20 | effectsEntity: {},
21 | ignoredActionTypes: null
22 | };
23 |
24 | const pendingReducer: Reducer = (
25 | state = defaultState,
26 | action: PayloadAction
27 | ): RPE.State => {
28 | if (action.type === REDUX_PENDING_EFFECTS_IGNORED_ACTION_TYPES) {
29 | return {
30 | ...state,
31 | ignoredActionTypes: (action as IgnoredActionTypesPayloadAction).payload
32 | };
33 | }
34 |
35 | if (action.type === REDUX_PENDING_EFFECTS_PATCH_EFFECT) {
36 | const { effectsEntity, ignoredActionTypes } = state;
37 | const {
38 | payload: { effectId, actionType }
39 | } = action as PatchEffectPayloadAction;
40 |
41 | if (
42 | ignoredActionTypes !== null &&
43 | ignoredActionTypes.includes(actionType)
44 | ) {
45 | return state;
46 | }
47 |
48 | let updatedEffectsEntity;
49 |
50 | if (effectsEntity[effectId] === undefined) {
51 | updatedEffectsEntity = { ...effectsEntity, [effectId]: actionType };
52 | } else {
53 | const { [effectId]: oldEffectId, ...restEffectsId } = effectsEntity;
54 | updatedEffectsEntity = restEffectsId;
55 | }
56 |
57 | return { ...state, effectsEntity: updatedEffectsEntity };
58 | }
59 |
60 | return state;
61 | };
62 |
63 | export function includePendingReducer(
64 | reducers: ReducersMapObject
65 | ): ReducersMapObject {
66 | (reducers as any)[REDUX_PENDING_EFFECTS] = pendingReducer;
67 | return reducers;
68 | }
69 |
--------------------------------------------------------------------------------
/example-app/src/pages/LibraryPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { Search } from 'grommet-icons';
4 | import { Box, Keyboard, TextInput } from 'grommet';
5 |
6 | import { RootState } from '../reducers/rootReducer';
7 | import { ErrorPage } from './ErrorPage';
8 | import { LibrarySearchingContent } from '../components/LibrarySearchingContent/LibrarySearchingContent';
9 | import { getLibraryContent } from '../actions';
10 |
11 | export const LibraryPage: React.FC = () => {
12 | const [value, setValue] = useState('');
13 | const [isValid, setIsValid] = useState(true);
14 | const dispatch = useDispatch();
15 | const { libraryData, error } = useSelector<
16 | RootState,
17 | Reducers.LibraryReducerState
18 | >(state => state.libraryReducer);
19 |
20 | const handleEnterOnInput = useCallback(
21 | value => {
22 | if (!value) {
23 | setIsValid(false);
24 |
25 | return;
26 | }
27 |
28 | dispatch(getLibraryContent(value));
29 | setIsValid(true);
30 | },
31 | [dispatch]
32 | );
33 |
34 | return (
35 |
36 |
50 |
51 | handleEnterOnInput(value)}>
52 | setValue(event.target.value)}
57 | />
58 |
59 |
60 | {error ? (
61 |
62 | ) : (
63 |
64 | )}
65 |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/example-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "workspaces": [
6 | "../lib"
7 | ],
8 | "scripts": {
9 | "start": "react-scripts start",
10 | "build": "react-scripts build",
11 | "test": "react-scripts test",
12 | "test:gui": "majestic",
13 | "lint": "monoreact lint"
14 | },
15 | "dependencies": {
16 | "@reduxjs/toolkit": "1.8.6",
17 | "grommet": "2.23.0",
18 | "grommet-icons": "4.7.0",
19 | "history": "5.3.0",
20 | "jest-fetch-mock": "3.0.3",
21 | "node-sass": "7.0.3",
22 | "react": "17.0.2",
23 | "react-dom": "17.0.2",
24 | "react-html-parser": "2.0.2",
25 | "react-redux": "8.0.2",
26 | "react-router-dom": "6.3.0",
27 | "react-scripts": "5.0.1",
28 | "redux": "4.2.0",
29 | "redux-pending-effects": "*",
30 | "redux-promise-middleware": "6.1.2",
31 | "redux-saga": "1.1.3",
32 | "redux-saga-tester": "1.0.874",
33 | "redux-thunk": "2.4.1",
34 | "styled-components": "5.3.5"
35 | },
36 | "devDependencies": {
37 | "@types/jest": "27.5.2",
38 | "@types/node": "17.0.35",
39 | "@types/react": "17.0.45",
40 | "@types/react-dom": "17.0.17",
41 | "@types/react-html-parser": "2.0.2",
42 | "@types/react-redux": "7.1.24",
43 | "@types/redux-mock-store": "1.0.3",
44 | "@typescript-eslint/parser": "5.41.0",
45 | "eslint-config-prettier": "8.5.0",
46 | "eslint-config-react-app": "6.0.0",
47 | "eslint-plugin-compat": "4.0.2",
48 | "eslint-plugin-prettier": "4.0.0",
49 | "eslint-plugin-sonarjs": "0.13.0",
50 | "majestic": "1.8.1",
51 | "prettier": "2.6.2",
52 | "redux-devtools-extension": "2.13.9",
53 | "redux-mock-store": "1.5.4",
54 | "tslib": "2.4.0",
55 | "typescript": "4.6.4",
56 | "yarn": "1.22.19"
57 | },
58 | "browserslist": {
59 | "production": [
60 | ">0.2%",
61 | "not dead",
62 | "not op_mini all"
63 | ],
64 | "development": [
65 | "last 1 chrome version",
66 | "last 1 firefox version",
67 | "last 1 safari version"
68 | ]
69 | },
70 | "jest": {
71 | "transformIgnorePatterns": [
72 | "/node_modules/(?!redux-pending-effects).js$"
73 | ]
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/example-app/src/actions/index.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, ThunkAction } from '@reduxjs/toolkit';
2 | import { AnyAction } from 'redux';
3 |
4 | import { nasaService } from '../services/NasaService';
5 | import {
6 | patentsActionsNames,
7 | libraryActionNames,
8 | astronomyPictureActionNames,
9 | marsRoverPhotosActionNames
10 | } from '../constants';
11 | import { RootState } from '../reducers/rootReducer';
12 |
13 | export const getPatents = (): Actions.GetPatents => ({
14 | type: patentsActionsNames.GET,
15 | payload: nasaService.getPatents()
16 | });
17 |
18 | export const getLibraryContent = createAsyncThunk(
19 | libraryActionNames.GET,
20 | async (requestValues: string) =>
21 | await nasaService.getLibraryContent(requestValues)
22 | );
23 |
24 | export const getAstronomyPictureData: Actions.AstronomyPictureTypes = {
25 | type: astronomyPictureActionNames.GET
26 | };
27 |
28 | export const getAstronomyPictureDataLoaded = (
29 | data: Global.AstronomyPictureDataShape
30 | ): Actions.AstronomyPictureTypes => ({
31 | type: astronomyPictureActionNames.FULFILLED,
32 | payload: data
33 | });
34 |
35 | export const getAstronomyPictureDataRejected = (
36 | errorMessage: string
37 | ): Actions.AstronomyPictureTypes => ({
38 | type: astronomyPictureActionNames.REJECTED,
39 | payload: {
40 | errorMessage
41 | }
42 | });
43 |
44 | export const getMarsRoverPhotosPending = (): Actions.MarsRoverPhotosTypes => ({
45 | type: marsRoverPhotosActionNames.PENDING
46 | });
47 |
48 | export const getMarsRoverPhotosFulFilled = (
49 | data: Global.MarsRoverPhotoDataShape[]
50 | ): Actions.MarsRoverPhotosTypes => ({
51 | type: marsRoverPhotosActionNames.FULFILLED,
52 | payload: data
53 | });
54 |
55 | export const getMarsRoverPhotosRejected = (
56 | error: string
57 | ): Actions.MarsRoverPhotosTypes => ({
58 | type: marsRoverPhotosActionNames.REJECTED,
59 | payload: error
60 | });
61 |
62 | export const getMarsRoverPhotos =
63 | (nasaService: any): ThunkAction =>
64 | async dispatch => {
65 | dispatch(getMarsRoverPhotosPending());
66 |
67 | try {
68 | const res = await nasaService.getMarsRoverPhotos();
69 |
70 | dispatch(getMarsRoverPhotosFulFilled(res));
71 | } catch (error) {
72 | dispatch(
73 | getMarsRoverPhotosRejected(
74 | (error as Error).message || (error as Response).statusText
75 | )
76 | );
77 | }
78 | };
79 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/example-app/src/reducers/testHelpers.ts:
--------------------------------------------------------------------------------
1 | import SagaTester from 'redux-saga-tester';
2 | import { MockParams } from 'jest-fetch-mock';
3 | import { configurePendingEffects } from 'redux-pending-effects';
4 | import { Middleware } from 'redux';
5 |
6 | import { rootReducer as reducer } from './rootReducer';
7 | import { defaultMiddlewares, sagaMiddleware, sagaOptions } from '../store';
8 | import {
9 | patentsActionsNames,
10 | libraryActionNames,
11 | astronomyPictureActionNames
12 | } from '../constants';
13 |
14 | type MockResponseParams = [string, MockParams];
15 |
16 | const apiKey = 'WmyhwhhQBZJIvTdIQ6KeYZUNenQY7Fazyd2nauB5';
17 |
18 | export const urls = {
19 | PATENTS: `https://api.nasa.gov/techtransfer/patent/?engine&api_key=${apiKey}`,
20 | LIBRARY_CONTENT: `https://api.nasa.gov/techtransfer/patent/?engine&api_key=${apiKey}`,
21 | ASTRONOMY_PICTURE: `https://api.nasa.gov/techtransfer/patent/?engine&api_key=${apiKey}`
22 | };
23 |
24 | export const patentsFetchMock: MockResponseParams = [
25 | JSON.stringify({ results: [] }),
26 | {
27 | url: urls.PATENTS
28 | }
29 | ];
30 | export const libraryContentFetchMock: MockResponseParams = [
31 | JSON.stringify({ collection: { items: [] } }),
32 | {
33 | url: urls.LIBRARY_CONTENT
34 | }
35 | ];
36 | export const astronomyPictureFetchMock: MockResponseParams = [
37 | JSON.stringify({ title: 'text', url: 'text', explanation: 'text' }),
38 | {
39 | url: urls.ASTRONOMY_PICTURE
40 | }
41 | ];
42 | export const rejectedFetchMockParam: Error = {
43 | name: '600',
44 | message: 'Some error'
45 | };
46 | export const createSagaTesterInstance = (middleware: Middleware[]) =>
47 | new SagaTester({
48 | initialState: undefined,
49 | reducers: reducer,
50 | middlewares: middleware,
51 | options: sagaOptions
52 | });
53 |
54 | const middlewares = [sagaMiddleware, ...defaultMiddlewares];
55 |
56 | const getPendingMiddlewaresWithIgnoredActionTypes = (
57 | actionNames: object
58 | ): Middleware[] => {
59 | const { middlewares } = configurePendingEffects({
60 | promise: true,
61 | toolkit: true,
62 | ignoredActionTypes: Object.values(actionNames)
63 | });
64 |
65 | return middlewares;
66 | };
67 |
68 | export const middlewaresWithPromiseActionsIgnored = [
69 | ...getPendingMiddlewaresWithIgnoredActionTypes(patentsActionsNames),
70 | ...middlewares
71 | ];
72 | export const middlewaresWithToolkitActionsIgnored = [
73 | ...getPendingMiddlewaresWithIgnoredActionTypes(libraryActionNames),
74 | ...middlewares
75 | ];
76 | export const middlewaresWithSagaActionsIgnored = [
77 | ...getPendingMiddlewaresWithIgnoredActionTypes(astronomyPictureActionNames),
78 | ...middlewares
79 | ];
80 |
81 | export const REDUX_PENDING_EFFECTS = '@@REDUX_PENDING_EFFECTS';
82 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 |
4 |
5 | ## Our Pledge
6 |
7 | We as contributors and maintainers, pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
8 |
9 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
10 |
11 |
12 |
13 | ## Our Standards
14 |
15 | Examples of behavior that contributes to creating a positive environment include:
16 |
17 | - Using welcoming and inclusive language
18 | - Being respectful of differing viewpoints and experiences
19 | - Gracefully accepting constructive criticism
20 | - Focusing on what is best for the community
21 | - Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | - The use of sexualized language or imagery and unwelcome sexual attention or advances
26 | - Trolling, insulting/derogatory comments, and personal or political attacks
27 | - Public or private harassment
28 | - Publishing others' private information, such as a physical or electronic address, without explicit permission
29 | - Other conduct which could reasonably be considered inappropriate in a professional setting
30 |
31 |
32 |
33 | ## Our Responsibilities
34 |
35 | Project maintainers are responsible for clarifying the standards of acceptable behavior and expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
36 |
37 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other not aligned contributions to this Code of Conduct. Also, possible to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
38 |
39 |
40 |
41 | ## Enforcement
42 |
43 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at tarsis.maksym@gmail.com. The project team will review and investigate all complaints and will respond in a way that it deems appropriate to the circumstances. The project team obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
44 |
45 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
46 |
47 |
48 |
49 | ## Attribution
50 |
51 | This Code of Conduct adapted from the [Contributor Covenant][homepage], version 2.0, available at [http://contributor-covenant.org/version/2/0][version].
52 |
53 | [homepage]: http://contributor-covenant.org
54 | [version]: http://contributor-covenant.org/version/2/0
55 |
--------------------------------------------------------------------------------
/lib/src/middlewares/promise.middleware.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * some code was borrowed from this:
3 | * https://github.com/pburtchaell/redux-promise-middleware/blob/master/src/index.js
4 | */
5 | import { AnyAction, Dispatch, MiddlewareAPI } from 'redux';
6 |
7 | import { isPromise } from '../helpers/utils';
8 | import { patchEffect } from '../store/actions';
9 | import { nanoid } from '../helpers/nanoid.utils';
10 | import { effectTypes } from '../helpers/const';
11 |
12 | export const pendingPromiseMiddleware =
13 | ({ dispatch }: MiddlewareAPI) =>
14 | (next: Dispatch) =>
15 | (action: AnyAction): AnyAction => {
16 | let promise;
17 | let data;
18 |
19 | if (action.payload) {
20 | const { payload } = action;
21 |
22 | if (isPromise(payload)) {
23 | promise = payload;
24 | } else if (isPromise(payload.promise)) {
25 | promise = payload.promise;
26 | data = payload.data;
27 | // when the promise returned by async function
28 | } else if (
29 | typeof payload === 'function' ||
30 | typeof payload.promise === 'function'
31 | ) {
32 | promise = payload.promise ? payload.promise() : payload();
33 | data = payload.promise ? payload.data : undefined;
34 |
35 | if (!isPromise(promise)) {
36 | return next({
37 | ...action,
38 | payload: promise
39 | });
40 | }
41 | } else {
42 | return next(action);
43 | }
44 | } else {
45 | return next(action);
46 | }
47 |
48 | const { type: actionType, meta } = action;
49 | const effectId = nanoid();
50 | const effectType = effectTypes.promise;
51 | const patchEffectPayload = { effectId, effectType, actionType };
52 |
53 | const getAction = (newPayload: any, isRejected: boolean): AnyAction => {
54 | const nextAction: AnyAction = {
55 | type: isRejected ? `${actionType}_REJECTED` : `${actionType}_FULFILLED`
56 | };
57 |
58 | if (newPayload !== null && typeof newPayload !== 'undefined') {
59 | nextAction.payload = newPayload;
60 | }
61 |
62 | if (meta !== undefined) {
63 | nextAction.meta = meta;
64 | }
65 |
66 | if (isRejected) {
67 | nextAction.error = true;
68 | }
69 |
70 | return nextAction;
71 | };
72 | const handleReject = (reason: any) => {
73 | const rejectedAction = getAction(reason, true);
74 | dispatch(rejectedAction);
75 | dispatch(patchEffect(patchEffectPayload));
76 | };
77 | const handleFulfill = (value = null) => {
78 | const resolvedAction = getAction(value, false);
79 | dispatch(resolvedAction);
80 | dispatch(patchEffect(patchEffectPayload));
81 |
82 | return { value, action: resolvedAction };
83 | };
84 |
85 | dispatch(patchEffect(patchEffectPayload));
86 | next({
87 | type: `${actionType}_PENDING`,
88 | // Include payload (for optimistic updates) if it is defined.
89 | ...(data !== undefined ? { payload: data } : {}),
90 | // Include meta data if it is defined.
91 | ...(meta !== undefined ? { meta } : {})
92 | });
93 |
94 | return promise.then(handleFulfill, handleReject);
95 | };
96 |
--------------------------------------------------------------------------------
/example-app/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | ignorePatterns: ['*.*ss'],
5 | plugins: [
6 | '@typescript-eslint',
7 | 'prettier',
8 | 'react',
9 | 'import',
10 | 'react-hooks',
11 | 'sonarjs',
12 | 'jsx-a11y'
13 | ],
14 | extends: [
15 | 'eslint:recommended',
16 | 'react-app',
17 | 'prettier',
18 | 'plugin:prettier/recommended',
19 | 'plugin:react/recommended',
20 | 'plugin:compat/recommended',
21 | 'plugin:sonarjs/recommended',
22 | 'plugin:import/errors',
23 | 'plugin:import/warnings',
24 | 'plugin:import/typescript',
25 | 'plugin:@typescript-eslint/recommended',
26 | 'plugin:jsx-a11y/recommended'
27 | ],
28 | settings: {
29 | 'import/resolver': {
30 | node: {
31 | paths: [path.resolve(__dirname, 'src')],
32 | extensions: ['.js', '.jsx', '.ts', '.tsx']
33 | }
34 | },
35 | 'import/parsers': {
36 | '@typescript-eslint/parser': ['.ts', '.tsx']
37 | },
38 | react: {
39 | version: 'detect'
40 | }
41 | },
42 | rules: {
43 | semi: [2, 'always'],
44 | quotes: 0,
45 | 'consistent-return': 0,
46 | 'jsx-quotes': 0,
47 | 'react/prop-types': 0,
48 | 'react/display-name': 0,
49 | 'react/no-array-index-key': 2,
50 | 'react/no-children-prop': 2,
51 | 'react/no-deprecated': 2,
52 | 'react/no-multi-comp': 2,
53 | 'react/no-typos': 2,
54 | 'react/no-this-in-sfc': 2,
55 | 'react/prefer-stateless-function': 2,
56 | 'react/no-unsafe': 2,
57 | 'react/self-closing-comp': 2,
58 | 'react/jsx-fragments': [2, 'element'],
59 | 'react/jsx-no-bind': 0,
60 | 'react/jsx-no-comment-textnodes': 2,
61 | 'react/jsx-no-duplicate-props': 2,
62 | 'react/jsx-no-target-blank': 2,
63 | 'react/jsx-curly-brace-presence': [2, 'never'],
64 | 'react/jsx-boolean-value': [2, 'always'],
65 | 'react/jsx-sort-props': [
66 | 2,
67 | {
68 | callbacksLast: true,
69 | shorthandLast: true,
70 | ignoreCase: true,
71 | noSortAlphabetically: false,
72 | reservedFirst: true
73 | }
74 | ],
75 | 'react/jsx-props-no-multi-spaces': 2,
76 | 'react/jsx-pascal-case': 2,
77 | 'react/jsx-wrap-multilines': [
78 | 2,
79 | {
80 | declaration: 'parens-new-line',
81 | assignment: 'parens-new-line',
82 | return: 'parens-new-line',
83 | arrow: 'parens-new-line',
84 | condition: 'parens-new-line',
85 | logical: 'parens-new-line',
86 | prop: 'parens-new-line'
87 | }
88 | ],
89 | 'import/no-unresolved': 0,
90 | 'import/named': 0,
91 | 'import/namespace': 2,
92 | 'import/default': 2,
93 | 'import/export': 2,
94 | 'import/no-cycle': 2,
95 | 'import/imports-first': [2, 'absolute-first'],
96 | 'import/newline-after-import': 2,
97 | 'import/prefer-default-export': 0,
98 | 'import/no-useless-path-segments': 2,
99 | 'import/no-default-export': 2,
100 | 'import/no-mutable-exports': 2,
101 | 'import/no-namespace': 2,
102 | 'import/no-extraneous-dependencies': 0,
103 | 'import/no-duplicates': 2,
104 | 'import/order': [
105 | 2,
106 | {
107 | 'newlines-between': 'always',
108 | groups: [
109 | ['builtin', 'external'],
110 | ['parent', 'internal', 'sibling', 'index', 'unknown']
111 | ]
112 | }
113 | ],
114 | 'no-useless-catch': 0,
115 | 'sonarjs/no-useless-catch': 0,
116 | 'sonarjs/cognitive-complexity': 0,
117 | 'sonarjs/no-duplicated-branches': 0,
118 | 'spaced-comment': 0,
119 | 'no-empty-function': 2,
120 | 'no-useless-constructor': 0,
121 | 'lines-between-class-members': 2,
122 | '@typescript-eslint/explicit-function-return-type': 0,
123 | '@typescript-eslint/no-explicit-any': 0,
124 | '@typescript-eslint/no-namespace': 0,
125 | '@typescript-eslint/no-redeclare': 0,
126 | '@typescript-eslint/no-unused-expressions': 0,
127 | '@typescript-eslint/explicit-module-boundary-types': 0
128 | },
129 | env: {
130 | browser: true,
131 | node: true,
132 | jest: true
133 | }
134 | };
135 |
--------------------------------------------------------------------------------
/example-app/src/services/NasaService.ts:
--------------------------------------------------------------------------------
1 | enum PatentDataIndexes {
2 | Id,
3 | Title = 2,
4 | Description,
5 | ImageUrl = 10
6 | }
7 |
8 | type LibraryItemResponseShape = {
9 | data: [
10 | {
11 | nasa_id: string;
12 | title: string;
13 | }
14 | ];
15 | links: [
16 | {
17 | href: string;
18 | }
19 | ];
20 | };
21 |
22 | type AstronomyPictureResponseShape = {
23 | title: string;
24 | url: string;
25 | explanation: string;
26 | };
27 |
28 | type MarsRoverPhotoResponseShape = {
29 | id: number;
30 | img_src: string;
31 | };
32 |
33 | class NasaService {
34 | private apiKey = 'WmyhwhhQBZJIvTdIQ6KeYZUNenQY7Fazyd2nauB5';
35 |
36 | async smartFetch(
37 | url: string,
38 | options?: Record
39 | ): Promise {
40 | const response = await fetch(url, options);
41 |
42 | if (response.status >= 400) {
43 | throw response;
44 | }
45 |
46 | if (response.status === 204) {
47 | return undefined;
48 | }
49 |
50 | return response.json();
51 | }
52 |
53 | async getPatents(): Promise {
54 | const patentsUrl = `https://api.nasa.gov/techtransfer/patent/?engine&api_key=${this.apiKey}`;
55 | const body = await this.smartFetch<{ results: [] }>(patentsUrl);
56 |
57 | return this.transformPatentsData(body && body.results);
58 | }
59 |
60 | private transformPatentsData = (
61 | patentsData: [] = []
62 | ): Global.PatentDataShape[] =>
63 | patentsData.map(patentData => ({
64 | id: patentData[PatentDataIndexes.Id],
65 | title: patentData[PatentDataIndexes.Title],
66 | description: patentData[PatentDataIndexes.Description],
67 | imageUrl: patentData[PatentDataIndexes.ImageUrl]
68 | }));
69 |
70 | async getLibraryContent(
71 | searchValue: string
72 | ): Promise {
73 | const libraryContentUrl = `https://images-api.nasa.gov/search?q=${searchValue}&page=1&media_type=image&year_start=1920&year_end=2020`;
74 | const body = await this.smartFetch<{ collection: { items: [] } }>(
75 | libraryContentUrl
76 | );
77 |
78 | return this.transformLibraryContentData(body && body.collection.items);
79 | }
80 |
81 | private transformLibraryContentData = (
82 | libraryContentData: [] = []
83 | ): Global.LibraryContentDataShape[] =>
84 | libraryContentData.map((item: LibraryItemResponseShape) => {
85 | const itemData = item.data[0];
86 | const itemLinks = item.links[0];
87 |
88 | return {
89 | id: itemData.nasa_id,
90 | title: itemData.title,
91 | link: itemLinks.href
92 | };
93 | });
94 |
95 | getAstronomyPictureData = async (): Promise<
96 | Global.AstronomyPictureDataShape | Error
97 | > => {
98 | const astronomyPictureDataUrl = `https://api.nasa.gov/planetary/apod?api_key=${this.apiKey}`;
99 | const body = await this.smartFetch(
100 | astronomyPictureDataUrl
101 | );
102 |
103 | if (!body) {
104 | throw new Error('No data found for this day');
105 | }
106 |
107 | return this.transformAstronomyPictureData(body);
108 | };
109 |
110 | private transformAstronomyPictureData = (
111 | data: AstronomyPictureResponseShape
112 | ): Global.AstronomyPictureDataShape => ({
113 | title: data.title,
114 | imageUrl: data.url,
115 | description: data.explanation
116 | });
117 |
118 | async getMarsRoverPhotos(): Promise {
119 | const marsRoverPhotosUrl = `https://api.nasa.gov/mars-photos/api/v1/rovers/curiosity/photos?sol=1000&api_key=${this.apiKey}`;
120 |
121 | const res = await this.smartFetch<{
122 | photos: MarsRoverPhotoResponseShape[];
123 | }>(marsRoverPhotosUrl);
124 |
125 | return this.transformMarsRoverPhotosData(res && res.photos);
126 | }
127 |
128 | private transformMarsRoverPhotosData = (
129 | data: MarsRoverPhotoResponseShape[] = []
130 | ): Global.MarsRoverPhotoDataShape[] =>
131 | data.map(item => ({
132 | id: item.id,
133 | imageUrl: item.img_src
134 | }));
135 | }
136 |
137 | export const nasaService = new NasaService();
138 |
--------------------------------------------------------------------------------
/example-app/src/components/Loader/Loader.module.scss:
--------------------------------------------------------------------------------
1 | $m-01: #3c4359;
2 | $m-02: #546c8c;
3 | $m-03: #7ea1bf;
4 | $m-04: #bacbd9;
5 | $m-05: #bf80a9;
6 |
7 | .content {
8 | width: 300px;
9 | height: 300px;
10 | position: relative;
11 | display: flex;
12 | justify-content: center;
13 | align-items: center;
14 | margin: 0 auto;
15 |
16 | .planet {
17 | width: 65%;
18 | height: 65%;
19 | background-color: $m-02;
20 | border-radius: 100%;
21 | position: absolute;
22 | display: flex;
23 | align-items: center;
24 | transform-origin: center center;
25 | box-shadow: inset 2px -10px 0px rgba(0, 0, 0, 0.1);
26 | animation: planet 5s ease infinite alternate;
27 |
28 | @keyframes planet {
29 | 0% {
30 | transform: rotate(10deg);
31 | }
32 |
33 | 100% {
34 | transform: rotate(-10deg);
35 | }
36 | }
37 |
38 | .ring {
39 | position: absolute;
40 | width: 300px;
41 | height: 300px;
42 | border-radius: 100%;
43 | background-color: $m-04;
44 | display: flex;
45 | align-items: center;
46 | justify-content: center;
47 | transform-origin: 33% center;
48 | box-shadow: 2px -10px 0px rgba(0, 0, 0, 0.1),
49 | inset -5px -10px 0px rgba(0, 0, 0, 0.1);
50 | animation: ring 3s ease infinite;
51 |
52 | @keyframes ring {
53 | 0% {
54 | transform: rotateX(110deg) rotateZ(0deg) translate(-50px, 5px);
55 | }
56 |
57 | 100% {
58 | transform: rotateX(110deg) rotateZ(360deg) translate(-50px, 5px);
59 | }
60 | }
61 |
62 | /* small ball */
63 | &:before {
64 | content: '';
65 | position: absolute;
66 | width: 10px;
67 | height: 30px;
68 | border-radius: 100%;
69 | background-color: $m-03;
70 | z-index: 2;
71 | left: calc(0px - 5px);
72 | box-shadow: inset -3px 3px 0px rgba(0, 0, 0, 0.2);
73 | }
74 |
75 | /* inner ring */
76 | &:after {
77 | content: '';
78 | position: absolute;
79 | width: 240px;
80 | height: 240px;
81 | border-radius: 100%;
82 | background-color: $m-03;
83 | box-shadow: inset 2px -10px 0px rgba(0, 0, 0, 0.1);
84 | }
85 | }
86 |
87 | /* to cover the back of the ring */
88 | .coverRing {
89 | position: absolute;
90 | width: 100%;
91 | height: 50%;
92 | border-bottom-left-radius: 80%;
93 | border-bottom-right-radius: 80%;
94 | border-top-left-radius: 100px;
95 | border-top-right-radius: 100px;
96 | transform: translate(0px, -17px);
97 | background-color: $m-02;
98 | z-index: 2;
99 | box-shadow: inset 0px -2px 0px rgba(0, 0, 0, 0.1);
100 | }
101 |
102 | /* planet spots */
103 | .spots {
104 | width: 100%;
105 | height: 100%;
106 | display: flex;
107 | align-items: center;
108 | justify-content: center;
109 | position: absolute;
110 | z-index: 2;
111 |
112 | span {
113 | width: 30px;
114 | height: 30px;
115 | background-color: $m-01;
116 | position: absolute;
117 | border-radius: 100%;
118 | box-shadow: inset -2 3 0 rgba(0, 0, 0, 0.3);
119 | animation: dots 5s ease infinite alternate;
120 |
121 | @keyframes dots {
122 | 0% {
123 | box-shadow: inset -3px 3px 0 rgba(0, 0, 0, 0.3);
124 | }
125 | 100% {
126 | box-shadow: inset 3px 3px 0 rgba(0, 0, 0, 0.3);
127 | }
128 | }
129 |
130 | &:nth-child(1) {
131 | top: 20px;
132 | right: 50px;
133 | }
134 |
135 | &:nth-child(2) {
136 | top: 40px;
137 | left: 50px;
138 | width: 15px;
139 | height: 15px;
140 | }
141 |
142 | &:nth-child(3) {
143 | top: 80px;
144 | left: 20px;
145 | width: 25px;
146 | height: 25px;
147 | }
148 |
149 | &:nth-child(4) {
150 | top: 80px;
151 | left: 90px;
152 | width: 40px;
153 | height: 40px;
154 | }
155 |
156 | &:nth-child(5) {
157 | top: 160px;
158 | left: 70px;
159 | width: 15px;
160 | height: 15px;
161 | }
162 |
163 | &:nth-child(6) {
164 | top: 165px;
165 | left: 125px;
166 | width: 10px;
167 | height: 10px;
168 | }
169 |
170 | &:nth-child(7) {
171 | top: 90px;
172 | left: 150px;
173 | width: 15px;
174 | height: 15px;
175 | }
176 | }
177 | }
178 | }
179 |
180 | p {
181 | color: $m-04;
182 | font-size: 14px;
183 | z-index: 2;
184 | position: absolute;
185 | bottom: -20px;
186 | animation: text 4s ease infinite;
187 | width: 100px;
188 | text-align: center;
189 |
190 | @keyframes text {
191 | 0% {
192 | transform: translateX(-30px);
193 | letter-spacing: 0;
194 | color: $m-04;
195 | }
196 |
197 | 25% {
198 | letter-spacing: 3px;
199 | color: $m-03;
200 | }
201 |
202 | 50% {
203 | transform: translateX(30px);
204 | letter-spacing: 0;
205 | color: $m-04;
206 | }
207 |
208 | 75% {
209 | letter-spacing: 3px;
210 | color: $m-03;
211 | }
212 |
213 | 100% {
214 | transform: translateX(-30px);
215 | letter-spacing: 0;
216 | color: $m-04;
217 | }
218 | }
219 | }
220 | }
221 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Redux Pending Effects
2 |
3 | 🦋 A redux toolkit that tracks your asynchronous [redux](http://redux.js.org) [actions](https://redux.js.org/basics/actions) (effects) and informs about the pending state using the selector function
4 |
5 |
6 | List of supported libraries that process redux effects:
7 |
8 | - [redux-toolkit](https://github.com/reduxjs/redux-toolkit)
9 | - [redux-saga](https://github.com/redux-saga/redux-saga)
10 | - [redux-promise-middleware](https://github.com/pburtchaell/redux-promise-middleware)
11 |
12 | It's worth mention that `redux-pending-effects` allows you to code simultaneously with all libraries above.
13 |
14 |
15 |
16 | ## Problem it solves
17 |
18 | Have you ever been in a situation where you need to add a global loader/spinner to any side effect that your application is processing? Perhaps you are using Redux and some third-party library for asynchronous processing, for example, redux-saga / promise middleware? Great, then it should be interesting to you.
19 |
20 | Why not handle the pending state manually for each action?
21 |
22 | - It is very unpleasant to create separately for this state and add start and end actions for these actions to each request.
23 | - This is an open place to make mistakes because it's very easy to forget to add or remove these actions.
24 | - It needs to be supported and somehow live with it.
25 |
26 | Well, `redux-pending-effects` does this from scratch:
27 |
28 | - tracks your asynchronous code
29 | - collects them in a bunch
30 | - efficiently calculates active pending effects
31 | - provides a selector for information about the current state of application loading
32 | - available for debugging in redux-devtools
33 | - independent of a particular asynchronous processing solution. Can be used simultaneously with `redux-saga` and `redux-toolkit`
34 | - replaces `redux-thunk` in the matters of side effects (not actions chaining) and `redux-promise-middleware` (essentially uses it out of the box)
35 |
36 |
37 |
38 | ## Quickstart
39 |
40 | ### Installation
41 |
42 | ```shell script
43 | npm install redux-pending-effects
44 | ```
45 |
46 |
47 |
48 | ### Extend reducers
49 |
50 | `redux-pending-effects` provides its own state for storing active effects (pending promise phase).
51 |
52 | ```javascript
53 | import { combineReducers } from 'redux';
54 | import { includePendingReducer } from 'redux-pending-effects';
55 |
56 | import { planetReducer as planet } from './planetReducer';
57 | import { universeReducer as universe } from './universeReducer';
58 |
59 | const appReducers = {
60 | planet,
61 | universe
62 | };
63 | const reducersWithPending = includePendingReducer(appReducers);
64 | export const rootReducer = combineReducers(reducersWithPending);
65 | ```
66 |
67 |
68 |
69 | ### Configuration
70 |
71 | The package provides a single entry point for set up via `configurePendingEffects`
72 |
73 | `configurePendingEffects` accepts a single configuration object parameter, with the following options:
74 |
75 | - `promise: boolean` (default `false`) - enable/disable tracking of asynchronous effects that you pass a promise to the payload.
76 | Yes, if the option enabled, you can pass promise to the payload, that is the way `redux-promise-middleware` does.
77 | For details, you can go to read the documentation of [redux-promise-middleware](https://github.com/pburtchaell/redux-promise-middleware)
78 | about how this works.
79 | - `toolkit: boolean` (default `false`) - enable/disable tracking of asynchronous effects produced by [redux-toolkit](https://github.com/reduxjs/redux-toolkit)
80 | - `saga: boolean` (default `false`) - enable/disable tracking of asynchronous effects produced by [redux-saga](https://github.com/redux-saga/redux-saga)
81 | - `ignoredActionTypes: string[]` (default `[]`) - list of action types to not track (do not react on actions with these types)
82 |
83 | `configurePendingEffects` returns an object with two properties:
84 |
85 | 1. `middlewares` - an array of defined redux middlewares
86 | 2. `sagaOptions` - options for `createSagaMiddleware` in case you intend to use `redux-saga`
87 |
88 |
89 |
90 | ### Example
91 |
92 | Let's show an example with all options enabled.
93 |
94 | ```javascript
95 | import { configurePendingEffects } from 'redux-pending-effects';
96 | import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
97 | import createSagaMiddleware from '@redux-saga/core';
98 |
99 | import { rootReducer as reducer } from './root.reducer';
100 | import { rootSaga } from './root.saga';
101 |
102 | const { middlewares, sagaOptions } = configurePendingEffects({
103 | promise: true,
104 | toolkit: true,
105 | saga: true,
106 | ignoredActionTypes: ['IGNORED_ACTION_1', 'IGNORED_ACTION_2']
107 | });
108 | const sagaMiddleware = createSagaMiddleware(sagaOptions);
109 | const toolkitMiddlewares = getDefaultMiddleware();
110 | const middleware = [...middlewares, ...toolkitMiddlewares, sagaMiddleware];
111 |
112 | export const store = configureStore({ reducer, middleware });
113 |
114 | sagaMiddleware.run(rootSaga);
115 | ```
116 |
117 |
118 |
119 | ### Selector
120 |
121 | Just a regular usage of redux selectors
122 |
123 | ```javascript
124 | import React from 'react';
125 | import { useSelector } from 'react-redux';
126 | import { selectIsPending } from 'redux-pending-effects';
127 |
128 | import { YourApplication } from './YourApplication';
129 | import { AppLoader } from './App.loader';
130 |
131 | export const App = () => {
132 | const isPending = useSelector(selectIsPending);
133 |
134 | return isPending ? : ;
135 | };
136 | ```
137 |
138 |
139 |
140 | ### Notes
141 |
142 | At the moment, this library completely replaces `redux-promise-middleware`.
143 | In the plans, through the collaboration, expand the API of `redux-promise-middleware` to reuse their internal API.
144 |
145 |
146 |
147 | ### Contributing
148 |
149 | Contributions are welcome. For significant changes, please open an issue first to discuss what you would like to change.
150 |
151 | If you made a PR, make sure to update tests as appropriate and keep the examples consistent.
152 |
153 |
154 |
155 | ### Contact
156 |
157 | Please reach me out if you have any questions or comments.
158 |
159 | - [GitHub](https://github.com/tarsinzer)
160 | - [Twitter](https://twitter.com/tarsinzer)
161 |
162 |
163 |
164 | ### References
165 |
166 | I find these packages useful and similar to this one. So, it's important to mention them here.
167 |
168 | - [redux-pending](https://www.npmjs.com/package/redux-pending)
169 | - [redux-pender](https://www.npmjs.com/package/redux-pender)
170 | - [redux-promise-middleware](https://www.npmjs.com/package/redux-promise-middleware)
171 |
172 | The main reason why I didn't choose them: they do one thing, and it's impossible to add something second to them.
173 |
174 |
175 |
176 | ### License
177 |
178 | This project is [MIT](https://choosealicense.com/licenses/mit/) licensed.
179 |
180 |
181 |
--------------------------------------------------------------------------------
/lib/README.md:
--------------------------------------------------------------------------------
1 | # Redux Pending Effects
2 |
3 | 🦋 A redux toolkit that tracks your asynchronous [redux](http://redux.js.org) [actions](https://redux.js.org/basics/actions) (effects) and informs about the pending state using the selector function
4 |
5 |
6 | List of supported libraries that process redux effects:
7 |
8 | - [redux-toolkit](https://github.com/reduxjs/redux-toolkit)
9 | - [redux-saga](https://github.com/redux-saga/redux-saga)
10 | - [redux-promise-middleware](https://github.com/pburtchaell/redux-promise-middleware)
11 |
12 | It's worth mention that `redux-pending-effects` allows you to code simultaneously with all libraries above.
13 |
14 |
15 |
16 | ## Problem it solves
17 |
18 | Have you ever been in a situation where you need to add a global loader/spinner to any side effect that your application is processing? Perhaps you are using Redux and some third-party library for asynchronous processing, for example, redux-saga / promise middleware? Great, then it should be interesting to you.
19 |
20 | Why not handle the pending state manually for each action?
21 |
22 | - It is very unpleasant to create separately for this state and add start and end actions for these actions to each request.
23 | - This is an open place to make mistakes because it's very easy to forget to add or remove these actions.
24 | - It needs to be supported and somehow live with it.
25 |
26 | Well, `redux-pending-effects` does this from scratch:
27 |
28 | - tracks your asynchronous code
29 | - collects them in a bunch
30 | - efficiently calculates active pending effects
31 | - provides a selector for information about the current state of application loading
32 | - available for debugging in redux-devtools
33 | - independent of a particular asynchronous processing solution. Can be used simultaneously with `redux-saga` and `redux-toolkit`
34 | - replaces `redux-thunk` in the matters of side effects (not actions chaining) and `redux-promise-middleware` (essentially uses it out of the box)
35 |
36 |
37 |
38 | ## Quickstart
39 |
40 | ### Installation
41 |
42 | ```shell script
43 | npm install redux-pending-effects
44 | ```
45 |
46 |
47 |
48 | ### Extend reducers
49 |
50 | `redux-pending-effects` provides its own state for storing active effects (pending promise phase).
51 |
52 | ```javascript
53 | import { combineReducers } from 'redux';
54 | import { includePendingReducer } from 'redux-pending-effects';
55 |
56 | import { planetReducer as planet } from './planetReducer';
57 | import { universeReducer as universe } from './universeReducer';
58 |
59 | const appReducers = {
60 | planet,
61 | universe
62 | };
63 | const reducersWithPending = includePendingReducer(appReducers);
64 | export const rootReducer = combineReducers(reducersWithPending);
65 | ```
66 |
67 |
68 |
69 | ### Configuration
70 |
71 | The package provides a single entry point for set up via `configurePendingEffects`
72 |
73 | `configurePendingEffects` accepts a single configuration object parameter, with the following options:
74 |
75 | - `promise: boolean` (default `false`) - enable/disable tracking of asynchronous effects that you pass a promise to the payload.
76 | Yes, if the option enabled, you can pass promise to the payload, that is the way `redux-promise-middleware` does.
77 | For details, you can go to read the documentation of [redux-promise-middleware](https://github.com/pburtchaell/redux-promise-middleware)
78 | about how this works.
79 | - `toolkit: boolean` (default `false`) - enable/disable tracking of asynchronous effects produced by [redux-toolkit](https://github.com/reduxjs/redux-toolkit)
80 | - `saga: boolean` (default `false`) - enable/disable tracking of asynchronous effects produced by [redux-saga](https://github.com/redux-saga/redux-saga)
81 | - `ignoredActionTypes: string[]` (default `[]`) - list of action types to not track (do not react on actions with these types)
82 |
83 | `configurePendingEffects` returns an object with two properties:
84 |
85 | 1. `middlewares` - an array of defined redux middlewares
86 | 2. `sagaOptions` - options for `createSagaMiddleware` in case you intend to use `redux-saga`
87 |
88 |
89 |
90 | ### Example
91 |
92 | Let's show an example with all options enabled.
93 |
94 | ```javascript
95 | import { configurePendingEffects } from 'redux-pending-effects';
96 | import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
97 | import createSagaMiddleware from '@redux-saga/core';
98 |
99 | import { rootReducer as reducer } from './root.reducer';
100 | import { rootSaga } from './root.saga';
101 |
102 | const { middlewares, sagaOptions } = configurePendingEffects({
103 | promise: true,
104 | toolkit: true,
105 | saga: true,
106 | ignoredActionTypes: ['IGNORED_ACTION_1', 'IGNORED_ACTION_2']
107 | });
108 | const sagaMiddleware = createSagaMiddleware(sagaOptions);
109 | const toolkitMiddlewares = getDefaultMiddleware();
110 | const middleware = [...middlewares, ...toolkitMiddlewares, sagaMiddleware];
111 |
112 | export const store = configureStore({ reducer, middleware });
113 |
114 | sagaMiddleware.run(rootSaga);
115 | ```
116 |
117 |
118 |
119 | ### Selector
120 |
121 | Just a regular usage of redux selectors
122 |
123 | ```javascript
124 | import React from 'react';
125 | import { useSelector } from 'react-redux';
126 | import { selectIsPending } from 'redux-pending-effects';
127 |
128 | import { YourApplication } from './YourApplication';
129 | import { AppLoader } from './App.loader';
130 |
131 | export const App = () => {
132 | const isPending = useSelector(selectIsPending);
133 |
134 | return isPending ? : ;
135 | };
136 | ```
137 |
138 |
139 |
140 | ### Notes
141 |
142 | At the moment, this library completely replaces `redux-promise-middleware`.
143 | In the plans, through the collaboration, expand the API of `redux-promise-middleware` to reuse their internal API.
144 |
145 |
146 |
147 | ### Contributing
148 |
149 | Contributions are welcome. For significant changes, please open an issue first to discuss what you would like to change.
150 |
151 | If you made a PR, make sure to update tests as appropriate and keep the examples consistent.
152 |
153 |
154 |
155 | ### Contact
156 |
157 | Please reach me out if you have any questions or comments.
158 |
159 | - [GitHub](https://github.com/tarsinzer)
160 | - [Twitter](https://twitter.com/tarsinzer)
161 |
162 |
163 |
164 | ### References
165 |
166 | I find these packages useful and similar to this one. So, it's important to mention them here.
167 |
168 | - [redux-pending](https://www.npmjs.com/package/redux-pending)
169 | - [redux-pender](https://www.npmjs.com/package/redux-pender)
170 | - [redux-promise-middleware](https://www.npmjs.com/package/redux-promise-middleware)
171 |
172 | The main reason why I didn't choose them: they do one thing, and it's impossible to add something second to them.
173 |
174 |
175 |
176 | ### License
177 |
178 | This project is [MIT](https://choosealicense.com/licenses/mit/) licensed.
179 |
180 |
181 |
--------------------------------------------------------------------------------
/example-app/src/reducers/selector.spec.ts:
--------------------------------------------------------------------------------
1 | import { Store } from 'redux';
2 | import { selectIsPending } from 'redux-pending-effects';
3 | import { configureStore } from '@reduxjs/toolkit';
4 | import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
5 |
6 | import { middleware, sagaMiddleware } from '../store';
7 | import {
8 | getPatents,
9 | getLibraryContent,
10 | getAstronomyPictureData
11 | } from '../actions';
12 | import {
13 | astronomyPictureActionNames,
14 | libraryActionNames,
15 | patentsActionsNames
16 | } from '../constants';
17 | import { astronomyPictureWorker } from '../sagas/astronomyPictureSagas';
18 | import { rootReducer as reducer } from './rootReducer';
19 | import { rootSaga } from '../sagas';
20 | import {
21 | patentsFetchMock,
22 | libraryContentFetchMock,
23 | astronomyPictureFetchMock,
24 | rejectedFetchMockParam,
25 | createSagaTesterInstance,
26 | middlewaresWithPromiseActionsIgnored,
27 | middlewaresWithToolkitActionsIgnored,
28 | middlewaresWithSagaActionsIgnored,
29 | urls,
30 | REDUX_PENDING_EFFECTS
31 | } from './testHelpers';
32 |
33 | describe('selector', () => {
34 | let store: Store;
35 |
36 | enableFetchMocks();
37 |
38 | beforeEach(() => {
39 | store = configureStore({ reducer, middleware });
40 | fetchMock.resetMocks();
41 | });
42 |
43 | test('should have default negative pending state', () => {
44 | expect(selectIsPending(store.getState())).toBe(false);
45 | });
46 |
47 | describe('with saga', () => {
48 | test('should trigger pending state when saga started', () => {
49 | sagaMiddleware.run(rootSaga);
50 | store.dispatch(getAstronomyPictureData);
51 | expect(selectIsPending(store.getState())).toBe(true);
52 | });
53 |
54 | test('should save correct name of action, that triggered pending effect', () => {
55 | sagaMiddleware.run(rootSaga);
56 | store.dispatch(getAstronomyPictureData);
57 |
58 | const pendingState = store.getState()[REDUX_PENDING_EFFECTS];
59 | const isTriggeredActionTypeIsPresent = Object.values(
60 | pendingState.effectsEntity
61 | ).includes(astronomyPictureActionNames.GET);
62 | expect(isTriggeredActionTypeIsPresent).toBe(true);
63 | });
64 |
65 | test('should complete pending state when saga completed with success', async () => {
66 | const sagaTester = createSagaTesterInstance(middleware);
67 |
68 | fetchMock.mockResponseOnce(...astronomyPictureFetchMock);
69 |
70 | sagaTester.start(rootSaga);
71 | sagaTester.dispatch(getAstronomyPictureData);
72 | await sagaTester.waitFor(astronomyPictureActionNames.FULFILLED);
73 | expect(selectIsPending(sagaTester.getState())).toBe(false);
74 | });
75 |
76 | test('should complete pending state when saga completed with failure', async () => {
77 | const sagaTester = createSagaTesterInstance(middleware);
78 |
79 | fetchMock.mockRejectOnce(rejectedFetchMockParam);
80 |
81 | sagaTester.start(rootSaga);
82 | sagaTester.dispatch(getAstronomyPictureData);
83 | await sagaTester.waitFor(astronomyPictureActionNames.REJECTED);
84 | expect(selectIsPending(sagaTester.getState())).toBe(false);
85 | });
86 |
87 | test('should not trigger pending state when fetch is started for ignored action', () => {
88 | const store: Store = configureStore({
89 | reducer,
90 | middleware: middlewaresWithSagaActionsIgnored
91 | });
92 |
93 | sagaMiddleware.run(rootSaga);
94 | store.dispatch(getAstronomyPictureData);
95 | expect(selectIsPending(store.getState())).toBe(false);
96 | });
97 | });
98 |
99 | describe('with toolkit', () => {
100 | test('should trigger pending state when toolkit started', () => {
101 | store.dispatch(getLibraryContent('test'));
102 | expect(selectIsPending(store.getState())).toBe(true);
103 | });
104 |
105 | test('should save correct name of action, that triggered pending effect', () => {
106 | store.dispatch(getLibraryContent('test'));
107 |
108 | const pendingState = store.getState()[REDUX_PENDING_EFFECTS];
109 | const isTriggeredActionTypeIsPresent = Object.values(
110 | pendingState.effectsEntity
111 | ).includes(libraryActionNames.PENDING);
112 | expect(isTriggeredActionTypeIsPresent).toBe(true);
113 | });
114 |
115 | test('should complete pending state when toolkit completed with success', async () => {
116 | fetchMock.mockResponseOnce(...libraryContentFetchMock);
117 | await store.dispatch(getLibraryContent('test'));
118 | expect(selectIsPending(store.getState())).toBe(false);
119 | });
120 |
121 | test('should complete pending state when toolkit completed with failure', async () => {
122 | fetchMock.mockRejectOnce(rejectedFetchMockParam);
123 | await store.dispatch(getLibraryContent('test'));
124 | expect(selectIsPending(store.getState())).toBe(false);
125 | });
126 |
127 | test('should not trigger pending state when fetch is started for ignored action', () => {
128 | const store: Store = configureStore({
129 | reducer,
130 | middleware: middlewaresWithToolkitActionsIgnored
131 | });
132 |
133 | store.dispatch(getLibraryContent('test'));
134 | expect(selectIsPending(store.getState())).toBe(false);
135 | });
136 | });
137 |
138 | describe('with promise', () => {
139 | test('should trigger pending state when promise started', () => {
140 | store.dispatch(getPatents());
141 | expect(selectIsPending(store.getState())).toBe(true);
142 | });
143 |
144 | test('should save correct name of action, that triggered pending effect', () => {
145 | store.dispatch(getPatents());
146 |
147 | const pendingState = store.getState()[REDUX_PENDING_EFFECTS];
148 | const isTriggeredActionTypeIsPresent = Object.values(
149 | pendingState.effectsEntity
150 | ).includes(patentsActionsNames.GET);
151 | expect(isTriggeredActionTypeIsPresent).toBe(true);
152 | });
153 |
154 | test('should complete pending state when promise completed with success', async () => {
155 | fetchMock.mockResponseOnce(...patentsFetchMock);
156 |
157 | const getPatentsAction: Actions.GetPatents = getPatents();
158 |
159 | store.dispatch(getPatentsAction);
160 | await getPatentsAction.payload;
161 | expect(selectIsPending(store.getState())).toBe(false);
162 | });
163 |
164 | test('should complete pending state when promise completed with failure', async () => {
165 | fetchMock.mockRejectOnce(rejectedFetchMockParam);
166 |
167 | const getPatentsAction: Actions.GetPatents = getPatents();
168 |
169 | try {
170 | store.dispatch(getPatentsAction);
171 | await getPatentsAction.payload;
172 | } catch (e) {
173 | } finally {
174 | expect(selectIsPending(store.getState())).toBe(false);
175 | }
176 | });
177 |
178 | test('should not trigger pending state when fetch is started for ignored action', () => {
179 | const store: Store = configureStore({
180 | reducer,
181 | middleware: middlewaresWithPromiseActionsIgnored
182 | });
183 |
184 | store.dispatch(getPatents());
185 | expect(selectIsPending(store.getState())).toBe(false);
186 | });
187 | });
188 |
189 | describe('with all', () => {
190 | test('should not complete pending state when one of the fetches(sent by promise and toolkit) is completed', async () => {
191 | fetchMock.mockResponseOnce(...patentsFetchMock);
192 |
193 | const getPatentsAction: Actions.GetPatents = getPatents();
194 |
195 | store.dispatch(getPatentsAction);
196 | store.dispatch(getLibraryContent('test'));
197 | await getPatentsAction.payload;
198 | expect(selectIsPending(store.getState())).toBe(true);
199 | });
200 |
201 | test('should complete pending state when all fetches(sent by promise and toolkit) are completed', async () => {
202 | fetchMock.mockResponses(patentsFetchMock, libraryContentFetchMock);
203 |
204 | const getPatentsAction: Actions.GetPatents = getPatents();
205 |
206 | store.dispatch(getPatentsAction);
207 |
208 | await Promise.all([
209 | store.dispatch(getLibraryContent('test')),
210 | getPatentsAction.payload
211 | ]);
212 |
213 | expect(selectIsPending(store.getState())).toBe(false);
214 | });
215 |
216 | test('should not complete pending state when one of the fetches(sent by promise and saga) is completed', async () => {
217 | fetchMock.mockResponse(...patentsFetchMock);
218 |
219 | const sagaTester = createSagaTesterInstance(middleware);
220 | const getPatentsAction: Actions.GetPatents = getPatents();
221 |
222 | sagaTester.start(rootSaga);
223 | sagaTester.dispatch(getPatentsAction);
224 | sagaTester.dispatch(getAstronomyPictureData);
225 | await getPatentsAction.payload;
226 | expect(selectIsPending(sagaTester.getState())).toBe(true);
227 | });
228 |
229 | test('should complete pending state when all fetches(sent by promise and saga) are completed', async () => {
230 | fetchMock.mockResponses(patentsFetchMock, astronomyPictureFetchMock);
231 |
232 | const sagaTester = createSagaTesterInstance(middleware);
233 | const getPatentsAction: Actions.GetPatents = getPatents();
234 |
235 | sagaTester.start(astronomyPictureWorker);
236 | sagaTester.dispatch(getPatentsAction);
237 | sagaTester.dispatch(getAstronomyPictureData);
238 |
239 | await Promise.all([
240 | getPatentsAction.payload,
241 | sagaTester.waitFor(astronomyPictureActionNames.FULFILLED)
242 | ]);
243 |
244 | expect(selectIsPending(sagaTester.getState())).toBe(false);
245 | });
246 |
247 | test('should not complete pending state when one of the fetches(sent by toolkit and saga) is completed', async () => {
248 | fetchMock.mockResponseOnce(...astronomyPictureFetchMock);
249 |
250 | const sagaTester = createSagaTesterInstance(middleware);
251 |
252 | sagaTester.start(rootSaga);
253 | sagaTester.dispatch(getAstronomyPictureData);
254 | sagaTester.dispatch(getLibraryContent('test'));
255 | await sagaTester.waitFor(astronomyPictureActionNames.FULFILLED);
256 | expect(selectIsPending(sagaTester.getState())).toBe(true);
257 | });
258 |
259 | test('should complete pending state when all fetches(sent by toolkit and saga) are completed', async () => {
260 | fetchMock.mockResponses(
261 | libraryContentFetchMock,
262 | astronomyPictureFetchMock
263 | );
264 |
265 | const sagaTester = createSagaTesterInstance(middleware);
266 | const libraryContentPromise = sagaTester.dispatch(
267 | getLibraryContent('test')
268 | );
269 |
270 | sagaTester.start(rootSaga);
271 | sagaTester.dispatch(getAstronomyPictureData);
272 |
273 | // eslint-disable-next-line compat/compat
274 | await Promise.all([
275 | libraryContentPromise,
276 | sagaTester.waitFor(astronomyPictureActionNames.FULFILLED)
277 | ]);
278 |
279 | expect(selectIsPending(sagaTester.getState())).toBe(false);
280 | });
281 |
282 | describe('when ignore', () => {
283 | test(`should not complete pending state when fetch is completed for ignored action, but isn't completed for tracked action (saga is ignored)`, async () => {
284 | fetchMock.mockResponseOnce(...astronomyPictureFetchMock);
285 |
286 | const sagaTester = createSagaTesterInstance(
287 | middlewaresWithSagaActionsIgnored
288 | );
289 |
290 | sagaTester.start(rootSaga);
291 | sagaTester.dispatch(getAstronomyPictureData);
292 | sagaTester.dispatch(getLibraryContent('test'));
293 | await sagaTester.waitFor(astronomyPictureActionNames.FULFILLED);
294 | expect(selectIsPending(sagaTester.getState())).toBe(true);
295 | });
296 |
297 | test(`should not complete pending state when fetch is completed for ignored action, but isn't completed for tracked action (toolkit is ignored)`, async () => {
298 | fetchMock.mockResponseOnce(...libraryContentFetchMock);
299 | fetchMock.dontMockIf(urls.PATENTS);
300 |
301 | const store: Store = configureStore({
302 | reducer,
303 | middleware: middlewaresWithToolkitActionsIgnored
304 | });
305 |
306 | store.dispatch(getPatents());
307 | await store.dispatch(getLibraryContent('test'));
308 | expect(selectIsPending(store.getState())).toBe(true);
309 | });
310 |
311 | test(`should not complete pending state when fetch is completed for ignored action, but isn't completed for tracked action (promise is ignored)`, async () => {
312 | fetchMock.mockResponseOnce(...patentsFetchMock);
313 |
314 | const store: Store = configureStore({
315 | reducer,
316 | middleware: middlewaresWithPromiseActionsIgnored
317 | });
318 | const getPatentsAction: Actions.GetPatents = getPatents();
319 |
320 | store.dispatch(getPatentsAction);
321 | store.dispatch(getLibraryContent('test'));
322 | await getPatentsAction.payload;
323 | expect(selectIsPending(store.getState())).toBe(true);
324 | });
325 | });
326 | });
327 | });
328 |
--------------------------------------------------------------------------------