mirror of
				https://github.com/Telecominfraproject/wlan-cloud-ui.git
				synced 2025-10-31 02:37:54 +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
	 Sean Macfarlane
					Sean Macfarlane