mirror of
https://github.com/Telecominfraproject/wlan-cloud-ui.git
synced 2025-10-29 18:02: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/imports-first": ["error", "absolute-first"],
|
||||||
"import/newline-after-import": "error",
|
"import/newline-after-import": "error",
|
||||||
"import/prefer-default-export": 0,
|
"import/prefer-default-export": 0,
|
||||||
|
"react/jsx-props-no-spreading": 0,
|
||||||
"semi": "error"
|
"semi": "error"
|
||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
@@ -27,5 +28,13 @@
|
|||||||
"Request": true,
|
"Request": true,
|
||||||
"fetch": 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 React from 'react';
|
||||||
|
import { compose } from 'redux';
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
import { Switch } from 'react-router-dom';
|
||||||
|
|
||||||
import "../../styles/antd.less";
|
import 'styles/antd.less';
|
||||||
import "../../styles/index.scss";
|
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 -->
|
<!-- Open Sans Font -->
|
||||||
<link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet" />
|
||||||
<title>ConnectUs</title>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { render } from 'react-dom';
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { ConnectedRouter } from 'react-router-redux';
|
import { ConnectedRouter } from 'react-router-redux';
|
||||||
|
|
||||||
import store, { history } from './store';
|
import App from 'containers/App';
|
||||||
|
import store from 'store';
|
||||||
import App from './containers/App';
|
import history from 'utils/history';
|
||||||
|
|
||||||
const renderApp = () =>
|
const renderApp = () =>
|
||||||
render(
|
render(
|
||||||
@@ -20,3 +20,5 @@ const renderApp = () =>
|
|||||||
if (process.env.NODE_ENV !== 'production' && module.hot) {
|
if (process.env.NODE_ENV !== 'production' && module.hot) {
|
||||||
module.hot.accept('containers/App', renderApp);
|
module.hot.accept('containers/App', renderApp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderApp();
|
||||||
|
|||||||
@@ -3,14 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createStore, applyMiddleware, compose } from 'redux';
|
import { createStore, applyMiddleware, compose } from 'redux';
|
||||||
import { createBrowserHistory } from 'history';
|
|
||||||
import { fromJS } from 'immutable';
|
import { fromJS } from 'immutable';
|
||||||
import { routerMiddleware } from 'react-router-redux';
|
import { routerMiddleware } from 'react-router-redux';
|
||||||
import createSagaMiddleware from 'redux-saga';
|
import createSagaMiddleware from 'redux-saga';
|
||||||
|
|
||||||
import createReducer from './reducers';
|
import history from 'utils/history';
|
||||||
|
import createReducer from 'reducers';
|
||||||
export const history = createBrowserHistory();
|
|
||||||
|
|
||||||
const initialState = {};
|
const initialState = {};
|
||||||
|
|
||||||
@@ -29,7 +27,6 @@ if (process.env.NODE_ENV === 'development' && isReduxLogger) {
|
|||||||
|
|
||||||
middlewares.push(
|
middlewares.push(
|
||||||
createLogger({
|
createLogger({
|
||||||
collapsed: true,
|
|
||||||
predicate: (getState, action) => getState && action,
|
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"
|
"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": {
|
"babel-runtime": {
|
||||||
"version": "6.26.0",
|
"version": "6.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||||
@@ -5267,9 +5276,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hoist-non-react-statics": {
|
"hoist-non-react-statics": {
|
||||||
"version": "2.5.5",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw=="
|
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||||
|
"requires": {
|
||||||
|
"react-is": "16.13.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"homedir-polyfill": {
|
"homedir-polyfill": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -5898,7 +5910,6 @@
|
|||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"loose-envify": "1.4.0"
|
"loose-envify": "1.4.0"
|
||||||
}
|
}
|
||||||
@@ -7337,6 +7348,13 @@
|
|||||||
"prop-types": "15.7.2",
|
"prop-types": "15.7.2",
|
||||||
"react-lifecycles-compat": "3.0.4",
|
"react-lifecycles-compat": "3.0.4",
|
||||||
"shallowequal": "1.1.0"
|
"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": {
|
"minimalistic-assert": {
|
||||||
@@ -9215,6 +9233,27 @@
|
|||||||
"scheduler": "0.19.0"
|
"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": {
|
"react-is": {
|
||||||
"version": "16.13.0",
|
"version": "16.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.8.tgz",
|
||||||
"integrity": "sha1-InQDWWtRUeGCN32rg1tdRfD4BU4="
|
"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": {
|
"read-pkg": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
|
||||||
@@ -9659,6 +9706,11 @@
|
|||||||
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
|
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"reselect": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA=="
|
||||||
|
},
|
||||||
"resize-observer-polyfill": {
|
"resize-observer-polyfill": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
"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": {
|
"dependencies": {
|
||||||
"antd": "^4.0.2",
|
"antd": "^4.0.2",
|
||||||
"history": "^4.10.1",
|
"history": "^4.10.1",
|
||||||
|
"hoist-non-react-statics": "^3.3.2",
|
||||||
"immutable": "^4.0.0-rc.12",
|
"immutable": "^4.0.0-rc.12",
|
||||||
|
"invariant": "^2.2.4",
|
||||||
|
"lodash": "^4.17.15",
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
"react": "^16.13.0",
|
"react": "^16.13.0",
|
||||||
"react-dom": "^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-redux": "^7.2.0",
|
||||||
"react-router-dom": "^5.1.2",
|
"react-router-dom": "^5.1.2",
|
||||||
"react-router-redux": "^4.0.8",
|
"react-router-redux": "^4.0.8",
|
||||||
"redux": "^4.0.5",
|
"redux": "^4.0.5",
|
||||||
"redux-immutable": "^4.0.0",
|
"redux-immutable": "^4.0.0",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"redux-saga": "^1.1.3"
|
"redux-saga": "^1.1.3",
|
||||||
|
"reselect": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.8.7",
|
"@babel/core": "^7.8.7",
|
||||||
@@ -35,6 +42,7 @@
|
|||||||
"@babel/preset-react": "^7.8.3",
|
"@babel/preset-react": "^7.8.3",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"babel-loader": "^8.0.6",
|
"babel-loader": "^8.0.6",
|
||||||
|
"babel-plugin-root-import": "^6.4.1",
|
||||||
"css-loader": "^3.4.2",
|
"css-loader": "^3.4.2",
|
||||||
"eslint": "^6.8.0",
|
"eslint": "^6.8.0",
|
||||||
"eslint-config-airbnb": "^18.0.1",
|
"eslint-config-airbnb": "^18.0.1",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const path = require('path');
|
|||||||
const HtmlWebPackPlugin = require('html-webpack-plugin');
|
const HtmlWebPackPlugin = require('html-webpack-plugin');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
mode: process.env.NODE_ENV,
|
||||||
entry: './app/index.js',
|
entry: './app/index.js',
|
||||||
output: {
|
output: {
|
||||||
path: path.join(__dirname, 'dist'),
|
path: path.join(__dirname, 'dist'),
|
||||||
@@ -71,7 +72,12 @@ module.exports = {
|
|||||||
optimization: {
|
optimization: {
|
||||||
minimizer: [new UglifyJsPlugin()],
|
minimizer: [new UglifyJsPlugin()],
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
modules: ['node_modules', path.resolve(`${__dirname}/app`)],
|
||||||
|
alias: {
|
||||||
|
app: path.resolve(`${__dirname}/app`),
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new HtmlWebPackPlugin({
|
new HtmlWebPackPlugin({
|
||||||
template: './app/index.html',
|
template: './app/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user