redux setup

This commit is contained in:
Sean Macfarlane
2020-03-11 11:28:29 -04:00
parent 0c81d47cb2
commit 63b392d73d
47 changed files with 1908 additions and 20 deletions

View File

@@ -13,6 +13,7 @@
"import/imports-first": ["error", "absolute-first"],
"import/newline-after-import": "error",
"import/prefer-default-export": 0,
"react/jsx-props-no-spreading": 0,
"semi": "error"
},
"globals": {
@@ -27,5 +28,13 @@
"Request": true,
"fetch": true
},
"parser": "babel-eslint"
"parser": "babel-eslint",
"settings": {
"import/resolver": {
"node": {
"extensions": [".js", ".jsx", ".ts", ".tsx"],
"moduleDirectory": ["node_modules", "app"]
}
}
}
}

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { Layout, Icon, Popover, Row } from 'antd';
import logoMobile from 'images/logoxmobile.jpg';
const { Header } = Layout;
const GlobalHeader = ({ collapsed, onMenuButtonClick, isMobile }) => {
const [popoverVisible, setPopoverVisible] = useState(false);
const hidePopover = () => {
setPopoverVisible(false);
};
const handleVisibleChange = visible => {
setPopoverVisible(visible);
};
const userOptions = (
<>
<Row>
<Link onClick={hidePopover} to="/accounts/customers/view">
Profile
</Link>
</Row>
<Row>
<Link onClick={hidePopover} to="/account">
Users
</Link>
</Row>
<Row>
<Link onClick={hidePopover} to="/accounts">
Advanced
</Link>
</Row>
<Row>
<Link onClick={hidePopover} to="/accounts/customersxw">
Rules Preference
</Link>
</Row>
</>
);
return (
<Header collapsed={collapsed} isMobile={isMobile}>
{isMobile && [
<Link to="/" key="mobileLogo">
<img src={logoMobile} alt="logo" width="32" />
</Link>,
]}
<Icon type={collapsed ? 'menu-unfold' : 'menu-fold'} onClick={onMenuButtonClick} />
<Popover
content={userOptions}
trigger="click"
getPopupContainer={e => e.parentElement}
visible={popoverVisible}
onVisibleChange={handleVisibleChange}
>
<Icon type="setting" />
</Popover>
</Header>
);
};
GlobalHeader.propTypes = {
collapsed: PropTypes.bool.isRequired,
onMenuButtonClick: PropTypes.func.isRequired,
isMobile: PropTypes.bool.isRequired,
};
export default GlobalHeader;

View File

@@ -0,0 +1,109 @@
.Sider {
height: 100vh;
left: 0;
position: fixed;
&.Mobile {
position: relative;
.Logo {
height: 32px;
}
}
.TopArea {
height: 64px;
}
.LogoContainer {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.Logo {
height: 44px;
}
.MenuIcon {
margin-right: 10px;
}
:global(.sidemenu) {
border: none;
margin-top: 14px;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
height: calc(100% - 78px);
width: auto;
.ant-menu-item {
display: flex;
min-height: 40px;
width: 100%;
align-items: center;
}
.ant-menu-submenu {
&.ant-menu-submenu-selected {
&.ant-menu-submenu-open {
.ant-menu-submenu-title {
color: rgba(0, 0, 0, 0.65);
background: transparent;
}
}
.ant-menu-submenu-title {
background: #e6f7ff;
color: #1890ff;
&::after {
content: '';
position: absolute;
right: 0;
top: 0;
bottom: 0;
border-right: 4px solid #1890ff;
transform: scaleY(0.0001);
opacity: 0;
transition: transform 0.15s cubic-bezier(0.215, 0.61, 0.355, 1),
opacity 0.15s cubic-bezier(0.215, 0.61, 0.355, 1);
}
}
}
.ant-menu-item {
padding-left: 24px !important;
}
}
&.ant-menu-inline {
.ant-menu-submenu {
&.ant-menu-submenu-selected {
&.ant-menu-submenu-open {
.ant-menu-submenu-title {
&::after {
transition: transform 0.15s cubic-bezier(0.645, 0.045, 0.355, 1),
opacity 0.15s cubic-bezier(0.645, 0.045, 0.355, 1);
opacity: 0;
transform: scaleY(0.0001);
}
}
}
.ant-menu-submenu-title {
&::after {
transition: transform 0.15s cubic-bezier(0.645, 0.045, 0.355, 1),
opacity 0.15s cubic-bezier(0.645, 0.045, 0.355, 1);
opacity: 1;
transform: scaleY(1);
}
}
}
}
}
}
}

View File

@@ -0,0 +1,264 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { Layout, Menu, Icon, Drawer } from 'antd';
import logo from 'images/logo-light.png';
import logoMobile from 'images/logoxmobile.jpg';
import styles from './Sider.module.scss';
const { Sider } = Layout;
const { SubMenu, Item } = Menu;
const ACCOUNTS = 'accounts';
const NETWORK = 'network';
const CONFIGURATION = 'configuration';
const INSIGHTS = 'insights';
const SYSTEM = 'system';
const HISTORY = 'history';
const rootSubmenuKeys = [ACCOUNTS, NETWORK, CONFIGURATION, INSIGHTS, SYSTEM, HISTORY];
const SideMenu = ({
locationState,
collapsed,
isMobile,
onMenuButtonClick,
onMenuItemClick,
onLogout,
}) => {
const [openKeys, setOpenKeys] = useState([]);
useEffect(() => {
setOpenKeys([]);
}, [collapsed]);
const enocMenuItems = [
{
key: 'dashboard',
icon: 'dashboard',
path: '/dashboard',
text: 'Dashboard',
onClick: onMenuItemClick,
},
{
key: 'profiles',
icon: 'profile',
path: '/profiles',
text: 'Profiles',
onClick: onMenuItemClick,
},
{
key: 'reports',
icon: 'area-chart',
path: '/analytics/qoe',
text: 'Insights',
onClick: onMenuItemClick,
},
{
key: 'client-devices',
icon: 'mobile',
path: '/network/client-devices',
text: 'Client Devices',
onClick: onMenuItemClick,
},
{
key: 'network-elements',
icon: 'api',
path: '/network/elements',
text: 'Network Elements',
onClick: onMenuItemClick,
},
{
key: 'alarms',
icon: 'notification',
path: '/network/alarms',
text: 'Alarms',
onClick: onMenuItemClick,
},
{
key: 'recommendations',
icon: 'check',
path: '/recommendations',
text: 'Recommendations',
onClick: onMenuItemClick,
},
{
key: 'settings',
icon: 'setting',
path: '/settings',
text: 'Settings',
onClick: onMenuItemClick,
},
{
key: ACCOUNTS,
icon: 'team',
text: 'Customers',
path: '/accounts/customers',
onClick: onMenuItemClick,
},
];
const commonMenuItems = [
{
key: 'logout',
icon: 'logout',
text: 'Sign Out',
onClick: onLogout,
},
];
const onOpenChange = keys => {
const latestOpenKey = keys.find(key => !openKeys.includes(key));
if (rootSubmenuKeys.indexOf(latestOpenKey) === -1) {
setOpenKeys(keys);
} else {
setOpenKeys(latestOpenKey ? [latestOpenKey] : []);
}
};
const getMenu = (config = { items: [] }, defaultSelectedKeys = []) => {
const items = [];
let keys = [];
let selectedKeys = [...defaultSelectedKeys];
config.items.forEach(item => {
if (item && item.key) {
if (item.children) {
const subMenu = getMenu({ items: item.children, parent: item });
if (subMenu.selectedKeys && subMenu.selectedKeys.length) {
selectedKeys = [...selectedKeys, ...subMenu.selectedKeys];
}
if (subMenu.openKeys && subMenu.openKeys.length) {
keys = [...keys, ...subMenu.openKeys];
}
items.push(
<SubMenu
key={item.key}
title={
<span>
<Icon type={item.icon} className={styles.MenuIcon} />
<span>{item.text}</span>
</span>
}
>
{subMenu.items}
</SubMenu>
);
} else {
const ItemComponent = item.Component || Item;
let LinkComponent = ({ ...restP }) => (
<Link
// preserveParams={this.getPreservedParams(item.path, locationState)}
{...restP}
/>
);
if (item.LinkComponent) {
LinkComponent = item.LinkComponent;
}
const path = locationState.pathname;
const pathAndHash = `${path}${locationState.hash}`; // for hash routing
if (path.startsWith(item.path) || pathAndHash.startsWith(item.path)) {
if (config.parent) {
keys.push(config.parent.key);
}
selectedKeys.push(item.key.toString());
}
items.push(
<ItemComponent key={item.key} className="ant-menu-item">
<LinkComponent onClick={item.onClick} to={item.path}>
<Icon type={item.icon} className={styles.MenuIcon} />
<span>{item.text}</span>
</LinkComponent>
</ItemComponent>
);
}
}
});
return {
items,
selectedKeys,
keys,
};
};
const menuConfig = {
items: [...enocMenuItems, ...commonMenuItems],
};
const menu = getMenu(menuConfig);
const sider = (
<Sider
collapsed={isMobile ? false : collapsed}
width="234px"
collapsedWidth="80px"
breakpoint="lg"
isMobile={isMobile}
className={`${styles.Sider} ${isMobile || collapsed ? styles.Mobile : ''}`}
>
<div className={styles.TopArea}>
<Link
className={styles.LogoContainer}
to="/"
// preserveParams={this.getPreservedParams('/', locationState)}
>
<img
className={styles.Logo}
alt="ConnectUs"
collapsed={collapsed}
isMobile={isMobile}
src={collapsed ? logoMobile : logo}
/>
</Link>
</div>
<Menu
className="sidemenu"
selectedKeys={menu.selectedKeys}
defaultOpenKeys={menu.openKeys}
onOpenChange={onOpenChange}
mode="inline"
inlineCollapsed={collapsed}
>
{menu.items}
</Menu>
</Sider>
);
if (isMobile) {
return (
<Drawer
getContainer={null}
level={null}
open={!collapsed}
handleChild={false}
onMaskClick={onMenuButtonClick}
>
{sider}
</Drawer>
);
}
return sider;
};
SideMenu.propTypes = {
locationState: PropTypes.instanceOf(Object).isRequired,
collapsed: PropTypes.bool.isRequired,
isMobile: PropTypes.bool.isRequired,
onMenuButtonClick: PropTypes.func.isRequired,
onMenuItemClick: PropTypes.func.isRequired,
onLogout: PropTypes.func.isRequired,
};
export default SideMenu;

View File

@@ -0,0 +1,39 @@
import { fromJS } from 'immutable';
export const AP_SETTING_BULK_UPDATE_REQUEST = fromJS({
_type: 'ApSettingBulkUpdateRequest',
equipmentIds: [],
settingChangeSet: {
is5GHz: [],
is2dot4GHz: [],
},
});
export const AP_SETTINGS = {
AUTO_EIRP_TX_POWER: 'AUTO_EIRP_TX_POWER',
AUTO_CELL_SIZE: 'AUTO_CELL_SIZE',
RADIO_MODE: 'RADIO_MODE',
LEGACY_BSS_RATE: 'LEGACY_BSS_RATE',
};
export const AP_SETTING_CHANGE = fromJS({
_type: 'ApSettingChange',
apSetting: null, // eg. AUTO_EIRP_TX_POWER or AUTO_CELL_SIZE
value: {
_type: 'AutoOrManualValue',
// auto: null,
// value: null,
},
});
export const AP_SETTING_CHANGE_STATE = fromJS({
_type: 'ApSettingChange',
apSetting: null, // eg. LEGACY_BSS_RATE
state: null,
});
export const AP_SETTING_CHANGE_RADIO_MODE = fromJS({
_type: 'ApSettingChange',
apSetting: 'RADIO_MODE',
radioMode: null,
});

View File

@@ -0,0 +1,6 @@
export const X5M = 'table_x5m';
export const X15M = 'table_x15m';
export const X30M = 'table_x30m';
export const X1H = 'table_x1h';
export const X4H = 'table_x4h';
export const X24H = 'table_x24h';

17
app/constants/app.js Normal file
View File

@@ -0,0 +1,17 @@
module.exports.MSP_PORTAL_TYPE = 'msp';
module.exports.TS_PORTAL_TYPE = 'ts';
module.exports.ENOC_PORTAL_TYPE = 'enoc';
module.exports.NAAS_PORTAL_TYPE = 'naas';
module.exports.TS_ENOC_PORTAL_TYPE = 'ts-enoc';
module.exports.MSP_ENOC_PORTAL_TYPE = 'msp-enoc';
module.exports.TS_NAAS_PORTAL_TYPE = 'ts-naas';
module.exports.MSP_NAAS_PORTAL_TYPE = 'msp-naas';
module.exports.TS_ENOC_BUILD_OUTPUT_PATH = 'ts-enoc-build';
module.exports.MSP_ENOC_BUILD_OUTPUT_PATH = 'msp-enoc-build';
module.exports.TS_NAAS_BUILD_OUTPUT_PATH = 'ts-naas-build';
module.exports.MSP_NAAS_BUILD_OUTPUT_PATH = 'msp-naas-build';

1
app/constants/company.js Normal file
View File

@@ -0,0 +1 @@
export const NAME = 'KodaCloud';

24
app/constants/devices.js Normal file
View File

@@ -0,0 +1,24 @@
export const COLUMNS_KEYS = {
CATEGORY: 'category',
MAC: 'mac',
DEVICE_NAME: 'name',
MODEL: 'model',
USER: 'user',
IP: 'ip',
VLAN: 'vlan',
HOST_NAME: 'hostname',
ACCESS_POINT: 'accessPoint',
SSID: 'ssid',
BAND: 'band',
SIGNAL: 'signal',
STATUS: 'statusText',
DEVICE_CLASSIFICATION: 'deviceClassification',
QOE_TEMPLATE: 'qoeTemplate',
BLACKLISTING_EXPIRY: 'blacklistingExpiry',
ACTIONS: 'actions',
};
export const STATUS_CONNECTED = 'connected';
export const STATUS_BLACKLISTED = 'blacklisted';
export const STATUS_CONNECTING = 'connecting';
export const STATUS_DISCONNECTED = 'disconnected';

View File

@@ -0,0 +1,2 @@
export const TYPE_CNA = 'CUSTOMER_NETWORK_AGENT';
export const TYPE_AP = 'AP';

12
app/constants/index.js Normal file
View File

@@ -0,0 +1,12 @@
export DEVICES from './devices';
export RECOMMENDATIONS from './recommendations';
export const DEFAULT_VALUE = '_KC_DEFAULT_KC_';
export const DEFAULT_TEMPLATE = '_KC_DEFAULT_KC_';
export const QOE_GOOD = 'Good';
export const QOE_AVERAGE = 'Average';
export const QOE_BAD = 'Poor';
export const QOE_IDLE = 'Idle';
export const CANCEL_ERROR = 'CANCEL_ERROR';

View File

@@ -0,0 +1,17 @@
export const CREATED_AT = 'Created At';
export const TYPE = 'Type';
export const MESSAGE = 'Message';
export const AP = 'AP';
export const RADIO = 'Radio';
export const TEMPLATE = 'Template';
export const LOCATION = 'Location';
export const SORT_OPTIONS = [
CREATED_AT,
TYPE,
MESSAGE,
AP,
RADIO,
TEMPLATE,
LOCATION,
];

View File

@@ -0,0 +1,7 @@
export const ONE_SECOND = 1000;
export const ONE_MINUTE = 60 * ONE_SECOND;
export const ONE_HOUR = 60 * ONE_MINUTE;
export const ONE_DAY = 24 * ONE_HOUR;
export const ONE_WEEK = 7 * ONE_DAY;
export const ONE_MONTH = 4.34524 * ONE_WEEK;
export const ONE_YEAR = 12 * ONE_MONTH;

View File

@@ -0,0 +1,22 @@
import React from 'react';
import T from 'prop-types';
import { Route } from 'react-router-dom';
import MasterLayout from 'containers/MasterLayout';
const RouteWithLayout = ({ component: Component, ...rest }) => (
<Route
{...rest}
render={props => (
<MasterLayout>
<Component {...props} />
</MasterLayout>
)}
/>
);
RouteWithLayout.propTypes = {
component: T.func.isRequired,
};
export default RouteWithLayout;

View File

@@ -1,8 +1,32 @@
import React from 'react';
import { compose } from 'redux';
import { Helmet } from 'react-helmet';
import { Switch } from 'react-router-dom';
import "../../styles/antd.less";
import "../../styles/index.scss";
import 'styles/antd.less';
import 'styles/index.scss';
const App = () => <h1>ConnectUs</h1>;
import injectReducer from 'utils/injectReducer';
import injectSaga from 'utils/injectSaga';
export default App;
import reducer from './reducer';
import saga from './saga';
import RouteWithLayout from './components/RouteWithLayout';
const App = () => (
<>
<Helmet titleTemplate="%s - ConnectUs" defaultTitle="ConnectUs">
<meta name="description" content="ConnectUs" />
</Helmet>
<Switch>
<RouteWithLayout exact path="/" component={Home} />
</Switch>
</>
);
const withReducer = injectReducer({ key: 'global', reducer });
const withSaga = injectSaga({ key: 'global', saga });
export default compose(withReducer, withSaga)(App);

View File

@@ -0,0 +1,17 @@
import { fromJS } from 'immutable';
// The initial state of the App
const initialState = fromJS({});
// const mergedState = initialState.merge(getItem(FILTERS_LS_KEY));
function appReducer(currentState = initialState, action) {
const state = currentState.setIn(['error'], false);
switch (action.type) {
default:
return state;
}
}
export default appReducer;

View File

View File

View File

@@ -0,0 +1,15 @@
.MainLayout {
height: 100vh;
margin-left: 234px;
&.mobile {
margin-left: 0;
}
&.collapsed {
margin-left: 80px;
}
}
.Footer {
text-align: center;
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import T from 'prop-types';
import IT from 'react-immutable-proptypes';
import { Layout as AntdLayout } from 'antd';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { createStructuredSelector } from 'reselect';
import GlobalHeader from 'components/GlobalHeader';
import SideMenu from 'components/SideMenu';
import { makeSelectLocation, makeSelectMenu, makeSelectError } from 'containers/App/selectors';
import styles from './MasterLayout.module.scss';
const { Content, Footer } = AntdLayout;
const MasterLayout = ({ children, locationState, menu }) => {
const collapsed = menu.get('collapsed');
const isMobile = menu.get('isMobile');
const currentYear = new Date().getFullYear();
return (
<AntdLayout>
<SideMenu
locationState={locationState}
collapsed={collapsed}
isMobile={isMobile}
onMenuButtonClick={handleMenuToggle}
onMenuItemClick={handleMenuItemClick}
onLogout={handleLogout}
/>
<AntdLayout collapsed={collapsed} isMobile={isMobile}>
<GlobalHeader
collapsed={collapsed}
onMenuButtonClick={handleMenuToggle}
isMobile={isMobile}
/>
<Content>{children}</Content>
<Footer className={styles.Footer}>
Copyright © {currentYear} ConnectUs Inc. All Rights Reserved.
</Footer>
</AntdLayout>
</AntdLayout>
);
};
MasterLayout.propTypes = {
children: T.node.isRequired,
locationState: T.instanceOf(Object).isRequired,
menu: IT.map.isRequired,
};
export const mapStateToProps = createStructuredSelector({
locationState: makeSelectLocation(),
menu: makeSelectMenu(),
globalError: makeSelectError(),
});
export function mapDispatchToProps(dispatch) {
return {
logoutUser: evt => {
if (evt !== undefined && evt.preventDefault) evt.preventDefault();
dispatch(logoutUserAction());
},
setMenu: (...params) => {
dispatch(portalActions.setMenu(...params));
},
};
}
const withConnect = connect(mapStateToProps, mapDispatchToProps);
export default compose(withConnect)(MasterLayout);

BIN
app/images/logo-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
app/images/logox40.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

BIN
app/images/logoxmobile.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

View File

@@ -9,7 +9,6 @@
<!-- Open Sans Font -->
<link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet" />
<title>ConnectUs</title>
</head>
<body>

View File

@@ -3,9 +3,9 @@ import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { ConnectedRouter } from 'react-router-redux';
import store, { history } from './store';
import App from './containers/App';
import App from 'containers/App';
import store from 'store';
import history from 'utils/history';
const renderApp = () =>
render(
@@ -20,3 +20,5 @@ const renderApp = () =>
if (process.env.NODE_ENV !== 'production' && module.hot) {
module.hot.accept('containers/App', renderApp);
}
renderApp();

View File

@@ -3,14 +3,12 @@
*/
import { createStore, applyMiddleware, compose } from 'redux';
import { createBrowserHistory } from 'history';
import { fromJS } from 'immutable';
import { routerMiddleware } from 'react-router-redux';
import createSagaMiddleware from 'redux-saga';
import createReducer from './reducers';
export const history = createBrowserHistory();
import history from 'utils/history';
import createReducer from 'reducers';
const initialState = {};
@@ -29,7 +27,6 @@ if (process.env.NODE_ENV === 'development' && isReduxLogger) {
middlewares.push(
createLogger({
collapsed: true,
predicate: (getState, action) => getState && action,
})
);

43
app/tests/store.test.js Executable file
View File

@@ -0,0 +1,43 @@
/**
* Test store addons
*/
import { browserHistory } from 'react-router-dom';
import configureStore from '../configureStore';
describe('configureStore', () => {
let store;
beforeAll(() => {
store = configureStore({}, browserHistory);
});
describe('injectedReducers', () => {
it('should contain an object for reducers', () => {
expect(typeof store.injectedReducers).toBe('object');
});
});
describe('injectedSagas', () => {
it('should contain an object for sagas', () => {
expect(typeof store.injectedSagas).toBe('object');
});
});
describe('runSaga', () => {
it('should contain a hook for `sagaMiddleware.run`', () => {
expect(typeof store.runSaga).toBe('function');
});
});
});
describe('configureStore params', () => {
it('should call window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__', () => {
/* eslint-disable no-underscore-dangle */
const compose = jest.fn();
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ = () => compose;
configureStore(undefined, browserHistory);
expect(compose).toHaveBeenCalled();
/* eslint-enable */
});
});

21
app/utils/checkStore.js Executable file
View File

@@ -0,0 +1,21 @@
import { conformsTo, isFunction, isObject } from 'lodash';
import invariant from 'invariant';
/**
* Validate the shape of redux store
*/
export default function checkStore(store) {
const shape = {
dispatch: isFunction,
subscribe: isFunction,
getState: isFunction,
replaceReducer: isFunction,
runSaga: isFunction,
injectedReducers: isObject,
injectedSagas: isObject,
};
invariant(
conformsTo(store, shape),
'(app/utils...) injectors: Expected a valid redux store',
);
}

3
app/utils/constants.js Executable file
View File

@@ -0,0 +1,3 @@
export const RESTART_ON_REMOUNT = '@@saga-injector/restart-on-remount';
export const DAEMON = '@@saga-injector/daemon';
export const ONCE_TILL_UNMOUNT = '@@saga-injector/once-till-unmount';

4
app/utils/history.js Executable file
View File

@@ -0,0 +1,4 @@
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();
export default history;

45
app/utils/injectReducer.js Executable file
View File

@@ -0,0 +1,45 @@
import React from 'react';
import hoistNonReactStatics from 'hoist-non-react-statics';
import { ReactReduxContext } from 'react-redux';
import getInjectors from './reducerInjectors';
/**
* Dynamically injects a reducer
*
* @param {string} key A key of the reducer
* @param {function} reducer A reducer that will be injected
*
*/
export default ({ key, reducer }) => WrappedComponent => {
class ReducerInjector extends React.Component {
static WrappedComponent = WrappedComponent;
static contextType = ReactReduxContext;
static displayName = `withReducer(${WrappedComponent.displayName ||
WrappedComponent.name ||
'Component'})`;
constructor(props, context) {
super(props, context);
getInjectors(context.store).injectReducer(key, reducer);
}
render() {
return <WrappedComponent {...this.props} />;
}
}
return hoistNonReactStatics(ReducerInjector, WrappedComponent);
};
const useInjectReducer = ({ key, reducer }) => {
const context = React.useContext(ReactReduxContext);
React.useEffect(() => {
getInjectors(context.store).injectReducer(key, reducer);
}, []);
};
export { useInjectReducer };

61
app/utils/injectSaga.js Executable file
View File

@@ -0,0 +1,61 @@
import React from 'react';
import hoistNonReactStatics from 'hoist-non-react-statics';
import { ReactReduxContext } from 'react-redux';
import getInjectors from './sagaInjectors';
/**
* Dynamically injects a saga, passes component's props as saga arguments
*
* @param {string} key A key of the saga
* @param {function} saga A root saga that will be injected
* @param {string} [mode] By default (constants.DAEMON) the saga will be started
* on component mount and never canceled or started again. Another two options:
* - constants.RESTART_ON_REMOUNT — the saga will be started on component mount and
* cancelled with `task.cancel()` on component unmount for improved performance,
* - constants.ONCE_TILL_UNMOUNT — behaves like 'RESTART_ON_REMOUNT' but never runs it again.
*
*/
export default ({ key, saga, mode }) => WrappedComponent => {
class InjectSaga extends React.Component {
static WrappedComponent = WrappedComponent;
static contextType = ReactReduxContext;
static displayName = `withSaga(${WrappedComponent.displayName ||
WrappedComponent.name ||
'Component'})`;
constructor(props, context) {
super(props, context);
this.injectors = getInjectors(context.store);
this.injectors.injectSaga(key, { saga, mode }, this.props);
}
componentWillUnmount() {
this.injectors.ejectSaga(key);
}
render() {
return <WrappedComponent {...this.props} />;
}
}
return hoistNonReactStatics(InjectSaga, WrappedComponent);
};
const useInjectSaga = ({ key, saga, mode }) => {
const context = React.useContext(ReactReduxContext);
React.useEffect(() => {
const injectors = getInjectors(context.store);
injectors.injectSaga(key, { saga, mode });
return () => {
injectors.ejectSaga(key);
};
}, []);
};
export { useInjectSaga };

13
app/utils/loadable.js Executable file
View File

@@ -0,0 +1,13 @@
import React, { lazy, Suspense } from 'react';
const loadable = (importFunc, { fallback = null } = { fallback: null }) => {
const LazyComponent = lazy(importFunc);
return props => (
<Suspense fallback={fallback}>
<LazyComponent {...props} />
</Suspense>
);
};
export default loadable;

34
app/utils/reducerInjectors.js Executable file
View File

@@ -0,0 +1,34 @@
import invariant from 'invariant';
import { isEmpty, isFunction, isString } from 'lodash';
import checkStore from './checkStore';
import createReducer from '../reducers';
export function injectReducerFactory(store, isValid) {
return function injectReducer(key, reducer) {
if (!isValid) checkStore(store);
invariant(
isString(key) && !isEmpty(key) && isFunction(reducer),
'(app/utils...) injectReducer: Expected `reducer` to be a reducer function',
);
// Check `store.injectedReducers[key] === reducer` for hot reloading when a key is the same but a reducer is different
if (
Reflect.has(store.injectedReducers, key) &&
store.injectedReducers[key] === reducer
)
return;
store.injectedReducers[key] = reducer; // eslint-disable-line no-param-reassign
store.replaceReducer(createReducer(store.injectedReducers));
};
}
export default function getInjectors(store) {
checkStore(store);
return {
injectReducer: injectReducerFactory(store, true),
};
}

44
app/utils/request.js Executable file
View File

@@ -0,0 +1,44 @@
/**
* Parses the JSON returned by a network request
*
* @param {object} response A response from a network request
*
* @return {object} The parsed JSON from the request
*/
function parseJSON(response) {
if (response.status === 204 || response.status === 205) {
return null;
}
return response.json();
}
/**
* Checks if a network request came back fine, and throws an error if not
*
* @param {object} response A response from a network request
*
* @return {object|undefined} Returns either the response, or throws an error
*/
function checkStatus(response) {
if (response.status >= 200 && response.status < 300) {
return response;
}
const error = new Error(response.statusText);
error.response = response;
throw error;
}
/**
* Requests a URL, returning a promise
*
* @param {string} url The URL we want to request
* @param {object} [options] The options we want to pass to "fetch"
*
* @return {object} The response data
*/
export default function request(url, options) {
return fetch(url, options)
.then(checkStatus)
.then(parseJSON);
}

91
app/utils/sagaInjectors.js Executable file
View File

@@ -0,0 +1,91 @@
import invariant from 'invariant';
import { isEmpty, isFunction, isString, conformsTo } from 'lodash';
import checkStore from './checkStore';
import { DAEMON, ONCE_TILL_UNMOUNT, RESTART_ON_REMOUNT } from './constants';
const allowedModes = [RESTART_ON_REMOUNT, DAEMON, ONCE_TILL_UNMOUNT];
const checkKey = key =>
invariant(
isString(key) && !isEmpty(key),
'(app/utils...) injectSaga: Expected `key` to be a non empty string',
);
const checkDescriptor = descriptor => {
const shape = {
saga: isFunction,
mode: mode => isString(mode) && allowedModes.includes(mode),
};
invariant(
conformsTo(descriptor, shape),
'(app/utils...) injectSaga: Expected a valid saga descriptor',
);
};
export function injectSagaFactory(store, isValid) {
return function injectSaga(key, descriptor = {}, args) {
if (!isValid) checkStore(store);
const newDescriptor = {
...descriptor,
mode: descriptor.mode || DAEMON,
};
const { saga, mode } = newDescriptor;
checkKey(key);
checkDescriptor(newDescriptor);
let hasSaga = Reflect.has(store.injectedSagas, key);
if (process.env.NODE_ENV !== 'production') {
const oldDescriptor = store.injectedSagas[key];
// enable hot reloading of daemon and once-till-unmount sagas
if (hasSaga && oldDescriptor.saga !== saga) {
oldDescriptor.task.cancel();
hasSaga = false;
}
}
if (
!hasSaga ||
(hasSaga && mode !== DAEMON && mode !== ONCE_TILL_UNMOUNT)
) {
/* eslint-disable no-param-reassign */
store.injectedSagas[key] = {
...newDescriptor,
task: store.runSaga(saga, args),
};
/* eslint-enable no-param-reassign */
}
};
}
export function ejectSagaFactory(store, isValid) {
return function ejectSaga(key) {
if (!isValid) checkStore(store);
checkKey(key);
if (Reflect.has(store.injectedSagas, key)) {
const descriptor = store.injectedSagas[key];
if (descriptor.mode && descriptor.mode !== DAEMON) {
descriptor.task.cancel();
// Clean up in production; in development we need `descriptor.saga` for hot reloading
if (process.env.NODE_ENV === 'production') {
// Need some value to be able to detect `ONCE_TILL_UNMOUNT` sagas in `injectSaga`
store.injectedSagas[key] = 'done'; // eslint-disable-line no-param-reassign
}
}
}
};
}
export default function getInjectors(store) {
checkStore(store);
return {
injectSaga: injectSagaFactory(store, true),
ejectSaga: ejectSagaFactory(store, true),
};
}

View File

@@ -0,0 +1,33 @@
/**
* Test injectors
*/
import checkStore from '../checkStore';
describe('checkStore', () => {
let store;
beforeEach(() => {
store = {
dispatch: () => {},
subscribe: () => {},
getState: () => {},
replaceReducer: () => {},
runSaga: () => {},
injectedReducers: {},
injectedSagas: {},
};
});
it('should not throw if passed valid store shape', () => {
expect(() => checkStore(store)).not.toThrow();
});
it('should throw if passed invalid store shape', () => {
expect(() => checkStore({})).toThrow();
expect(() => checkStore({ ...store, injectedSagas: null })).toThrow();
expect(() => checkStore({ ...store, injectedReducers: null })).toThrow();
expect(() => checkStore({ ...store, runSaga: null })).toThrow();
expect(() => checkStore({ ...store, replaceReducer: null })).toThrow();
});
});

View File

@@ -0,0 +1,98 @@
/**
* Test injectors
*/
import { memoryHistory } from 'react-router-dom';
import React from 'react';
import { Provider } from 'react-redux';
import renderer from 'react-test-renderer';
import { render } from 'react-testing-library';
import configureStore from '../../configureStore';
import injectReducer, { useInjectReducer } from '../injectReducer';
import * as reducerInjectors from '../reducerInjectors';
// Fixtures
const Component = () => null;
const reducer = s => s;
describe('injectReducer decorator', () => {
let store;
let injectors;
let ComponentWithReducer;
beforeAll(() => {
reducerInjectors.default = jest.fn().mockImplementation(() => injectors);
});
beforeEach(() => {
store = configureStore({}, memoryHistory);
injectors = {
injectReducer: jest.fn(),
};
ComponentWithReducer = injectReducer({ key: 'test', reducer })(Component);
reducerInjectors.default.mockClear();
});
it('should inject a given reducer', () => {
renderer.create(
<Provider store={store}>
<ComponentWithReducer />
</Provider>,
);
expect(injectors.injectReducer).toHaveBeenCalledTimes(1);
expect(injectors.injectReducer).toHaveBeenCalledWith('test', reducer);
});
it('should set a correct display name', () => {
expect(ComponentWithReducer.displayName).toBe('withReducer(Component)');
expect(
injectReducer({ key: 'test', reducer })(() => null).displayName,
).toBe('withReducer(Component)');
});
it('should propagate props', () => {
const props = { testProp: 'test' };
const renderedComponent = renderer.create(
<Provider store={store}>
<ComponentWithReducer {...props} />
</Provider>,
);
const {
props: { children },
} = renderedComponent.getInstance();
expect(children.props).toEqual(props);
});
});
describe('useInjectReducer hook', () => {
let store;
let injectors;
let ComponentWithReducer;
beforeAll(() => {
injectors = {
injectReducer: jest.fn(),
};
reducerInjectors.default = jest.fn().mockImplementation(() => injectors);
store = configureStore({}, memoryHistory);
ComponentWithReducer = () => {
useInjectReducer({ key: 'test', reducer });
return null;
};
});
it('should inject a given reducer', () => {
render(
<Provider store={store}>
<ComponentWithReducer />
</Provider>,
);
expect(injectors.injectReducer).toHaveBeenCalledTimes(1);
expect(injectors.injectReducer).toHaveBeenCalledWith('test', reducer);
});
});

View File

@@ -0,0 +1,149 @@
/**
* Test injectors
*/
import { memoryHistory } from 'react-router-dom';
import { put } from 'redux-saga/effects';
import renderer from 'react-test-renderer';
import { render } from 'react-testing-library';
import React from 'react';
import { Provider } from 'react-redux';
import configureStore from '../../configureStore';
import injectSaga, { useInjectSaga } from '../injectSaga';
import * as sagaInjectors from '../sagaInjectors';
// Fixtures
const Component = () => null;
function* testSaga() {
yield put({ type: 'TEST', payload: 'yup' });
}
describe('injectSaga decorator', () => {
let store;
let injectors;
let ComponentWithSaga;
beforeAll(() => {
sagaInjectors.default = jest.fn().mockImplementation(() => injectors);
});
beforeEach(() => {
store = configureStore({}, memoryHistory);
injectors = {
injectSaga: jest.fn(),
ejectSaga: jest.fn(),
};
ComponentWithSaga = injectSaga({
key: 'test',
saga: testSaga,
mode: 'testMode',
})(Component);
sagaInjectors.default.mockClear();
});
it('should inject given saga, mode, and props', () => {
const props = { test: 'test' };
renderer.create(
<Provider store={store}>
<ComponentWithSaga {...props} />
</Provider>,
);
expect(injectors.injectSaga).toHaveBeenCalledTimes(1);
expect(injectors.injectSaga).toHaveBeenCalledWith(
'test',
{ saga: testSaga, mode: 'testMode' },
props,
);
});
it('should eject on unmount with a correct saga key', () => {
const props = { test: 'test' };
const renderedComponent = renderer.create(
<Provider store={store}>
<ComponentWithSaga {...props} />
</Provider>,
);
renderedComponent.unmount();
expect(injectors.ejectSaga).toHaveBeenCalledTimes(1);
expect(injectors.ejectSaga).toHaveBeenCalledWith('test');
});
it('should set a correct display name', () => {
expect(ComponentWithSaga.displayName).toBe('withSaga(Component)');
expect(
injectSaga({ key: 'test', saga: testSaga })(() => null).displayName,
).toBe('withSaga(Component)');
});
it('should propagate props', () => {
const props = { testProp: 'test' };
const renderedComponent = renderer.create(
<Provider store={store}>
<ComponentWithSaga {...props} />
</Provider>,
);
const {
props: { children },
} = renderedComponent.getInstance();
expect(children.props).toEqual(props);
});
});
describe('useInjectSaga hook', () => {
let store;
let injectors;
let ComponentWithSaga;
beforeAll(() => {
sagaInjectors.default = jest.fn().mockImplementation(() => injectors);
});
beforeEach(() => {
store = configureStore({}, memoryHistory);
injectors = {
injectSaga: jest.fn(),
ejectSaga: jest.fn(),
};
ComponentWithSaga = () => {
useInjectSaga({
key: 'test',
saga: testSaga,
mode: 'testMode',
});
return null;
};
sagaInjectors.default.mockClear();
});
it('should inject given saga and mode', () => {
const props = { test: 'test' };
render(
<Provider store={store}>
<ComponentWithSaga {...props} />
</Provider>,
);
expect(injectors.injectSaga).toHaveBeenCalledTimes(1);
expect(injectors.injectSaga).toHaveBeenCalledWith('test', {
saga: testSaga,
mode: 'testMode',
});
});
it('should eject on unmount with a correct saga key', () => {
const props = { test: 'test' };
const { unmount } = render(
<Provider store={store}>
<ComponentWithSaga {...props} />
</Provider>,
);
unmount();
expect(injectors.ejectSaga).toHaveBeenCalledTimes(1);
expect(injectors.ejectSaga).toHaveBeenCalledWith('test');
});
});

View File

@@ -0,0 +1,100 @@
/**
* Test injectors
*/
import produce from 'immer';
import { memoryHistory } from 'react-router-dom';
import identity from 'lodash/identity';
import configureStore from '../../configureStore';
import getInjectors, { injectReducerFactory } from '../reducerInjectors';
// Fixtures
const initialState = { reduced: 'soon' };
/* eslint-disable default-case, no-param-reassign */
const reducer = (state = initialState, action) =>
produce(state, draft => {
switch (action.type) {
case 'TEST':
draft.reduced = action.payload;
break;
}
});
describe('reducer injectors', () => {
let store;
let injectReducer;
describe('getInjectors', () => {
beforeEach(() => {
store = configureStore({}, memoryHistory);
});
it('should return injectors', () => {
expect(getInjectors(store)).toEqual(
expect.objectContaining({
injectReducer: expect.any(Function),
}),
);
});
it('should throw if passed invalid store shape', () => {
Reflect.deleteProperty(store, 'dispatch');
expect(() => getInjectors(store)).toThrow();
});
});
describe('injectReducer helper', () => {
beforeEach(() => {
store = configureStore({}, memoryHistory);
injectReducer = injectReducerFactory(store, true);
});
it('should check a store if the second argument is falsy', () => {
const inject = injectReducerFactory({});
expect(() => inject('test', reducer)).toThrow();
});
it('it should not check a store if the second argument is true', () => {
Reflect.deleteProperty(store, 'dispatch');
expect(() => injectReducer('test', reducer)).not.toThrow();
});
it("should validate a reducer and reducer's key", () => {
expect(() => injectReducer('', reducer)).toThrow();
expect(() => injectReducer(1, reducer)).toThrow();
expect(() => injectReducer(1, 1)).toThrow();
});
it('given a store, it should provide a function to inject a reducer', () => {
injectReducer('test', reducer);
const actual = store.getState().test;
const expected = initialState;
expect(actual).toEqual(expected);
});
it('should not assign reducer if already existing', () => {
store.replaceReducer = jest.fn();
injectReducer('test', reducer);
injectReducer('test', reducer);
expect(store.replaceReducer).toHaveBeenCalledTimes(1);
});
it('should assign reducer if different implementation for hot reloading', () => {
store.replaceReducer = jest.fn();
injectReducer('test', reducer);
injectReducer('test', identity);
expect(store.replaceReducer).toHaveBeenCalledTimes(2);
});
});
});

80
app/utils/tests/request.test.js Executable file
View File

@@ -0,0 +1,80 @@
/**
* Test the request function
*/
import 'whatwg-fetch';
import request from '../request';
describe('request', () => {
// Before each test, stub the fetch function
beforeEach(() => {
window.fetch = jest.fn();
});
describe('stubbing successful response', () => {
// Before each test, pretend we got a successful response
beforeEach(() => {
const res = new Response('{"hello":"world"}', {
status: 200,
headers: {
'Content-type': 'application/json',
},
});
window.fetch.mockReturnValue(Promise.resolve(res));
});
it('should format the response correctly', done => {
request('/thisurliscorrect')
.catch(done)
.then(json => {
expect(json.hello).toBe('world');
done();
});
});
});
describe('stubbing 204 response', () => {
// Before each test, pretend we got a successful response
beforeEach(() => {
const res = new Response('', {
status: 204,
statusText: 'No Content',
});
window.fetch.mockReturnValue(Promise.resolve(res));
});
it('should return null on 204 response', done => {
request('/thisurliscorrect')
.catch(done)
.then(json => {
expect(json).toBeNull();
done();
});
});
});
describe('stubbing error response', () => {
// Before each test, pretend we got an unsuccessful response
beforeEach(() => {
const res = new Response('', {
status: 404,
statusText: 'Not Found',
headers: {
'Content-type': 'application/json',
},
});
window.fetch.mockReturnValue(Promise.resolve(res));
});
it('should catch errors', done => {
request('/thisdoesntexist').catch(err => {
expect(err.response.status).toBe(404);
expect(err.response.statusText).toBe('Not Found');
done();
});
});
});
});

View File

@@ -0,0 +1,231 @@
/**
* Test injectors
*/
import { memoryHistory } from 'react-router-dom';
import { put } from 'redux-saga/effects';
import configureStore from '../../configureStore';
import getInjectors, {
injectSagaFactory,
ejectSagaFactory,
} from '../sagaInjectors';
import { DAEMON, ONCE_TILL_UNMOUNT, RESTART_ON_REMOUNT } from '../constants';
function* testSaga() {
yield put({ type: 'TEST', payload: 'yup' });
}
describe('injectors', () => {
const originalNodeEnv = process.env.NODE_ENV;
let store;
let injectSaga;
let ejectSaga;
describe('getInjectors', () => {
beforeEach(() => {
store = configureStore({}, memoryHistory);
});
it('should return injectors', () => {
expect(getInjectors(store)).toEqual(
expect.objectContaining({
injectSaga: expect.any(Function),
ejectSaga: expect.any(Function),
}),
);
});
it('should throw if passed invalid store shape', () => {
Reflect.deleteProperty(store, 'dispatch');
expect(() => getInjectors(store)).toThrow();
});
});
describe('ejectSaga helper', () => {
beforeEach(() => {
store = configureStore({}, memoryHistory);
injectSaga = injectSagaFactory(store, true);
ejectSaga = ejectSagaFactory(store, true);
});
it('should check a store if the second argument is falsy', () => {
const eject = ejectSagaFactory({});
expect(() => eject('test')).toThrow();
});
it('should not check a store if the second argument is true', () => {
Reflect.deleteProperty(store, 'dispatch');
injectSaga('test', { saga: testSaga });
expect(() => ejectSaga('test')).not.toThrow();
});
it("should validate saga's key", () => {
expect(() => ejectSaga('')).toThrow();
expect(() => ejectSaga(1)).toThrow();
});
it('should cancel a saga in RESTART_ON_REMOUNT mode', () => {
const cancel = jest.fn();
store.injectedSagas.test = { task: { cancel }, mode: RESTART_ON_REMOUNT };
ejectSaga('test');
expect(cancel).toHaveBeenCalled();
});
it('should not cancel a daemon saga', () => {
const cancel = jest.fn();
store.injectedSagas.test = { task: { cancel }, mode: DAEMON };
ejectSaga('test');
expect(cancel).not.toHaveBeenCalled();
});
it('should ignore saga that was not previously injected', () => {
expect(() => ejectSaga('test')).not.toThrow();
});
it("should remove non daemon saga's descriptor in production", () => {
process.env.NODE_ENV = 'production';
injectSaga('test', { saga: testSaga, mode: RESTART_ON_REMOUNT });
injectSaga('test1', { saga: testSaga, mode: ONCE_TILL_UNMOUNT });
ejectSaga('test');
ejectSaga('test1');
expect(store.injectedSagas.test).toBe('done');
expect(store.injectedSagas.test1).toBe('done');
process.env.NODE_ENV = originalNodeEnv;
});
it("should not remove daemon saga's descriptor in production", () => {
process.env.NODE_ENV = 'production';
injectSaga('test', { saga: testSaga, mode: DAEMON });
ejectSaga('test');
expect(store.injectedSagas.test.saga).toBe(testSaga);
process.env.NODE_ENV = originalNodeEnv;
});
it("should not remove daemon saga's descriptor in development", () => {
injectSaga('test', { saga: testSaga, mode: DAEMON });
ejectSaga('test');
expect(store.injectedSagas.test.saga).toBe(testSaga);
});
});
describe('injectSaga helper', () => {
beforeEach(() => {
store = configureStore({}, memoryHistory);
injectSaga = injectSagaFactory(store, true);
ejectSaga = ejectSagaFactory(store, true);
});
it('should check a store if the second argument is falsy', () => {
const inject = injectSagaFactory({});
expect(() => inject('test', testSaga)).toThrow();
});
it('it should not check a store if the second argument is true', () => {
Reflect.deleteProperty(store, 'dispatch');
expect(() => injectSaga('test', { saga: testSaga })).not.toThrow();
});
it("should validate saga's key", () => {
expect(() => injectSaga('', { saga: testSaga })).toThrow();
expect(() => injectSaga(1, { saga: testSaga })).toThrow();
});
it("should validate saga's descriptor", () => {
expect(() => injectSaga('test')).toThrow();
expect(() => injectSaga('test', { saga: 1 })).toThrow();
expect(() =>
injectSaga('test', { saga: testSaga, mode: 'testMode' }),
).toThrow();
expect(() => injectSaga('test', { saga: testSaga, mode: 1 })).toThrow();
expect(() =>
injectSaga('test', { saga: testSaga, mode: RESTART_ON_REMOUNT }),
).not.toThrow();
expect(() =>
injectSaga('test', { saga: testSaga, mode: DAEMON }),
).not.toThrow();
expect(() =>
injectSaga('test', { saga: testSaga, mode: ONCE_TILL_UNMOUNT }),
).not.toThrow();
});
it('should pass args to saga.run', () => {
const args = {};
store.runSaga = jest.fn();
injectSaga('test', { saga: testSaga }, args);
expect(store.runSaga).toHaveBeenCalledWith(testSaga, args);
});
it('should not start daemon and once-till-unmount sagas if were started before', () => {
store.runSaga = jest.fn();
injectSaga('test1', { saga: testSaga, mode: DAEMON });
injectSaga('test1', { saga: testSaga, mode: DAEMON });
injectSaga('test2', { saga: testSaga, mode: ONCE_TILL_UNMOUNT });
injectSaga('test2', { saga: testSaga, mode: ONCE_TILL_UNMOUNT });
expect(store.runSaga).toHaveBeenCalledTimes(2);
});
it('should start any saga that was not started before', () => {
store.runSaga = jest.fn();
injectSaga('test1', { saga: testSaga });
injectSaga('test2', { saga: testSaga, mode: DAEMON });
injectSaga('test3', { saga: testSaga, mode: ONCE_TILL_UNMOUNT });
expect(store.runSaga).toHaveBeenCalledTimes(3);
});
it('should restart a saga if different implementation for hot reloading', () => {
const cancel = jest.fn();
store.injectedSagas.test = { saga: testSaga, task: { cancel } };
store.runSaga = jest.fn();
function* testSaga1() {
yield put({ type: 'TEST', payload: 'yup' });
}
injectSaga('test', { saga: testSaga1 });
expect(cancel).toHaveBeenCalledTimes(1);
expect(store.runSaga).toHaveBeenCalledWith(testSaga1, undefined);
});
it('should not cancel saga if different implementation in production', () => {
process.env.NODE_ENV = 'production';
const cancel = jest.fn();
store.injectedSagas.test = {
saga: testSaga,
task: { cancel },
mode: RESTART_ON_REMOUNT,
};
function* testSaga1() {
yield put({ type: 'TEST', payload: 'yup' });
}
injectSaga('test', { saga: testSaga1, mode: DAEMON });
expect(cancel).toHaveBeenCalledTimes(0);
process.env.NODE_ENV = originalNodeEnv;
});
it('should save an entire descriptor in the saga registry', () => {
injectSaga('test', { saga: testSaga, foo: 'bar' });
expect(store.injectedSagas.test.foo).toBe('bar');
});
});
});

31
jest.config.js Executable file
View File

@@ -0,0 +1,31 @@
module.exports = {
collectCoverageFrom: [
'app/**/*.{js,jsx}',
'!app/**/*.test.{js,jsx}',
'!app/*/RbGenerated*/*.{js,jsx}',
'!app/app.js',
'!app/global-styles.js',
'!app/*/*/Loadable.{js,jsx}',
],
coverageThreshold: {
global: {
statements: 98,
branches: 91,
functions: 98,
lines: 98,
},
},
moduleDirectories: ['node_modules', 'app'],
moduleNameMapper: {
'.*\\.(css|less|styl|scss|sass)$': '<rootDir>/internals/mocks/cssModule.js',
'.*\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/internals/mocks/image.js',
},
setupFilesAfterEnv: [
'<rootDir>/internals/testing/test-bundler.js',
'react-testing-library/cleanup-after-each',
],
setupFiles: ['raf/polyfill'],
testRegex: 'tests/.*\\.test\\.js$',
snapshotSerializers: [],
};

11
jsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"allowSyntheticDefaultImports": true,
"baseUrl": "./",
"paths": {
"~/*": ["./app/*"]
}
}
}

60
package-lock.json generated
View File

@@ -1809,6 +1809,15 @@
"object.assign": "4.1.0"
}
},
"babel-plugin-root-import": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/babel-plugin-root-import/-/babel-plugin-root-import-6.4.1.tgz",
"integrity": "sha512-xwEwOwilsg4ZJr5I8bySvpwxCfF4lhYtqySktyTyAG8gcyDYTIDjjvds8P+IHb5gTbM/jiE9hU8e5ZQPQv8TrA==",
"dev": true,
"requires": {
"slash": "3.0.0"
}
},
"babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
@@ -5267,9 +5276,12 @@
}
},
"hoist-non-react-statics": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw=="
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"requires": {
"react-is": "16.13.0"
}
},
"homedir-polyfill": {
"version": "1.0.3",
@@ -5898,7 +5910,6 @@
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"dev": true,
"requires": {
"loose-envify": "1.4.0"
}
@@ -7337,6 +7348,13 @@
"prop-types": "15.7.2",
"react-lifecycles-compat": "3.0.4",
"shallowequal": "1.1.0"
},
"dependencies": {
"hoist-non-react-statics": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw=="
}
}
},
"minimalistic-assert": {
@@ -9215,6 +9233,27 @@
"scheduler": "0.19.0"
}
},
"react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
},
"react-helmet": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.2.1.tgz",
"integrity": "sha512-CnwD822LU8NDBnjCpZ4ySh8L6HYyngViTZLfBBb3NjtrpN8m49clH8hidHouq20I51Y6TpCTISCBbqiY5GamwA==",
"requires": {
"object-assign": "4.1.1",
"prop-types": "15.7.2",
"react-fast-compare": "2.0.4",
"react-side-effect": "1.2.0"
}
},
"react-immutable-proptypes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz",
"integrity": "sha1-Aj1vObsVyXwHHp5g0A0TbqxfoLQ="
},
"react-is": {
"version": "16.13.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.0.tgz",
@@ -9306,6 +9345,14 @@
"resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.8.tgz",
"integrity": "sha1-InQDWWtRUeGCN32rg1tdRfD4BU4="
},
"react-side-effect": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.2.0.tgz",
"integrity": "sha512-v1ht1aHg5k/thv56DRcjw+WtojuuDHFUgGfc+bFHOWsF4ZK6C2V57DO0Or0GPsg6+LSTE0M6Ry/gfzhzSwbc5w==",
"requires": {
"shallowequal": "1.1.0"
}
},
"read-pkg": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
@@ -9659,6 +9706,11 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
"dev": true
},
"reselect": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz",
"integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA=="
},
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",

View File

@@ -18,16 +18,23 @@
"dependencies": {
"antd": "^4.0.2",
"history": "^4.10.1",
"hoist-non-react-statics": "^3.3.2",
"immutable": "^4.0.0-rc.12",
"invariant": "^2.2.4",
"lodash": "^4.17.15",
"prop-types": "^15.7.2",
"react": "^16.13.0",
"react-dom": "^16.13.0",
"react-helmet": "^5.2.1",
"react-immutable-proptypes": "^2.1.0",
"react-redux": "^7.2.0",
"react-router-dom": "^5.1.2",
"react-router-redux": "^4.0.8",
"redux": "^4.0.5",
"redux-immutable": "^4.0.0",
"redux-logger": "^3.0.6",
"redux-saga": "^1.1.3"
"redux-saga": "^1.1.3",
"reselect": "^4.0.0"
},
"devDependencies": {
"@babel/core": "^7.8.7",
@@ -35,6 +42,7 @@
"@babel/preset-react": "^7.8.3",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.0.6",
"babel-plugin-root-import": "^6.4.1",
"css-loader": "^3.4.2",
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.0.1",

View File

@@ -3,6 +3,7 @@ const path = require('path');
const HtmlWebPackPlugin = require('html-webpack-plugin');
module.exports = {
mode: process.env.NODE_ENV,
entry: './app/index.js',
output: {
path: path.join(__dirname, 'dist'),
@@ -71,7 +72,12 @@ module.exports = {
optimization: {
minimizer: [new UglifyJsPlugin()],
},
resolve: {
modules: ['node_modules', path.resolve(`${__dirname}/app`)],
alias: {
app: path.resolve(`${__dirname}/app`),
},
},
plugins: [
new HtmlWebPackPlugin({
template: './app/index.html',