├── docs ├── .nojekyll ├── CNAME ├── banner.png ├── favicon.ico ├── _media │ ├── logo.png │ ├── by-lundegaard.png │ └── logo-transparent.png ├── _coverpage.md ├── getting-started │ ├── examples.md │ ├── introduction.md │ └── quick-start.md ├── packages │ ├── thunk.md │ ├── epics-react.md │ ├── reducers-react.md │ ├── middleware-react.md │ ├── reducers.md │ ├── stream-creators.md │ ├── epics.md │ ├── namespaces-react.md │ ├── middleware.md │ ├── actions.md │ └── namespaces.md ├── _sidebar.md ├── tutorial │ ├── 04-view-state-management.md │ └── 03-multi-instance-components.md ├── faq │ ├── general.md │ └── internals.md └── index.html ├── packages ├── stream-creators │ ├── src │ │ ├── index.js │ │ ├── streamCreators.js │ │ └── streamCreators.test.js │ └── package.json ├── reducers │ ├── src │ │ ├── constants.js │ │ ├── __snapshots__ │ │ │ └── constants.test.js.snap │ │ ├── constants.test.js │ │ ├── composeReducers.js │ │ ├── filterReducer.js │ │ ├── index.js │ │ ├── composeReducers.test.js │ │ ├── combineReducerSchema.js │ │ ├── combineReducerEntries.js │ │ ├── combineReducers.js │ │ ├── filterReducer.test.js │ │ ├── makeReducer.js │ │ ├── combineReducerSchema.test.js │ │ ├── makeEnhancer.js │ │ ├── makeReducer.test.js │ │ ├── combineReducers.test.js │ │ └── combineReducerEntries.test.js │ └── package.json ├── epics │ ├── src │ │ ├── index.js │ │ ├── makeEnhancer.js │ │ └── makeEnhancer.test.js │ └── package.json ├── namespaces │ ├── src │ │ ├── constants.js │ │ ├── constants.test.js │ │ ├── __snapshots__ │ │ │ └── constants.test.js.snap │ │ ├── getNamespaceByAction.js │ │ ├── preventNamespace.test.js │ │ ├── attachNamespace.js │ │ ├── defaultNamespace.js │ │ ├── getStateByNamespace.js │ │ ├── preventNamespace.js │ │ ├── getStateByFeatureAndNamespace.js │ │ ├── getNamespaceByAction.test.js │ │ ├── getStateByAction.js │ │ ├── getStateByFeatureAndAction.js │ │ ├── index.js │ │ ├── getStateByNamespace.test.js │ │ ├── getStateByAction.test.js │ │ ├── mergeNamespace.js │ │ ├── attachNamespace.test.js │ │ ├── getStateByFeatureAndNamespace.test.js │ │ ├── isActionFromNamespace.js │ │ ├── getStateByFeatureAndAction.test.js │ │ ├── defaultNamespace.test.js │ │ └── isActionFromNamespace.test.js │ └── package.json ├── injectors-react │ ├── src │ │ ├── index.js │ │ ├── constants.js │ │ ├── constants.test.js │ │ ├── __snapshots__ │ │ │ └── constants.test.js.snap │ │ ├── makeDecorator.test.js │ │ ├── makeDecorator.js │ │ ├── makeHook.test.js │ │ └── makeHook.js │ └── package.json ├── actions │ ├── src │ │ ├── alwaysUndefined.js │ │ ├── isErrorAction.js │ │ ├── isErrorAction.test.js │ │ ├── isNotErrorAction.test.js │ │ ├── isNotErrorAction.js │ │ ├── index.js │ │ ├── makeEmptyActionCreator.test.js │ │ ├── makePayloadActionCreator.test.js │ │ ├── makeEmptyActionCreator.js │ │ ├── makePayloadActionCreator.js │ │ ├── configureActionCreator.test.js │ │ ├── makePayloadMetaActionCreator.js │ │ ├── configureActionCreator.js │ │ └── makePayloadMetaActionCreator.test.js │ └── package.json ├── middleware │ ├── src │ │ ├── index.js │ │ ├── composeMiddleware.js │ │ ├── composeMiddleware.test.js │ │ └── makeEnhancer.js │ └── package.json ├── utils-react │ ├── src │ │ ├── index.js │ │ ├── getDisplayName.test.js │ │ ├── getDisplayName.js │ │ ├── mapProps.js │ │ ├── withProps.js │ │ ├── mapProps.test.js │ │ └── withProps.test.js │ └── package.json ├── injectors │ ├── src │ │ ├── index.js │ │ ├── makeStoreInterface.js │ │ ├── makeStoreInterface.test.js │ │ ├── createEntries.js │ │ ├── enhanceStore.js │ │ ├── createEntries.test.js │ │ └── enhanceStore.test.js │ └── package.json ├── utils │ ├── src │ │ ├── withoutOnce.js │ │ ├── prefixedValueMirror.test.js │ │ ├── getKeysLength.js │ │ ├── getKeysLength.test.js │ │ ├── index.js │ │ ├── pickFunctions.js │ │ ├── pickFunctions.test.js │ │ ├── withoutOnce.test.js │ │ ├── prefixedValueMirror.js │ │ ├── includesTimes.js │ │ └── includesTimes.test.js │ └── package.json ├── namespaces-react │ ├── src │ │ ├── contexts.js │ │ ├── contexts.test.js │ │ ├── index.js │ │ ├── useNamespace.js │ │ ├── useNamespacedDispatch.js │ │ ├── withNamespaceProvider.js │ │ ├── withNamespaceProvider.test.js │ │ ├── useNamespacedSelector.js │ │ ├── NamespaceProvider.js │ │ ├── useNamespace.test.js │ │ ├── useNamespacedSelector.test.js │ │ ├── useNamespacedDispatch.test.js │ │ ├── namespacedConnect.js │ │ └── NamespaceProvider.test.js │ └── package.json ├── epics-react │ ├── src │ │ └── index.js │ └── package.json ├── reducers-react │ ├── src │ │ └── index.js │ └── package.json ├── middleware-react │ ├── src │ │ └── index.js │ └── package.json ├── thunk │ ├── src │ │ ├── index.js │ │ └── index.test.js │ └── package.json └── react │ ├── package.json │ └── src │ └── index.js ├── .huskyrc ├── tests └── enzymeSetup.js ├── .lintstagedrc ├── .eslintignore ├── VERSION_LOCKS.md ├── lerna.json ├── .editorconfig ├── .prettierrc ├── .travis.yml ├── jest.config.js ├── rollup ├── utils.js └── plugins.js ├── babel.config.js ├── CONTRIBUTING.md ├── LICENSE ├── .eslintrc.js ├── package.json ├── README.md └── rollup.config.js /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | redux-tools.js.org -------------------------------------------------------------------------------- /packages/stream-creators/src/index.js: -------------------------------------------------------------------------------- 1 | export * from './streamCreators'; 2 | -------------------------------------------------------------------------------- /docs/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lundegaard/redux-tools/HEAD/docs/banner.png -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lundegaard/redux-tools/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /docs/_media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lundegaard/redux-tools/HEAD/docs/_media/logo.png -------------------------------------------------------------------------------- /packages/reducers/src/constants.js: -------------------------------------------------------------------------------- 1 | export const ROOT_KEY = '@redux-tools/reducers/ROOT_KEY'; 2 | -------------------------------------------------------------------------------- /packages/epics/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as makeEnhancer, storeInterface } from './makeEnhancer'; 2 | -------------------------------------------------------------------------------- /docs/_media/by-lundegaard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lundegaard/redux-tools/HEAD/docs/_media/by-lundegaard.png -------------------------------------------------------------------------------- /docs/_media/logo-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lundegaard/redux-tools/HEAD/docs/_media/logo-transparent.png -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "pre-push": "yarn && yarn test && yarn lint" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/namespaces/src/constants.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_FEATURE = 'namespaces'; 2 | export const NAMESPACE_PREVENTED = 'NAMESPACE_PREVENTED'; 3 | -------------------------------------------------------------------------------- /packages/injectors-react/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as makeDecorator } from './makeDecorator'; 2 | export { default as makeHook } from './makeHook'; 3 | -------------------------------------------------------------------------------- /packages/actions/src/alwaysUndefined.js: -------------------------------------------------------------------------------- 1 | import { always } from 'ramda'; 2 | 3 | const alwaysUndefined = always(undefined); 4 | 5 | export default alwaysUndefined; 6 | -------------------------------------------------------------------------------- /tests/enzymeSetup.js: -------------------------------------------------------------------------------- 1 | const { configure } = require('enzyme'); 2 | const Adapter = require('enzyme-adapter-react-16'); 3 | 4 | configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /packages/injectors-react/src/constants.js: -------------------------------------------------------------------------------- 1 | export const IS_SERVER = Boolean( 2 | typeof process !== 'undefined' && process && process.versions && process.versions.node 3 | ); 4 | -------------------------------------------------------------------------------- /packages/middleware/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as makeEnhancer, storeInterface } from './makeEnhancer'; 2 | export { default as composeMiddleware } from './composeMiddleware'; 3 | -------------------------------------------------------------------------------- /packages/reducers/src/__snapshots__/constants.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`constants should not be changed 1`] = `"@redux-tools/reducers/ROOT_KEY"`; 4 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "**/*.js": [ 3 | "prettier --ignore-path .gitignore --write", 4 | "yarn lint --fix" 5 | ], 6 | "**/*.{mdx,md,html,json}": [ 7 | "prettier --write" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | build/* 3 | stats.json 4 | packages/*/node_modules/* 5 | packages/*/build/* 6 | packages/*/dist/* 7 | packages/*/es/* 8 | packages/*/lib/* 9 | coverage/* 10 | docs/* 11 | -------------------------------------------------------------------------------- /packages/utils-react/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as getDisplayName } from './getDisplayName'; 2 | export { default as withProps } from './withProps'; 3 | export { default as mapProps } from './mapProps'; 4 | -------------------------------------------------------------------------------- /packages/reducers/src/constants.test.js: -------------------------------------------------------------------------------- 1 | import { ROOT_KEY } from './constants'; 2 | 3 | describe('constants', () => { 4 | it('should not be changed', () => { 5 | expect(ROOT_KEY).toMatchSnapshot(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/namespaces/src/constants.test.js: -------------------------------------------------------------------------------- 1 | import * as Constants from './constants'; 2 | 3 | describe('constants', () => { 4 | it('should not be changed', () => { 5 | expect(Constants).toMatchSnapshot(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/injectors-react/src/constants.test.js: -------------------------------------------------------------------------------- 1 | import * as Constants from './constants'; 2 | 3 | describe('constants', () => { 4 | it('should not be changed', () => { 5 | expect(Constants).toMatchSnapshot(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/injectors/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as createEntries } from './createEntries'; 2 | export { default as enhanceStore } from './enhanceStore'; 3 | export { default as makeStoreInterface } from './makeStoreInterface'; 4 | -------------------------------------------------------------------------------- /packages/injectors-react/src/__snapshots__/constants.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`constants should not be changed 1`] = ` 4 | Object { 5 | "IS_SERVER": true, 6 | "__esModule": true, 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /packages/utils/src/withoutOnce.js: -------------------------------------------------------------------------------- 1 | import { curry, remove, indexOf, reduce } from 'ramda'; 2 | 3 | const withoutOnce = curry((xs, from) => 4 | reduce((acc, x) => remove(indexOf(x, acc), 1, acc), from, xs) 5 | ); 6 | 7 | export default withoutOnce; 8 | -------------------------------------------------------------------------------- /VERSION_LOCKS.md: -------------------------------------------------------------------------------- 1 | # Version locks 2 | 3 | This file describes reasons for why some dependencies have an exact version instead of a caret one. 4 | 5 | - `eslint-plugin-import@2.20.0` because higher version throws ordering errors for imports on Windows 6 | -------------------------------------------------------------------------------- /packages/middleware/src/composeMiddleware.js: -------------------------------------------------------------------------------- 1 | import { map, applyTo, compose } from 'ramda'; 2 | 3 | const composeMiddleware = (...middleware) => reduxApi => next => 4 | compose(...map(applyTo(reduxApi), middleware))(next); 5 | 6 | export default composeMiddleware; 7 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "lerna": "2.2.0", 3 | "version": "0.9.1", 4 | "command": { 5 | "init": { 6 | "exact": true 7 | }, 8 | "publish": { 9 | "ignoreChanges": ["*.md", "test/**"] 10 | } 11 | }, 12 | "npmClient": "yarn", 13 | "useWorkspaces": true 14 | } 15 | -------------------------------------------------------------------------------- /packages/actions/src/isErrorAction.js: -------------------------------------------------------------------------------- 1 | import { propEq } from 'ramda'; 2 | 3 | /** 4 | * Returns whether the action is an error action or not. 5 | * 6 | * @param {Object} action Redux action 7 | * @returns {boolean} 8 | */ 9 | export default propEq('error', true); 10 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/contexts.js: -------------------------------------------------------------------------------- 1 | import { alwaysNull } from 'ramda-extension'; 2 | import { createContext } from 'react'; 3 | 4 | export const NamespaceContext = createContext({ 5 | namespaces: {}, 6 | useNamespace: alwaysNull, 7 | isUseNamespaceProvided: false, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/reducers/src/composeReducers.js: -------------------------------------------------------------------------------- 1 | import { reduceRight } from 'ramda'; 2 | 3 | const composeReducers = (...reducers) => (state, action) => 4 | reduceRight((reducer, currentState) => reducer(currentState, action), state, reducers); 5 | 6 | export default composeReducers; 7 | -------------------------------------------------------------------------------- /packages/epics-react/src/index.js: -------------------------------------------------------------------------------- 1 | import { storeInterface } from '@redux-tools/epics'; 2 | import { makeHook, makeDecorator } from '@redux-tools/injectors-react'; 3 | 4 | export const useEpics = makeHook(storeInterface); 5 | export const withEpics = makeDecorator(storeInterface, useEpics); 6 | -------------------------------------------------------------------------------- /packages/utils/src/prefixedValueMirror.test.js: -------------------------------------------------------------------------------- 1 | import prefixedValueMirror from './prefixedValueMirror'; 2 | 3 | describe('prefixedValueMirror', () => { 4 | it('correctly transforms an array', () => { 5 | expect(prefixedValueMirror('yo', ['HI'])).toEqual({ HI: 'yo/HI' }); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /docs/_coverpage.md: -------------------------------------------------------------------------------- 1 | [![logo](_media/logo-transparent.png ':size=200')](#main) 2 | 3 | > Maintaining large Redux applications with ease. 4 | 5 | [GitHub](https://github.com/lundegaard/redux-tools/) 6 | [Getting Started](#main) 7 | 8 | 9 | 10 | ![color](#f0f0f0) 11 | -------------------------------------------------------------------------------- /packages/reducers/src/filterReducer.js: -------------------------------------------------------------------------------- 1 | import { isActionFromNamespace } from '@redux-tools/namespaces'; 2 | 3 | const filterReducer = (reducer, namespace) => (state, action) => 4 | isActionFromNamespace(namespace, action) ? reducer(state, action) : state; 5 | 6 | export default filterReducer; 7 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/contexts.test.js: -------------------------------------------------------------------------------- 1 | import { NamespaceContext } from './contexts'; 2 | 3 | describe('NamespaceContext', () => { 4 | it('is a React context', () => { 5 | expect(NamespaceContext.Consumer).toBeDefined(); 6 | expect(NamespaceContext.Provider).toBeDefined(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/reducers/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as makeEnhancer, storeInterface } from './makeEnhancer'; 2 | export { default as composeReducers } from './composeReducers'; 3 | export { default as makeReducer } from './makeReducer'; 4 | export { default as combineReducers } from './combineReducers'; 5 | -------------------------------------------------------------------------------- /packages/reducers-react/src/index.js: -------------------------------------------------------------------------------- 1 | import { makeHook, makeDecorator } from '@redux-tools/injectors-react'; 2 | import { storeInterface } from '@redux-tools/reducers'; 3 | 4 | export const useReducers = makeHook(storeInterface); 5 | export const withReducers = makeDecorator(storeInterface, useReducers); 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | 7 | [*.js] 8 | indent_style = tab 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [package.json] 14 | indent_style = space 15 | 16 | [*.md] 17 | indent_style = space 18 | -------------------------------------------------------------------------------- /packages/namespaces/src/__snapshots__/constants.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`constants should not be changed 1`] = ` 4 | Object { 5 | "DEFAULT_FEATURE": "namespaces", 6 | "NAMESPACE_PREVENTED": "NAMESPACE_PREVENTED", 7 | "__esModule": true, 8 | } 9 | `; 10 | -------------------------------------------------------------------------------- /packages/actions/src/isErrorAction.test.js: -------------------------------------------------------------------------------- 1 | import isErrorAction from './isErrorAction'; 2 | 3 | describe('isErrorAction', () => { 4 | it('correctly identifies an error action', () => { 5 | expect(isErrorAction({ error: true })).toBe(true); 6 | expect(isErrorAction({ error: false })).toBe(false); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/middleware-react/src/index.js: -------------------------------------------------------------------------------- 1 | import { makeHook, makeDecorator } from '@redux-tools/injectors-react'; 2 | import { storeInterface } from '@redux-tools/middleware'; 3 | 4 | export const useMiddleware = makeHook(storeInterface); 5 | export const withMiddleware = makeDecorator(storeInterface, useMiddleware); 6 | -------------------------------------------------------------------------------- /packages/utils/src/getKeysLength.js: -------------------------------------------------------------------------------- 1 | import { o, length, keys } from 'ramda'; 2 | 3 | /** 4 | * Returns a number of object keys. 5 | * 6 | * @param {Object} source object 7 | * @returns {number} number of object keys 8 | */ 9 | const getKeysLength = o(length, keys); 10 | 11 | export default getKeysLength; 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5", 7 | "useTabs": true, 8 | "overrides": [ 9 | { 10 | "files": "package.json", 11 | "options": { 12 | "tabWidth": 2, 13 | "useTabs": false 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | before_install: 5 | - curl -o- -L https://yarnpkg.com/install.sh | bash 6 | - export PATH=$HOME/.yarn/bin:$PATH 7 | cache: 8 | yarn: true 9 | directories: 10 | - node_modules 11 | script: 12 | - yarn install 13 | - yarn test 14 | - yarn lint 15 | -------------------------------------------------------------------------------- /packages/utils/src/getKeysLength.test.js: -------------------------------------------------------------------------------- 1 | import getKeysLength from './getKeysLength'; 2 | 3 | const entry = { 4 | a: 'foo', 5 | b: 'bar', 6 | }; 7 | 8 | describe('getKeysLength', () => { 9 | it('correctly returns number of object keys', () => { 10 | expect(getKeysLength(entry)).toEqual(2); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/actions/src/isNotErrorAction.test.js: -------------------------------------------------------------------------------- 1 | import isNotErrorAction from './isNotErrorAction'; 2 | 3 | describe('isNotErrorAction', () => { 4 | it('correctly identifies an error action', () => { 5 | expect(isNotErrorAction({ error: true })).toBe(false); 6 | expect(isNotErrorAction({ error: false })).toBe(true); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/actions/src/isNotErrorAction.js: -------------------------------------------------------------------------------- 1 | import { complement } from 'ramda'; 2 | 3 | import isErrorAction from './isErrorAction'; 4 | 5 | /** 6 | * Returns whether the action is NOT an error action. 7 | * 8 | * @param {Object} action Redux action 9 | * @returns {boolean} 10 | */ 11 | export default complement(isErrorAction); 12 | -------------------------------------------------------------------------------- /packages/utils/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as prefixedValueMirror } from './prefixedValueMirror'; 2 | export { default as includesTimes } from './includesTimes'; 3 | export { default as withoutOnce } from './withoutOnce'; 4 | export { default as pickFunctions } from './pickFunctions'; 5 | export { default as getKeysLength } from './getKeysLength'; 6 | -------------------------------------------------------------------------------- /packages/namespaces/src/getNamespaceByAction.js: -------------------------------------------------------------------------------- 1 | import { path } from 'ramda'; 2 | 3 | /** 4 | * Returns the namespace of an action. 5 | * 6 | * @param {Object} action Redux action 7 | * @returns {?string} namespace of the action 8 | */ 9 | const getNamespaceByAction = path(['meta', 'namespace']); 10 | 11 | export default getNamespaceByAction; 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const ignorePatterns = ['/.history/', '/node_modules/', '/es', '/dist', '/lib', '/rollup']; 2 | 3 | module.exports = { 4 | bail: true, 5 | verbose: true, 6 | testPathIgnorePatterns: ignorePatterns, 7 | coveragePathIgnorePatterns: ignorePatterns, 8 | snapshotSerializers: ['enzyme-to-json/serializer'], 9 | setupFilesAfterEnv: ['/tests/enzymeSetup.js'], 10 | }; 11 | -------------------------------------------------------------------------------- /packages/namespaces/src/preventNamespace.test.js: -------------------------------------------------------------------------------- 1 | import { NAMESPACE_PREVENTED } from './constants'; 2 | import preventNamespace from './preventNamespace'; 3 | 4 | describe('preventNamespace', () => { 5 | it('sets default namespace', () => { 6 | expect(preventNamespace({ meta: { namespace: 'foo' } })).toEqual({ 7 | meta: { namespace: NAMESPACE_PREVENTED }, 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/namespaces/src/attachNamespace.js: -------------------------------------------------------------------------------- 1 | import mergeNamespace from './mergeNamespace'; 2 | 3 | /** 4 | * Associates an action with a namespace, overwriting any previous namespace. 5 | * 6 | * @param {?string} namespace namespace to attach 7 | * @param {Object} action action to add the namespace to 8 | */ 9 | const attachNamespace = mergeNamespace(true); 10 | 11 | export default attachNamespace; 12 | -------------------------------------------------------------------------------- /packages/namespaces/src/defaultNamespace.js: -------------------------------------------------------------------------------- 1 | import mergeNamespace from './mergeNamespace'; 2 | 3 | /** 4 | * Associates an action with a namespace unless it is already associated with some namespace. 5 | * 6 | * @param {?string} namespace namespace to attach 7 | * @param {Object} action action to add the namespace to 8 | */ 9 | const defaultNamespace = mergeNamespace(false); 10 | 11 | export default defaultNamespace; 12 | -------------------------------------------------------------------------------- /packages/utils/src/pickFunctions.js: -------------------------------------------------------------------------------- 1 | import { pickBy } from 'ramda'; 2 | import { isFunction } from 'ramda-extension'; 3 | 4 | /** 5 | * Returns a partial copy of an object containing only the keys that satisfy the supplied predicate. 6 | * 7 | * @param {Object} from source object 8 | * @returns {Object} object new object containing only functions 9 | */ 10 | const pickFunctions = pickBy(isFunction); 11 | 12 | export default pickFunctions; 13 | -------------------------------------------------------------------------------- /packages/utils-react/src/getDisplayName.test.js: -------------------------------------------------------------------------------- 1 | import getDisplayName from './getDisplayName'; 2 | 3 | describe('getDisplayName', () => { 4 | it('returns the most appropriate display name', () => { 5 | expect(getDisplayName({ displayName: 'yo', name: 'hi' })).toBe('yo'); 6 | expect(getDisplayName({ name: 'hi' })).toBe('hi'); 7 | expect(getDisplayName('div')).toBe('div'); 8 | expect(getDisplayName({})).toBe('Component'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /rollup/utils.js: -------------------------------------------------------------------------------- 1 | import { split, compose, join, prepend, tail, map } from 'ramda'; 2 | import { toPascalCase, toKebabCase } from 'ramda-extension'; 3 | 4 | export const getGlobalName = compose( 5 | join(''), 6 | prepend('ReduxTools'), 7 | map(toPascalCase), 8 | tail, 9 | split('/') 10 | ); 11 | 12 | export const getFileName = compose( 13 | join('-'), 14 | prepend('redux-tools'), 15 | map(toKebabCase), 16 | tail, 17 | split('/') 18 | ); 19 | -------------------------------------------------------------------------------- /packages/reducers/src/composeReducers.test.js: -------------------------------------------------------------------------------- 1 | import composeReducers from './composeReducers'; 2 | 3 | describe('composeReducers', () => { 4 | it('correctly composes reducers', () => { 5 | const reducer = composeReducers( 6 | (state, action) => state - action.payload, 7 | (state, action) => state * action.payload, 8 | (state, action) => state + action.payload 9 | ); 10 | 11 | expect(reducer(1, { payload: 3 })).toBe(9); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/namespaces/src/getStateByNamespace.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_FEATURE } from './constants'; 2 | import getStateByFeatureAndNamespace from './getStateByFeatureAndNamespace'; 3 | 4 | /** 5 | * Returns Redux state by namespace. 6 | * 7 | * @param {string} namespace 8 | * @param {Object} state 9 | * @returns {*} state slice 10 | */ 11 | const getStateByNamespace = getStateByFeatureAndNamespace(DEFAULT_FEATURE); 12 | 13 | export default getStateByNamespace; 14 | -------------------------------------------------------------------------------- /packages/namespaces/src/preventNamespace.js: -------------------------------------------------------------------------------- 1 | import { NAMESPACE_PREVENTED } from './constants'; 2 | import mergeNamespace from './mergeNamespace'; 3 | 4 | /** 5 | * Associates an action with a "global" namespace, overwriting any previous namespace. 6 | * 7 | * @param {Object} action action to add the "global" namespace to 8 | */ 9 | const preventNamespace = action => mergeNamespace(true, NAMESPACE_PREVENTED, action); 10 | 11 | export default preventNamespace; 12 | -------------------------------------------------------------------------------- /packages/utils/src/pickFunctions.test.js: -------------------------------------------------------------------------------- 1 | import pickFunctions from './pickFunctions'; 2 | 3 | const functionA = () => 'a'; 4 | const functionB = () => 'b'; 5 | 6 | const entryA = { 7 | a: functionA, 8 | b: functionB, 9 | c: 'foo', 10 | d: 'bar', 11 | }; 12 | 13 | describe('pickByFunction', () => { 14 | it('correctly picks functions from source object', () => { 15 | expect(pickFunctions(entryA)).toEqual({ a: functionA, b: functionB }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/namespaces/src/getStateByFeatureAndNamespace.js: -------------------------------------------------------------------------------- 1 | import { curry, path } from 'ramda'; 2 | 3 | /** 4 | * Returns Redux state by feature and namespace. 5 | * 6 | * @param {string} feature 7 | * @param {string} namespace 8 | * @param {Object} state 9 | * @returns {*} state slice 10 | */ 11 | const getStateByFeatureAndNamespace = curry((feature, namespace, state) => 12 | path([feature, namespace], state) 13 | ); 14 | 15 | export default getStateByFeatureAndNamespace; 16 | -------------------------------------------------------------------------------- /packages/namespaces/src/getNamespaceByAction.test.js: -------------------------------------------------------------------------------- 1 | import getNamespaceByAction from './getNamespaceByAction'; 2 | 3 | const fooAction = { meta: { namespace: 'foo' } }; 4 | 5 | describe('getNamespaceByAction', () => { 6 | it('returns the namespace of an action', () => { 7 | expect(getNamespaceByAction(fooAction)).toBe('foo'); 8 | }); 9 | 10 | it('returns undefined if action does not have meta', () => { 11 | expect(getNamespaceByAction({})).toBeUndefined(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/namespaces/src/getStateByAction.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_FEATURE } from './constants'; 2 | import getStateByFeatureAndAction from './getStateByFeatureAndAction'; 3 | 4 | /** 5 | * Returns Redux state by action namespace. 6 | * 7 | * @param {Object} action action with a `meta.namespace` property 8 | * @param {Object} state 9 | * @returns {*} state slice 10 | */ 11 | const getStateByAction = getStateByFeatureAndAction(DEFAULT_FEATURE); 12 | 13 | export default getStateByAction; 14 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as NamespaceProvider } from './NamespaceProvider'; 2 | export { default as namespacedConnect } from './namespacedConnect'; 3 | export { default as useNamespace } from './useNamespace'; 4 | export { default as useNamespacedDispatch } from './useNamespacedDispatch'; 5 | export { default as useNamespacedSelector } from './useNamespacedSelector'; 6 | export { default as withNamespaceProvider } from './withNamespaceProvider'; 7 | export { NamespaceContext } from './contexts'; 8 | -------------------------------------------------------------------------------- /packages/utils-react/src/getDisplayName.js: -------------------------------------------------------------------------------- 1 | import { prop, unless, both, always } from 'ramda'; 2 | import { isString, isNotEmpty, dispatch } from 'ramda-extension'; 3 | 4 | /** 5 | * Returns the display name of a React component, falling back appropriately. 6 | * 7 | * @param {React.Component} Component React component to get the name of 8 | * @returns {string} 9 | */ 10 | export default dispatch([ 11 | prop('displayName'), 12 | prop('name'), 13 | unless(both(isString, isNotEmpty), always('Component')), 14 | ]); 15 | -------------------------------------------------------------------------------- /packages/utils/src/withoutOnce.test.js: -------------------------------------------------------------------------------- 1 | import withoutOnce from './withoutOnce'; 2 | 3 | describe('withoutOnce', () => { 4 | const a = { a: 1 }; 5 | const b = { b: 1 }; 6 | const c = { c: 1 }; 7 | 8 | it('removes each item exactly once', () => { 9 | expect(withoutOnce([a, b, c], [a, b, c, a, b, c, a, b, c])).toEqual([a, b, c, a, b, c]); 10 | }); 11 | 12 | it('removes each item exactly once for multiple occurrences', () => { 13 | expect(withoutOnce([a, b, c, a, b, c], [a, b, c, a, b, c, a, b, c])).toEqual([a, b, c]); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/utils-react/src/mapProps.js: -------------------------------------------------------------------------------- 1 | import hoistNonReactStatics from 'hoist-non-react-statics'; 2 | import { curry } from 'ramda'; 3 | import React from 'react'; 4 | 5 | import getDisplayName from './getDisplayName'; 6 | 7 | const mapProps = curry((getProps, NextComponent) => { 8 | const MapProps = props => ; 9 | 10 | hoistNonReactStatics(MapProps, NextComponent); 11 | 12 | MapProps.displayName = `MapProps(${getDisplayName(NextComponent)})`; 13 | 14 | return MapProps; 15 | }); 16 | 17 | export default mapProps; 18 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/useNamespace.js: -------------------------------------------------------------------------------- 1 | import { alwaysNull } from 'ramda-extension'; 2 | import { useContext } from 'react'; 3 | 4 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 5 | 6 | import { NamespaceContext } from './contexts'; 7 | 8 | const useNamespace = feature => { 9 | const { namespaces = {}, useNamespace = alwaysNull } = useContext(NamespaceContext); 10 | const namespace = useNamespace(feature ?? DEFAULT_FEATURE, namespaces); 11 | 12 | return namespaces[feature] ?? namespace ?? null; 13 | }; 14 | 15 | export default useNamespace; 16 | -------------------------------------------------------------------------------- /packages/utils/src/prefixedValueMirror.js: -------------------------------------------------------------------------------- 1 | import { o, map, curry } from 'ramda'; 2 | import { valueMirror } from 'ramda-extension'; 3 | 4 | const addPrefix = prefix => map(x => `${prefix}/${x}`); 5 | 6 | /** 7 | * Works just like `valueMirror` from ramda-extension, but prefixes each value. 8 | * 9 | * @param {string} prefix a string to prefix each value with 10 | * @param {Array} xs array of values to mirror as keys 11 | * @returns {Object} object with values set to `prefix/key` 12 | */ 13 | export default curry((prefix, xs) => o(addPrefix(prefix), valueMirror)(xs)); 14 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const { NODE_ENV, BABEL_ENV } = process.env; 2 | const cjs = NODE_ENV === 'test' || BABEL_ENV === 'commonjs'; 3 | const loose = true; 4 | 5 | module.exports = { 6 | presets: [['@babel/env', { loose, modules: false }]], 7 | plugins: [ 8 | ['@babel/plugin-proposal-class-properties', { loose }], 9 | ['@babel/plugin-proposal-object-rest-spread', { loose }], 10 | '@babel/plugin-transform-react-jsx', 11 | cjs && ['@babel/plugin-transform-modules-commonjs', { loose }], 12 | ['@babel/plugin-transform-runtime', { useESModules: !cjs }], 13 | ].filter(Boolean), 14 | }; 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Fork the project and clone your fork. 4 | 5 | 2. Create a local feature branch: 6 | 7 | $ git checkout -b 8 | 9 | 3. Make one or more atomic commits. Each commit should have a descriptive commit message, wrapped at 72 characters. 10 | 11 | 4. Run `yarn test` and address any errors. Preferably, fix commits in place using `git rebase` or `git commit --amend` to make the changes easier to review and to keep the history tidy. 12 | 13 | 5. Push to your fork: 14 | 15 | $ git push origin 16 | 17 | 6. Open a pull request. 18 | -------------------------------------------------------------------------------- /packages/utils/src/includesTimes.js: -------------------------------------------------------------------------------- 1 | import { o, filter, curry, equals } from 'ramda'; 2 | import { equalsLength } from 'ramda-extension'; 3 | 4 | /** 5 | * Returns whether an element is included exactly N times in an array. 6 | * 7 | * @param {number} times how many times the element must be included 8 | * @param {Object[]} xs array to search through 9 | * @param {Object} x element to search for 10 | * @returns {boolean} whether the element is included exactly N times 11 | */ 12 | const includesTimes = curry((times, x, xs) => o(equalsLength(times), filter(equals(x)))(xs)); 13 | 14 | export default includesTimes; 15 | -------------------------------------------------------------------------------- /packages/namespaces/src/getStateByFeatureAndAction.js: -------------------------------------------------------------------------------- 1 | import { curry } from 'ramda'; 2 | 3 | import getStateByFeatureAndNamespace from './getStateByFeatureAndNamespace'; 4 | 5 | /** 6 | * Returns Redux state by feature and action namespace. 7 | * 8 | * @param {string} feature 9 | * @param {Object} action action with a `meta.namespace` property 10 | * @param {Object} state 11 | * @returns {*} state slice 12 | */ 13 | const getStateByFeatureAndAction = curry((feature, action, state) => 14 | getStateByFeatureAndNamespace(feature, action?.meta?.namespace, state) 15 | ); 16 | 17 | export default getStateByFeatureAndAction; 18 | -------------------------------------------------------------------------------- /packages/injectors/src/makeStoreInterface.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { curry, o, path } from 'ramda'; 3 | import { defaultToEmptyArray, toPascalCase } from 'ramda-extension'; 4 | 5 | const makeStoreInterface = type => { 6 | invariant(type, 'The type of the injectables must be defined.'); 7 | 8 | return { 9 | type, 10 | injectionKey: `inject${toPascalCase(type)}`, 11 | ejectionKey: `eject${toPascalCase(type)}`, 12 | getEntries: o(defaultToEmptyArray, path(['entries', type])), 13 | setEntries: curry((entries, store) => (store.entries = { ...store.entries, [type]: entries })), 14 | }; 15 | }; 16 | 17 | export default makeStoreInterface; 18 | -------------------------------------------------------------------------------- /packages/reducers/src/combineReducerSchema.js: -------------------------------------------------------------------------------- 1 | import { map, isEmpty } from 'ramda'; 2 | 3 | import combineReducers from './combineReducers'; 4 | import composeReducers from './composeReducers'; 5 | import { ROOT_KEY } from './constants'; 6 | 7 | const combineReducerSchema = ({ [ROOT_KEY]: rootReducers, ...otherReducers }) => { 8 | const resolvedRootReducers = rootReducers ?? []; 9 | 10 | if (isEmpty(otherReducers)) { 11 | return composeReducers(...resolvedRootReducers); 12 | } 13 | 14 | return composeReducers( 15 | ...resolvedRootReducers, 16 | combineReducers(map(combineReducerSchema, otherReducers)) 17 | ); 18 | }; 19 | 20 | export default combineReducerSchema; 21 | -------------------------------------------------------------------------------- /rollup/plugins.js: -------------------------------------------------------------------------------- 1 | import babelPlugin from 'rollup-plugin-babel'; 2 | import cjsPlugin from 'rollup-plugin-commonjs'; 3 | import nodeResolvePlugin from 'rollup-plugin-node-resolve'; 4 | import { terser as terserPlugin } from 'rollup-plugin-terser'; 5 | 6 | const { LERNA_ROOT_PATH } = process.env; 7 | 8 | export const cjs = cjsPlugin(); 9 | 10 | export const terser = terserPlugin({ 11 | compress: { 12 | pure_getters: true, 13 | unsafe: true, 14 | unsafe_comps: true, 15 | warnings: false, 16 | }, 17 | }); 18 | 19 | export const nodeResolve = nodeResolvePlugin(); 20 | 21 | export const babel = babelPlugin({ 22 | cwd: LERNA_ROOT_PATH, 23 | runtimeHelpers: true, 24 | }); 25 | -------------------------------------------------------------------------------- /packages/utils-react/src/withProps.js: -------------------------------------------------------------------------------- 1 | import hoistNonReactStatics from 'hoist-non-react-statics'; 2 | import { curry } from 'ramda'; 3 | import { isFunction } from 'ramda-extension'; 4 | import React from 'react'; 5 | 6 | import getDisplayName from './getDisplayName'; 7 | 8 | const withProps = curry((otherProps, NextComponent) => { 9 | const WithProps = props => ( 10 | 11 | ); 12 | 13 | hoistNonReactStatics(WithProps, NextComponent); 14 | 15 | WithProps.displayName = `WithProps(${getDisplayName(NextComponent)})`; 16 | 17 | return WithProps; 18 | }); 19 | 20 | export default withProps; 21 | -------------------------------------------------------------------------------- /packages/namespaces/src/index.js: -------------------------------------------------------------------------------- 1 | export { default as attachNamespace } from './attachNamespace'; 2 | export { default as defaultNamespace } from './defaultNamespace'; 3 | export { default as getNamespaceByAction } from './getNamespaceByAction'; 4 | export { default as isActionFromNamespace } from './isActionFromNamespace'; 5 | export * from './constants'; 6 | export { default as getStateByAction } from './getStateByAction'; 7 | export { default as getStateByFeatureAndAction } from './getStateByFeatureAndAction'; 8 | export { default as getStateByNamespace } from './getStateByNamespace'; 9 | export { default as getStateByFeatureAndNamespace } from './getStateByFeatureAndNamespace'; 10 | export { default as preventNamespace } from './preventNamespace'; 11 | -------------------------------------------------------------------------------- /docs/getting-started/examples.md: -------------------------------------------------------------------------------- 1 | # Examples {docsify-ignore-all} 2 | 3 | !> Unfortunately, the examples section is not quite ready yet :( 4 | 5 | 27 | -------------------------------------------------------------------------------- /packages/actions/src/index.js: -------------------------------------------------------------------------------- 1 | import makeEmptyActionCreator from './makeEmptyActionCreator'; 2 | import makePayloadActionCreator from './makePayloadActionCreator'; 3 | 4 | export { prefixedValueMirror as makeActionTypes } from '@redux-tools/utils'; 5 | 6 | export { default as configureActionCreator } from './configureActionCreator'; 7 | export { default as makePayloadMetaActionCreator } from './makePayloadMetaActionCreator'; 8 | 9 | export { default as isErrorAction } from './isErrorAction'; 10 | export { default as isNotErrorAction } from './isNotErrorAction'; 11 | 12 | export { makeEmptyActionCreator, makePayloadActionCreator }; 13 | export const makeConstantActionCreator = makeEmptyActionCreator; 14 | export const makeSimpleActionCreator = makePayloadActionCreator; 15 | -------------------------------------------------------------------------------- /packages/actions/src/makeEmptyActionCreator.test.js: -------------------------------------------------------------------------------- 1 | import makeEmptyActionCreator from './makeEmptyActionCreator'; 2 | 3 | describe('makeEmptyActionCreator', () => { 4 | it('throws when a type is empty string', () => { 5 | expect(() => makeEmptyActionCreator('')).toThrow(); 6 | }); 7 | 8 | it('throws when a type is empty', () => { 9 | expect(() => makeEmptyActionCreator()).toThrow(); 10 | }); 11 | 12 | it('handles a call with 0 arguments', () => { 13 | const actionCreator = makeEmptyActionCreator('TYPE'); 14 | expect(actionCreator()).toEqual({ type: 'TYPE' }); 15 | }); 16 | 17 | it('throws when a payload is received', () => { 18 | const actionCreator = makeEmptyActionCreator('TYPE'); 19 | expect(() => actionCreator('yo')).toThrow(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/useNamespacedDispatch.js: -------------------------------------------------------------------------------- 1 | import { o } from 'ramda'; 2 | import { useMemo } from 'react'; 3 | import { useDispatch } from 'react-redux'; 4 | 5 | import { defaultNamespace, DEFAULT_FEATURE } from '@redux-tools/namespaces'; 6 | 7 | import useNamespace from './useNamespace'; 8 | 9 | const useNamespacedDispatch = ({ feature: optionFeature, namespace: optionNamespace } = {}) => { 10 | const dispatch = useDispatch(); 11 | const feature = optionFeature ?? DEFAULT_FEATURE; 12 | const contextNamespace = useNamespace(feature); 13 | const namespace = optionNamespace ?? contextNamespace ?? null; 14 | 15 | return useMemo(() => o(dispatch, defaultNamespace(namespace)), [dispatch, namespace]); 16 | }; 17 | 18 | export default useNamespacedDispatch; 19 | -------------------------------------------------------------------------------- /docs/packages/thunk.md: -------------------------------------------------------------------------------- 1 | # Thunk (Extended) 2 | 3 | > yarn add @redux-tools/thunk 4 | 5 | A [Redux Thunk](https://github.com/reduxjs/redux-thunk) clone adapted for Redux Tools: it passes the namespace of a thunk dispatched via `namespacedConnect`'s `mapDispatchToProps` or `useNamespacedDispatch` down to all actions dispatched from within the thunk. 6 | 7 | Make sure to inject this middleware before any other middleware. 8 | 9 | ## API Reference 10 | 11 | Same as of Redux Thunk, with two exceptions: 12 | 13 | - Instead of `dispatch, getState`, the thunk receives `{ dispatch, getState, getNamespacedState, namespace }` as a single argument. 14 | - Instead of `withExtraArgument`, you can use `withDependencies` with an object, which is spread into the aforementioned single argument. 15 | -------------------------------------------------------------------------------- /packages/namespaces/src/getStateByNamespace.test.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_FEATURE } from './constants'; 2 | import getStateByNamespace from './getStateByNamespace'; 3 | 4 | const state = { 5 | [DEFAULT_FEATURE]: { 6 | foo: { value: 'Wassup' }, 7 | }, 8 | }; 9 | 10 | describe('getStateByNamespace', () => { 11 | it('retrieves correct state slice an existing namespace is passed', () => { 12 | expect(getStateByNamespace('foo', state)).toBe(state[DEFAULT_FEATURE].foo); 13 | }); 14 | 15 | it('returns undefined when a nonexistent namespace is passed', () => { 16 | expect(getStateByNamespace('bar', state)).toBeUndefined(); 17 | }); 18 | 19 | it('returns undefined when no namespace is passed', () => { 20 | expect(getStateByNamespace(undefined, state)).toBeUndefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/actions/src/makePayloadActionCreator.test.js: -------------------------------------------------------------------------------- 1 | import makePayloadActionCreator from './makePayloadActionCreator'; 2 | 3 | describe('makePayloadActionCreator', () => { 4 | it('throws when a type is empty string', () => { 5 | expect(() => makePayloadActionCreator('')).toThrow(); 6 | }); 7 | 8 | it('throws when a type is empty', () => { 9 | expect(() => makePayloadActionCreator()).toThrow(); 10 | }); 11 | 12 | it('uses the argument as the action payload', () => { 13 | const actionCreator = makePayloadActionCreator('TYPE'); 14 | expect(actionCreator('yo')).toEqual({ type: 'TYPE', payload: 'yo' }); 15 | }); 16 | 17 | it('throws when an undefined payload is received', () => { 18 | const actionCreator = makePayloadActionCreator('TYPE'); 19 | expect(() => actionCreator()).toThrow(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/utils-react/src/mapProps.test.js: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import { noop } from 'ramda-extension'; 3 | import React from 'react'; 4 | 5 | import mapProps from './mapProps'; 6 | 7 | describe('mapProps', () => { 8 | it('accepts a function', () => { 9 | const Component = mapProps(({ foo }) => ({ foo: foo.toUpperCase() }), noop); 10 | const wrapper = shallow(); 11 | expect(wrapper.find(noop).prop('foo')).toBe('BAR'); 12 | }); 13 | 14 | it('does not preserve existing props', () => { 15 | const Component = mapProps(({ foo }) => ({ foo: foo.toUpperCase() }), noop); 16 | const wrapper = shallow(); 17 | expect(wrapper.find(noop).prop('foo')).toBe('BAR'); 18 | expect(wrapper.find(noop).prop('bar')).not.toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/utils", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-utils.js", 6 | "module": "es/redux-tools-utils.js", 7 | "unpkg": "dist/redux-tools-utils.js", 8 | "description": "Utility functions for @redux-tools packages.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "ramda": ">=0.26.1", 18 | "ramda-extension": ">=0.8.0" 19 | }, 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "sideEffects": false 24 | } 25 | -------------------------------------------------------------------------------- /packages/stream-creators/src/streamCreators.js: -------------------------------------------------------------------------------- 1 | import { prop } from 'ramda'; 2 | import { map } from 'rxjs/operators'; 3 | 4 | import { DEFAULT_FEATURE, getStateByFeatureAndNamespace } from '@redux-tools/namespaces'; 5 | 6 | /** 7 | * Stream creator to pass as `streamCreator` to the enhancer. Adds a `namespacedState$` argument 8 | * to each epic, allowing access to state based on the namespace of the epic. 9 | */ 10 | export const namespacedState$ = ({ feature, namespace, state$ }) => 11 | state$.pipe(map(getStateByFeatureAndNamespace(feature ?? DEFAULT_FEATURE, namespace))); 12 | 13 | /** 14 | * Stream creator to pass as `streamCreator` to the enhancer. Adds a `globalAction$` argument 15 | * to each epic, allowing access to actions of all namespaces, not just the epic's. 16 | */ 17 | export const globalAction$ = prop('globalAction$'); 18 | -------------------------------------------------------------------------------- /packages/namespaces/src/getStateByAction.test.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_FEATURE } from './constants'; 2 | import getStateByAction from './getStateByAction'; 3 | 4 | const state = { 5 | [DEFAULT_FEATURE]: { 6 | foo: { value: 'Wassup' }, 7 | }, 8 | }; 9 | 10 | describe('getStateByAction', () => { 11 | it('retrieves correct state slice when an existing namespace is passed', () => { 12 | expect(getStateByAction({ meta: { namespace: 'foo' } }, state)).toBe( 13 | state[DEFAULT_FEATURE].foo 14 | ); 15 | }); 16 | 17 | it('returns undefined when a nonexistent namespace is passed', () => { 18 | expect(getStateByAction({ meta: { namespace: 'bar' } }, state)).toBeUndefined(); 19 | }); 20 | 21 | it('returns undefined when no namespace is passed', () => { 22 | expect(getStateByAction({ meta: { namespace: undefined } }, state)).toBeUndefined(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/namespaces/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/namespaces", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-namespaces.js", 6 | "module": "es/redux-tools-namespaces.js", 7 | "unpkg": "dist/redux-tools-namespaces.js", 8 | "description": "Utility functions for namespacing of Redux actions.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "ramda": ">=0.26.1", 18 | "ramda-extension": ">=0.8.0" 19 | }, 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "sideEffects": false 24 | } 25 | -------------------------------------------------------------------------------- /packages/namespaces/src/mergeNamespace.js: -------------------------------------------------------------------------------- 1 | import { curry, mergeDeepRight, mergeDeepLeft, isNil } from 'ramda'; 2 | import { isFunction } from 'ramda-extension'; 3 | 4 | import getNamespaceByAction from './getNamespaceByAction'; 5 | 6 | const mergeNamespace = curry((isForced, namespace, action) => { 7 | if (isNil(namespace)) { 8 | return action; 9 | } 10 | 11 | if (isFunction(action)) { 12 | const nextAction = (...args) => action(...args); 13 | 14 | if (isForced) { 15 | nextAction.meta = { namespace }; 16 | } else { 17 | nextAction.meta = { namespace: getNamespaceByAction(action) ?? namespace }; 18 | } 19 | 20 | return nextAction; 21 | } 22 | 23 | if (isForced) { 24 | return mergeDeepLeft({ meta: { namespace } }, action); 25 | } 26 | 27 | return mergeDeepRight({ meta: { namespace } }, action); 28 | }); 29 | 30 | export default mergeNamespace; 31 | -------------------------------------------------------------------------------- /packages/reducers/src/combineReducerEntries.js: -------------------------------------------------------------------------------- 1 | import { reduce, o, assocPath, path } from 'ramda'; 2 | 3 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 4 | 5 | import combineReducerSchema from './combineReducerSchema'; 6 | import { ROOT_KEY } from './constants'; 7 | import filterReducer from './filterReducer'; 8 | 9 | const combineReducerEntries = o( 10 | combineReducerSchema, 11 | reduce((schema, entry) => { 12 | const pathDefinition = [ 13 | ...(entry.namespace ? [entry.feature ?? DEFAULT_FEATURE] : []), 14 | ...(entry.namespace ? [entry.namespace] : []), 15 | ...entry.path, 16 | ROOT_KEY, 17 | ]; 18 | 19 | return assocPath( 20 | pathDefinition, 21 | [...(path(pathDefinition, schema) ?? []), filterReducer(entry.value, entry.namespace)], 22 | schema 23 | ); 24 | }, {}) 25 | ); 26 | 27 | export default combineReducerEntries; 28 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/withNamespaceProvider.js: -------------------------------------------------------------------------------- 1 | import hoistNonReactStatics from 'hoist-non-react-statics'; 2 | import { mergeLeft } from 'ramda'; 3 | import React from 'react'; 4 | 5 | import { getDisplayName } from '@redux-tools/utils-react'; 6 | 7 | import NamespaceProvider from './NamespaceProvider'; 8 | 9 | const withNamespaceProvider = options => NextComponent => { 10 | const WithNamespaceProvider = props => ( 11 | // NOTE: `NamespaceProvider` will ignore any unknown props. 12 | 13 | 14 | 15 | ); 16 | 17 | hoistNonReactStatics(WithNamespaceProvider, NextComponent); 18 | 19 | WithNamespaceProvider.displayName = `WithNamespaceProvider(${getDisplayName(NextComponent)})`; 20 | 21 | return WithNamespaceProvider; 22 | }; 23 | 24 | export default withNamespaceProvider; 25 | -------------------------------------------------------------------------------- /packages/namespaces/src/attachNamespace.test.js: -------------------------------------------------------------------------------- 1 | import attachNamespace from './attachNamespace'; 2 | 3 | describe('attachNamespace', () => { 4 | it('adds a namespace to an action', () => { 5 | expect(attachNamespace('yo', {})).toEqual({ meta: { namespace: 'yo' } }); 6 | }); 7 | 8 | it('returns the original action when no namespace is passed', () => { 9 | const action = {}; 10 | expect(attachNamespace(null, action)).toBe(action); 11 | }); 12 | 13 | it('adds a namespace to a function', () => { 14 | const thunk = () => 'YOLO'; 15 | const wrappedThunk = attachNamespace('yo', thunk); 16 | expect(wrappedThunk.meta.namespace).toBe('yo'); 17 | expect(wrappedThunk()).toBe('YOLO'); 18 | }); 19 | 20 | it('overwrites existing namespace', () => { 21 | expect(attachNamespace('hi', { meta: { namespace: 'yo' } })).toEqual({ 22 | meta: { namespace: 'hi' }, 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/reducers/src/combineReducers.js: -------------------------------------------------------------------------------- 1 | import { reduce, keys } from 'ramda'; 2 | 3 | import { pickFunctions } from '@redux-tools/utils'; 4 | 5 | // NOTE: Custom implementation so existing keys are always preserved. 6 | export default reducers => { 7 | const finalReducers = pickFunctions(reducers); 8 | const finalReducerKeys = keys(finalReducers); 9 | 10 | return (state = {}, action) => 11 | reduce( 12 | (previousState, reducerKey) => { 13 | const reducer = finalReducers[reducerKey]; 14 | const previousStateForKey = previousState[reducerKey]; 15 | const nextStateForKey = reducer(previousStateForKey, action); 16 | 17 | if (nextStateForKey === previousStateForKey) { 18 | return previousState; 19 | } 20 | 21 | return { 22 | ...previousState, 23 | [reducerKey]: nextStateForKey, 24 | }; 25 | }, 26 | state, 27 | finalReducerKeys 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/withNamespaceProvider.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import { alwaysNull } from 'ramda-extension'; 3 | import React from 'react'; 4 | 5 | import { NamespaceContext } from './contexts'; 6 | import withNamespaceProvider from './withNamespaceProvider'; 7 | 8 | describe('withNamespaceProvider', () => { 9 | beforeEach(() => jest.resetAllMocks()); 10 | 11 | it('configures NamespaceContext with options having priority over props', () => { 12 | const Test = withNamespaceProvider({ namespace: 'foo' })(NamespaceContext.Consumer); 13 | const renderProp = jest.fn(alwaysNull); 14 | 15 | mount( 16 | 17 | {renderProp} 18 | 19 | ); 20 | 21 | expect(renderProp).toHaveBeenCalledWith({ 22 | isUseNamespaceProvided: false, 23 | namespaces: { bar: 'foo' }, 24 | useNamespace: alwaysNull, 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/useNamespacedSelector.js: -------------------------------------------------------------------------------- 1 | import { o } from 'ramda'; 2 | import { useMemo } from 'react'; 3 | import { useSelector } from 'react-redux'; 4 | 5 | import { getStateByFeatureAndNamespace, DEFAULT_FEATURE } from '@redux-tools/namespaces'; 6 | 7 | import useNamespace from './useNamespace'; 8 | 9 | const useNamespacedSelector = ( 10 | selector, 11 | equalityFn, 12 | { feature: optionFeature, namespace: optionNamespace } = {} 13 | ) => { 14 | const feature = optionFeature ?? DEFAULT_FEATURE; 15 | const contextNamespace = useNamespace(feature); 16 | const namespace = optionNamespace ?? contextNamespace ?? null; 17 | 18 | const namespacedSelector = useMemo( 19 | () => o(selector, getStateByFeatureAndNamespace(feature, namespace)), 20 | [selector, feature, namespace] 21 | ); 22 | 23 | return useSelector(namespacedSelector, equalityFn); 24 | }; 25 | 26 | export default useNamespacedSelector; 27 | -------------------------------------------------------------------------------- /packages/stream-creators/src/streamCreators.test.js: -------------------------------------------------------------------------------- 1 | import { of } from 'rxjs'; 2 | 3 | import { getStateByFeatureAndNamespace } from '@redux-tools/namespaces'; 4 | 5 | import { globalAction$, namespacedState$ } from './streamCreators'; 6 | 7 | jest.mock('@redux-tools/namespaces'); 8 | 9 | getStateByFeatureAndNamespace.mockImplementation((feature, namespace) => state => 10 | `${feature}-${namespace}/${state}` 11 | ); 12 | 13 | describe('namespacedState$', () => { 14 | it('returns a namespaced state stream', () => { 15 | const result = namespacedState$({ state$: of('state'), namespace: 'ns', feature: 'f' }); 16 | 17 | result.subscribe(namespacedState => { 18 | expect(namespacedState).toEqual('f-ns/state'); 19 | }); 20 | }); 21 | }); 22 | 23 | describe('globalAction$', () => { 24 | it('returns the global action stream', () => { 25 | expect(globalAction$({ globalAction$: 'yo' })).toBe('yo'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/actions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/actions", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-actions.js", 6 | "module": "es/redux-tools-actions.js", 7 | "unpkg": "dist/redux-tools-actions.js", 8 | "description": "Alternative implementation of redux-actions using Ramda.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "publishConfig": { 17 | "access": "public" 18 | }, 19 | "dependencies": { 20 | "@redux-tools/utils": "^0.9.1", 21 | "invariant": "^2.2.4", 22 | "ramda": ">=0.26.1", 23 | "ramda-extension": ">=0.8.0" 24 | }, 25 | "sideEffects": false 26 | } 27 | -------------------------------------------------------------------------------- /packages/reducers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/reducers", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-reducers.js", 6 | "module": "es/redux-tools-reducers.js", 7 | "unpkg": "dist/redux-tools-reducers.js", 8 | "description": "Redux store enhancer for asynchronous injection of reducers.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik " 13 | ], 14 | "dependencies": { 15 | "@redux-tools/injectors": "^0.9.1", 16 | "@redux-tools/namespaces": "^0.9.1", 17 | "@redux-tools/utils": "^0.9.1", 18 | "invariant": "^2.2.4", 19 | "ramda": ">=0.26.1", 20 | "ramda-extension": ">=0.8.0" 21 | }, 22 | "peerDependencies": { 23 | "redux": "^4.0.4" 24 | }, 25 | "publishConfig": { 26 | "access": "public" 27 | }, 28 | "sideEffects": false 29 | } 30 | -------------------------------------------------------------------------------- /packages/injectors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/injectors", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-injectors.js", 6 | "module": "es/redux-tools-injectors.js", 7 | "unpkg": "dist/redux-tools-injectors.js", 8 | "description": "Core logic for asynchronous Redux dependency injection.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "@redux-tools/namespaces": "^0.9.1", 18 | "@redux-tools/utils": "^0.9.1", 19 | "invariant": "^2.2.4", 20 | "ramda": ">=0.26.1", 21 | "ramda-extension": ">=0.8.0" 22 | }, 23 | "publishConfig": { 24 | "access": "public" 25 | }, 26 | "sideEffects": false 27 | } 28 | -------------------------------------------------------------------------------- /packages/thunk/src/index.js: -------------------------------------------------------------------------------- 1 | import { o } from 'ramda'; 2 | import { isFunction } from 'ramda-extension'; 3 | 4 | import { 5 | getNamespaceByAction, 6 | defaultNamespace, 7 | getStateByFeatureAndNamespace, 8 | DEFAULT_FEATURE, 9 | } from '@redux-tools/namespaces'; 10 | 11 | const makeThunkMiddleware = dependencies => ({ dispatch, getState }) => next => action => { 12 | if (isFunction(action)) { 13 | const namespace = getNamespaceByAction(action); 14 | 15 | const getNamespacedState = feature => 16 | getStateByFeatureAndNamespace(feature ?? DEFAULT_FEATURE, namespace, getState()); 17 | 18 | return action({ 19 | dispatch: o(dispatch, defaultNamespace(namespace)), 20 | getState, 21 | namespace, 22 | getNamespacedState, 23 | ...dependencies, 24 | }); 25 | } 26 | 27 | return next(action); 28 | }; 29 | 30 | const thunkMiddleware = makeThunkMiddleware(); 31 | 32 | thunkMiddleware.withDependencies = makeThunkMiddleware; 33 | 34 | export default thunkMiddleware; 35 | -------------------------------------------------------------------------------- /packages/namespaces/src/getStateByFeatureAndNamespace.test.js: -------------------------------------------------------------------------------- 1 | import getStateByFeatureAndNamespace from './getStateByFeatureAndNamespace'; 2 | 3 | const state = { 4 | someFeature: { 5 | foo: { value: 'Wassup' }, 6 | }, 7 | }; 8 | 9 | describe('getStateByFeatureAndNamespace', () => { 10 | it('retrieves correct state slice an existing namespace is passed', () => { 11 | expect(getStateByFeatureAndNamespace('someFeature', 'foo', state)).toBe(state.someFeature.foo); 12 | }); 13 | 14 | it('returns undefined when a nonexistent namespace is passed', () => { 15 | expect(getStateByFeatureAndNamespace('someFeature', 'bar', state)).toBeUndefined(); 16 | }); 17 | 18 | it('returns undefined when a nonexistent feature is passed', () => { 19 | expect(getStateByFeatureAndNamespace('randomFeature', 'foo', state)).toBeUndefined(); 20 | }); 21 | 22 | it('returns undefined when no namespace is passed', () => { 23 | expect(getStateByFeatureAndNamespace('someFeature', undefined, state)).toBeUndefined(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/namespaces/src/isActionFromNamespace.js: -------------------------------------------------------------------------------- 1 | import { curry } from 'ramda'; 2 | 3 | import { NAMESPACE_PREVENTED } from './constants'; 4 | import getNamespaceByAction from './getNamespaceByAction'; 5 | 6 | /** 7 | * An action is from a namespace if the passed namespace is nil (it's a global reducer/epic), 8 | * or if the action's namespace is nil (it's a global action) or if the namespaces match 9 | * or if the action's namespace has been prevented. 10 | * 11 | * @param {?string} namespace namespace to match the action with 12 | * @param {Object} action action with an optionally defined meta.namespace property 13 | * @returns {boolean} whether the action is from the namespace or not 14 | */ 15 | const isActionFromNamespace = curry((namespace, action) => { 16 | const actionNamespace = getNamespaceByAction(action); 17 | 18 | if (!namespace || !actionNamespace || actionNamespace === NAMESPACE_PREVENTED) { 19 | return true; 20 | } 21 | 22 | return namespace === actionNamespace; 23 | }); 24 | 25 | export default isActionFromNamespace; 26 | -------------------------------------------------------------------------------- /packages/actions/src/makeEmptyActionCreator.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | 3 | import alwaysUndefined from './alwaysUndefined'; 4 | import configureActionCreator from './configureActionCreator'; 5 | 6 | /** 7 | * Creates an action creator with supplied type, no payload and no meta. 8 | * 9 | * @sig String -> () -> {type: String} 10 | */ 11 | const makeEmptyActionCreator = type => { 12 | const actionCreator = configureActionCreator(type, alwaysUndefined, alwaysUndefined); 13 | 14 | // NOTE: Regular function so we can use `arguments` without entering any parameters. 15 | // An arrow function allows us to do `...args`, but that hurts autocompletion. 16 | return function() { 17 | invariant( 18 | arguments.length === 0, 19 | 'You passed an argument to an action creator created by makeEmptyActionCreator(' + 20 | type + 21 | '). Did you mean to use makePayloadActionCreator(' + 22 | type + 23 | ')?' 24 | ); 25 | 26 | return actionCreator(undefined); 27 | }; 28 | }; 29 | 30 | export default makeEmptyActionCreator; 31 | -------------------------------------------------------------------------------- /packages/thunk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/thunk", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-thunk.js", 6 | "module": "es/redux-tools-thunk.js", 7 | "unpkg": "dist/redux-tools-thunk.js", 8 | "description": "Redux Thunk clone which supports namespacing.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "@redux-tools/namespaces": "^0.9.1", 18 | "@redux-tools/reducers": "^0.9.1", 19 | "ramda": ">=0.26.1", 20 | "ramda-extension": ">=0.8.0" 21 | }, 22 | "peerDependencies": { 23 | "redux": "^4.0.4" 24 | }, 25 | "devDependencies": { 26 | "redux": "4.0.5" 27 | }, 28 | "publishConfig": { 29 | "access": "public" 30 | }, 31 | "sideEffects": false 32 | } 33 | -------------------------------------------------------------------------------- /packages/utils-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/utils-react", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-utils-react.js", 6 | "module": "es/redux-tools-utils-react.js", 7 | "unpkg": "dist/redux-tools-utils-react.js", 8 | "description": "React-specific utility functions for @redux-tools packages.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "hoist-non-react-statics": "^3.3.0", 18 | "ramda": ">=0.26.1", 19 | "ramda-extension": ">=0.8.0" 20 | }, 21 | "devDependencies": { 22 | "enzyme": "3.11.0" 23 | }, 24 | "peerDependencies": { 25 | "react": ">=16.8.3" 26 | }, 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "sideEffects": false 31 | } 32 | -------------------------------------------------------------------------------- /packages/reducers/src/filterReducer.test.js: -------------------------------------------------------------------------------- 1 | describe('filterReducer', () => { 2 | const state = { foo: 'bar' }; 3 | const newState = { bar: 'baz' }; 4 | 5 | beforeEach(() => jest.resetModules()); 6 | 7 | it('calls reducer when namespace matches', () => { 8 | jest.doMock('@redux-tools/namespaces', () => ({ isActionFromNamespace: jest.fn(() => true) })); 9 | const { default: filterReducer } = require('./filterReducer'); 10 | const reducer = jest.fn(() => newState); 11 | 12 | expect(filterReducer(reducer, 'matchedNamespace')(state, {})).toBe(newState); 13 | expect(reducer).toHaveBeenCalledWith(state, {}); 14 | }); 15 | 16 | it('does not call reducer when namespace does not match', () => { 17 | jest.doMock('@redux-tools/namespaces', () => ({ isActionFromNamespace: jest.fn(() => false) })); 18 | const { default: filterReducer } = require('./filterReducer'); 19 | const reducer = jest.fn(() => newState); 20 | 21 | expect(filterReducer(reducer, 'randomNamespace')(state, {})).toBe(state); 22 | expect(reducer).not.toHaveBeenCalled(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/middleware/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/middleware", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-middleware.js", 6 | "module": "es/redux-tools-middleware.js", 7 | "unpkg": "dist/redux-tools-middleware.js", 8 | "description": "Redux store enhancer for asynchronous injection of middleware.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "@redux-tools/injectors": "^0.9.1", 18 | "@redux-tools/namespaces": "^0.9.1", 19 | "@redux-tools/reducers": "^0.9.1", 20 | "invariant": "^2.2.4", 21 | "ramda": ">=0.26.1", 22 | "ramda-extension": ">=0.8.0" 23 | }, 24 | "peerDependencies": { 25 | "redux": "^4.0.4" 26 | }, 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "sideEffects": false 31 | } 32 | -------------------------------------------------------------------------------- /packages/epics-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/epics-react", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-epics-react.js", 6 | "module": "es/redux-tools-epics-react.js", 7 | "unpkg": "dist/redux-tools-epics-react.js", 8 | "description": "React bindings for the @redux-tools/epics package.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "publishConfig": { 17 | "access": "public" 18 | }, 19 | "dependencies": { 20 | "@redux-tools/epics": "^0.9.1", 21 | "@redux-tools/injectors-react": "^0.9.1", 22 | "ramda": ">=0.26.1" 23 | }, 24 | "peerDependencies": { 25 | "react": ">=16.8.3", 26 | "react-redux": "^6.0.0 || ^7.0.0", 27 | "redux": "^4.0.4", 28 | "redux-observable": "^1.0.0", 29 | "rxjs": "^6.3.3" 30 | }, 31 | "sideEffects": false 32 | } 33 | -------------------------------------------------------------------------------- /packages/stream-creators/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/stream-creators", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-stream-creators.js", 6 | "module": "es/redux-tools-stream-creators.js", 7 | "unpkg": "dist/redux-tools-stream-creators.js", 8 | "description": "Collection of stream creators for the @redux-tools/epics package.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "@redux-tools/epics": "^0.9.1", 18 | "@redux-tools/namespaces": "^0.9.1", 19 | "@redux-tools/reducers": "^0.9.1", 20 | "ramda": ">=0.26.1" 21 | }, 22 | "peerDependencies": { 23 | "redux": "^4.0.4", 24 | "redux-observable": "^1.0.0", 25 | "rxjs": "^6.3.3" 26 | }, 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "sideEffects": false 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Lundegaard a.s. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/actions/src/makePayloadActionCreator.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { identity } from 'ramda'; 3 | 4 | import alwaysUndefined from './alwaysUndefined'; 5 | import configureActionCreator from './configureActionCreator'; 6 | 7 | /** 8 | * Creates an action creator with supplied type. The resulting action payload is the first arg. 9 | * 10 | * @sig String -> a -> {type: String, payload: a} 11 | */ 12 | const makePayloadActionCreator = type => { 13 | const actionCreator = configureActionCreator(type, identity, alwaysUndefined); 14 | 15 | return function(payload) { 16 | invariant( 17 | arguments.length !== 0, 18 | 'You did not pass an argument to an action creator created by makePayloadActionCreator(' + 19 | type + 20 | '). Did you mean to use makeEmptyActionCreator(' + 21 | type + 22 | ')?' 23 | ); 24 | 25 | invariant( 26 | arguments.length <= 1, 27 | 'You passed more than one argument to an action creator created by makePayloadActionCreator(' + 28 | type + 29 | ').' 30 | ); 31 | 32 | return actionCreator(payload); 33 | }; 34 | }; 35 | 36 | export default makePayloadActionCreator; 37 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - Getting Started 2 | 3 | - [Introduction](/) 4 | - [Quick Start](/getting-started/quick-start) 5 | - [Examples (WIP)](/getting-started/examples) 6 | 7 | - Tutorial 8 | 9 | - [Dependency Injection](/tutorial/01-dependency-injection) 10 | - [Namespacing](/tutorial/02-namespacing) 11 | - [Multi-Instance Components](/tutorial/03-multi-instance-components) 12 | - [View State Management](/tutorial/04-view-state-management) 13 | 14 | - FAQ 15 | 16 | - [General](/faq/general 'FAQ: General - Redux Tools') 17 | - [Internals](/faq/internals 'FAQ: Internals - Redux Tools') 18 | 19 | - Packages 20 | 21 | - [Actions](/packages/actions) 22 | - [Namespaces](/packages/namespaces) 23 | - [Namespaces (React Bindings)](/packages/namespaces-react) 24 | - [Reducers](/packages/reducers) 25 | - [Reducers (React Bindings)](/packages/reducers-react) 26 | - [Middleware](/packages/middleware) 27 | - [Middleware (React Bindings)](/packages/middleware-react) 28 | - [Epics](/packages/epics) 29 | - [Epics (React Bindings)](/packages/epics-react) 30 | - [Stream Creators](/packages/stream-creators) 31 | - [Thunk (Extended)](/packages/thunk) 32 | -------------------------------------------------------------------------------- /packages/namespaces/src/getStateByFeatureAndAction.test.js: -------------------------------------------------------------------------------- 1 | import getStateByFeatureAndAction from './getStateByFeatureAndAction'; 2 | 3 | const state = { 4 | someFeature: { 5 | foo: { value: 'Wassup' }, 6 | }, 7 | }; 8 | 9 | describe('getStateByFeatureAndAction', () => { 10 | it('retrieves correct state slice when an existing namespace is passed', () => { 11 | expect(getStateByFeatureAndAction('someFeature', { meta: { namespace: 'foo' } }, state)).toBe( 12 | state.someFeature.foo 13 | ); 14 | }); 15 | 16 | it('returns undefined when a nonexistent namespace is passed', () => { 17 | expect( 18 | getStateByFeatureAndAction('someFeature', { meta: { namespace: 'bar' } }, state) 19 | ).toBeUndefined(); 20 | }); 21 | 22 | it('returns undefined when a nonexistent feature is passed', () => { 23 | expect( 24 | getStateByFeatureAndAction('randomFeature', { meta: { namespace: 'foo' } }, state) 25 | ).toBeUndefined(); 26 | }); 27 | 28 | it('returns undefined when no namespace is passed', () => { 29 | expect( 30 | getStateByFeatureAndAction('someFeature', { meta: { namespace: undefined } }, state) 31 | ).toBeUndefined(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/utils/src/includesTimes.test.js: -------------------------------------------------------------------------------- 1 | import includesTimes from './includesTimes'; 2 | 3 | const entries = [ 4 | { path: ['foo'], value: 'bar', namespace: 'ns' }, 5 | { path: ['foo'], value: 'bar', namespace: 'ns' }, 6 | { path: ['bar'], value: 'baz', namespace: 'ns' }, 7 | ]; 8 | 9 | describe('includesTimes', () => { 10 | it('returns true for an entry which is included exactly N times', () => { 11 | expect( 12 | includesTimes( 13 | 2, 14 | { 15 | path: ['foo'], 16 | value: 'bar', 17 | namespace: 'ns', 18 | }, 19 | entries 20 | ) 21 | ).toBe(true); 22 | }); 23 | 24 | it('returns false for an entry which is included more times', () => { 25 | expect( 26 | includesTimes( 27 | 0, 28 | { 29 | path: ['bar'], 30 | value: 'baz', 31 | namespace: 'ns', 32 | }, 33 | entries 34 | ) 35 | ).toBe(false); 36 | }); 37 | 38 | it('returns false for an entry which is not included at all', () => { 39 | expect( 40 | includesTimes( 41 | 1, 42 | { 43 | path: ['LOL'], 44 | value: 'NOPE', 45 | namespace: 'ns', 46 | }, 47 | entries 48 | ) 49 | ).toBe(false); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/injectors/src/makeStoreInterface.test.js: -------------------------------------------------------------------------------- 1 | import makeStoreInterface from './makeStoreInterface'; 2 | 3 | describe('makeStoreInterface', () => { 4 | const storeInterface = makeStoreInterface('things'); 5 | 6 | it('creates an object', () => { 7 | expect(storeInterface).toBeInstanceOf(Object); 8 | }); 9 | 10 | it('passes correct string attributes down', () => { 11 | expect(storeInterface.type).toBe('things'); 12 | expect(storeInterface.injectionKey).toBe('injectThings'); 13 | expect(storeInterface.ejectionKey).toBe('ejectThings'); 14 | }); 15 | 16 | it('passes correct getters down', () => { 17 | expect(storeInterface.getEntries({ entries: { things: ['foo'] } })).toEqual(['foo']); 18 | expect(storeInterface.getEntries({})).toEqual([]); 19 | }); 20 | 21 | it('passes correct setters down', () => { 22 | const storeA = {}; 23 | storeInterface.setEntries(['foo'], storeA); 24 | expect(storeA.entries.things).toEqual(['foo']); 25 | const storeB = { entries: { otherThings: ['bar'] } }; 26 | storeInterface.setEntries(['foo'], storeB); 27 | expect(storeB.entries.things).toEqual(['foo']); 28 | expect(storeB.entries.otherThings).toEqual(['bar']); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/actions/src/configureActionCreator.test.js: -------------------------------------------------------------------------------- 1 | import { prop, identity } from 'ramda'; 2 | 3 | import alwaysUndefined from './alwaysUndefined'; 4 | import configureActionCreator from './configureActionCreator'; 5 | 6 | describe('configureActionCreator', () => { 7 | it('correctly applies getPayload and getMeta', () => { 8 | const actionCreator = configureActionCreator('TYPE', prop('foo'), prop('bar')); 9 | expect(actionCreator({ foo: 'Foo', bar: 'Bar' })).toEqual({ 10 | type: 'TYPE', 11 | payload: 'Foo', 12 | meta: 'Bar', 13 | }); 14 | }); 15 | 16 | it('creates variadic action creator', () => { 17 | const actionCreator = configureActionCreator( 18 | 'TYPE', 19 | x => x, 20 | (_, y) => y 21 | ); 22 | 23 | expect(actionCreator({ foo: 'Foo', bar: 'Bar' }, 'Baz')).toEqual({ 24 | type: 'TYPE', 25 | payload: { foo: 'Foo', bar: 'Bar' }, 26 | meta: 'Baz', 27 | }); 28 | }); 29 | 30 | it('correctly applies the `error` prop', () => { 31 | const actionCreator = configureActionCreator('TYPE', identity, alwaysUndefined); 32 | const error = new Error(); 33 | expect(actionCreator(error)).toEqual({ type: 'TYPE', error: true, payload: error }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /packages/actions/src/makePayloadMetaActionCreator.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { nthArg, isNil } from 'ramda'; 3 | import { isPlainObject } from 'ramda-extension'; 4 | 5 | import configureActionCreator from './configureActionCreator'; 6 | 7 | /** 8 | * Creates a new binary action creator which will use the first argument as the payload and the second argument as the meta. 9 | * 10 | * @sig String -> (a, {}) -> {type: String, payload: a, meta: {}} 11 | */ 12 | const makePayloadMetaActionCreator = type => { 13 | const actionCreator = configureActionCreator(type, nthArg(0), nthArg(1)); 14 | 15 | return (payload, meta) => { 16 | invariant( 17 | !isNil(meta), 18 | 'You did not pass the meta object to an action creator created by makePayloadMetaActionCreator(' + 19 | type + 20 | ').' 21 | ); 22 | 23 | invariant( 24 | isPlainObject(meta), 25 | 'Action creator created by makePayloadMetaActionCreator(' + 26 | type + 27 | ') expects the meta argument to be a plain object. Instead, it received ' + 28 | meta + 29 | '.' 30 | ); 31 | 32 | return actionCreator(payload, meta); 33 | }; 34 | }; 35 | 36 | export default makePayloadMetaActionCreator; 37 | -------------------------------------------------------------------------------- /packages/reducers-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/reducers-react", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-reducers-react.js", 6 | "module": "es/redux-tools-reducers-react.js", 7 | "unpkg": "dist/redux-tools-reducers-react.js", 8 | "description": "React bindings for the @redux-tools/reducers package.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "@redux-tools/injectors-react": "^0.9.1", 18 | "@redux-tools/reducers": "^0.9.1", 19 | "ramda": ">=0.26.1", 20 | "ramda-extension": ">=0.8.0" 21 | }, 22 | "peerDependencies": { 23 | "react": ">=16.8.3", 24 | "react-redux": "^6.0.0 || ^7.0.0", 25 | "redux": "^4.0.4" 26 | }, 27 | "devDependencies": { 28 | "enzyme": "3.11.0", 29 | "redux": "4.0.5" 30 | }, 31 | "publishConfig": { 32 | "access": "public" 33 | }, 34 | "sideEffects": false 35 | } 36 | -------------------------------------------------------------------------------- /packages/namespaces/src/defaultNamespace.test.js: -------------------------------------------------------------------------------- 1 | import defaultNamespace from './defaultNamespace'; 2 | 3 | describe('defaultNamespace', () => { 4 | it('adds a namespace to an action', () => { 5 | expect(defaultNamespace('yo', {})).toEqual({ meta: { namespace: 'yo' } }); 6 | }); 7 | 8 | it('returns the original action when no namespace is passed', () => { 9 | const action = {}; 10 | expect(defaultNamespace(null, action)).toBe(action); 11 | }); 12 | 13 | it('adds a namespace to a function', () => { 14 | const thunk = () => 'YOLO'; 15 | const wrappedThunk = defaultNamespace('yo', thunk); 16 | expect(wrappedThunk.meta.namespace).toBe('yo'); 17 | expect(wrappedThunk()).toBe('YOLO'); 18 | }); 19 | 20 | it('does not overwrite existing namespace', () => { 21 | expect(defaultNamespace('hi', { meta: { namespace: 'yo' } })).toEqual({ 22 | meta: { namespace: 'yo' }, 23 | }); 24 | }); 25 | 26 | it('does not overwrite namespace of a function', () => { 27 | const thunk = () => 'YOLO'; 28 | thunk.meta = { namespace: 'yo' }; 29 | const wrappedThunk = defaultNamespace('what', thunk); 30 | expect(wrappedThunk.meta.namespace).toBe('yo'); 31 | expect(wrappedThunk()).toBe('YOLO'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/middleware-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/middleware-react", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-middleware-react.js", 6 | "module": "es/redux-tools-middleware-react.js", 7 | "unpkg": "dist/redux-tools-middleware-react.js", 8 | "description": "React bindings for the @redux-tools/middleware package.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "@redux-tools/injectors-react": "^0.9.1", 18 | "@redux-tools/middleware": "^0.9.1", 19 | "ramda": ">=0.26.1", 20 | "ramda-extension": ">=0.8.0" 21 | }, 22 | "peerDependencies": { 23 | "react": ">=16.8.3", 24 | "react-redux": "^6.0.0 || ^7.0.0", 25 | "redux": "^4.0.4" 26 | }, 27 | "devDependencies": { 28 | "enzyme": "3.11.0", 29 | "redux": "4.0.5" 30 | }, 31 | "publishConfig": { 32 | "access": "public" 33 | }, 34 | "sideEffects": false 35 | } 36 | -------------------------------------------------------------------------------- /packages/epics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/epics", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-epics.js", 6 | "module": "es/redux-tools-epics.js", 7 | "unpkg": "dist/redux-tools-epics.js", 8 | "description": "Redux store enhancer for asynchronous injection of epics.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "@redux-tools/injectors": "^0.9.1", 18 | "@redux-tools/namespaces": "^0.9.1", 19 | "@redux-tools/utils": "^0.9.1", 20 | "ramda": ">=0.26.1", 21 | "ramda-extension": ">=0.8.0" 22 | }, 23 | "peerDependencies": { 24 | "redux": "^4.0.4", 25 | "redux-observable": "^1.0.0", 26 | "rxjs": "^6.3.3" 27 | }, 28 | "devDependencies": { 29 | "redux": "4.0.5", 30 | "redux-observable": "1.2.0", 31 | "rxjs": "6.5.4" 32 | }, 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "sideEffects": false 37 | } 38 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/react", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-react.js", 6 | "module": "es/redux-tools-react.js", 7 | "unpkg": "dist/redux-tools-react.js", 8 | "description": "Everything you need to get started with Redux Tools in a React application.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "@redux-tools/actions": "^0.9.1", 18 | "@redux-tools/middleware": "^0.9.1", 19 | "@redux-tools/middleware-react": "^0.9.1", 20 | "@redux-tools/namespaces": "^0.9.1", 21 | "@redux-tools/namespaces-react": "^0.9.1", 22 | "@redux-tools/reducers": "^0.9.1", 23 | "@redux-tools/reducers-react": "^0.9.1" 24 | }, 25 | "peerDependencies": { 26 | "react": ">=16.8.3", 27 | "react-redux": "^6.0.0 || ^7.0.0", 28 | "redux": "^4.0.4" 29 | }, 30 | "publishConfig": { 31 | "access": "public" 32 | }, 33 | "sideEffects": false 34 | } 35 | -------------------------------------------------------------------------------- /packages/actions/src/configureActionCreator.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { curry, compose, o, applySpec, always, ifElse, is, T, reject } from 'ramda'; 3 | import { isNilOrEmptyString } from 'ramda-extension'; 4 | 5 | import alwaysUndefined from './alwaysUndefined'; 6 | 7 | const isUndefined = value => value === undefined; 8 | 9 | /** 10 | * Creates an action creator with supplied type and payload & meta getters. 11 | * 12 | * @sig String -> (a -> b) -> (a -> c) -> a -> {type: String, payload: b, meta: c} 13 | * 14 | * @example 15 | * 16 | * const reset = makeEmptyActionCreator("RESET") 17 | * const add = makePayloadActionCreator("ADD"); 18 | * const fetchItems = configureActionCreator("FETCH_ITEMS", R.prop("items"), R.always({ page: 0 })) 19 | */ 20 | 21 | const configureActionCreator = curry((type, getPayload, getMeta) => { 22 | invariant(!isNilOrEmptyString(type), `Action type must be a non-empty string (received ${type})`); 23 | 24 | const actionCreator = compose( 25 | reject(isUndefined), 26 | applySpec({ 27 | type: always(type), 28 | payload: getPayload, 29 | meta: getMeta, 30 | error: o(ifElse(is(Error), T, alwaysUndefined), getPayload), 31 | }) 32 | ); 33 | 34 | return actionCreator; 35 | }); 36 | 37 | export default configureActionCreator; 38 | -------------------------------------------------------------------------------- /packages/injectors-react/src/makeDecorator.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import { noop } from 'ramda-extension'; 3 | import React from 'react'; 4 | 5 | import { makeStoreInterface } from '@redux-tools/injectors'; 6 | import { NamespaceProvider } from '@redux-tools/namespaces-react'; 7 | 8 | import makeDecorator from './makeDecorator'; 9 | import makeHook from './makeHook'; 10 | 11 | const storeInterface = makeStoreInterface('things'); 12 | const useThings = makeHook(storeInterface); 13 | const withThings = makeDecorator(storeInterface, useThings); 14 | 15 | jest.mock('./constants', () => ({ IS_SERVER: false })); 16 | 17 | describe('makeDecorator', () => { 18 | const store = { 19 | injectThings: jest.fn(), 20 | ejectThings: jest.fn(), 21 | subscribe: jest.fn(), 22 | getState: jest.fn(), 23 | dispatch: jest.fn(), 24 | }; 25 | 26 | beforeEach(() => { 27 | jest.clearAllMocks(); 28 | }); 29 | 30 | it('calls proper store methods when mounted', () => { 31 | const Root = withThings({ foo: noop })(noop); 32 | 33 | mount( 34 | 35 | 36 | 37 | ); 38 | 39 | expect(store.injectThings).toHaveBeenCalledTimes(1); 40 | expect(store.injectThings.mock.calls[0][0]).toEqual({ foo: noop }, { namespace: 'yolo' }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/react/src/index.js: -------------------------------------------------------------------------------- 1 | // NOTE: We need to list all exports manually because of potential naming collisions. 2 | export { 3 | isErrorAction, 4 | isNotErrorAction, 5 | configureActionCreator, 6 | makeActionTypes, 7 | makeEmptyActionCreator, 8 | makePayloadActionCreator, 9 | makeConstantActionCreator, 10 | makeSimpleActionCreator, 11 | makePayloadMetaActionCreator, 12 | } from '@redux-tools/actions'; 13 | 14 | export { makeEnhancer as makeMiddlewareEnhancer, composeMiddleware } from '@redux-tools/middleware'; 15 | 16 | export { useMiddleware, withMiddleware } from '@redux-tools/middleware-react'; 17 | 18 | export { 19 | attachNamespace, 20 | defaultNamespace, 21 | getNamespaceByAction, 22 | isActionFromNamespace, 23 | getStateByAction, 24 | getStateByFeatureAndAction, 25 | getStateByNamespace, 26 | getStateByFeatureAndNamespace, 27 | } from '@redux-tools/namespaces'; 28 | 29 | export { 30 | composeReducers, 31 | makeEnhancer as makeReducersEnhancer, 32 | makeReducer, 33 | combineReducers, 34 | } from '@redux-tools/reducers'; 35 | 36 | export { useReducers, withReducers } from '@redux-tools/reducers-react'; 37 | 38 | export { 39 | namespacedConnect, 40 | useNamespace, 41 | NamespaceProvider, 42 | withNamespaceProvider, 43 | useNamespacedSelector, 44 | useNamespacedDispatch, 45 | } from '@redux-tools/namespaces-react'; 46 | -------------------------------------------------------------------------------- /packages/utils-react/src/withProps.test.js: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import { noop } from 'ramda-extension'; 3 | import React from 'react'; 4 | 5 | import withProps from './withProps'; 6 | 7 | describe('withProps', () => { 8 | it('accepts an object', () => { 9 | const Component = withProps({ foo: 'bar' }, noop); 10 | const wrapper = shallow(); 11 | expect(wrapper.find(noop).prop('foo')).toBe('bar'); 12 | }); 13 | 14 | it('preserves existing props when an object is passed', () => { 15 | const Component = withProps({ foo: 'bar' }, noop); 16 | const wrapper = shallow(); 17 | expect(wrapper.find(noop).prop('foo')).toBe('bar'); 18 | expect(wrapper.find(noop).prop('bar')).toBe('baz'); 19 | }); 20 | 21 | it('accepts a function', () => { 22 | const Component = withProps(({ foo }) => ({ foo: foo.toUpperCase() }), noop); 23 | const wrapper = shallow(); 24 | expect(wrapper.find(noop).prop('foo')).toBe('BAR'); 25 | }); 26 | 27 | it('preserves existing props when a function is passed', () => { 28 | const Component = withProps(({ foo }) => ({ foo: foo.toUpperCase() }), noop); 29 | const wrapper = shallow(); 30 | expect(wrapper.find(noop).prop('foo')).toBe('BAR'); 31 | expect(wrapper.find(noop).prop('bar')).toBe('baz'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/namespaces-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/namespaces-react", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-namespaces-react.js", 6 | "module": "es/redux-tools-namespaces-react.js", 7 | "unpkg": "dist/redux-tools-namespaces-react.js", 8 | "description": "React bindings for the @redux-tools/namespaces package.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy ", 15 | "Veronika Wernerova " 16 | ], 17 | "dependencies": { 18 | "@redux-tools/namespaces": "^0.9.1", 19 | "@redux-tools/reducers": "^0.9.1", 20 | "@redux-tools/utils-react": "^0.9.1", 21 | "hoist-non-react-statics": "^3.3.0", 22 | "prop-types": "^15.7.2", 23 | "ramda": ">=0.26.1", 24 | "ramda-extension": ">=0.8.0" 25 | }, 26 | "peerDependencies": { 27 | "react": ">=16.8.3", 28 | "react-redux": "^6.0.0 || ^7.0.0", 29 | "redux": "^4.0.4" 30 | }, 31 | "devDependencies": { 32 | "enzyme": "3.11.0" 33 | }, 34 | "publishConfig": { 35 | "access": "public" 36 | }, 37 | "sideEffects": false 38 | } 39 | -------------------------------------------------------------------------------- /packages/injectors-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redux-tools/injectors-react", 3 | "version": "0.9.1", 4 | "license": "MIT", 5 | "main": "lib/redux-tools-injectors-react.js", 6 | "module": "es/redux-tools-injectors-react.js", 7 | "unpkg": "dist/redux-tools-injectors-react.js", 8 | "description": "Core React functionality for asynchronous React injectors.", 9 | "repository": "https://github.com/lundegaard/redux-tools", 10 | "contributors": [ 11 | "Tomas Konrady ", 12 | "Vaclav Jancarik ", 13 | "Martin Kadlec ", 14 | "Sergey Dunaevskiy " 15 | ], 16 | "dependencies": { 17 | "@redux-tools/injectors": "^0.9.1", 18 | "@redux-tools/namespaces": "^0.9.1", 19 | "@redux-tools/namespaces-react": "^0.9.1", 20 | "@redux-tools/utils": "^0.9.1", 21 | "@redux-tools/utils-react": "^0.9.1", 22 | "hoist-non-react-statics": "^3.3.0", 23 | "invariant": "^2.2.4", 24 | "prop-types": "^15.7.2", 25 | "ramda": ">=0.26.1", 26 | "ramda-extension": ">=0.8.0" 27 | }, 28 | "peerDependencies": { 29 | "react": ">=16.8.3", 30 | "react-redux": "^6.0.0 || ^7.0.0" 31 | }, 32 | "devDependencies": { 33 | "enzyme": "3.11.0" 34 | }, 35 | "publishConfig": { 36 | "access": "public" 37 | }, 38 | "sideEffects": false 39 | } 40 | -------------------------------------------------------------------------------- /docs/packages/epics-react.md: -------------------------------------------------------------------------------- 1 | # Epics (React Bindings) 2 | 3 | > yarn add @redux-tools/epics-react 4 | 5 | This package provides React bindings for the [@redux-tools/epics](/packages/epics) package. 6 | 7 | ## Usage Example 8 | 9 | ```js 10 | import React from 'react'; 11 | import { withEpics } from '@redux-tools/epics-react'; 12 | import someEpic from './someEpic'; 13 | 14 | const Container = () => null; 15 | 16 | export default withEpics(someEpic)(Container); 17 | ``` 18 | 19 | ## API Reference 20 | 21 | ### withEpics() 22 | 23 | Creates a component decorator which handles the lifecycle of passed epics, injecting and ejecting them automatically. The namespace of the epics is determined based on React context. 24 | 25 | **Parameters** 26 | 27 | 1. `epics` ( _Function|Array|Object_ ): The epics to use. 28 | 2. `options` ( _Object_ ): Options for the decorator. The following keys are supported: 29 | - [`isGlobal`] \( _boolean_ ): Pass `true` if the epics shouldn't be namespaced. 30 | - [`isNamespaced`] \( _boolean_ ): Pass `true` if the epics must be namespaced. 31 | - [`isPersistent`] \( _boolean_ ): Whether the epic should be automatically ejected after the component is unmounted. 32 | - [`namespace`] \( _string_ ): Namespace to inject the epics under. If passed, the epics will not handle actions from other namespaces. 33 | - [`feature`] \( _string_ ): Feature to resolve the namespace by (if using namespace providers). 34 | 35 | **Returns** 36 | 37 | ( _Function_ ): A component decorator. 38 | -------------------------------------------------------------------------------- /packages/thunk/src/index.test.js: -------------------------------------------------------------------------------- 1 | import { identity } from 'ramda'; 2 | import { createStore, applyMiddleware } from 'redux'; 3 | 4 | import { attachNamespace, getNamespaceByAction } from '@redux-tools/namespaces'; 5 | 6 | import thunkMiddleware from './index'; 7 | 8 | describe('thunkMiddleware', () => { 9 | const listener = jest.fn(); 10 | 11 | const listenerMiddleware = () => next => action => { 12 | listener(action); 13 | next(action); 14 | }; 15 | 16 | const store = createStore(identity, applyMiddleware(thunkMiddleware, listenerMiddleware)); 17 | 18 | beforeEach(() => { 19 | jest.clearAllMocks(); 20 | }); 21 | 22 | it('lets objects through', () => { 23 | const action = { type: 'BEARS' }; 24 | store.dispatch(action); 25 | expect(listener).toHaveBeenCalledWith(action); 26 | }); 27 | 28 | it('handles thunks without a namespace', () => { 29 | const action = { type: 'BEETS' }; 30 | 31 | store.dispatch(({ dispatch }) => { 32 | dispatch(action); 33 | }); 34 | 35 | expect(listener).toHaveBeenCalledWith(action); 36 | }); 37 | 38 | it('handles thunks with a namespace', () => { 39 | const action = { type: 'BATTLESTAR_GALACTICA' }; 40 | 41 | store.dispatch( 42 | attachNamespace('MICHAEL', ({ dispatch }) => { 43 | dispatch(action); 44 | }) 45 | ); 46 | 47 | expect(listener).toHaveBeenCalledTimes(1); 48 | expect(listener.mock.calls[0][0].type).toEqual(action.type); 49 | expect(getNamespaceByAction(listener.mock.calls[0][0])).toEqual('MICHAEL'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/actions/src/makePayloadMetaActionCreator.test.js: -------------------------------------------------------------------------------- 1 | import makePayloadMetaActionCreator from './makePayloadMetaActionCreator'; 2 | 3 | describe('makePayloadMetaActionCreator', () => { 4 | it('throws when a type is empty string', () => { 5 | expect(() => makePayloadMetaActionCreator('')).toThrow(); 6 | }); 7 | 8 | it('throws when a type is empty', () => { 9 | expect(() => makePayloadMetaActionCreator()).toThrow(); 10 | }); 11 | 12 | it('throws when called with 0 arguments', () => { 13 | const actionCreator = makePayloadMetaActionCreator('TYPE'); 14 | expect(() => actionCreator()).toThrow(); 15 | }); 16 | 17 | it('throws when called with wrong meta type', () => { 18 | const actionCreator = makePayloadMetaActionCreator('TYPE'); 19 | expect(() => actionCreator(undefined, [])).toThrow(); 20 | }); 21 | 22 | it('throws when called with payload and no meta', () => { 23 | const actionCreator = makePayloadMetaActionCreator('TYPE'); 24 | expect(() => actionCreator('yo')).toThrow(); 25 | }); 26 | 27 | it('handles a call with missing payload', () => { 28 | const actionCreator = makePayloadMetaActionCreator('TYPE'); 29 | expect(actionCreator(undefined, {})).toEqual({ 30 | type: 'TYPE', 31 | meta: {}, 32 | }); 33 | }); 34 | 35 | it('uses the argument as the action payload and meta', () => { 36 | const actionCreator = makePayloadMetaActionCreator('TYPE'); 37 | expect(actionCreator('yo', {})).toEqual({ 38 | type: 'TYPE', 39 | payload: 'yo', 40 | meta: {}, 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /docs/packages/reducers-react.md: -------------------------------------------------------------------------------- 1 | # Reducers (React Bindings) 2 | 3 | > yarn add @redux-tools/reducers-react 4 | 5 | This package provides React bindings for the [@redux-tools/reducers](/packages/reducers) package. 6 | 7 | ## Usage Example 8 | 9 | ```js 10 | import React from 'react'; 11 | import { withReducers } from '@redux-tools/reducers-react'; 12 | import someReducer from './someReducer'; 13 | 14 | const Container = () => null; 15 | 16 | export default withReducers({ someReducer })(Container); 17 | ``` 18 | 19 | ## API Reference 20 | 21 | ### withReducers() 22 | 23 | Creates a component decorator which handles the lifecycle of passed reducers, mounting and unmounting them automatically. The namespace of the reducers is determined based on React context. 24 | 25 | **Parameters** 26 | 27 | 1. `reducers` ( _Function|Array|Object_ ): The reducers to use. 28 | 2. [`options`] \( _Object_ ): Options for the decorator. The following keys are supported: 29 | - [`isGlobal`] \( _boolean_ ): Pass `true` if the reducers shouldn't be namespaced. 30 | - [`isNamespaced`] \( _boolean_ ): Pass `true` if the reducers must be namespaced. 31 | - [`isPersistent`] \( _boolean_ ): Define whether reducer should be auto-ejected after unmount. 32 | - [`feature`] \( _string_ ): Namespace to inject the reducer under. If passed, the reducer will not handle actions from other namespaces. 33 | - [`namespace`] \( _string_ ): Namespace the reducers were injected under. 34 | 35 | **Returns** 36 | 37 | ( _Function_ ): A component decorator. 38 | -------------------------------------------------------------------------------- /packages/namespaces/src/isActionFromNamespace.test.js: -------------------------------------------------------------------------------- 1 | import { NAMESPACE_PREVENTED } from './constants'; 2 | import isActionFromNamespace from './isActionFromNamespace'; 3 | 4 | const fooAction = { meta: { namespace: 'foo' } }; 5 | const barAction = { meta: { namespace: 'bar' } }; 6 | const actionWithPreventedNamespace = { meta: { namespace: NAMESPACE_PREVENTED } }; 7 | 8 | describe('isActionFromNamespace', () => { 9 | it('returns true when action is global and reducer is global', () => { 10 | expect(isActionFromNamespace(undefined, {})).toBe(true); 11 | }); 12 | 13 | it('returns true when action is global and reducer is namespaced', () => { 14 | expect(isActionFromNamespace('foo', {})).toBe(true); 15 | }); 16 | 17 | it('returns true when action is namespaced and reducer is global', () => { 18 | expect(isActionFromNamespace(undefined, fooAction)).toBe(true); 19 | }); 20 | 21 | it('returns true when namespaces match', () => { 22 | expect(isActionFromNamespace('foo', fooAction)).toBe(true); 23 | }); 24 | 25 | it('returns false when namespaces do not match', () => { 26 | expect(isActionFromNamespace('foo', barAction)).toBe(false); 27 | }); 28 | 29 | it('returns true when action had its namespace prevented and reducer is namespaced', () => { 30 | expect(isActionFromNamespace('foo', actionWithPreventedNamespace)).toBe(true); 31 | }); 32 | 33 | it('returns true when action had its namespace prevented and reducer is global', () => { 34 | expect(isActionFromNamespace(undefined, actionWithPreventedNamespace)).toBe(true); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/middleware/src/composeMiddleware.test.js: -------------------------------------------------------------------------------- 1 | import composeMiddleware from './composeMiddleware'; 2 | 3 | describe('composeMiddleware', () => { 4 | const logger = jest.fn(); 5 | const someReduxApi = { dispatch: jest.fn(), getState: jest.fn() }; 6 | const someNext = jest.fn(); 7 | const someAction = { type: 'FOO' }; 8 | 9 | const middleware = reduxApi => next => action => { 10 | logger({ reduxApi, next, action }); 11 | 12 | return next(action); 13 | }; 14 | 15 | beforeEach(() => { 16 | jest.clearAllMocks(); 17 | }); 18 | 19 | it('handles a single middleware', () => { 20 | const composedMiddleware = composeMiddleware(middleware); 21 | composedMiddleware(someReduxApi)(someNext)(someAction); 22 | 23 | expect(logger).toHaveBeenCalledTimes(1); 24 | 25 | expect(logger.mock.calls[0][0]).toEqual({ 26 | reduxApi: someReduxApi, 27 | next: someNext, 28 | action: someAction, 29 | }); 30 | }); 31 | 32 | it('handles multiple middleware', () => { 33 | const composedMiddleware = composeMiddleware(middleware, middleware); 34 | composedMiddleware(someReduxApi)(someNext)(someAction); 35 | 36 | expect(logger).toHaveBeenCalledTimes(2); 37 | 38 | expect(logger.mock.calls[0][0].reduxApi).toBe(someReduxApi); 39 | expect(logger.mock.calls[0][0].next).not.toBe(someNext); 40 | expect(logger.mock.calls[0][0].next).toBeInstanceOf(Function); 41 | expect(logger.mock.calls[0][0].action).toBe(someAction); 42 | 43 | expect(logger.mock.calls[1][0]).toEqual({ 44 | reduxApi: someReduxApi, 45 | next: someNext, 46 | action: someAction, 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /docs/packages/middleware-react.md: -------------------------------------------------------------------------------- 1 | # Middleware (React Bindings) 2 | 3 | > yarn add @redux-tools/middleware-react 4 | 5 | This package provides React bindings for the [@redux-tools/middleware](/packages/middleware) package. 6 | 7 | ## Usage Example 8 | 9 | ```js 10 | import React from 'react'; 11 | import { withMiddleware } from '@redux-tools/middleware-react'; 12 | import someMiddleware from './someMiddleware'; 13 | 14 | const Container = () => null; 15 | 16 | export default withMiddleware(someMiddleware)(Container); 17 | ``` 18 | 19 | ## API Reference 20 | 21 | ### withMiddleware() 22 | 23 | Creates a component decorator which handles the lifecycle of passed middleware, injecting and ejecting them automatically. The namespace of the middleware is determined based on React context. 24 | 25 | **Parameters** 26 | 27 | 1. `middleware` ( _Function|Array|Object_ ): The middleware to use. 28 | 2. [`options`] \( _Object_ ): Options for the decorator. The following keys are supported: 29 | - [`isGlobal`] \( _boolean_ ): Pass `true` if the middleware shouldn't be namespaced. 30 | - [`isNamespaced`] \( _boolean_ ): Pass `true` if the middleware must be namespaced. 31 | - [`isPersistent`] \( _boolean_ ): Whether the middleware should be automatically ejected after the component is unmounted. 32 | - [`namespace`] \( _string_ ): Namespace to inject the middleware under. If passed, the middleware will not handle actions from other namespaces. 33 | - [`feature`] \( _string_ ): Feature to resolve the namespace by (if using namespace providers). 34 | 35 | **Returns** 36 | 37 | ( _Function_ ): A component decorator. 38 | -------------------------------------------------------------------------------- /docs/tutorial/04-view-state-management.md: -------------------------------------------------------------------------------- 1 | # View State Management {docsify-ignore-all} 2 | 3 | Redux Tools support deep reducer injection, meaning that you can organize state for huge applications very easily. 4 | 5 | ```json 6 | { 7 | "userManagement": { 8 | "userDetail": null, 9 | "userList": [], 10 | "hasPermissions": true 11 | } 12 | } 13 | ``` 14 | 15 | ```js 16 | // Dummy reducer, manages `state.userManagement.userList` 17 | const userListReducer = () => []; 18 | 19 | const UserList = () => { 20 | const users = useSelector(getUsers); 21 | 22 | return users.map(user => user.name); 23 | }; 24 | 25 | export default withReducers({ 26 | userManagement: { 27 | userList: userListReducer, 28 | }, 29 | })(UserList); 30 | ``` 31 | 32 | ```js 33 | // Dummy reducer, manages `state.userManagement.userDetail` 34 | const userDetailReducer = () => null; 35 | 36 | const UserDetail = ({ userId }) => { 37 | const user = useSelector(getUser(userId)); 38 | 39 | return user.name; 40 | }; 41 | 42 | export default withReducers({ 43 | userManagement: { 44 | userDetail: userDetailReducer, 45 | }, 46 | })(UserDetail); 47 | ``` 48 | 49 | ```js 50 | // Dummy reducer, manages `state.userManagement` and is composed with the inner reducers. 51 | const userManagementReducer = state => ({ 52 | ...state, 53 | hasPermissions: true, 54 | }); 55 | 56 | const UserManagement = () => ( 57 | 58 | 59 | 60 | 61 | ); 62 | 63 | export default withReducers({ userManagement: userManagementReducer })(UserDetail); 64 | ``` 65 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/NamespaceProvider.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { mergeDeepWith, flip, or } from 'ramda'; 3 | import React, { useContext, useMemo } from 'react'; 4 | import { Provider as ReactReduxProvider } from 'react-redux'; 5 | 6 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 7 | 8 | import { NamespaceContext } from './contexts'; 9 | 10 | // NOTE: `flip(or)` gives priority to second argument. 11 | const mergeContextValues = mergeDeepWith(flip(or)); 12 | 13 | const NamespaceProvider = ({ children, feature, namespace, store, useNamespace }) => { 14 | const context = useContext(NamespaceContext); 15 | 16 | const nextContext = useMemo( 17 | () => 18 | mergeContextValues(context, { 19 | // NOTE: Defaulting here is safer than using `Provider.defaultProps`, 20 | // because passing `null` would not result in a fallback to `DEFAULT_FEATURE`. 21 | namespaces: { [feature ?? DEFAULT_FEATURE]: namespace }, 22 | useNamespace, 23 | isUseNamespaceProvided: Boolean(useNamespace), 24 | }), 25 | [context, feature, namespace, useNamespace] 26 | ); 27 | 28 | const providerElement = ( 29 | {children} 30 | ); 31 | 32 | return store ? ( 33 | {providerElement} 34 | ) : ( 35 | providerElement 36 | ); 37 | }; 38 | 39 | NamespaceProvider.propTypes = { 40 | children: PropTypes.node.isRequired, 41 | feature: PropTypes.string, 42 | namespace: PropTypes.string, 43 | store: PropTypes.object, 44 | useNamespace: PropTypes.func, 45 | }; 46 | 47 | export default NamespaceProvider; 48 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['react-union', 'prettier', 'prettier/react'], 4 | plugins: ['react-hooks'], 5 | rules: { 6 | 'padding-line-between-statements': [ 7 | 'error', 8 | { 9 | blankLine: 'always', 10 | prev: ['block', 'block-like', 'export', 'import', 'multiline-expression'], 11 | next: '*', 12 | }, 13 | { 14 | blankLine: 'always', 15 | prev: '*', 16 | next: ['block', 'block-like', 'export', 'import', 'return', 'throw'], 17 | }, 18 | { 19 | blankLine: 'any', 20 | prev: ['export', 'import'], 21 | next: ['export', 'import'], 22 | }, 23 | { 24 | blankLine: 'never', 25 | prev: 'case', 26 | next: '*', 27 | }, 28 | ], 29 | // NOTE: Sadly, `sort-imports` is not really compatible with `import/order`. 30 | 'import/order': [ 31 | 'error', 32 | { 33 | 'newlines-between': 'always', 34 | pathGroups: [ 35 | { 36 | pattern: '@redux-tools/**', 37 | group: 'external', 38 | position: 'after', 39 | }, 40 | ], 41 | pathGroupsExcludedImportTypes: ['builtin'], 42 | alphabetize: { 43 | order: 'asc', 44 | caseInsensitive: false, 45 | }, 46 | }, 47 | ], 48 | 'react-hooks/rules-of-hooks': 'error', 49 | 'react-hooks/exhaustive-deps': 'warn', 50 | 'no-console': ['error', { allow: ['warn', 'error', 'info'] }], 51 | 'import/no-extraneous-dependencies': [ 52 | 'error', 53 | { devDependencies: ['packages/**/*.test.js', '*.js', 'rollup/*.js', 'tests/*.js'] }, 54 | ], 55 | // TODO: Look at `common-tags` to solve indentation issues with multiline template strings. 56 | 'prefer-template': 'off', 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /docs/faq/general.md: -------------------------------------------------------------------------------- 1 | # FAQ: General {docsify-ignore-all} 2 | 3 | ## How to use Redux Tools with React Union? 4 | 5 | 1. Wrap your `` component in a ``. 6 | 2. Pass `() => useContext(WidgetContext).namespace` as the `useNamespace` prop to the namespace provider. 7 | 8 | That's it! All injector decorators, `namespacedConnect`, and namespaced hooks will always access the namespace of the widget they are being used in. 9 | 10 | To use injectors globally, pass `isGlobal: true` to them. To access global state and affect global state, use the decorators and hooks from React Redux directly. 11 | 12 | ## Redux DevTools have stopped working for me. How do I fix them? 13 | 14 | You might need to configure the DevTools slightly. 15 | 16 | ```js 17 | import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction'; 18 | 19 | const composeEnhancers = composeWithDevTools({ 20 | latency: 0, 21 | }); 22 | ``` 23 | 24 | If you're having issues with infinite loops outside production environments, passing `shouldHotReload: false` might help you get around this issue. 25 | 26 | > `store.replaceReducer` will otherwise cause all prior actions to be redispatched to the new reducer, updating the state. This might cause a rerender of some of your components. However, if you are creating components dynamically without proper memoization, they will be completely remounted. Whenever a Redux Tools decorator is remounted, the injectables are reinjected accordingly. This will cause another `store.replaceReducer` call and an infinite loop. 27 | 28 | See the [API Documentation](https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/API/Arguments.md) for more info. 29 | -------------------------------------------------------------------------------- /packages/injectors-react/src/makeDecorator.js: -------------------------------------------------------------------------------- 1 | import hoistNonReactStatics from 'hoist-non-react-statics'; 2 | import invariant from 'invariant'; 3 | import PropTypes from 'prop-types'; 4 | import { toPascalCase, isObject } from 'ramda-extension'; 5 | import React from 'react'; 6 | 7 | import { useNamespace } from '@redux-tools/namespaces-react'; 8 | import { getDisplayName } from '@redux-tools/utils-react'; 9 | 10 | const makeDecorator = (storeInterface, useInjectables) => { 11 | invariant(isObject(storeInterface), 'The store interface is undefined.'); 12 | 13 | const { type } = storeInterface; 14 | const decoratorName = type ? `With${toPascalCase(type)}` : 'Injector'; 15 | 16 | return (injectables, options = {}) => NextComponent => { 17 | const Injector = props => { 18 | // eslint-disable-next-line react/destructuring-assignment 19 | const feature = options.feature ?? props.feature ?? null; 20 | const contextNamespace = useNamespace(feature); 21 | // eslint-disable-next-line react/destructuring-assignment 22 | const namespace = options.namespace ?? props.namespace ?? contextNamespace ?? null; 23 | const isInitialized = useInjectables(injectables, { ...options, feature, namespace }); 24 | 25 | if (isInitialized) { 26 | return ; 27 | } 28 | 29 | return null; 30 | }; 31 | 32 | Injector.propTypes = { 33 | feature: PropTypes.string, 34 | namespace: PropTypes.string, 35 | }; 36 | 37 | hoistNonReactStatics(Injector, NextComponent); 38 | 39 | Injector.displayName = `${decoratorName}(${getDisplayName(NextComponent)})`; 40 | 41 | return Injector; 42 | }; 43 | }; 44 | 45 | export default makeDecorator; 46 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/useNamespace.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import { always } from 'ramda'; 3 | import { alwaysNull } from 'ramda-extension'; 4 | import React from 'react'; 5 | 6 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 7 | 8 | import { NamespaceContext } from './contexts'; 9 | import useNamespace from './useNamespace'; 10 | 11 | const Test = ({ children }) => { 12 | children(); 13 | 14 | return null; 15 | }; 16 | 17 | const alwaysFoo = always('foo'); 18 | 19 | describe('useNamespace', () => { 20 | it('returns namespace from context', () => { 21 | let namespace; 22 | 23 | mount( 24 | 25 | {() => (namespace = useNamespace('foo'))} 26 | 27 | ); 28 | 29 | expect(namespace).toEqual('bar'); 30 | }); 31 | 32 | it('returns namespace from `useNamespace` if context does not contain feature', () => { 33 | let namespace; 34 | 35 | mount( 36 | 37 | {() => (namespace = useNamespace('random'))} 38 | 39 | ); 40 | 41 | expect(namespace).toEqual('foo'); 42 | }); 43 | 44 | it('does not fall back to default feature when no namespace could be resolved', () => { 45 | let namespace; 46 | 47 | mount( 48 | 51 | {() => (namespace = useNamespace('random'))} 52 | 53 | ); 54 | 55 | expect(namespace).toBeFalsy(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/injectors/src/createEntries.js: -------------------------------------------------------------------------------- 1 | import { o, chain, map, toPairs, mergeRight } from 'ramda'; 2 | import { isArray, rejectNil, isObject } from 'ramda-extension'; 3 | 4 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 5 | 6 | const createRawEntries = injectables => { 7 | // NOTE: `isFunction` from ramda-extension returns `false` for `jest.fn()`. 8 | if (typeof injectables === 'function') { 9 | return [{ path: [], value: injectables }]; 10 | } 11 | 12 | if (isArray(injectables)) { 13 | return chain(createRawEntries, injectables); 14 | } 15 | 16 | if (isObject(injectables)) { 17 | const createRawEntriesFromPair = ([key, injectable]) => { 18 | const entries = createRawEntries(injectable); 19 | 20 | const prependEntryPath = entry => ({ 21 | ...entry, 22 | path: [key, ...entry.path], 23 | }); 24 | 25 | return map(prependEntryPath, entries); 26 | }; 27 | 28 | return o(chain(createRawEntriesFromPair), toPairs)(injectables); 29 | } 30 | 31 | return []; 32 | }; 33 | 34 | /** 35 | * Converts the input of `store.injectSomething()` or `store.ejectSomething()` 36 | * to an array of standalone entries. 37 | * 38 | * @param {*} injectables injectables to create the entries from 39 | * @param {Object} props props to store in all entries, i.e. `namespace` and `feature` 40 | * @returns {Object[]} an array of entries 41 | */ 42 | const createEntries = (injectables, { feature, namespace } = {}) => { 43 | const sanitizedProps = rejectNil({ 44 | namespace, 45 | feature: feature ?? (namespace ? DEFAULT_FEATURE : null), 46 | }); 47 | 48 | const rawEntries = createRawEntries(injectables); 49 | const addPropsToEntries = map(mergeRight(sanitizedProps)); 50 | 51 | return addPropsToEntries(rawEntries); 52 | }; 53 | 54 | export default createEntries; 55 | -------------------------------------------------------------------------------- /packages/injectors/src/enhanceStore.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { pluck, concat } from 'ramda'; 3 | import { noop, isObject, toScreamingSnakeCase } from 'ramda-extension'; 4 | 5 | import { withoutOnce } from '@redux-tools/utils'; 6 | 7 | import createEntries from './createEntries'; 8 | 9 | const enhanceStore = (prevStore, storeInterface, { onEjected = noop, onInjected = noop } = {}) => { 10 | invariant( 11 | isObject(prevStore), 12 | 'You must pass a Redux store as the first argument to `enhanceStore()`' 13 | ); 14 | 15 | invariant( 16 | isObject(storeInterface), 17 | 'You must pass a store interface as the second argument to `enhanceStore()`' 18 | ); 19 | 20 | const { injectionKey, ejectionKey, getEntries, setEntries, type } = storeInterface; 21 | const { dispatch = noop } = prevStore; 22 | const actionType = toScreamingSnakeCase(type); 23 | 24 | const inject = (injectables, props = {}) => { 25 | const entries = createEntries(injectables, props); 26 | 27 | const nextEntries = concat(getEntries(nextStore), entries); 28 | setEntries(nextEntries, nextStore); 29 | onInjected({ injectables, props, entries }); 30 | 31 | dispatch({ 32 | type: `@redux-tools/${actionType}_INJECTED`, 33 | payload: pluck('path', entries), 34 | meta: props, 35 | }); 36 | }; 37 | 38 | const eject = (injectables, props = {}) => { 39 | const entries = createEntries(injectables, props); 40 | const nextEntries = withoutOnce(entries, getEntries(nextStore)); 41 | setEntries(nextEntries, nextStore); 42 | onEjected({ injectables, props, entries }); 43 | 44 | dispatch({ 45 | type: `@redux-tools/${actionType}_EJECTED`, 46 | payload: pluck('path', entries), 47 | meta: props, 48 | }); 49 | }; 50 | 51 | const nextStore = { 52 | ...prevStore, 53 | [injectionKey]: inject, 54 | [ejectionKey]: eject, 55 | }; 56 | 57 | setEntries([], nextStore); 58 | 59 | return nextStore; 60 | }; 61 | 62 | export default enhanceStore; 63 | -------------------------------------------------------------------------------- /packages/reducers/src/makeReducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | prop, 3 | compose, 4 | useWith, 5 | __, 6 | defaultTo, 7 | identity, 8 | cond, 9 | map, 10 | append, 11 | T, 12 | includes, 13 | } from 'ramda'; 14 | import { overHead, isString, isFunction, isArray } from 'ramda-extension'; 15 | 16 | const createTypeEqualsPredicate = condition => (state, action) => { 17 | if (isString(condition)) { 18 | return action.type === condition; 19 | } else if (isArray(condition)) { 20 | return includes(action.type, condition); 21 | } else if (isFunction(condition)) { 22 | return condition(action); 23 | } else { 24 | throw new TypeError( 25 | // eslint-disable-next-line prefer-template 26 | 'The condition passed to makeReducer must be a string, an array of strings, or a predicate. ' + 27 | 'Instead, it received ' + 28 | condition + 29 | '.' 30 | ); 31 | } 32 | }; 33 | 34 | const mergeReducers = ([typePredicate, reducer, errorReducer]) => { 35 | const newReducer = (state, action) => { 36 | if (prop('error', action) && errorReducer) { 37 | return errorReducer(state, action); 38 | } 39 | 40 | return reducer(state, action); 41 | }; 42 | 43 | return [typePredicate, newReducer]; 44 | }; 45 | 46 | /** 47 | * Creates a complex reducer from (type, reducer[, errorReducer]) tuples. 48 | * 49 | * @sig [[String, Reducer, Reducer]] -> Reducer 50 | * 51 | * @example 52 | * 53 | * const initialState = 1 54 | * const reducer = makeReducer([ 55 | * ["ADD", (state, action) => state + action.payload], 56 | * ["RESET", always(initialState)], 57 | * ], initialState); 58 | * 59 | * reducer(undefined, {}) // 1 60 | * reducer(3, { type: "ADD", payload: 2 }) // 5 61 | * reducer(3, { type: "RESET" }) // 1 62 | * reducer(3, { type: "LOAD_ITEMS" }) // 3 63 | */ 64 | const makeReducer = (tuples, initialState) => 65 | compose( 66 | // eslint-disable-next-line react-hooks/rules-of-hooks 67 | useWith(__, [defaultTo(initialState), identity]), 68 | cond, 69 | map(mergeReducers), 70 | append([T, identity, identity]), 71 | map(overHead(createTypeEqualsPredicate)) 72 | )(tuples); 73 | 74 | export default makeReducer; 75 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/useNamespacedSelector.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import React from 'react'; 3 | 4 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 5 | 6 | import NamespaceProvider from './NamespaceProvider'; 7 | import useNamespacedSelector from './useNamespacedSelector'; 8 | 9 | const Test = ({ children }) => { 10 | children(); 11 | 12 | return null; 13 | }; 14 | 15 | const mockState = { 16 | [DEFAULT_FEATURE]: { 17 | foo: { value: 1 }, 18 | }, 19 | someFeature: { 20 | bar: { value: 2 }, 21 | }, 22 | }; 23 | 24 | jest.mock('react-redux', () => ({ useSelector: selector => selector(mockState) })); 25 | 26 | describe('useNamespacedDispatch', () => { 27 | beforeEach(() => jest.resetAllMocks()); 28 | 29 | it('accesses namespace found in context under the default feature', () => { 30 | let result; 31 | 32 | mount( 33 | 34 | {() => (result = useNamespacedSelector(state => state.value))} 35 | 36 | ); 37 | 38 | expect(result).toBe(1); 39 | }); 40 | 41 | it('accesses namespace found in context under a specified feature', () => { 42 | let result; 43 | 44 | mount( 45 | 46 | 47 | {() => 48 | (result = useNamespacedSelector(state => state.value, undefined, { 49 | feature: 'someFeature', 50 | })) 51 | } 52 | 53 | 54 | ); 55 | 56 | expect(result).toBe(2); 57 | }); 58 | 59 | it('returns undefined if no namespace could be resolved', () => { 60 | let result; 61 | 62 | mount({() => (result = useNamespacedSelector(state => state))}); 63 | 64 | expect(result).toBe(undefined); 65 | }); 66 | 67 | it('prefers static namespace to one found in context', () => { 68 | let result; 69 | 70 | mount( 71 | 72 | 73 | {() => 74 | (result = useNamespacedSelector(state => state.value, undefined, { 75 | namespace: 'foo', 76 | })) 77 | } 78 | 79 | 80 | ); 81 | 82 | expect(result).toBe(1); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /packages/injectors/src/createEntries.test.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 2 | 3 | import createEntries from './createEntries'; 4 | 5 | const foo = () => {}; 6 | 7 | const bar = () => {}; 8 | 9 | describe('createEntries', () => { 10 | it('handles functions', () => { 11 | expect(createEntries(foo)).toEqual([{ path: [], value: foo }]); 12 | }); 13 | 14 | it('handles arrays', () => { 15 | expect(createEntries([foo, bar])).toEqual([ 16 | { path: [], value: foo }, 17 | { path: [], value: bar }, 18 | ]); 19 | }); 20 | 21 | it('handles simple objects', () => { 22 | expect(createEntries({ foo, bar })).toEqual([ 23 | { path: ['foo'], value: foo }, 24 | { path: ['bar'], value: bar }, 25 | ]); 26 | }); 27 | 28 | it('handles nested objects', () => { 29 | expect(createEntries({ a: { foo }, b: { bar } })).toEqual([ 30 | { path: ['a', 'foo'], value: foo }, 31 | { path: ['b', 'bar'], value: bar }, 32 | ]); 33 | }); 34 | 35 | it('handles complex objects', () => { 36 | expect(createEntries({ a: { foo }, b: bar, c: [foo, bar] })).toEqual([ 37 | { path: ['a', 'foo'], value: foo }, 38 | { path: ['b'], value: bar }, 39 | { path: ['c'], value: foo }, 40 | { path: ['c'], value: bar }, 41 | ]); 42 | }); 43 | 44 | it('handles complex arrays', () => { 45 | expect(createEntries([{ a: { foo } }, { b: bar }, { c: [foo, bar] }, foo])).toEqual([ 46 | { path: ['a', 'foo'], value: foo }, 47 | { path: ['b'], value: bar }, 48 | { path: ['c'], value: foo }, 49 | { path: ['c'], value: bar }, 50 | { path: [], value: foo }, 51 | ]); 52 | }); 53 | 54 | it('supplies additional props to the entries', () => { 55 | expect(createEntries({ a: { foo, bar } }, { namespace: 'foo', feature: 'bar' })).toEqual([ 56 | { path: ['a', 'foo'], value: foo, namespace: 'foo', feature: 'bar' }, 57 | { path: ['a', 'bar'], value: bar, namespace: 'foo', feature: 'bar' }, 58 | ]); 59 | }); 60 | 61 | it('supplies the default feature if the namespace is defined', () => { 62 | expect(createEntries({ a: { foo } }, { namespace: 'foo' })).toEqual([ 63 | { path: ['a', 'foo'], value: foo, namespace: 'foo', feature: DEFAULT_FEATURE }, 64 | ]); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/epics/src/makeEnhancer.js: -------------------------------------------------------------------------------- 1 | import { equals, includes } from 'ramda'; 2 | import { Subject } from 'rxjs'; 3 | import * as Rx from 'rxjs/operators'; 4 | 5 | import { enhanceStore, makeStoreInterface } from '@redux-tools/injectors'; 6 | import { isActionFromNamespace, defaultNamespace } from '@redux-tools/namespaces'; 7 | import { includesTimes } from '@redux-tools/utils'; 8 | 9 | export const storeInterface = makeStoreInterface('epics'); 10 | 11 | const makeEnhancer = ({ epicMiddleware, streamCreator }) => createStore => (...args) => { 12 | const prevStore = createStore(...args); 13 | 14 | const injectedEntries$ = new Subject(); 15 | const ejectedEntries$ = new Subject(); 16 | 17 | const rootEpic = (globalAction$, state$, dependencies) => 18 | injectedEntries$.pipe( 19 | Rx.mergeAll(), 20 | Rx.filter(injectedEntry => 21 | includesTimes(1, injectedEntry, storeInterface.getEntries(nextStore)) 22 | ), 23 | Rx.mergeMap(injectedEntry => { 24 | const { value: epic, namespace, ...otherProps } = injectedEntry; 25 | const action$ = globalAction$.pipe(Rx.filter(isActionFromNamespace(namespace))); 26 | 27 | const outputAction$ = streamCreator 28 | ? epic( 29 | action$, 30 | state$, 31 | streamCreator({ namespace, action$, globalAction$, state$, ...otherProps }), 32 | dependencies 33 | ) 34 | : epic(action$, state$, dependencies); 35 | 36 | return outputAction$.pipe( 37 | Rx.map(defaultNamespace(namespace)), 38 | // NOTE: takeUntil should ALWAYS be the last operator in `.pipe()` 39 | // https://blog.angularindepth.com/rxjs-avoiding-takeuntil-leaks-fb5182d047ef 40 | Rx.takeUntil( 41 | ejectedEntries$.pipe( 42 | Rx.mergeAll(), 43 | Rx.filter(equals(injectedEntry)), 44 | Rx.filter( 45 | ejectedEntry => !includes(ejectedEntry, storeInterface.getEntries(nextStore)) 46 | ) 47 | ) 48 | ) 49 | ); 50 | }) 51 | ); 52 | 53 | epicMiddleware.run(rootEpic); 54 | 55 | const nextStore = enhanceStore(prevStore, storeInterface, { 56 | onInjected: ({ entries }) => injectedEntries$.next(entries), 57 | onEjected: ({ entries }) => ejectedEntries$.next(entries), 58 | }); 59 | 60 | return nextStore; 61 | }; 62 | 63 | export default makeEnhancer; 64 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Redux Tools 6 | 7 | 11 | 15 | 16 | 17 | 32 | 33 | 42 | 43 | 44 |
45 | by Lundegaard 52 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /docs/packages/reducers.md: -------------------------------------------------------------------------------- 1 | # Reducers 2 | 3 | > yarn add @redux-tools/reducers 4 | 5 | This package provides a store enhancer for injecting reducers into a Redux store after the store is created. 6 | 7 | ## Usage Example 8 | 9 | ```js 10 | import { createStore } from 'redux'; 11 | import { makeEnhancer, makeReducer } from '@redux-tools/reducers'; 12 | import ActionTypes from './actionTypes'; 13 | 14 | const someReducer = makeReducer( 15 | [ 16 | [ActionTypes.ADD, (count, action) => count + action.payload], 17 | [ActionTypes.INCREMENT, count => count + 1], 18 | ], 19 | 0 20 | ); 21 | 22 | const store = createStore(state => state, makeEnhancer()); 23 | 24 | store.injectReducers({ some: someReducer }); 25 | ``` 26 | 27 | ## API Reference 28 | 29 | ### makeEnhancer() 30 | 31 | A function which creates an enhancer to pass to `createStore()`. 32 | 33 | #### store.injectReducers() 34 | 35 | This function will store passed reducers internally and replace the existing reducer with a fresh one. 36 | 37 | **Parameters** 38 | 39 | 1. `reducers` ( _Function|Array|Object_ ): Reducers to inject 40 | 2. `options` ( _Object_ ): Injection options. The following keys are supported: 41 | - [`namespace`] \( _string_ ): Namespace to inject the reducer under. If passed, the reducer will not handle actions from other namespaces and will store its state in `state.namespaces[namespace]` instead of in the root. 42 | - [`feature`] \( _string_ ): This string will be used instead of the default `namespaces` key to store the reducer state, allowing you to use Redux Tools for feature-based store structure (similar to Redux Form, e.g. `state.form.contact.values`). 43 | 44 | #### store.ejectReducers() 45 | 46 | Opposite to `store.injectReducers`. This function will remove the injected reducers. Make sure that you pass the correct namespace and reducers (keys and values), otherwise the reducers will not be removed. 47 | 48 | **Parameters** 49 | 50 | 1. `reducers` ( _Function|Array|Object_ ): Reducers to eject. Make sure that both the keys and values match the injected ones. 51 | 2. `options` ( _Object_ ): Ejection options. The following keys are supported: 52 | - [`namespace`] \( _string_ ): Namespace the reducers were injected under. 53 | - [`feature`] \( _string_ ): Namespace the reducers were injected under. 54 | -------------------------------------------------------------------------------- /packages/reducers/src/combineReducerSchema.test.js: -------------------------------------------------------------------------------- 1 | import { inc, map } from 'ramda'; 2 | 3 | import combineReducerSchema from './combineReducerSchema'; 4 | import { ROOT_KEY } from './constants'; 5 | 6 | const fooAction = { type: 'FOO' }; 7 | 8 | describe('combineReducerSchema', () => { 9 | it('handles a schema with a root key', () => { 10 | const reducer = combineReducerSchema({ [ROOT_KEY]: [inc] }); 11 | 12 | expect(reducer(0, fooAction)).toBe(1); 13 | }); 14 | 15 | it('handles a schema with multiple reducers under the root key', () => { 16 | const reducer = combineReducerSchema({ [ROOT_KEY]: [inc, inc, inc] }); 17 | 18 | expect(reducer(0, fooAction)).toBe(3); 19 | }); 20 | 21 | it('handles a schema without any reducers under the root key', () => { 22 | const reducer = combineReducerSchema({ [ROOT_KEY]: [] }); 23 | 24 | expect(reducer(0, fooAction)).toBe(0); 25 | }); 26 | 27 | it('handles a schema with a reducer under a nested root key', () => { 28 | const reducer = combineReducerSchema({ foo: { bar: { [ROOT_KEY]: [inc] } } }); 29 | 30 | expect(reducer({ foo: { bar: 0 } }, fooAction)).toEqual({ foo: { bar: 1 } }); 31 | }); 32 | 33 | it('handles a schema with multiple reducers under a nested root key', () => { 34 | const reducer = combineReducerSchema({ foo: { bar: { [ROOT_KEY]: [inc, inc] } } }); 35 | 36 | expect(reducer({ foo: { bar: 0 } }, fooAction)).toEqual({ foo: { bar: 2 } }); 37 | }); 38 | 39 | it('handles a schema without any reducers under a nested root key', () => { 40 | const reducer = combineReducerSchema({ foo: { bar: { [ROOT_KEY]: [] } } }); 41 | 42 | expect(reducer({ foo: { bar: 0 } }, fooAction)).toEqual({ foo: { bar: 0 } }); 43 | }); 44 | 45 | it('preserves any additional keys', () => { 46 | const reducer = combineReducerSchema({ foo: { bar: { [ROOT_KEY]: [inc] } } }); 47 | const initialState = { foo: { bar: 0, baz: 0 }, qux: 0 }; 48 | const expectedState = { foo: { bar: 1, baz: 0 }, qux: 0 }; 49 | 50 | expect(reducer(initialState, fooAction)).toEqual(expectedState); 51 | }); 52 | 53 | it('handles a schema with multiple root keys', () => { 54 | const reducer = combineReducerSchema({ 55 | [ROOT_KEY]: [map(map(inc))], 56 | foo: { 57 | [ROOT_KEY]: [map(inc)], 58 | bar: { 59 | [ROOT_KEY]: [inc], 60 | }, 61 | }, 62 | }); 63 | 64 | expect(reducer({ foo: { bar: 0 } }, fooAction)).toEqual({ foo: { bar: 3 } }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-tools", 3 | "private": true, 4 | "license": "MIT", 5 | "repository": "https://github.com/lundegaard/redux-tools", 6 | "contributors": [ 7 | "Tomas Konrady ", 8 | "Vaclav Jancarik ", 9 | "Martin Kadlec ", 10 | "Sergey Dunaevskiy " 11 | ], 12 | "engines": { 13 | "node": ">=10" 14 | }, 15 | "workspaces": [ 16 | "packages/*" 17 | ], 18 | "devDependencies": { 19 | "@babel/core": "7.8.6", 20 | "@babel/plugin-proposal-class-properties": "7.8.3", 21 | "@babel/plugin-proposal-object-rest-spread": "7.8.3", 22 | "@babel/plugin-transform-modules-commonjs": "7.8.3", 23 | "@babel/plugin-transform-react-jsx": "7.8.3", 24 | "@babel/plugin-transform-runtime": "7.8.3", 25 | "@babel/preset-env": "7.8.6", 26 | "babel-core": "^7.0.0-bridge", 27 | "babel-eslint": "10.1.0", 28 | "babel-jest": "25.1.0", 29 | "cross-env": "7.0.1", 30 | "enzyme": "3.11.0", 31 | "enzyme-adapter-react-16": "1.15.2", 32 | "enzyme-to-json": "3.4.4", 33 | "eslint": "6.8.0", 34 | "eslint-config-prettier": "6.10.0", 35 | "eslint-config-react-union": "0.15.2", 36 | "eslint-plugin-babel": "5.3.0", 37 | "eslint-plugin-import": "2.20.0", 38 | "eslint-plugin-react": "7.18.3", 39 | "eslint-plugin-react-hooks": "2.5.0", 40 | "husky": "4.2.3", 41 | "invariant": "2.2.4", 42 | "jest": "25.1.0", 43 | "lerna": "3.20.2", 44 | "lint-staged": "10.0.8", 45 | "prettier": "1.19.1", 46 | "ramda": "0.27.0", 47 | "ramda-extension": "0.10.2", 48 | "react": "16.13.0", 49 | "react-dom": "16.13.0", 50 | "react-redux": "7.2.0", 51 | "redux": "4.0.5", 52 | "redux-observable": "1.2.0", 53 | "rollup": "1.32.0", 54 | "rollup-plugin-auto-external": "2.0.0", 55 | "rollup-plugin-babel": "4.3.3", 56 | "rollup-plugin-commonjs": "10.1.0", 57 | "rollup-plugin-node-resolve": "5.2.0", 58 | "rollup-plugin-replace": "2.2.0", 59 | "rollup-plugin-terser": "5.2.0", 60 | "rxjs": "6.5.4", 61 | "rxjs-marbles": "5.0.4", 62 | "serve": "11.3.0" 63 | }, 64 | "scripts": { 65 | "test": "jest", 66 | "lint": "yarn lint:eslint", 67 | "lint:eslint": "eslint --ext .js ./", 68 | "build": "lerna exec -- rollup -c=../../rollup.config.js", 69 | "prepublish": "yarn build", 70 | "docs": "serve docs" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/useNamespacedDispatch.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import React from 'react'; 3 | 4 | import NamespaceProvider from './NamespaceProvider'; 5 | import useNamespacedDispatch from './useNamespacedDispatch'; 6 | 7 | const Test = ({ children }) => { 8 | children(); 9 | 10 | return null; 11 | }; 12 | 13 | const mockDispatch = jest.fn(); 14 | 15 | const action = { type: 'FOO' }; 16 | 17 | jest.mock('react-redux', () => ({ useDispatch: () => mockDispatch })); 18 | 19 | describe('useNamespacedDispatch', () => { 20 | beforeEach(() => jest.resetAllMocks()); 21 | 22 | it('attaches namespace found in context under the default feature', () => { 23 | mount( 24 | 25 | 26 | {() => { 27 | const dispatch = useNamespacedDispatch(); 28 | 29 | dispatch(action); 30 | }} 31 | 32 | 33 | ); 34 | 35 | expect(mockDispatch).toHaveBeenCalledTimes(1); 36 | expect(mockDispatch.mock.calls[0][0]).toEqual({ ...action, meta: { namespace: 'foo' } }); 37 | }); 38 | 39 | it('attaches namespace found in context under a specified feature', () => { 40 | mount( 41 | 42 | 43 | {() => { 44 | const dispatch = useNamespacedDispatch({ feature: 'bar' }); 45 | 46 | dispatch(action); 47 | }} 48 | 49 | 50 | ); 51 | 52 | expect(mockDispatch).toHaveBeenCalledTimes(1); 53 | expect(mockDispatch.mock.calls[0][0]).toEqual({ ...action, meta: { namespace: 'foo' } }); 54 | }); 55 | 56 | it('does not attach namespace if no could be found in context', () => { 57 | mount( 58 | 59 | {() => { 60 | const dispatch = useNamespacedDispatch({ feature: 'qux' }); 61 | 62 | dispatch(action); 63 | }} 64 | 65 | ); 66 | 67 | expect(mockDispatch).toHaveBeenCalledTimes(1); 68 | expect(mockDispatch.mock.calls[0][0]).toBe(action); 69 | }); 70 | 71 | it('prefers static namespace to one found in context', () => { 72 | mount( 73 | 74 | 75 | {() => { 76 | const dispatch = useNamespacedDispatch({ namespace: 'bar' }); 77 | 78 | dispatch(action); 79 | }} 80 | 81 | 82 | ); 83 | 84 | expect(mockDispatch).toHaveBeenCalledTimes(1); 85 | expect(mockDispatch.mock.calls[0][0]).toEqual({ ...action, meta: { namespace: 'bar' } }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /docs/tutorial/03-multi-instance-components.md: -------------------------------------------------------------------------------- 1 | # Multi-Instance Components {docsify-ignore-all} 2 | 3 | Redux Tools allow you to use the injection mechanism for generic multi-instance components too. This includes cool stuff like data grids, multi-step forms, and carousels. 4 | 5 | Assume that you want to have multiple data grids mounted over a single page lifecycle, and you also want to store their state in Redux. To do that, you only need to write a reducer which manages the state of a single data grid, meaning that you never have to distinguish the individual data grids. 6 | 7 | ```js 8 | import { withReducers } from '@redux-tools/react'; 9 | import { DataGridPresenter } from './components'; 10 | 11 | // This reducer doesn't do anything, it has a static state. 12 | const dataGridReducer = () => ({ data: [] }); 13 | 14 | const DataGrid = withReducers(dataGridReducer, { 15 | feature: 'grids', 16 | })(DataGridPresenter); 17 | 18 | const Example = () => ; 19 | ``` 20 | 21 | After the `` element is mounted, `state.grids.DATA_GRID_1.data` will be an empty array. Any actions dispatched via `namespacedConnect` or `useNamespacedDispatch` will only affect this data grid instance, same applies to `withEpics` and `withMiddleware`. 22 | 23 | If you want to affect this data grid instance by Redux actions from the outside, you will need to associate these actions with its namespace (i.e. set their `meta.namespace` property). The recommended way to do this is to use the `attachNamespace(namespace, action)` utility function from [@redux-tools/namespaces](/packages/namespaces?id=attachNamespace). Alternatively, if dispatching from within a React component, you can also use the `useNamespacedDispatch({ namespace })` hook from [@redux-tools/namespaces-react](/packages/namespaces-react?id=useNamespacedDispatch). 24 | 25 | ## Complex Components 26 | 27 | If your multi-instance components get more complex, you might eventually need to access the namespace from some nested components. Use the `withNamespaceProvider` decorator to make the `namespace` prop accessible everywhere via React context. 28 | 29 | ```js 30 | const feature = 'grids'; 31 | 32 | const DataGrid = compose( 33 | withReducers(dataGridReducer, { feature }), 34 | withNamespaceProvider({ feature }) 35 | )(DataGridPresenter); 36 | 37 | const Example = () => ; 38 | ``` 39 | 40 | This way, the `DATA_GRID_1` namespace is not only passed as a prop, but it is also available to all inner decorators as well as by calling `useNamespace(feature)`. 41 | -------------------------------------------------------------------------------- /docs/packages/stream-creators.md: -------------------------------------------------------------------------------- 1 | # Stream Creators 2 | 3 | > yarn add @redux-tools/stream-creators 4 | 5 | The [@redux-tools/epics](/packages/epics) enhancer accepts an optional `streamCreator` option, allowing you to add an additional argument to all epics. This package is a collection of various useful stream creators. 6 | 7 | ## Usage 8 | 9 | ```js 10 | import { namespacedState$ } from '@redux-tools/stream-creators'; 11 | import { makeEnhancer as makeEpicsEnhancer } from '@redux-tools/epics'; 12 | import { identity, compose } from 'ramda'; 13 | import { createStore, applyMiddleware } from 'redux'; 14 | import { createEpicMiddleware } from 'redux-observable'; 15 | 16 | const epicMiddleware = createEpicMiddleware(); 17 | 18 | const store = createStore( 19 | identity, 20 | compose( 21 | makeEpicsEnhancer({ epicMiddleware, streamCreator: namespacedState$ }), 22 | applyMiddleware(epicMiddleware) 23 | ) 24 | ); 25 | ``` 26 | 27 | If you want to pass multiple stream creators, you can use the `applySpec` function from Ramda (or any other equivalent). 28 | 29 | ```js 30 | const store = createStore( 31 | identity, 32 | compose( 33 | makeEpicsEnhancer({ 34 | epicMiddleware, 35 | streamCreator: applySpec({ namespacedState$, globalAction$ }), 36 | }), 37 | applyMiddleware(epicMiddleware) 38 | ) 39 | ); 40 | ``` 41 | 42 | ```js 43 | const epic = (action$, state$, { namespacedState$, globalAction$ }) => 44 | action$.pipe(ignoreElements()); 45 | ``` 46 | 47 | ## API Reference 48 | 49 | ### namespacedState\$ 50 | 51 | A stream creator for namespaced state. Similar to `state$`, except it will always be the state associated with the epic. Therefore, if you have an epic injected under the `foo` namespace, `namespacedState$` will use `state.namespaces.foo`. 52 | 53 | **Parameters** 54 | 55 | 1. `bag` ( _Object_ ): Provided by the enhancer. Contains `namespace`, `feature`, `action$`, `globalAction$`, and `state$`. 56 | 57 | **Returns** 58 | 59 | ( _Observable_ ): An observable similar to `state$` in redux-observable, except using the namespaced state. 60 | 61 | ### globalAction\$ 62 | 63 | A stream creator for global actions. By default, every injected epic only accepts actions matching its namespace (see [@redux-tools/namespaces](/packages/namespaces) for more info). This stream creator allows you to react to actions from other namespaces if you need to. 64 | 65 | **Parameters** 66 | 67 | 1. `bag` ( _Object_ ): Provided by the enhancer. Contains `namespace`, `feature`, `action$`, `globalAction$`, and `state$`. 68 | 69 | **Returns** 70 | 71 | ( _Observable_ ): The original unfiltered `action$` passed to the epic. 72 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/namespacedConnect.js: -------------------------------------------------------------------------------- 1 | import { compose, cond, apply, __, isNil, T, map, o, dissoc } from 'ramda'; 2 | import { alwaysEmptyObject, isFunction, isObject } from 'ramda-extension'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { 6 | defaultNamespace, 7 | DEFAULT_FEATURE, 8 | getStateByFeatureAndNamespace, 9 | } from '@redux-tools/namespaces'; 10 | import { withProps, mapProps } from '@redux-tools/utils-react'; 11 | 12 | import useNamespace from './useNamespace'; 13 | 14 | export const NAMESPACED_CONNECT_PROPS = 'NAMESPACED_CONNECT_PROPS'; 15 | 16 | export const wrapMapStateToProps = mapStateToProps => (state, ownProps) => 17 | mapStateToProps 18 | ? mapStateToProps( 19 | getStateByFeatureAndNamespace( 20 | ownProps[NAMESPACED_CONNECT_PROPS].feature, 21 | ownProps[NAMESPACED_CONNECT_PROPS].namespace, 22 | state 23 | ), 24 | ownProps, 25 | state 26 | ) 27 | : {}; 28 | 29 | const wrapActionCreator = wrappedDispatch => actionCreator => 30 | compose(wrappedDispatch, actionCreator); 31 | 32 | const throwTypeError = () => { 33 | throw new TypeError('mapDispatchToProps is not an object or a function'); 34 | }; 35 | 36 | export const wrapMapDispatchToProps = mapDispatchToProps => (dispatch, ownProps) => { 37 | const wrappedDispatch = o( 38 | dispatch, 39 | defaultNamespace(ownProps[NAMESPACED_CONNECT_PROPS].namespace) 40 | ); 41 | 42 | return cond([ 43 | [isNil, alwaysEmptyObject], 44 | [isFunction, apply(__, [wrappedDispatch, ownProps])], 45 | [isObject, map(wrapActionCreator(wrappedDispatch))], 46 | [T, throwTypeError], 47 | ])(mapDispatchToProps); 48 | }; 49 | 50 | const rawNamespacedConnect = (mapStateToProps, mapDispatchToProps, ...args) => 51 | connect( 52 | wrapMapStateToProps(mapStateToProps), 53 | wrapMapDispatchToProps(mapDispatchToProps), 54 | ...args 55 | ); 56 | 57 | const namespacedConnect = ( 58 | mapStateToProps, 59 | mapDispatchToProps, 60 | mergeProps, 61 | { feature: optionFeature, namespace: optionNamespace, ...options } = {} 62 | ) => 63 | compose( 64 | withProps(({ feature: propFeature, namespace: propNamespace }) => { 65 | const feature = optionFeature ?? propFeature ?? DEFAULT_FEATURE; 66 | const contextNamespace = useNamespace(feature); 67 | 68 | return { 69 | [NAMESPACED_CONNECT_PROPS]: { 70 | feature, 71 | namespace: optionNamespace ?? propNamespace ?? contextNamespace, 72 | }, 73 | }; 74 | }), 75 | rawNamespacedConnect(mapStateToProps, mapDispatchToProps, mergeProps, options), 76 | mapProps(dissoc(NAMESPACED_CONNECT_PROPS)) 77 | ); 78 | 79 | export default namespacedConnect; 80 | -------------------------------------------------------------------------------- /docs/packages/epics.md: -------------------------------------------------------------------------------- 1 | # Epics 2 | 3 | > yarn add @redux-tools/epics 4 | 5 | This package provides a store enhancer for injecting epics into a Redux store after the store is created. 6 | 7 | ## Usage Example 8 | 9 | ```js 10 | import { createStore, applyMiddleware } from 'redux'; 11 | import { createEpicMiddleware } from 'redux-observable'; 12 | import { makeEnhancer } from '@redux-tools/epics'; 13 | import { identity, compose } from 'ramda'; 14 | import { epicA, epicB } from './epics'; 15 | 16 | const epicMiddleware = createEpicMiddleware(); 17 | 18 | const store = createStore( 19 | identity, 20 | compose(makeEnhancer({ epicMiddleware }), applyMiddleware(epicMiddleware)) 21 | ); 22 | 23 | store.injectEpics({ epicA, epicB }); 24 | ``` 25 | 26 | ## API Reference 27 | 28 | ### makeEnhancer() 29 | 30 | A function which creates an enhancer to pass to `createStore()`. 31 | 32 | **Parameters** 33 | 34 | 1. `options` ( _Function|Array|Object_ ): 35 | - `epicMiddleware` ( _Middleware_ ): Return value of `createEpicMiddleware()`. Note that you must mount this middleware yourself (using the `applyMiddleware(epicMiddleware)` enhancer). 36 | - `streamCreator` ( _Function_ ): [Stream creator](/packages/stream-creators), the return value of which will be passed as the 3rd argument to all injected epics. 37 | 38 | **Returns** 39 | 40 | ( _Enhancer_ ): A Redux store enhancer which you can pass to `createStore()`. 41 | 42 | #### store.injectEpics() 43 | 44 | This function will store passed epics internally and start them if they're not running already. 45 | 46 | **Parameters** 47 | 48 | 1. `epics` ( _Function|Array|Object_ ): Epics to inject. 49 | 2. `options` ( _Object_ ): Injection options. The following keys are supported: 50 | - [`namespace`] \( _string_ ): Namespace to inject the epic under. If passed, the epic will not handle actions from other namespaces and will always include the namespace in all returned actions. 51 | - [`feature`] \( _string_ ): Feature to resolve the namespace by (if using namespace providers). 52 | 53 | #### store.ejectEpics() 54 | 55 | Opposite to `store.injectEpics`. This function will stop the passed epics. Make sure that you pass the correct namespace and epics (keys and values), otherwise the epics will not be stopped. 56 | 57 | **Parameters** 58 | 59 | 1. `epics` ( _Function|Array|Object_ ): Epics to eject. Make sure that both the keys and values match the injected ones. 60 | 2. `options` ( _Object_ ): Ejection options. The following keys are supported: 61 | - [`namespace`] \( _string_ ): Namespace the epics were injected under. 62 | - [`feature`] \( _string_ ): Feature the epics were injected under. 63 | -------------------------------------------------------------------------------- /docs/packages/namespaces-react.md: -------------------------------------------------------------------------------- 1 | # Namespaces (React Bindings) 2 | 3 | > yarn add @redux-tools/namespaces-react 4 | 5 | This package provides React bindings for the [@redux-tools/namespaces](/packages/namespaces) package. 6 | 7 | ## API Reference 8 | 9 | ### \ 10 | 11 | Provides a namespacing strategy to its children. See the [tutorial](/tutorial/02-namespacing?id=namespace-provider) for more information about the usage. 12 | 13 | **Props** 14 | 15 | - [`useNamespace`] \( _Function_ ): A hook which returns the appropriate namespace based on React context. 16 | - [`namespace`] \( _string_ ): A namespace to use for all nested components. Has priority over `useNamespace`. 17 | - [`feature`] \( _string_ ): Feature to set the namespace for. 18 | 19 | ### namespacedConnect() 20 | 21 | Works just like `connect()` from React Redux, except it always accesses namespaced state and automatically adds the correct namespace to dispatched actions. You can use the fourth `options` argument to configure the `namespace` and `feature` used. 22 | 23 | Passes global state as the third argument to `mapStateToProps`. 24 | 25 | See [React Redux docs](https://react-redux.js.org/docs/api) for more info. 26 | 27 | ### withNamespaceProvider() 28 | 29 | A decorator that wraps a component in a namespace provider. Accepts the same props as ``. See the [multi-instance components tutorial](/tutorial/03-multi-instance-components) to learn more about the usage. 30 | 31 | ### useNamespacedDispatch() 32 | 33 | Works just like `useDispatch` from React Redux, except it automatically adds the correct namespace to dispatched actions. Accepts an additional `options` parameter. 34 | 35 | **Parameters** 36 | 37 | 1. [`options`] \( _Object_ ): Options for the hook. 38 | - [`options.feature`] \( _string_ ): Feature to retrieve the namespace from context by. Falls back to `DEFAULT_FEATURE`. 39 | - [`options.namespace`] \( _string_ ): Forced namespace. Falls back to namespace retrieved from React context. 40 | 41 | ### useNamespacedSelector() 42 | 43 | Works just like `useSelector` from React Redux, except it always accesses namespaced state. Accepts an additional `options` parameter. 44 | 45 | **Parameters** 46 | 47 | 1. `selector` ( _Function_ ): See [React Redux docs](https://react-redux.js.org/api/hooks#useselector) for more info. 48 | 2. [`equalityFn`] \( _Function_ ): See [React Redux docs](https://react-redux.js.org/api/hooks#useselector) for more info. 49 | 3. [`options`] \( _Object_ ): Options for the hook. 50 | - [`options.feature`] \( _string_ ): Feature to access the state of. Falls back to `DEFAULT_FEATURE`. 51 | - [`options.namespace`] \( _string_ ): Forced namespace. Falls back to namespace retrieved from React context. 52 | -------------------------------------------------------------------------------- /docs/packages/middleware.md: -------------------------------------------------------------------------------- 1 | # Middleware 2 | 3 | > yarn add @redux-tools/middleware 4 | 5 | This package provides a store enhancer for injecting middleware into a Redux store after the store is created. 6 | 7 | ## Usage Example 8 | 9 | ```js 10 | import { applyMiddleware, compose, createStore } from 'redux'; 11 | import { makeEnhancer as makeEnhancerMiddleware } from '@redux-tools/middleware'; 12 | 13 | const someMiddleware = () => next => action => 14 | next({ 15 | ...action, 16 | type: `${action.type}_MODIFIED`, 17 | }); 18 | 19 | // Create enhancer & middleware for dynamic injections 20 | const middlewareEnhancer = makeEnhancerMiddleware(); 21 | const { injectedMiddleware } = middlewareEnhancer; 22 | 23 | const enhancers = compose(applyMiddleware(injectedMiddleware), middlewareEnhancer); 24 | 25 | const store = createStore(state => state, enhancers); 26 | 27 | store.injectMiddleware(someMiddleware); 28 | store.dispatch({ type: 'EXAMPLE' }); // Reducer receives `EXAMPLE_MODIFIED` under the hood. 29 | ``` 30 | 31 | ## API Reference 32 | 33 | ### makeEnhancer() 34 | 35 | A function which creates an enhancer to pass to `createStore()`. 36 | 37 | #### store.injectMiddleware() 38 | 39 | This function will store passed middleware internally and replace the existing middleware with a fresh one. 40 | 41 | **Parameters** 42 | 43 | 1. `middleware` ( _Function|Array|Object_ ): Middleware to inject 44 | 2. `options` ( _Object_ ): Injection options. The following keys are supported: 45 | - [`namespace`] \( _string_ ): Namespace to inject the middleware under. If passed, the middleware will not handle actions from other namespaces. 46 | - [`feature`] \( _string_ ): Feature to resolve the namespace by (if using namespace providers). 47 | 48 | #### store.ejectMiddleware() 49 | 50 | Opposite to `store.injectMiddleware`. This function will remove the injected middleware. Make sure that you pass the correct namespace and middleware (keys and values), otherwise the middleware will not be removed. 51 | 52 | **Parameters** 53 | 54 | 1. `middleware` ( _Function|Array|Object_ ): Middleware to eject. Make sure that both the keys and values match the injected ones. 55 | 2. `options` ( _Object_ ): Ejection options. The following keys are supported: 56 | - [`namespace`] \( _string_ ): Namespace the middleware were injected under. 57 | - [`feature`] \( _string_ ): Feature the middleware were injected under. 58 | 59 | ### composeMiddleware() 60 | 61 | Composes multiple middleware into a single middleware. The former middleware always wrap around the latter middleware, meaning that code called before `next(action)` will get executed sooner in the first middleware than the last, but vice versa when the code is called after `next(action)`. 62 | 63 | **Parameters** 64 | 65 | 1. `middleware` ( _...Middleware_ ): Middleware to compose. 66 | 67 | **Returns** 68 | 69 | ( _Middleware_ ): Composed middleware. 70 | -------------------------------------------------------------------------------- /packages/injectors/src/enhanceStore.test.js: -------------------------------------------------------------------------------- 1 | import { noop } from 'ramda-extension'; 2 | 3 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 4 | 5 | import enhanceStore from './enhanceStore'; 6 | import makeStoreInterface from './makeStoreInterface'; 7 | 8 | describe('enhanceStore', () => { 9 | const storeInterface = makeStoreInterface('things'); 10 | 11 | it('sets entries based on type', () => { 12 | const store = enhanceStore({}, storeInterface); 13 | expect(store.entries.things).toEqual([]); 14 | }); 15 | 16 | it('spreads passed store to new store', () => { 17 | const store = enhanceStore({ dispatch: noop, getState: noop }, storeInterface); 18 | expect(store.dispatch).toBe(noop); 19 | expect(store.getState).toBe(noop); 20 | }); 21 | 22 | it('sets injection methods based on type', () => { 23 | const store = enhanceStore({}, storeInterface); 24 | expect(store.injectThings).toBeInstanceOf(Function); 25 | expect(store.ejectThings).toBeInstanceOf(Function); 26 | }); 27 | 28 | it('updates entries on injection', () => { 29 | const store = enhanceStore({}, storeInterface); 30 | store.injectThings({ foo: noop }, { namespace: 'bar' }); 31 | expect(store.entries.things).toEqual([ 32 | { path: ['foo'], value: noop, namespace: 'bar', feature: DEFAULT_FEATURE }, 33 | ]); 34 | 35 | store.ejectThings({ foo: noop }, { namespace: 'bar' }); 36 | expect(store.entries.things).toEqual([]); 37 | }); 38 | 39 | it('dispatches actions on injection', () => { 40 | const dispatch = jest.fn(); 41 | const store = enhanceStore({ dispatch }, storeInterface); 42 | store.injectThings({ foo: noop }, { namespace: 'bar' }); 43 | expect(dispatch).toHaveBeenCalledTimes(1); 44 | const injectedAction = dispatch.mock.calls[0][0]; 45 | expect(injectedAction.type).toBe('@redux-tools/THINGS_INJECTED'); 46 | expect(injectedAction.payload).toEqual([['foo']]); 47 | expect(injectedAction.meta).toEqual({ namespace: 'bar' }); 48 | jest.clearAllMocks(); 49 | store.ejectThings({ foo: noop }, { namespace: 'bar' }); 50 | expect(dispatch).toHaveBeenCalledTimes(1); 51 | const ejectedAction = dispatch.mock.calls[0][0]; 52 | expect(ejectedAction.type).toBe('@redux-tools/THINGS_EJECTED'); 53 | expect(ejectedAction.payload).toEqual([['foo']]); 54 | expect(ejectedAction.meta).toEqual({ namespace: 'bar' }); 55 | }); 56 | 57 | it('calls handlers on ejection', () => { 58 | const onInjected = jest.fn(); 59 | const onEjected = jest.fn(); 60 | const store = enhanceStore({}, storeInterface, { onInjected, onEjected }); 61 | store.injectThings({ foo: noop }, { namespace: 'bar' }); 62 | expect(onInjected).toHaveBeenCalledTimes(1); 63 | expect(onEjected).not.toHaveBeenCalled(); 64 | jest.clearAllMocks(); 65 | store.ejectThings({ foo: noop }, { namespace: 'bar' }); 66 | expect(onEjected).toHaveBeenCalledTimes(1); 67 | expect(onInjected).not.toHaveBeenCalled(); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /packages/reducers/src/makeEnhancer.js: -------------------------------------------------------------------------------- 1 | import { 2 | identity, 3 | pluck, 4 | juxt, 5 | difference, 6 | reduce, 7 | dissocPath, 8 | head, 9 | isEmpty, 10 | path, 11 | reduced, 12 | tail, 13 | } from 'ramda'; 14 | 15 | import { enhanceStore, makeStoreInterface } from '@redux-tools/injectors'; 16 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 17 | 18 | import combineReducerEntries from './combineReducerEntries'; 19 | import composeReducers from './composeReducers'; 20 | 21 | export const storeInterface = makeStoreInterface('reducers'); 22 | 23 | const cleanupReducer = (state, action) => { 24 | if (action.type !== '@redux-tools/CLEAN_UP_STATE') { 25 | return state; 26 | } 27 | 28 | return reduce( 29 | (previousState, pathDefinition) => { 30 | const fullPathDefinition = [ 31 | ...(action.meta.namespace ? [action.meta.feature ?? DEFAULT_FEATURE] : []), 32 | ...(action.meta.namespace ? [action.meta.namespace] : []), 33 | ...pathDefinition, 34 | ]; 35 | 36 | // GIVEN: fullPathDefinition = ['a', 'b', 'c'] 37 | // THEN: partialPathDefinitions = [['a', 'b', 'c'], ['a', 'b'], ['a']] 38 | const partialPathDefinitions = reduce( 39 | (previousPartialPathDefinitions, key) => [ 40 | [...(head(previousPartialPathDefinitions) ?? []), key], 41 | ...previousPartialPathDefinitions, 42 | ], 43 | [], 44 | fullPathDefinition 45 | ); 46 | 47 | return reduce( 48 | (populatedState, partialPathDefinition) => 49 | isEmpty(path(partialPathDefinition, populatedState)) 50 | ? dissocPath(partialPathDefinition, populatedState) 51 | : reduced(populatedState), 52 | // NOTE: `dissocPath` and `tail` are used here to kick-start the cascading process. 53 | dissocPath(fullPathDefinition, previousState), 54 | tail(partialPathDefinitions) 55 | ); 56 | }, 57 | state, 58 | action.payload 59 | ); 60 | }; 61 | 62 | const makeEnhancer = ({ initialReducers } = {}) => createStore => (reducer = identity, ...args) => { 63 | const prevStore = createStore(reducer, ...args); 64 | 65 | const handleEntriesChanged = () => 66 | nextStore.replaceReducer( 67 | composeReducers( 68 | reducer, 69 | combineReducerEntries(storeInterface.getEntries(nextStore)), 70 | cleanupReducer 71 | ) 72 | ); 73 | 74 | const handleEjected = ({ entries, props }) => { 75 | const nextEntries = storeInterface.getEntries(nextStore); 76 | const fullyEjectedEntries = difference(entries, nextEntries); 77 | 78 | nextStore.dispatch({ 79 | type: '@redux-tools/CLEAN_UP_STATE', 80 | payload: pluck('path', fullyEjectedEntries), 81 | meta: props, 82 | }); 83 | }; 84 | 85 | const nextStore = enhanceStore(prevStore, storeInterface, { 86 | onInjected: handleEntriesChanged, 87 | onEjected: juxt([handleEntriesChanged, handleEjected]), 88 | }); 89 | 90 | if (initialReducers) { 91 | nextStore.injectReducers(initialReducers); 92 | } 93 | 94 | return nextStore; 95 | }; 96 | 97 | export default makeEnhancer; 98 | -------------------------------------------------------------------------------- /docs/faq/internals.md: -------------------------------------------------------------------------------- 1 | # FAQ: Internals {docsify-ignore-all} 2 | 3 | ## What happens under the hood when we inject something? 4 | 5 | The lifecycle of all injectables (i.e. reducers, middleware, and epics) is 6 | always the same. Let's assume that we've got a new store with an epics enhancer (always exported as 7 | `makeEnhancer` from the appropriate package). The enhancer has added two methods to our store: 8 | 9 | - `injectEpics()` 10 | - `ejectEpics()` 11 | 12 | This enhancer also defines a `store.entries.epics` array, storing all the injected epics. 13 | 14 | ```json 15 | { 16 | "entries": { 17 | "epics": [] 18 | } 19 | } 20 | ``` 21 | 22 | The object above is the same structure where the functions like `dispatch` and `getState` are – the Redux store. Remember that the Redux store is [just an object](https://redux.js.org/api/store), meaning that we can safely add any additional properties we desire. This makes it possible to see all the injected epics just by examining the store. 23 | 24 | If we inject a bunch of epics, they will get converted into entries first. 25 | 26 | ```js 27 | store.injectEpics({ someEpic, anotherEpic }, { namespace: 'foo', feature: 'bar' }); 28 | ``` 29 | 30 | This call will create the following entries under the hood: 31 | 32 | ```js 33 | const entries = [ 34 | { 35 | value: someEpic, 36 | path: ['someEpic'], 37 | namespace: 'foo', 38 | feature: 'bar', 39 | }, 40 | { 41 | value: anotherEpic, 42 | path: ['anotherEpic'], 43 | namespace: 'foo', 44 | feature: 'bar', 45 | }, 46 | ]; 47 | ``` 48 | 49 | These entries are added to the `store.entries.epics` array, including duplicates. This is to allow having multiple decorators injecting the same entries – if one of the decorators unmounts, we want the other entries to be preserved. 50 | 51 | New entries are processed differently based on the type of the injectable. 52 | 53 | - Epics: the root epic consumes a stream of epics. [Sounds crazy?](https://redux-observable.js.org/docs/recipes/AddingNewEpicsAsynchronously.html) 54 | - Reducers: the root reducer is assembled from `store.entries.reducers` directly. 55 | - Middleware: the root middleware essentially iterates over current `store.entries.middleware` per each action. 56 | 57 | The `path` property is mainly useful for reducers, allowing deep reducer injection for a [view-based state structure](/tutorial/04-view-state-management). Additionally, Redux Tools allow us to inject epics and middleware as functions or within an array. 58 | 59 | ```js 60 | store.injectEpics([someEpic, anotherEpic]); 61 | store.injectEpics(someEpic); 62 | ``` 63 | 64 | The calls above will result in 3 entries being stored: 65 | 66 | ```js 67 | const entries = [ 68 | { value: someEpic, path: [] }, 69 | { value: anotherEpic, path: [] }, 70 | { value: someEpic, path: [] }, 71 | ]; 72 | ``` 73 | 74 | Because the `path` of both `someEpic` entries is the same, the epic will only be run once. However, in order to fully eject `someEpic`, `store.ejectEpics(someEpic)` has to be called twice. 75 | -------------------------------------------------------------------------------- /packages/injectors-react/src/makeHook.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import { always } from 'ramda'; 3 | import { noop } from 'ramda-extension'; 4 | import React from 'react'; 5 | 6 | import { makeStoreInterface } from '@redux-tools/injectors'; 7 | import { NamespaceProvider } from '@redux-tools/namespaces-react'; 8 | 9 | import makeHook from './makeHook'; 10 | 11 | const storeInterface = makeStoreInterface('things'); 12 | const useThings = makeHook(storeInterface); 13 | 14 | jest.mock('./constants', () => ({ IS_SERVER: false })); 15 | 16 | const injectables = { foo: noop }; 17 | 18 | const Test = ({ children }) => { 19 | children(); 20 | 21 | return null; 22 | }; 23 | 24 | describe('makeHook', () => { 25 | const store = { 26 | injectThings: jest.fn(), 27 | ejectThings: jest.fn(), 28 | subscribe: jest.fn(), 29 | getState: jest.fn(), 30 | dispatch: jest.fn(), 31 | }; 32 | 33 | beforeEach(() => { 34 | jest.clearAllMocks(); 35 | }); 36 | 37 | it('calls proper store methods', () => { 38 | mount( 39 | 40 | {() => useThings(injectables)} 41 | 42 | ); 43 | 44 | expect(store.injectThings).toHaveBeenCalledTimes(1); 45 | expect(store.injectThings.mock.calls[0][0]).toEqual({ foo: noop }, { namespace: 'yolo' }); 46 | }); 47 | 48 | it('warns if useNamespace is provided, but the namespace could not be resolved and isGlobal is not passed', () => { 49 | const warn = jest.spyOn(global.console, 'warn').mockImplementation(() => {}); 50 | 51 | mount( 52 | 53 | {() => useThings(injectables)} 54 | 55 | ); 56 | 57 | expect(warn).toHaveBeenCalled(); 58 | }); 59 | 60 | it('does not warn if namespace is missing and isGlobal is not passed', () => { 61 | const warn = jest.spyOn(global.console, 'warn'); 62 | 63 | mount( 64 | 65 | {() => useThings(injectables)} 66 | 67 | ); 68 | 69 | expect(warn).toHaveBeenCalledTimes(0); 70 | }); 71 | 72 | it('does not warn if namespace is passed and isGlobal is not passed', () => { 73 | const warn = jest.spyOn(global.console, 'warn'); 74 | 75 | mount( 76 | 77 | {() => useThings(injectables)} 78 | 79 | ); 80 | 81 | expect(warn).toHaveBeenCalledTimes(0); 82 | }); 83 | 84 | it('throws if isNamespaced is passed, but no namespace could be resolved', () => { 85 | jest.spyOn(global.console, 'error').mockImplementation(() => {}); 86 | 87 | expect(() => { 88 | mount( 89 | 90 | {() => useThings(injectables, { isNamespaced: true })} 91 | 92 | ); 93 | }).toThrowError( 94 | "You're injecting things marked as namespaced, but no namespace could be resolved." 95 | ); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/namespaces-react/src/NamespaceProvider.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import { always } from 'ramda'; 3 | import { noop, alwaysNull } from 'ramda-extension'; 4 | import React from 'react'; 5 | import { ReactReduxContext } from 'react-redux'; 6 | 7 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 8 | 9 | import NamespaceProvider from './NamespaceProvider'; 10 | import { NamespaceContext } from './contexts'; 11 | 12 | describe('NamespaceProvider', () => { 13 | const store = { subscribe: noop, dispatch: noop, getState: noop }; 14 | const renderProp = jest.fn(alwaysNull); 15 | const alwaysFoo = always('foo'); 16 | 17 | beforeEach(() => jest.resetAllMocks()); 18 | 19 | it('passes props to NamespaceContext correctly', () => { 20 | mount( 21 | 22 | {renderProp} 23 | 24 | ); 25 | 26 | expect(renderProp).toHaveBeenCalledWith({ 27 | namespaces: { [DEFAULT_FEATURE]: 'ns' }, 28 | useNamespace: alwaysFoo, 29 | isUseNamespaceProvided: true, 30 | }); 31 | }); 32 | 33 | it('allows seamless nesting', () => { 34 | mount( 35 | 36 | 37 | 38 | {renderProp} 39 | 40 | 41 | 42 | ); 43 | 44 | expect(renderProp).toHaveBeenCalledWith({ 45 | namespaces: { [DEFAULT_FEATURE]: 'yo' }, 46 | useNamespace: alwaysFoo, 47 | isUseNamespaceProvided: true, 48 | }); 49 | }); 50 | 51 | it('allows seamless nesting with features', () => { 52 | mount( 53 | 54 | 55 | 56 | {renderProp} 57 | 58 | 59 | 60 | ); 61 | 62 | expect(renderProp).toHaveBeenCalledWith({ 63 | namespaces: { [DEFAULT_FEATURE]: 'yo', grids: 'ns' }, 64 | useNamespace: alwaysFoo, 65 | isUseNamespaceProvided: true, 66 | }); 67 | }); 68 | 69 | it('uses a react-redux provider if store is passed', () => { 70 | mount( 71 | 72 | {renderProp} 73 | 74 | ); 75 | 76 | expect(renderProp.mock.calls[0][0].store).toBe(store); 77 | }); 78 | 79 | it('defaults isUseNamespaceProvided to false', () => { 80 | mount( 81 | 82 | 83 | {renderProp} 84 | 85 | 86 | ); 87 | 88 | expect(renderProp).toHaveBeenCalledWith({ 89 | namespaces: { [DEFAULT_FEATURE]: 'yo', grids: 'ns' }, 90 | useNamespace: alwaysNull, 91 | isUseNamespaceProvided: false, 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Redux Tools 4 | 5 |

6 | 7 |

8 | 9 | by Lundegaard 10 | 11 |

12 | 13 |

14 | 🛠 💪 💉 15 |

16 | 17 |

18 | Maintaining large Redux applications with ease. 19 |

20 | 21 |

22 | A collection of tools for maintaining large Redux applications by enabling dependency injection of Redux code and development of multi-instance components by namespacing their state. 23 |

24 | 25 |

26 | 27 | MIT License 28 | 29 | 30 | 31 | Downloads 32 | 33 | 34 | 35 | Version 36 | 37 |

38 | 39 | Redux Tools consist mainly of: 40 | 41 | - [Store enhancers](https://github.com/reduxjs/redux/blob/master/docs/Glossary.md#store-enhancer) for injecting reducers, middleware, and epics into your Redux store after the store is created. 42 | - Utility functions for less verbose definitions of action creators and reducers. 43 | - Logic for managing your state via namespaces. 44 | 45 | ## Documentation & API Reference 46 | 47 | See [redux-tools.js.org](https://redux-tools.js.org/), powered by Docsify. 48 | 49 | ## Installation 50 | 51 | The `@redux-tools/react` package contains everything you'll need to get started with using Redux Tools in a React application. Use either of these commands, depending on the package manager you prefer: 52 | 53 | ```sh 54 | yarn add @redux-tools/react 55 | 56 | npm i @redux-tools/react 57 | ``` 58 | 59 | Please visit [redux-tools.js.org](https://redux-tools.js.org/) to see all available packages. 60 | 61 | ## Changelog 62 | 63 | See the [CHANGELOG.md](CHANGELOG.md) file. 64 | 65 | ## Resources 66 | 67 | - [Beyond Simplicity: Using Redux in Dynamic Applications](https://medium.com/@wafflepie/beyond-simplicity-using-redux-in-dynamic-applications-ae9e0aea928c) (published 21 Jan 2019) 68 | 69 | ## Contributing 70 | 71 | We are open to all ideas and suggestions, feel free to open an issue or a pull request! 72 | 73 | See the [contribution guide](https://github.com/lundegaard/redux-tools/blob/master/CONTRIBUTING.md) for guidelines. 74 | 75 | ## Related Projects 76 | 77 | - [validarium](https://github.com/lundegaard/validarium) – Validations done right. 78 | - [lundium](https://github.com/lundegaard/lundium) – Beautiful React component library. 79 | - [react-union](https://github.com/lundegaard/react-union) – Integrate React apps into various CMSs seamlessly. 80 | 81 | ## License 82 | 83 | All packages are distributed under the MIT license. See the license [here](https://github.com/lundegaard/redux-tools/blob/master/LICENSE). 84 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import autoExternal from 'rollup-plugin-auto-external'; 4 | import replace from 'rollup-plugin-replace'; 5 | 6 | import * as plugins from './rollup/plugins'; 7 | import { getGlobalName, getFileName } from './rollup/utils'; 8 | 9 | const { LERNA_PACKAGE_NAME } = process.env; 10 | const PACKAGE_ROOT_PATH = process.cwd(); 11 | const INPUT_FILE = path.join(PACKAGE_ROOT_PATH, 'src/index.js'); 12 | 13 | // NOTE: Packages which are meant to be "plug and play" for prototyping using unpkg. 14 | const presets = ['@redux-tools/react']; 15 | 16 | // NOTE: Only add globals which must be loaded manually when prototyping with a preset. 17 | const globals = { 18 | react: 'React', 19 | 'react-dom': 'ReactDOM', 20 | 'react-redux': 'ReactRedux', 21 | redux: 'Redux', 22 | }; 23 | 24 | const globalName = getGlobalName(LERNA_PACKAGE_NAME); 25 | const fileName = getFileName(LERNA_PACKAGE_NAME); 26 | 27 | export default [ 28 | // NOTE: CJS 29 | { 30 | input: INPUT_FILE, 31 | output: { 32 | file: path.join(PACKAGE_ROOT_PATH, 'lib', `${fileName}.js`), 33 | format: 'cjs', 34 | indent: false, 35 | }, 36 | // HACK: Necessary, because `autoExternal` plugin does not handle deep imports. 37 | // https://github.com/stevenbenisek/rollup-plugin-auto-external/issues/7 38 | external: ['rxjs/operators'], 39 | plugins: [autoExternal(), plugins.nodeResolve, plugins.babel, plugins.cjs], 40 | }, 41 | 42 | // NOTE: ES 43 | { 44 | input: INPUT_FILE, 45 | output: { 46 | file: path.join(PACKAGE_ROOT_PATH, 'es', `${fileName}.js`), 47 | format: 'es', 48 | indent: false, 49 | }, 50 | // HACK: Necessary, because `autoExternal` plugin does not handle deep imports. 51 | // https://github.com/stevenbenisek/rollup-plugin-auto-external/issues/7 52 | external: ['rxjs/operators'], 53 | plugins: [autoExternal(), plugins.nodeResolve, plugins.babel, plugins.cjs], 54 | }, 55 | 56 | // NOTE: Only build UMD for the presets. 57 | // The individual packages are not meant to be used with UMD. 58 | ...(presets.includes(LERNA_PACKAGE_NAME) 59 | ? [ 60 | // NOTE: UMD Development 61 | { 62 | input: INPUT_FILE, 63 | external: Object.keys(globals), 64 | output: { 65 | file: path.join(PACKAGE_ROOT_PATH, 'dist', `${fileName}.js`), 66 | format: 'umd', 67 | name: globalName, 68 | indent: false, 69 | globals, 70 | }, 71 | plugins: [ 72 | replace({ 'process.env.NODE_ENV': JSON.stringify('development') }), 73 | plugins.nodeResolve, 74 | plugins.babel, 75 | plugins.cjs, 76 | ], 77 | }, 78 | 79 | // NOTE: UMD Production 80 | { 81 | input: INPUT_FILE, 82 | external: Object.keys(globals), 83 | output: { 84 | file: path.join(PACKAGE_ROOT_PATH, 'dist', `${fileName}.min.js`), 85 | format: 'umd', 86 | name: globalName, 87 | indent: false, 88 | globals, 89 | }, 90 | plugins: [ 91 | replace({ 'process.env.NODE_ENV': JSON.stringify('production') }), 92 | plugins.nodeResolve, 93 | plugins.babel, 94 | plugins.cjs, 95 | plugins.terser, 96 | ], 97 | }, 98 | ] 99 | : []), 100 | ]; 101 | -------------------------------------------------------------------------------- /packages/reducers/src/makeReducer.test.js: -------------------------------------------------------------------------------- 1 | import { propEq } from 'ramda'; 2 | 3 | import makeReducer from './makeReducer'; 4 | 5 | describe('makeReducer', () => { 6 | it('handles actions with error: true', () => { 7 | const reducer = makeReducer([['TEST', () => 'ok', () => 'nope']]); 8 | expect(reducer('something', { type: 'TEST' })).toBe('ok'); 9 | expect(reducer('something', { type: 'TEST', error: true })).toBe('nope'); 10 | }); 11 | 12 | it('handles actions with error: true and missing error reducer', () => { 13 | const reducer = makeReducer([['TEST', () => 'ok']]); 14 | expect(reducer('something', { type: 'TEST', error: true })).toBe('ok'); 15 | }); 16 | 17 | it('handles initialState', () => { 18 | const reducer = makeReducer([['TEST', () => 'ok', () => 'nope']], 'initialState'); 19 | expect(reducer(undefined, { type: 'UNKNOWN' })).toBe('initialState'); 20 | expect(reducer(undefined, { type: 'TEST' })).toBe('ok'); 21 | }); 22 | 23 | it('handles multiple types of actions', () => { 24 | const reducer = makeReducer([ 25 | ['FIRST', () => 'first'], 26 | ['SECOND', () => 'second'], 27 | ['THIRD', () => 'third'], 28 | ]); 29 | 30 | expect(reducer(undefined, { type: 'FIRST' })).toBe('first'); 31 | expect(reducer(undefined, { type: 'SECOND' })).toBe('second'); 32 | expect(reducer(undefined, { type: 'THIRD' })).toBe('third'); 33 | }); 34 | 35 | it('handles reducers which depend on actions', () => { 36 | const reducer = makeReducer([['ADD', (state, { payload }) => state + payload]]); 37 | expect(reducer(5, { type: 'ADD', payload: 3 })).toBe(8); 38 | }); 39 | 40 | it('handles array as an action type', () => { 41 | const reducer = makeReducer([[['TEST', 'SECOND'], () => 'ok', () => 'nope']]); 42 | expect(reducer('something', { type: 'TEST' })).toBe('ok'); 43 | expect(reducer('something', { type: 'SECOND' })).toBe('ok'); 44 | expect(reducer('something', { type: 'TEST', error: true })).toBe('nope'); 45 | expect(reducer('something', { type: 'UNKNOWN' })).toBe('something'); 46 | }); 47 | 48 | it('handles initialState and an array as an action type', () => { 49 | const reducer = makeReducer([[['TEST', 'SECOND'], () => 'ok', () => 'nope']], 'initialState'); 50 | expect(reducer(undefined, { type: 'UNKNOWN' })).toBe('initialState'); 51 | expect(reducer(undefined, { type: 'TEST' })).toBe('ok'); 52 | expect(reducer(undefined, { type: 'TEST', error: true })).toBe('nope'); 53 | }); 54 | 55 | it('handles function as an action type', () => { 56 | const reducer = makeReducer([[propEq('type', 'TEST'), () => 'ok', () => 'nope']]); 57 | expect(reducer('something', { type: 'TEST' })).toBe('ok'); 58 | expect(reducer('something', { type: 'TEST', error: true })).toBe('nope'); 59 | expect(reducer('something', { type: 'UNKNOWN' })).toBe('something'); 60 | }); 61 | 62 | it('handles initialState and a function as an action type', () => { 63 | const reducer = makeReducer( 64 | [[propEq('type', 'TEST'), () => 'ok', () => 'nope']], 65 | 'initialState' 66 | ); 67 | expect(reducer(undefined, { type: 'UNKNOWN' })).toBe('initialState'); 68 | expect(reducer(undefined, { type: 'TEST' })).toBe('ok'); 69 | expect(reducer(undefined, { type: 'TEST', error: true })).toBe('nope'); 70 | }); 71 | 72 | it('throws with wrong action type condition', () => { 73 | const reducer = makeReducer([[5, () => 'ok', () => 'nope']]); 74 | expect(() => reducer('something', { type: 5 })).toThrow(); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /docs/packages/actions.md: -------------------------------------------------------------------------------- 1 | # Actions 2 | 3 | > yarn add @redux-tools/actions 4 | 5 | This package is a collection of utility functions for creating [FSA-compliant](https://github.com/redux-utilities/flux-standard-action) action creators and reducers. 6 | 7 | ## Best Practices 8 | 9 | Only use `makeEmptyActionCreator`, `makePayloadActionCreator`, and `makePayloadMetaActionCreator` in your applications. Do not use any other action creator factories in your own code. 10 | 11 | Use the appropriate action creator factory based on the number of arguments the action creator should expect: 12 | 13 | - `makeEmptyActionCreator` for actions without parameters. 14 | - `makePayloadActionCreator` for parameterized actions. 15 | - `makePayloadMetaActionCreator` if you need to pass any metadata. 16 | 17 | !> Any custom logic should be left to the reducer; action creator usage should be transparent. You should be able to rely on your action creators to always use the arguments directly as `action.payload` or `action.meta`. 18 | 19 | ## Usage Example 20 | 21 | ```js 22 | import { 23 | makeActionTypes, 24 | makeEmptyActionCreator, 25 | makePayloadActionCreator, 26 | } from '@redux-tools/actions'; 27 | 28 | export const ActionTypes = makeActionTypes('@counter', ['ADD', 'INCREMENT']); 29 | 30 | export const add = makePayloadActionCreator(ActionTypes.ADD); // 1 arg. 31 | export const increment = makeEmptyActionCreator(ActionTypes.INCREMENT); // 0 args. 32 | ``` 33 | 34 | ## API Reference 35 | 36 | ### makeActionTypes() 37 | 38 | Creates an object with values set to `/`. 39 | 40 | **Parameters** 41 | 42 | 1. `prefix` ( _string_ ): The action prefix. 43 | 2. `actionTypes` ( _Array_ ): Array of values to mirror as keys. 44 | 45 | **Returns** 46 | 47 | ( _Object_ ): Object with values set to `/`. 48 | 49 | ### makeEmptyActionCreator() 50 | 51 | Creates a new nullary action creator. Also reexported as `makeConstantActionCreator` for backwards compatibility. 52 | 53 | **Parameters** 54 | 55 | 1. `type` ( _string_ ): The action type. 56 | 57 | **Returns** 58 | 59 | ( _() -> Action_ ): An action creator. 60 | 61 | ### makePayloadActionCreator() 62 | 63 | Creates a new unary action creator which will use the argument as the payload. Also reexported as `makeSimpleActionCreator` for backwards compatibility. 64 | 65 | **Parameters** 66 | 67 | 1. `type` ( _string_ ): The action type. 68 | 69 | **Returns** 70 | 71 | ( _any -> Action_ ): An action creator. 72 | 73 | ### makePayloadMetaActionCreator() 74 | 75 | Creates a new binary action creator which will use the first argument as the payload and the second argument as the meta. 76 | 77 | **Parameters** 78 | 79 | 1. `type` ( _string_ ): The action type. 80 | 81 | **Returns** 82 | 83 | ( _(any, Object) -> Action_ ): An action creator. 84 | 85 | ### configureActionCreator() 86 | 87 | !> This action creator factory is only meant to be used to create other action creator factories. We do not recommend using `configureActionCreator` in your application code. See [best practices](#best-practices). 88 | 89 | Creates a new unary action creator which will apply the provided functions to an argument, producing the `payload` and `meta` properties. 90 | 91 | **Parameters** 92 | 93 | 1. `type` ( _string_ ): The action type. 94 | 2. `getPayload` ( _(...any) –> any_ ): Payload getter. 95 | 3. `getMeta` ( _(...any) -> any_ ): Meta getter. 96 | 97 | **Returns** 98 | 99 | ( _(...any) -> Action_ ): An action creator. 100 | -------------------------------------------------------------------------------- /packages/reducers/src/combineReducers.test.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | 3 | import combineReducers from './combineReducers'; 4 | 5 | describe('combineReducers', () => { 6 | const foo = (state = {}) => state; 7 | const bar = (state = {}) => state; 8 | const ACTION = { type: 'ACTION' }; 9 | 10 | it('returns a composite reducer that maps the state keys to given reducers', () => { 11 | const reducer = combineReducers({ 12 | counter: (state = 0, action) => (action.type === 'increment' ? state + 1 : state), 13 | stack: (state = [], action) => (action.type === 'push' ? [...state, action.value] : state), 14 | }); 15 | const s1 = reducer(undefined, { type: 'increment' }); 16 | expect(s1).toEqual({ counter: 1, stack: [] }); 17 | const s2 = reducer(s1, { type: 'push', value: 'a' }); 18 | expect(s2).toEqual({ counter: 1, stack: ['a'] }); 19 | }); 20 | 21 | it('ignores all props which are not a function', () => { 22 | const reducer = combineReducers({ 23 | fake: true, 24 | broken: 'string', 25 | another: { nested: 'object' }, 26 | stack: (state = []) => state, 27 | }); 28 | expect(Object.keys(reducer(undefined, { type: 'push' }))).toEqual(['stack']); 29 | }); 30 | 31 | it('maintains referential equality if the reducers it is combining do', () => { 32 | const reducer = combineReducers({ 33 | child1(state = {}) { 34 | return state; 35 | }, 36 | child2(state = {}) { 37 | return state; 38 | }, 39 | child3(state = {}) { 40 | return state; 41 | }, 42 | }); 43 | const initialState = reducer(undefined, { type: '@@INIT' }); 44 | expect(reducer(initialState, { type: 'FOO' })).toBe(initialState); 45 | }); 46 | 47 | it('does not have referential equality if one of the reducers changes something', () => { 48 | const reducer = combineReducers({ 49 | child1(state = {}) { 50 | return state; 51 | }, 52 | child2(state = { count: 0 }, action) { 53 | switch (action.type) { 54 | case 'increment': 55 | return { count: state.count + 1 }; 56 | default: 57 | return state; 58 | } 59 | }, 60 | child3(state = {}) { 61 | return state; 62 | }, 63 | }); 64 | const initialState = reducer(undefined, { type: '@@INIT' }); 65 | expect(reducer(initialState, { type: 'increment' })).not.toBe(initialState); 66 | }); 67 | 68 | it('should return an updated state when additional reducers are passed to combineReducers', () => { 69 | const originalCompositeReducer = combineReducers({ foo }); 70 | const store = createStore(originalCompositeReducer); 71 | store.dispatch(ACTION); 72 | const initialState = store.getState(); 73 | store.replaceReducer(combineReducers({ foo, bar })); 74 | store.dispatch(ACTION); 75 | const nextState = store.getState(); 76 | expect(nextState).not.toBe(initialState); 77 | }); 78 | 79 | it('should return an updated state when reducers passed to combineReducers are changed', () => { 80 | const baz = (state = {}) => state; 81 | const originalCompositeReducer = combineReducers({ foo, bar }); 82 | const store = createStore(originalCompositeReducer); 83 | store.dispatch(ACTION); 84 | const initialState = store.getState(); 85 | store.replaceReducer(combineReducers({ baz, bar })); 86 | store.dispatch(ACTION); 87 | const nextState = store.getState(); 88 | expect(nextState).not.toBe(initialState); 89 | }); 90 | 91 | it('should return the same state when reducers passed to combineReducers are not changed', () => { 92 | const originalCompositeReducer = combineReducers({ foo, bar }); 93 | const store = createStore(originalCompositeReducer); 94 | store.dispatch(ACTION); 95 | const initialState = store.getState(); 96 | store.replaceReducer(combineReducers({ foo, bar })); 97 | store.dispatch(ACTION); 98 | const nextState = store.getState(); 99 | expect(nextState).toBe(initialState); 100 | }); 101 | 102 | it('should preserve state when one or more reducers are removed', () => { 103 | const originalCompositeReducer = combineReducers({ foo, bar }); 104 | const store = createStore(originalCompositeReducer); 105 | store.dispatch(ACTION); 106 | const initialState = store.getState(); 107 | store.replaceReducer(combineReducers({ bar })); 108 | const nextState = store.getState(); 109 | expect(nextState).toBe(initialState); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /docs/getting-started/introduction.md: -------------------------------------------------------------------------------- 1 | # Redux Tools {docsify-ignore-all} 2 | 3 | [![GitHub Stars](https://img.shields.io/github/stars/lundegaard/redux-tools)](https://github.com/lundegaard/redux-tools) 4 | [![GitHub Issues](https://img.shields.io/github/issues/lundegaard/redux-tools?color=bada55)](https://github.com/lundegaard/redux-tools/issues) 5 | [![License](https://img.shields.io/badge/licence-MIT-ff69b4)](https://github.com/lundegaard/redux-tools) 6 | [![Downloads](https://badgen.net/npm/dm/@redux-tools/reducers)](https://npmjs.com/package/@redux-tools/reducers) 7 | [![Version](https://badgen.net/npm/v/@redux-tools/reducers)](https://npmjs.com/package/@redux-tools/reducers) 8 | 9 | A collection of tools for maintaining large Redux applications by enabling dependency injection of Redux code and development of multi-instance components by namespacing their state. 10 | 11 | Redux Tools consist mainly of: 12 | 13 | - [Store enhancers](https://github.com/reduxjs/redux/blob/master/docs/Glossary.md#store-enhancer) for injecting reducers, middleware, and epics into your Redux store after the store is created. 14 | - Utility functions for less verbose definitions of action creators and reducers. 15 | - Logic for managing your state via [namespaces](/tutorial/02-namespacing). 16 | 17 | Although the Redux Tools core is platform-agnostic, [React](https://github.com/facebook/react/) bindings are included for tying the injection mechanism to the lifecycle of your components. The [quick start guide](/getting-started/quick-start) and the [tutorial](/tutorial/01-dependency-injection) use React as the view library of choice. 18 | 19 | ## Installation 20 | 21 | The `@redux-tools/react` package contains everything you'll need to get started with using Redux Tools in a React application. Use either of these commands, depending on the package manager you prefer: 22 | 23 | ```sh 24 | yarn add @redux-tools/react 25 | 26 | npm i @redux-tools/react 27 | ``` 28 | 29 | ## Packages 30 | 31 | Here are the packages `@redux-tools/react` reexports: 32 | 33 | - [@redux-tools/actions](/packages/actions), functions for creating [FSA-compliant](https://github.com/redux-utilities/flux-standard-action) action creators and reducers. 34 | - [@redux-tools/namespaces](/packages/namespaces), logic for associating Redux actions with a namespace. 35 | - [@redux-tools/namespaces-react](/packages/namespaces), React bindings for the `namespaces` package. 36 | - [@redux-tools/reducers](/packages/reducers), store enhancer for asynchronous injection of reducers. 37 | - [@redux-tools/reducers-react](/packages/reducers-react), React bindings for the `reducers` package. 38 | - [@redux-tools/middleware](/packages/middleware), store enhancer for asynchronous injection of middleware. 39 | - [@redux-tools/middleware-react](/packages/middleware-react), React bindings for the `middleware` package. 40 | 41 | Take a look at the [package index](https://github.com/lundegaard/redux-tools/blob/master/packages/react/src/index.js) to see all the available exports. 42 | 43 | Based on your preferred method of handling side effects, install any of the following packages as well: 44 | 45 | - [@redux-tools/epics](/packages/epics), store enhancer for asynchronous injection of [epics](https://redux-observable.js.org/). 46 | - [@redux-tools/epics-react](/packages/epics-react), React bindings for the `epics` package. 47 | - [@redux-tools/stream-creators](/packages/stream-creators), collection of stream creators for the `epics` package. 48 | - [@redux-tools/thunk](/packages/thunk), custom implementation of [Redux Thunk](https://github.com/reduxjs/redux-thunk) with namespacing support. 49 | 50 | ## Changelog 51 | 52 | See the [CHANGELOG.md](https://github.com/lundegaard/redux-tools/blob/master/CHANGELOG.md) file in our [repository](https://github.com/lundegaard/redux-tools). 53 | 54 | ## Resources 55 | 56 | - [Beyond Simplicity: Using Redux in Dynamic Applications](https://medium.com/@wafflepie/beyond-simplicity-using-redux-in-dynamic-applications-ae9e0aea928c) (published 21 Jan 2019) 57 | 58 | ## Related Projects 59 | 60 | - [validarium](https://github.com/lundegaard/validarium) – Validations done right. 61 | - [lundium](https://github.com/lundegaard/lundium) – Beautiful React component library. 62 | - [react-union](https://github.com/lundegaard/react-union) – Integrate React apps into various CMSs seamlessly. 63 | 64 | ## License 65 | 66 | All packages are distributed under the MIT license. See the license [here](https://github.com/lundegaard/redux-tools/blob/master/LICENSE). 67 | -------------------------------------------------------------------------------- /packages/middleware/src/makeEnhancer.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { map, compose, uniq, forEach, o } from 'ramda'; 3 | 4 | import { enhanceStore, makeStoreInterface } from '@redux-tools/injectors'; 5 | import { 6 | isActionFromNamespace, 7 | defaultNamespace, 8 | getStateByFeatureAndNamespace, 9 | DEFAULT_FEATURE, 10 | } from '@redux-tools/namespaces'; 11 | 12 | export const storeInterface = makeStoreInterface('middleware'); 13 | 14 | const noopEntry = { 15 | path: ['@redux-tools/NOOP_MIDDLEWARE'], 16 | value: () => next => action => next(action), 17 | }; 18 | 19 | const makeEnhancer = () => { 20 | // NOTE: Keys are entries, values are middleware with bound `dispatch` and `getState`. 21 | let initializedEntries = new Map(); 22 | 23 | // NOTE: Sadly, because of how enhancers and middleware are structured, we need some escape hatches 24 | // from scopes and closures. This is ugly, but I don't think we can solve this differently. 25 | // NOTE: `outerNext` is either the next middleware in `applyMiddleware` or `store.dispatch`. 26 | let outerNext; 27 | 28 | // NOTE: This default implementation is necessary to ensure that the middleware works even without 29 | // any injected middleware. 30 | // NOTE `enhancerNext` calls all injected middleware and then `outerNext`. 31 | let enhancerNext = action => { 32 | invariant(outerNext, 'You need to apply the enhancer to a Redux store.'); 33 | 34 | return outerNext(action); 35 | }; 36 | 37 | const injectedMiddleware = () => next => { 38 | invariant(!outerNext, 'You cannot apply the injected middleware to multiple Redux stores.'); 39 | outerNext = next; 40 | 41 | return action => enhancerNext(action); 42 | }; 43 | 44 | // NOTE: composeEntries :: [Entry] -> Next 45 | const composeEntries = entries => { 46 | const chain = map(entry => { 47 | const { namespace } = entry; 48 | 49 | // NOTE: `innerNext` is either the next injected middleware or `outerNext`. 50 | return innerNext => { 51 | // NOTE: `entryNext` is a wrapper over the currently iterated-over injected middleware. 52 | const entryNext = initializedEntries.get(entry)(innerNext); 53 | 54 | return action => 55 | isActionFromNamespace(namespace, action) ? entryNext(action) : innerNext(action); 56 | }; 57 | }, entries); 58 | 59 | // NOTE: `pipe` is used to preserve injection order. 60 | return compose(...chain)(outerNext); 61 | }; 62 | 63 | const enhancer = createStore => (...args) => { 64 | const prevStore = createStore(...args); 65 | 66 | // NOTE: All of this logic is just to achieve the following behaviour: 67 | // Every middleware is curried. In standard Redux, the first two arguments are bound immediately. 68 | // However, when injecting the middleware, we are not able to easily provide the second argument 69 | // immediately, because it changes whenever an entry is injected or ejected. That's why we only 70 | // bind the first argument and then provide `next` once per any injection call. This behaviour 71 | // is covered by unit tests, which may help explain this better. 72 | const handleEntriesChanged = () => { 73 | const nextEntries = [ 74 | ...uniq(storeInterface.getEntries(nextStore)), 75 | // NOTE: This is just a safeguard, because although `R.compose` is variadic, 76 | // it still needs at least one function as an argument. 77 | noopEntry, 78 | ]; 79 | 80 | const nextInitializedEntries = new Map(); 81 | 82 | // NOTE: We copy all necessary entries because it's simpler/faster than finding what has changed. 83 | forEach(entry => { 84 | const { namespace } = entry; 85 | const { dispatch, getState } = nextStore; 86 | 87 | nextInitializedEntries.set( 88 | entry, 89 | initializedEntries.get(entry) || 90 | entry.value({ 91 | namespace, 92 | dispatch: o(dispatch, defaultNamespace(namespace)), 93 | getState: nextStore.getState, 94 | getNamespacedState: namespace 95 | ? feature => 96 | getStateByFeatureAndNamespace( 97 | feature ?? entry.feature ?? DEFAULT_FEATURE, 98 | namespace, 99 | getState() 100 | ) 101 | : null, 102 | }) 103 | ); 104 | }, nextEntries); 105 | 106 | initializedEntries = nextInitializedEntries; 107 | enhancerNext = composeEntries(nextEntries); 108 | }; 109 | 110 | const nextStore = enhanceStore(prevStore, storeInterface, { 111 | onInjected: handleEntriesChanged, 112 | onEjected: handleEntriesChanged, 113 | }); 114 | 115 | return nextStore; 116 | }; 117 | 118 | enhancer.injectedMiddleware = injectedMiddleware; 119 | 120 | return enhancer; 121 | }; 122 | 123 | export default makeEnhancer; 124 | -------------------------------------------------------------------------------- /packages/injectors-react/src/makeHook.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { all, includes, omit, isNil } from 'ramda'; 3 | import { toPascalCase, isNotNil, rejectNil, isObject } from 'ramda-extension'; 4 | import { useLayoutEffect, useState, useEffect, useDebugValue, useContext } from 'react'; 5 | import { ReactReduxContext } from 'react-redux'; 6 | 7 | import { createEntries } from '@redux-tools/injectors'; 8 | import { DEFAULT_FEATURE } from '@redux-tools/namespaces'; 9 | import { useNamespace, NamespaceContext } from '@redux-tools/namespaces-react'; 10 | 11 | import { IS_SERVER } from './constants'; 12 | 13 | const useUniversalLayoutEffect = IS_SERVER ? useEffect : useLayoutEffect; 14 | const getOtherProps = omit(['isGlobal', 'global', 'isPersistent', 'persist']); 15 | 16 | const makeHook = storeInterface => { 17 | invariant(isObject(storeInterface), 'The store interface is undefined.'); 18 | 19 | const { getEntries, ejectionKey, injectionKey, type } = storeInterface; 20 | 21 | const pascalCaseType = toPascalCase(type); 22 | const hookName = `use${pascalCaseType}`; 23 | 24 | const useInjectables = (injectables, options = {}) => { 25 | const locationMessages = [`@redux-tools ${type}`, injectables]; 26 | 27 | const warn = (...args) => console.warn(...locationMessages, ...args); 28 | 29 | // NOTE: `options.global` and `options.persist` are deprecated. 30 | const isGlobal = options.isGlobal ?? options.global ?? false; 31 | const isPersistent = options.isPersistent ?? options.persist ?? false; 32 | const isNamespaced = options.isNamespaced ?? false; 33 | const feature = options.feature ?? null; 34 | const contextNamespace = useNamespace(feature); 35 | const { store } = useContext(ReactReduxContext); 36 | const { isUseNamespaceProvided } = useContext(NamespaceContext); 37 | const namespace = isGlobal ? null : options.namespace ?? contextNamespace; 38 | const inject = store[injectionKey]; 39 | const eject = store[ejectionKey]; 40 | 41 | // NOTE: On the server, the injectables should be injected beforehand. 42 | const [isInitialized, setIsInitialized] = useState(IS_SERVER); 43 | 44 | const props = rejectNil({ 45 | ...getOtherProps(options), 46 | feature, 47 | namespace, 48 | }); 49 | 50 | // TODO: Refactor when React DevTools support multiple debug values or non-primitive structures. 51 | useDebugValue( 52 | String([ 53 | `Namespace: ${namespace}`, 54 | `Feature: ${feature}`, 55 | `Type: ${pascalCaseType}`, 56 | `Initialized: ${isInitialized}`, 57 | ]) 58 | ); 59 | 60 | if (IS_SERVER) { 61 | const areEntriesAlreadyInjected = all( 62 | entry => includes(entry, getEntries(store)), 63 | createEntries(injectables, props) 64 | ); 65 | 66 | if (!areEntriesAlreadyInjected) { 67 | warn( 68 | `When rendering on the server, inject all ${type} before calling`, 69 | "'ReactDOMServer.renderToString()'. You should do this inside an", 70 | "'async getInitialProps()' function, i.e. where you fetch data and", 71 | 'do other side effects. If you need to do server-side injections', 72 | 'during rendering, open an issue.' 73 | ); 74 | } 75 | } 76 | 77 | const effectDependencies = [ 78 | namespace, 79 | feature, 80 | isGlobal, 81 | isPersistent, 82 | inject, 83 | eject, 84 | injectables, 85 | ]; 86 | 87 | // NOTE: This doesn't run on the server, but won't trigger `useLayoutEffect` warnings either. 88 | useUniversalLayoutEffect(() => { 89 | if (isGlobal && isNotNil(feature) && feature !== DEFAULT_FEATURE) { 90 | warn( 91 | `You are using a feature (${feature}) with global ${type}.`, 92 | 'This will have no effect.' 93 | ); 94 | } 95 | 96 | if (isUseNamespaceProvided && isNil(namespace) && !isGlobal) { 97 | warn( 98 | `You're injecting ${type}, but the namespace could not be resolved from React context!`, 99 | 'They will be injected globally. If this is intended, consider passing', 100 | `'isGlobal: true' to the injector, e.g. '${hookName}(${type}, { isGlobal: true })'.` 101 | ); 102 | } 103 | 104 | if (isNotNil(options.global)) { 105 | warn(`'global: ${options.global}' is deprecated. Use 'isGlobal: ${options.global}'.`); 106 | } 107 | 108 | if (isNotNil(options.persist)) { 109 | warn( 110 | `'persist: ${options.persist}' is deprecated. Use 'isPersistent: ${options.persist}'.` 111 | ); 112 | } 113 | 114 | invariant( 115 | !isNamespaced || isNotNil(namespace), 116 | `You're injecting ${type} marked as namespaced, but no namespace could be resolved.` 117 | ); 118 | 119 | invariant(inject, `'store.${injectionKey}' missing. Are you using the enhancer correctly?`); 120 | invariant(eject, `'store.${ejectionKey}' missing. Are you using the enhancer correctly?`); 121 | 122 | inject(injectables, props); 123 | setIsInitialized(true); 124 | 125 | return () => { 126 | if (!isPersistent) { 127 | eject(injectables, props); 128 | } 129 | }; 130 | }, effectDependencies); 131 | 132 | return isInitialized; 133 | }; 134 | 135 | return useInjectables; 136 | }; 137 | 138 | export default makeHook; 139 | -------------------------------------------------------------------------------- /packages/epics/src/makeEnhancer.test.js: -------------------------------------------------------------------------------- 1 | import { identity, compose } from 'ramda'; 2 | import { createStore, applyMiddleware } from 'redux'; 3 | import { createEpicMiddleware, ofType } from 'redux-observable'; 4 | import { Subject, Observable } from 'rxjs'; 5 | import * as Rx from 'rxjs/operators'; 6 | 7 | import makeEnhancer from './makeEnhancer'; 8 | 9 | const incrementEpic = action$ => 10 | action$.pipe( 11 | ofType('PING'), 12 | Rx.map(action => ({ 13 | type: 'PONG', 14 | payload: action.payload + 1, 15 | })) 16 | ); 17 | 18 | describe('makeEnhancer', () => { 19 | let store; 20 | let epicMiddleware; 21 | const logger = jest.fn(); 22 | const dependencies = { dependency: 'dependency' }; 23 | 24 | const loggerMiddleware = () => next => action => { 25 | next(action); 26 | logger(action); 27 | }; 28 | 29 | beforeEach(() => { 30 | jest.clearAllMocks(); 31 | epicMiddleware = createEpicMiddleware({ dependencies }); 32 | store = createStore( 33 | identity, 34 | compose(makeEnhancer({ epicMiddleware }), applyMiddleware(epicMiddleware, loggerMiddleware)) 35 | ); 36 | }); 37 | 38 | it('returns a Redux store with defined functions', () => { 39 | expect(store.injectEpics).toBeInstanceOf(Function); 40 | expect(store.ejectEpics).toBeInstanceOf(Function); 41 | }); 42 | 43 | it('runs an epic in the supplied middleware', () => { 44 | epicMiddleware = jest.fn(); 45 | epicMiddleware.run = jest.fn(); 46 | const enhancer = makeEnhancer({ epicMiddleware }); 47 | createStore(identity, enhancer); 48 | expect(epicMiddleware.run).toHaveBeenCalled(); 49 | }); 50 | 51 | it('passes actions to an injected epic', () => { 52 | store.injectEpics(incrementEpic); 53 | jest.clearAllMocks(); 54 | store.dispatch({ type: 'PING', payload: 1 }); 55 | expect(logger).toHaveBeenCalledTimes(2); 56 | expect(logger.mock.calls[1][0]).toEqual({ type: 'PONG', payload: 2 }); 57 | }); 58 | 59 | it('handles same epics with different keys', () => { 60 | store.injectEpics({ foo: incrementEpic }); 61 | store.injectEpics({ bar: incrementEpic }); 62 | jest.clearAllMocks(); 63 | store.dispatch({ type: 'PING', payload: 1 }); 64 | expect(logger).toHaveBeenCalledTimes(3); 65 | expect(logger.mock.calls[1][0]).toEqual({ type: 'PONG', payload: 2 }); 66 | expect(logger.mock.calls[2][0]).toEqual({ type: 'PONG', payload: 2 }); 67 | }); 68 | 69 | it('adds namespace to emitted actions', () => { 70 | store.injectEpics({ foo: incrementEpic }, { namespace: 'ns' }); 71 | jest.clearAllMocks(); 72 | store.dispatch({ type: 'PING', payload: 1 }); 73 | expect(logger).toHaveBeenCalledTimes(2); 74 | expect(logger.mock.calls[1][0]).toEqual({ 75 | type: 'PONG', 76 | payload: 2, 77 | meta: { namespace: 'ns' }, 78 | }); 79 | }); 80 | 81 | it('passes only valid actions to a namespaced epic', () => { 82 | store.injectEpics({ foo: incrementEpic }, { namespace: 'ns' }); 83 | jest.clearAllMocks(); 84 | store.dispatch({ type: 'PING', payload: 1 }); 85 | store.dispatch({ type: 'PING', payload: 2, meta: { namespace: 'ns' } }); 86 | store.dispatch({ type: 'PING', payload: 3, meta: { namespace: 'other' } }); 87 | expect(logger).toHaveBeenCalledTimes(5); 88 | 89 | expect(logger.mock.calls[1][0]).toEqual({ 90 | type: 'PONG', 91 | payload: 2, 92 | meta: { namespace: 'ns' }, 93 | }); 94 | 95 | expect(logger.mock.calls[3][0]).toEqual({ 96 | type: 'PONG', 97 | payload: 3, 98 | meta: { namespace: 'ns' }, 99 | }); 100 | }); 101 | 102 | it('stops an epic when ejected', () => { 103 | store.injectEpics({ foo: incrementEpic }, { namespace: 'ns' }); 104 | store.ejectEpics({ foo: incrementEpic }, { namespace: 'ns' }); 105 | jest.clearAllMocks(); 106 | store.dispatch({ type: 'PING', payload: 1 }); 107 | expect(logger).toHaveBeenCalledTimes(1); 108 | }); 109 | 110 | it('passes correct arguments to the epic when streamCreator is omitted', () => { 111 | const epic = jest.fn(() => new Subject()); 112 | store.injectEpics({ foo: epic }, { namespace: 'ns' }); 113 | expect(epic).toHaveBeenCalledTimes(1); 114 | expect(epic.mock.calls[0][0]).toBeInstanceOf(Observable); 115 | expect(epic.mock.calls[0][1]).toBeInstanceOf(Observable); 116 | expect(epic.mock.calls[0][2]).toEqual(dependencies); 117 | }); 118 | 119 | it('passes correct arguments to the epic when streamCreator is defined', () => { 120 | const streamCreator = jest.fn(() => 'streamCreator'); 121 | epicMiddleware = createEpicMiddleware({ dependencies }); 122 | const enhancer = makeEnhancer({ epicMiddleware, streamCreator }); 123 | 124 | store = createStore( 125 | identity, 126 | compose(enhancer, applyMiddleware(epicMiddleware, loggerMiddleware)) 127 | ); 128 | 129 | const epic = jest.fn(() => new Subject()); 130 | store.injectEpics({ foo: epic }, { namespace: 'ns' }); 131 | expect(epic).toHaveBeenCalledTimes(1); 132 | expect(epic.mock.calls[0][0]).toBeInstanceOf(Observable); 133 | expect(epic.mock.calls[0][1]).toBeInstanceOf(Observable); 134 | expect(epic.mock.calls[0][2]).toEqual('streamCreator'); 135 | expect(epic.mock.calls[0][3]).toEqual(dependencies); 136 | 137 | expect(streamCreator).toHaveBeenCalledTimes(1); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /docs/getting-started/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | Let's take a look on how we'd use Redux Tools in a simple React application. 4 | 5 | ## Global Injection 6 | 7 | This is the go-to way to use Redux Tools in standard SPAs. 8 | 9 | _**duck.js**_ 10 | 11 | - a duck (see [Ducks: Redux Reducer Bundles](https://github.com/erikras/ducks-modular-redux)) 12 | - exports a reducer and an action creator 13 | 14 | ```js 15 | import { makeActionTypes, makeEmptyActionCreator, makeReducer } from '@redux-tools/react'; 16 | 17 | export const ActionTypes = makeActionTypes('duck', ['INCREMENT']); 18 | export const increment = makeEmptyActionCreator(ActionTypes.INCREMENT); 19 | 20 | export default makeReducer([[ActionTypes.INCREMENT, count => count + 1]], 0); 21 | ``` 22 | 23 | _**Counter.js**_ 24 | 25 | - a connected React component 26 | - can visualize a number from Redux 27 | - can dispatch an `INCREMENT` action 28 | 29 | ```js 30 | import React from 'react'; 31 | import { o } from 'ramda'; 32 | import { withReducers } from '@redux-tools/react'; 33 | import { connect } from 'react-redux'; 34 | 35 | import countReducer, { increment } from './duck'; 36 | 37 | const Counter = ({ count, increment }) => ; 38 | 39 | const enhance = o( 40 | withReducers({ count: countReducer }), 41 | connect(state => ({ count: state.count }), { increment }) 42 | ); 43 | 44 | export default enhance(Counter); 45 | ``` 46 | 47 | _**index.js**_ 48 | 49 | - the root of our application 50 | - responsible for creating the Redux store and integrating Redux Tools into it 51 | - connects the React application to the Redux store 52 | 53 | ```js 54 | import React from 'react'; 55 | import { render } from 'react-dom'; 56 | import { createStore } from 'redux'; 57 | import { makeReducersEnhancer } from '@redux-tools/react'; 58 | import { Provider } from 'react-redux'; 59 | import { identity } from 'ramda'; 60 | 61 | import Counter from './Counter'; 62 | 63 | const store = createStore(identity, makeReducersEnhancer()); 64 | 65 | render( 66 | 67 | 68 | , 69 | document.getElementById('root') 70 | ); 71 | ``` 72 | 73 | The state structure will look like this: 74 | 75 | ```json 76 | { 77 | "count": 0 78 | } 79 | ``` 80 | 81 | ## Namespaced Injection 82 | 83 | This is the go-to way to use Redux Tools if any of these apply: 84 | 85 | - Your application is cleary split into standalone modules which rarely communicate with one another. 86 | - Your application consists of widgets which store their data in Redux and can be mounted multiple times. 87 | 88 | _**duck.js**_ 89 | 90 | - a duck file (see [Ducks: Redux Reducer Bundles](https://github.com/erikras/ducks-modular-redux)) 91 | - exports a reducer and an action creator 92 | 93 | ```js 94 | import { makeActionTypes, makeEmptyActionCreator, makeReducer } from '@redux-tools/react'; 95 | 96 | export const ActionTypes = makeActionTypes('duck', ['INCREMENT']); 97 | export const increment = makeEmptyActionCreator(ActionTypes.INCREMENT); 98 | 99 | export default makeReducer([[ActionTypes.INCREMENT, count => count + 1]], 0); 100 | ``` 101 | 102 | _**Counter.js**_ 103 | 104 | - a connected React component 105 | - expects a `namespace` prop to be passed to it 106 | - can visualize a number from a specified Redux namespace 107 | - can dispatch an `INCREMENT` action to a specified Redux namespace 108 | 109 | ```js 110 | import React from 'react'; 111 | import { o } from 'ramda'; 112 | import { withReducers, namespacedConnect } from '@redux-tools/react'; 113 | 114 | import countReducer, { increment } from './duck'; 115 | 116 | const Counter = ({ count, increment }) => ; 117 | 118 | const mapStateToProps = namespacedState => ({ count: namespacedState.count }); 119 | const mapDispatchToProps = { increment }; 120 | 121 | const enhance = o( 122 | withReducers({ count: countReducer }), 123 | // NOTE: `namespacedConnect` is just like `connect`, but it works over namespaces 124 | namespacedConnect(mapStateToProps, mapDispatchToProps) 125 | ); 126 | 127 | export default enhance(Counter); 128 | ``` 129 | 130 | _**index.js**_ 131 | 132 | - the root of our application 133 | - responsible for creating the Redux store and integrating Redux Tools into it 134 | - connects the React application to the Redux store 135 | 136 | ```js 137 | import React from 'react'; 138 | import { render } from 'react-dom'; 139 | import { createStore } from 'redux'; 140 | import { makeReducersEnhancer } from '@redux-tools/react'; 141 | import { Provider } from 'react-redux'; 142 | import { identity } from 'ramda'; 143 | 144 | import Counter from './Counter'; 145 | 146 | const store = createStore(identity, makeReducersEnhancer()); 147 | 148 | render( 149 | 150 | 151 | 152 | 153 | , 154 | document.getElementById('root') 155 | ); 156 | ``` 157 | 158 | The state structure will look like this: 159 | 160 | ```json 161 | { 162 | "namespaces": { 163 | "foo": { "count": 0 }, 164 | "bar": { "count": 0 }, 165 | "baz": { "count": 0 } 166 | } 167 | } 168 | ``` 169 | 170 | See more examples in the [examples section](/getting-started/examples). 171 | -------------------------------------------------------------------------------- /docs/packages/namespaces.md: -------------------------------------------------------------------------------- 1 | # Namespaces 2 | 3 | > yarn add @redux-tools/namespaces 4 | 5 | This package provides the main logic for associating Redux actions with a namespace. 6 | 7 | ## API Reference 8 | 9 | ### isActionFromNamespace() 10 | 11 | A function that checks if a Redux action is from a certain namespace. Returns `false` when both namespaces are defined and **not** equal, otherwise returns `true`. 12 | 13 | There are essentially two edge cases: 14 | 15 | - If the action has no namespace, it is a global action and therefore affects **all** namespaces. 16 | - If the tested namespace is undefined, it is a global namespace and is therefore affected by **all** actions. 17 | - This allows easier usage of other packages (notably using `namespacedConnect` for dispatching actions affecting global reducers and epics, instead of having to use `connect` separately). 18 | 19 | **Parameters** 20 | 21 | 1. [`namespace`] \( _string_ ): The namespace to check against. 22 | 2. `action` ( _Action_ ): The Redux action to check. Should have a `meta.namespace` property. 23 | 24 | **Returns** 25 | 26 | ( _boolean_ ): Whether the Redux action is from the specified namespace. 27 | 28 | ### getNamespaceByAction() 29 | 30 | Returns the namespace of an action. 31 | 32 | **Parameters** 33 | 34 | 1. `action` ( _Action_ ): The Redux action to get the namespace of. Should have a `meta.namespace` property. 35 | 36 | **Returns** 37 | 38 | ( _any_ ): The value of the `meta.namespace` property. 39 | 40 | ### attachNamespace() 41 | 42 | Associates an action with a namespace, overwriting any previous namespace. 43 | 44 | **Parameters** 45 | 46 | 1. [`namespace`] \( _string_ ): The namespace to attach. 47 | 2. `action` ( _Action_ ): Action to attach the namespace to. 48 | 49 | **Returns** 50 | 51 | ( _Action_ ): A new Redux action with a `meta.namespace` property. 52 | 53 | ### defaultNamespace() 54 | 55 | Associates an action with a namespace unless it is already associated with some namespace. 56 | 57 | **Parameters** 58 | 59 | 1. [`namespace`] \( _string_ ): The namespace to default to. 60 | 2. `action` ( _Action_ ): Action to default the namespace of. 61 | 62 | **Returns** 63 | 64 | ( _Action_ ): A new Redux action with a `meta.namespace` property. 65 | 66 | ### getStateByFeatureAndAction() 67 | 68 | Returns Redux state by feature and action namespace. 69 | 70 | **Parameters** 71 | 72 | 1. `feature` ( _string_ ): Feature to retrieve the state by. 73 | 2. `action` ( _Action_ ): Redux action with a `meta.namespace` property. 74 | 3. `state` ( _Object_ ): Redux state. 75 | 76 | **Returns** 77 | 78 | ( _any_ ): Redux state slice. 79 | 80 | **Example** 81 | 82 | ```js 83 | import { getStateByFeatureAndAction } from '@redux-tools/namespaces'; 84 | 85 | const state = { 86 | namespaces: { 87 | foo: { value: 'bar' }, 88 | }, 89 | }; 90 | 91 | const action = { 92 | meta: { 93 | namespace: 'foo', 94 | }, 95 | }; 96 | 97 | getStateByFeatureAndAction('namespaces', action, state); // { value: 'bar' } 98 | ``` 99 | 100 | ### getStateByAction() 101 | 102 | Returns Redux state by action namespace. 103 | 104 | **Parameters** 105 | 106 | 1. `action` ( _Action_ ): Redux action with a `meta.namespace` property. 107 | 2. `state` ( _Object_ ): Redux state. 108 | 109 | **Returns** 110 | 111 | ( _any_ ): Redux state slice. 112 | 113 | **Example** 114 | 115 | ```js 116 | import { DEFAULT_FEATURE, getStateByAction } from '@redux-tools/namespaces'; 117 | 118 | const state = { 119 | [DEFAULT_FEATURE]: { 120 | foo: { value: 'bar' }, 121 | }, 122 | }; 123 | 124 | const action = { 125 | meta: { 126 | namespace: 'foo', 127 | }, 128 | }; 129 | 130 | getStateByAction(action, state); // { value: 'bar' } 131 | ``` 132 | 133 | ### getStateByFeatureAndNamespace() 134 | 135 | Returns Redux state by feature and namespace. 136 | 137 | **Parameters** 138 | 139 | 1. `feature` ( _string_ ): Feature to retrieve the state by. 140 | 2. `namespace` ( _string_ ): Namespace to retrieve the state by. 141 | 3. `state` ( _Object_ ): Redux state. 142 | 143 | **Returns** 144 | 145 | ( _any_ ): Redux state slice. 146 | 147 | **Example** 148 | 149 | ```js 150 | import { getStateByFeatureNamespace } from '@redux-tools/namespaces'; 151 | 152 | const state = { 153 | namespaces: { 154 | foo: { value: 'bar' }, 155 | }, 156 | }; 157 | 158 | getStateByFeatureNamespace('namespaces', 'foo', state); // { value: 'bar' } 159 | ``` 160 | 161 | ### getStateByNamespace() 162 | 163 | Returns Redux state by namespace. 164 | 165 | **Parameters** 166 | 167 | 1. `namespace` ( _string_ ): Namespace to retrieve the state by. 168 | 2. `state` ( _Object_ ): Redux state. 169 | 170 | **Returns** 171 | 172 | ( _any_ ): Redux state slice. 173 | 174 | **Example** 175 | 176 | ```js 177 | import { DEFAULT_FEATURE, getStateByNamespace } from '@redux-tools/namespaces'; 178 | 179 | const state = { 180 | [DEFAULT_FEATURE]: { 181 | foo: { value: 'bar' }, 182 | }, 183 | }; 184 | 185 | getStateByNamespace('foo', state); // { value: 'bar' } 186 | ``` 187 | 188 | ### preventNamespace() 189 | 190 | Associates an action with a "global" namespace, ensuring that this action's namespace won't be overwritten by any namespaced injectables. 191 | 192 | **Parameters** 193 | 194 | 1. `action` ( _Action_ ): Action to prevent the namespace of. 195 | 196 | **Returns** 197 | 198 | ( _Action_ ): A new Redux action with a `meta.namespace` property. 199 | -------------------------------------------------------------------------------- /packages/reducers/src/combineReducerEntries.test.js: -------------------------------------------------------------------------------- 1 | import { inc, o, defaultTo, map, multiply } from 'ramda'; 2 | 3 | import combineReducerEntries from './combineReducerEntries'; 4 | 5 | const globalAction = { type: 'ACTION' }; 6 | const actionA = { type: 'ACTION', meta: { namespace: 'a' } }; 7 | const actionB = { type: 'ACTION', meta: { namespace: 'b' } }; 8 | 9 | const incOrZero = o(defaultTo(0), inc); 10 | const mapIncOrZero = map(incOrZero); 11 | 12 | const globalEntry = { path: [], value: mapIncOrZero }; 13 | 14 | const fooEntry = { path: ['foo'], value: incOrZero }; 15 | const barEntry = { path: ['bar'], value: incOrZero }; 16 | 17 | const namespacedEntryA = { path: [], value: mapIncOrZero, namespace: 'a', feature: 'grids' }; 18 | const namespacedEntryB = { path: [], value: mapIncOrZero, namespace: 'b', feature: 'grids' }; 19 | 20 | const namespacedFooEntryA = { path: ['foo'], value: incOrZero, namespace: 'a', feature: 'grids' }; 21 | const namespacedBarEntryA = { path: ['bar'], value: incOrZero, namespace: 'a', feature: 'grids' }; 22 | 23 | const namespacedFooEntryB = { path: ['foo'], value: incOrZero, namespace: 'b', feature: 'grids' }; 24 | const namespacedBarEntryB = { path: ['bar'], value: incOrZero, namespace: 'b', feature: 'grids' }; 25 | 26 | describe('combineReducerEntries', () => { 27 | it('handles no entries', () => { 28 | const reducer = combineReducerEntries([]); 29 | 30 | expect(reducer(null, globalAction)).toBe(null); 31 | }); 32 | 33 | it('handles named entries', () => { 34 | const reducer = combineReducerEntries([fooEntry, barEntry]); 35 | 36 | expect(reducer({ foo: 0, bar: 0 }, globalAction)).toEqual({ foo: 1, bar: 1 }); 37 | }); 38 | 39 | it('handles named entries with additional keys already in state', () => { 40 | const reducer = combineReducerEntries([fooEntry, barEntry]); 41 | 42 | expect(reducer({ foo: 0, bar: 0, baz: 0 }, globalAction)).toEqual({ foo: 1, bar: 1, baz: 0 }); 43 | }); 44 | 45 | it('handles named entries with missing keys in state', () => { 46 | const reducer = combineReducerEntries([fooEntry, barEntry]); 47 | 48 | expect(reducer({ foo: 0 }, globalAction)).toEqual({ foo: 1, bar: 0 }); 49 | }); 50 | 51 | it('handles global entries', () => { 52 | const reducer = combineReducerEntries([globalEntry]); 53 | 54 | expect(reducer({ foo: 0, bar: 0, baz: 0 }, globalAction)).toEqual({ foo: 1, bar: 1, baz: 1 }); 55 | }); 56 | 57 | it('handles global entries alongside named entries', () => { 58 | const reducer = combineReducerEntries([globalEntry, fooEntry]); 59 | 60 | expect(reducer({ foo: 0, bar: 0, baz: 0 }, globalAction)).toEqual({ foo: 2, bar: 1, baz: 1 }); 61 | }); 62 | 63 | it('applies global entries after named entries', () => { 64 | const reducer = combineReducerEntries([globalEntry, { path: ['foo'], value: multiply(2) }]); 65 | 66 | // NOTE: `foo: 6` for the reverse order: (2 + 1) * 2 = 6 67 | // NOTE: `foo: 5` for the current order: (2 * 2) + 1 = 5 68 | expect(reducer({ foo: 2, bar: 1 }, globalAction)).toEqual({ foo: 5, bar: 2 }); 69 | }); 70 | 71 | it('handles namespaced entries with the same namespace', () => { 72 | const reducer = combineReducerEntries([namespacedFooEntryA, namespacedBarEntryA]); 73 | 74 | expect(reducer({ grids: { a: { foo: 1, bar: 2 } } }, globalAction)).toEqual({ 75 | grids: { a: { foo: 2, bar: 3 } }, 76 | }); 77 | }); 78 | 79 | it('handles namespaced entries with different namespaces', () => { 80 | const reducer = combineReducerEntries([namespacedFooEntryA, namespacedBarEntryB]); 81 | 82 | expect(reducer({ grids: { a: { foo: 1 }, b: { bar: 2 } } }, globalAction)).toEqual({ 83 | grids: { a: { foo: 2 }, b: { bar: 3 } }, 84 | }); 85 | }); 86 | 87 | it('handles namespaced entries operating over the entire slice', () => { 88 | const reducer = combineReducerEntries([namespacedEntryA, namespacedEntryB]); 89 | 90 | expect(reducer({ grids: { a: { qux: 1 }, b: { quux: 2 } } }, globalAction)).toEqual({ 91 | grids: { a: { qux: 2 }, b: { quux: 3 } }, 92 | }); 93 | }); 94 | 95 | it('filters named entries based on namespace', () => { 96 | const reducer = combineReducerEntries([namespacedFooEntryA, namespacedBarEntryB]); 97 | 98 | expect(reducer({ grids: { a: { foo: 1 }, b: { bar: 2 } } }, actionA)).toEqual({ 99 | grids: { a: { foo: 2 }, b: { bar: 2 } }, 100 | }); 101 | }); 102 | 103 | it('filters unnamed entries based on namespace', () => { 104 | const reducer = combineReducerEntries([namespacedEntryA, namespacedEntryB]); 105 | 106 | expect(reducer({ grids: { a: { qux: 0 }, b: { qux: 0 } } }, actionB)).toEqual({ 107 | grids: { a: { qux: 0 }, b: { qux: 1 } }, 108 | }); 109 | }); 110 | 111 | it('handles mixed entries', () => { 112 | const reducer = combineReducerEntries([ 113 | fooEntry, 114 | barEntry, 115 | namespacedEntryA, 116 | namespacedEntryB, 117 | namespacedFooEntryA, 118 | namespacedBarEntryA, 119 | namespacedFooEntryB, 120 | namespacedBarEntryB, 121 | ]); 122 | 123 | expect( 124 | reducer( 125 | { 126 | foo: 0, 127 | bar: 1, 128 | grids: { 129 | a: { foo: 2, bar: 3, qux: 4 }, 130 | b: { foo: 5, bar: 6, qux: 7 }, 131 | }, 132 | }, 133 | globalAction 134 | ) 135 | ).toEqual({ 136 | foo: 1, 137 | bar: 2, 138 | grids: { 139 | a: { foo: 4, bar: 5, qux: 5 }, 140 | b: { foo: 7, bar: 8, qux: 8 }, 141 | }, 142 | }); 143 | }); 144 | }); 145 | --------------------------------------------------------------------------------