mirror of
https://github.com/Telecominfraproject/wlan-cloud-ui.git
synced 2025-10-29 09:52:36 +00:00
redux setup
This commit is contained in:
11
.eslintrc
11
.eslintrc
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
app/components/GlobalHeader/index.js
Normal file
73
app/components/GlobalHeader/index.js
Normal 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;
|
||||
109
app/components/SideMenu/Sider.module.scss
Normal file
109
app/components/SideMenu/Sider.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
264
app/components/SideMenu/index.js
Normal file
264
app/components/SideMenu/index.js
Normal 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;
|
||||
39
app/constants/api/dataTypes/apSettingBulkUpdate.js
Normal file
39
app/constants/api/dataTypes/apSettingBulkUpdate.js
Normal 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,
|
||||
});
|
||||
6
app/constants/api/linkQualityHistory/interval.js
Normal file
6
app/constants/api/linkQualityHistory/interval.js
Normal 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
17
app/constants/app.js
Normal 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
1
app/constants/company.js
Normal file
@@ -0,0 +1 @@
|
||||
export const NAME = 'KodaCloud';
|
||||
24
app/constants/devices.js
Normal file
24
app/constants/devices.js
Normal 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';
|
||||
2
app/constants/equipment.js
Normal file
2
app/constants/equipment.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export const TYPE_CNA = 'CUSTOMER_NETWORK_AGENT';
|
||||
export const TYPE_AP = 'AP';
|
||||
12
app/constants/index.js
Normal file
12
app/constants/index.js
Normal 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';
|
||||
17
app/constants/recommendations.js
Normal file
17
app/constants/recommendations.js
Normal 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,
|
||||
];
|
||||
7
app/constants/time_ranges.js
Normal file
7
app/constants/time_ranges.js
Normal 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;
|
||||
22
app/containers/App/components/RouteWithLayout.js
Normal file
22
app/containers/App/components/RouteWithLayout.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
17
app/containers/App/reducer.js
Normal file
17
app/containers/App/reducer.js
Normal 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;
|
||||
0
app/containers/App/saga.js
Normal file
0
app/containers/App/saga.js
Normal file
0
app/containers/Dashboard/index.js
Normal file
0
app/containers/Dashboard/index.js
Normal file
15
app/containers/MasterLayout/MasterLayout.module.scss
Normal file
15
app/containers/MasterLayout/MasterLayout.module.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
.MainLayout {
|
||||
height: 100vh;
|
||||
margin-left: 234px;
|
||||
|
||||
&.mobile {
|
||||
margin-left: 0;
|
||||
}
|
||||
&.collapsed {
|
||||
margin-left: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.Footer {
|
||||
text-align: center;
|
||||
}
|
||||
74
app/containers/MasterLayout/index.js
Normal file
74
app/containers/MasterLayout/index.js
Normal 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
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
BIN
app/images/logox40.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 575 KiB |
BIN
app/images/logoxmobile.jpg
Normal file
BIN
app/images/logoxmobile.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 558 KiB |
@@ -9,7 +9,6 @@
|
||||
|
||||
<!-- Open Sans Font -->
|
||||
<link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet" />
|
||||
<title>ConnectUs</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
43
app/tests/store.test.js
Executable 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
21
app/utils/checkStore.js
Executable 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
3
app/utils/constants.js
Executable 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
4
app/utils/history.js
Executable file
@@ -0,0 +1,4 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
const history = createBrowserHistory();
|
||||
export default history;
|
||||
45
app/utils/injectReducer.js
Executable file
45
app/utils/injectReducer.js
Executable 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
61
app/utils/injectSaga.js
Executable 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
13
app/utils/loadable.js
Executable 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
34
app/utils/reducerInjectors.js
Executable 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
44
app/utils/request.js
Executable 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
91
app/utils/sagaInjectors.js
Executable 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),
|
||||
};
|
||||
}
|
||||
33
app/utils/tests/checkStore.test.js
Executable file
33
app/utils/tests/checkStore.test.js
Executable 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();
|
||||
});
|
||||
});
|
||||
98
app/utils/tests/injectReducer.test.js
Executable file
98
app/utils/tests/injectReducer.test.js
Executable 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);
|
||||
});
|
||||
});
|
||||
149
app/utils/tests/injectSaga.test.js
Executable file
149
app/utils/tests/injectSaga.test.js
Executable 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');
|
||||
});
|
||||
});
|
||||
100
app/utils/tests/reducerInjectors.test.js
Executable file
100
app/utils/tests/reducerInjectors.test.js
Executable 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
80
app/utils/tests/request.test.js
Executable 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
231
app/utils/tests/sagaInjectors.test.js
Executable file
231
app/utils/tests/sagaInjectors.test.js
Executable 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
31
jest.config.js
Executable 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
11
jsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"module": "commonjs",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
60
package-lock.json
generated
60
package-lock.json
generated
@@ -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",
|
||||
|
||||
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user