├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .nvmrc ├── LICENSE ├── README.md ├── examples ├── components │ ├── App │ │ ├── App.js │ │ └── App.scss │ ├── Button │ │ ├── Button.js │ │ └── Button.scss │ ├── Logo │ │ └── Logo.js │ ├── Modal │ │ ├── Modal.js │ │ └── Modal.scss │ └── ModalLogin │ │ ├── ModalLogin.js │ │ └── ModalLogin.scss ├── containers │ ├── SignalContainer.js │ └── SignalOverlayContainer.js ├── index.html ├── index.js ├── index.scss ├── reducers.js └── webpack.config.js ├── gulpfile.babel.js ├── logo.png ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── index.spec.js │ └── reducer.spec.js ├── actions.js ├── constants │ ├── ActionTypes.js │ ├── ModalStates.js │ ├── SignalEvents.js │ └── SignalTypes.js ├── createContainer.js ├── eventHandler.js ├── index.js ├── isModal.js ├── reducer.js ├── selectors.js ├── utils.js └── withSignal.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015-no-commonjs", 4 | "react", 5 | "stage-2" 6 | ], 7 | "env": { 8 | "development": { 9 | "plugins": [ 10 | "transform-es2015-modules-commonjs" 11 | ] 12 | }, 13 | "test": { 14 | "plugins": [ 15 | "transform-es2015-modules-commonjs", 16 | "transform-react-jsx-source", 17 | "istanbul" 18 | ] 19 | }, 20 | "production": { 21 | "plugins": [ 22 | "transform-es2015-modules-commonjs" 23 | ] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | 13 | [*.js] 14 | indent_style = space 15 | indent_size = 2 16 | trim_trailing_whitespace = false 17 | insert_final_newline = true 18 | max_line_length = f -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack.config*.js 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "standard", 5 | "standard-react", 6 | "plugin:jest/recommended" 7 | ], 8 | "env": { 9 | "browser": true, 10 | "mocha": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | *.iml 4 | .nyc_output 5 | coverage 6 | flow-coverage 7 | node_modules 8 | dist 9 | lib 10 | es 11 | npm-debug.log 12 | .DS_Store 13 | stats.json 14 | dist-examples 15 | .publish 16 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples 2 | scripts 3 | docs 4 | .babelrc 5 | .eslint* 6 | .idea 7 | .editorconfig 8 | .npmignore 9 | .nyc_output 10 | .travis.yml 11 | webpack.* 12 | coverage 13 | package-lock.json 14 | yarn.lock 15 | .publish 16 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 9.8.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Mike Vercoelen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](http://mikevercoelen.github.io/redux-signal/) 2 | 3 | # Redux Signal 4 | 5 | Flexible, scalable library for creating modals with React and Redux. 6 | 7 | * `signals`: small, quick notification modals (confirmations, alerts etc.) 8 | * `modals`: fully customizable modals for your forms etc. 9 | 10 | ## Demo 11 | 12 | [mikevercoelen.github.io/redux-signal](http://mikevercoelen.github.io/redux-signal/) 13 | 14 | ## Example 15 | 16 | ```js 17 | import React from 'react' 18 | import { withSignal, SignalTypes } from 'redux-signal' 19 | 20 | // As an example, this app has a ProfileView. The user can upload an avatar, 21 | // and if the avatar file is too large, we want to display a notification popup 22 | 23 | class ProfileView extends React.Component { 24 | onAvatarFileUpload = () => { 25 | if (avatarIsToLarge) { // use your imagination... 26 | this.props.createSignal({ 27 | type: SignalTypes.OK, 28 | title: 'Warning', 29 | message: 'The file was too large' 30 | }) 31 | } 32 | } 33 | 34 | render () { 35 | // ... 36 | } 37 | } 38 | 39 | export default withSignal(ProfileView) 40 | ``` 41 | 42 | More example code [available here](https://github.com/mikevercoelen/redux-signal/tree/master/examples) 43 | 44 | ## Table of Contents 45 | 46 | - [Installation](#installation) 47 | - [Introduction](#introduction) 48 | - [The problem](#the-problem) 49 | - [How does it work](#how-does-it-work?) 50 | - [Setup](#setup) 51 | - [Reducer](#reducer-setup) 52 | - [SignalContainer](#signalcontainer-setup) 53 | - [Signals](#signals) 54 | - [Signal types](#signal-types) 55 | - [Handling events](#handling-events) 56 | - [Modals](#modals) 57 | - [Overlay](#overlay) 58 | - [API](#api) 59 | - [createContainer](#createcontainer) 60 | - [withSignal](#withsignal) 61 | - [createSignal](#createsignal) 62 | - [getHasVisibleModal](#gethasvisiblemodal) 63 | 64 | 65 | ## Installation 66 | 67 | `npm install redux-signal --save` 68 | 69 | ## Introduction 70 | 71 | ### The problem 72 | Setting up a flexible solution for modals with Redux is hard. Our apps mostly need 2 types of modals: simple modals (such as confirms / warnings etc.) and custom modals. 73 | 74 | We might need multiple modals open at once, so we need some stacking order and we only want to display one overlay component. We also want our Redux state to be clean and serializable. 75 | 76 | So meet Redux Signal, hi. 77 | 78 | In Redux Signal there are 2 types of modals: ***signals*** and ***modals***. Signals are simple notifications: like warnings when things go wrong, confirmations when a user tries to removing an item, or user feedback when an item has been removed etc. Modals are fully customizable, modals. Like login popups etc. 79 | 80 | ***NOTE***: 81 | 82 | Redux Signal is not a library for the styling of your modals, that is your responsibility, however we made [`react-modal-construction-kit`](https://github.com/mikevercoelen/react-modal-construction-kit) for making your life even easier. 83 | 84 | Check out the [examples](https://github.com/mikevercoelen/redux-signal/tree/master/examples) for more info. 85 | 86 | ### How does it work? 87 | Redux Signal uses an `event / feedback queue` mechanism for signals so we handle events in a clean way, without cluttering our app state with functions etc. See [Handling events](#handling-events) for more info. 88 | 89 | ## Setup 90 | 91 | ### Reducer setup 92 | 93 | The first thing you need to do is to include the signal reducer in your rootReducer. Please make sure it's mounted at your rootReducer as `signal`, we are working on making this flexible in the future. 94 | 95 | `reducers/index.js` 96 | 97 | ```js 98 | import { combineReducers } from 'redux' 99 | import { reducer as signalReducer } from 'redux-signal' 100 | 101 | export const rootReducer = combineReducers({ 102 | signal: signalReducer 103 | }) 104 | ``` 105 | 106 | ### SignalContainer setup 107 | 108 | The second thing you need to do, is to create a `SignalContainer`. Again: Redux-signal is not responsible for your Modal look and feel, you need your own Modal component ([`react-modal-construction-kit`](https://github.com/mikevercoelen/react-modal-construction-kit)) 109 | 110 | The `SignalContainer` is ***the link between signal and your modal component*** 111 | 112 | So let's create a `SignalContainer` component: 113 | 114 | `containers/SignalContainer.js` 115 | 116 | ```js 117 | import React from 'react' 118 | import PropTypes from 'prop-types' 119 | 120 | // These are your application specific components we use in this demo example 121 | import Button from '../components/Button/Button' 122 | import Modal from '../components/Modal/Modal' 123 | 124 | import { 125 | createContainer, 126 | SignalEvents, 127 | SignalTypes 128 | } from 'redux-signal' 129 | 130 | const SignalContainer = ({ 131 | event, 132 | destroy, 133 | close, 134 | modal 135 | }) => { 136 | // modal contains all the properties you submit when calling `createSignal`, so you have all the freedom 137 | // to do whatever you want (title, message, isRequired) only isFirst and isVisible are required. 138 | 139 | return ( 140 | event(modal, eventType))}> 145 | {modal.message} 146 | 147 | ) 148 | } 149 | 150 | SignalContainer.propTypes = { 151 | event: PropTypes.func, 152 | destroy: PropTypes.func, 153 | close: PropTypes.func, 154 | modal: PropTypes.object 155 | } 156 | 157 | function getModalLabel (modal, labelType, otherwise) { 158 | return (modal.labels && modal.labels[labelType]) || {otherwise} 159 | } 160 | 161 | function getFooter (modal, onModalEvent) { 162 | switch (modal.type) { 163 | case SignalTypes.YES_NO: 164 | return [ 165 | , 171 | 177 | ] 178 | case SignalTypes.YES_NO_CANCEL: 179 | return [ 180 | , 185 | , 191 | 197 | ] 198 | 199 | case SignalTypes.OK_CANCEL: 200 | return [ 201 | , 206 | 212 | ] 213 | case SignalTypes.OK: 214 | return ( 215 | 220 | ) 221 | } 222 | 223 | return null 224 | } 225 | 226 | export default createContainer(SignalContainer) 227 | ``` 228 | 229 | Once you've created the `SignalContainer` (which, again, is the link between your Modal and `redux-signal`) you have to use it somewhere in your application. 230 | The most logical place would be your main layout, use it like so: 231 | 232 | ```js 233 | 234 | ``` 235 | 236 | Now you've setup everything you need for `redux-signal` and can start using `createSignal` to show `signals`. 237 | 238 | ## Signals 239 | 240 | ### Showing a signal 241 | 242 | 1. Wrap the component where you want to show a signal with [`withSignal`](#withsignal), which injects the component with a few props from which one is called: `createSignal`. 243 | 2. Use [`createSignal`](#createsignal) to show a signal. 244 | 245 | Example: 246 | 247 | `components/Demo.js` 248 | 249 | ```js 250 | import React from 'react' 251 | 252 | import { 253 | withSignal, 254 | withSignalPropTypes, 255 | SignalTypes 256 | } from 'redux-signal' 257 | 258 | const Demo = ({ createSignal }) => { 259 | return ( 260 |
261 | 271 |
272 | ) 273 | } 274 | 275 | Demo.propTypes = { 276 | ...withSignalPropTypes 277 | } 278 | 279 | export default withSignal(Demo) 280 | ``` 281 | 282 | ### Signal types 283 | 284 | There are 4 `SignalTypes`: 285 | 286 | * `OK` 287 | * `OK_CANCEL` 288 | * `YES_NO` 289 | * `YES_NO_CANCEL` 290 | 291 | It's your responsibility for handling the signal type in the `SignalContainer`, in our example [SignalContainer](#signalcontainer-setup) you can see the `type` is being used to show different buttons. 292 | 293 | More info see [createSignal API](#createsignal) 294 | 295 | ### Handling events 296 | 297 | Lets say you want to have a confirmation popup when a user wants to remove an item. You want to handle the events when clicked on the `yes` button or `no` button. You can do so by using `eventHandler` 298 | 299 | ```js 300 | import React from 'react' 301 | 302 | import { 303 | withSignal, 304 | withSignalPropTypes, 305 | SignalTypes, 306 | eventHandler 307 | } from 'redux-signal' 308 | 309 | const KillTheWorldEvents = eventHandler() 310 | 311 | const Demo = ({ createSignal }) => { 312 | return ( 313 |
314 | 325 | window.alert("You have killed the world.")} 327 | onNo={() => window.alert("Thank god, you are a good kid."} /> 328 |
329 | ) 330 | } 331 | 332 | Demo.propTypes = { 333 | ...withSignalPropTypes 334 | } 335 | 336 | export default withSignal(Demo) 337 | ``` 338 | 339 | ## Modals 340 | 341 | TODO: for now check out the [modal examples code](https://github.com/mikevercoelen/redux-signal/blob/master/examples/components/ModalLogin/ModalLogin.js). 342 | 343 | ## Overlay 344 | 345 | TODO: for now check out the [SignalOverlayContainer in the examples code](https://github.com/mikevercoelen/redux-signal/blob/master/examples/containers/SignalOverlayContainer.js). 346 | 347 | ## API 348 | 349 | ### createContainer 350 | 351 | The following props will be available once a component has been wrapped with `createContainer`: 352 | 353 | | Property | Type | Description | 354 | |:---|:---|:---| 355 | | `event` | function | Dispatch a signal event | 356 | | `close` | function | Closes the signal, not to be confused with `destroy`, which removes the DOM element. Close should be used to close, destroy on transition end | 357 | | `destroy` | function | Destroys the signal, should be used on transition end when using a transitioned modal, see examples for more info on it's use | 358 | | `modal` | object | All the props passed to `createSignal` | 359 | 360 | ### withSignal 361 | 362 | The following props will be available once a component has been wrapped with `withSignal`: 363 | 364 | | Property | Type | Description | 365 | |:---|:---|:---| 366 | | `createSignal` | function({ type, ...props }) | See [createSignal](#createsignal) | 367 | | `signalEvent` | function | Dispatch a signal event | 368 | | `setModalState` | function(modalId, ModalState) | Set the state of a modal | 369 | | `showModal` | function(modalId) | Shows a modal | 370 | | `hideModal` | function(modalId) | Hides a modal | 371 | 372 | ### createSignal 373 | 374 | This method will be available in the props of a component wrapped with `withSignal` 375 | 376 | The method expects an object with the following parameters: 377 | 378 | | Property | Type | Default | Description | 379 | |:---|:---|:---|:---| 380 | | `type` | SignalType (enum) | - | The type of Signal see: [Signal types](#signal-types) | 381 | | `eventHandler` | EventHandler | - | ***(optional)*** pass in an EventHandler to handle events | 382 | | `...props` | - | - | All other props passed to `createSignal` can be accessed in your `SignalContainer`'s `modal` object prop, see [createContainer](#createcontainer) | 383 | 384 | ### getHasVisibleModal 385 | 386 | This is a selector, and returns true or false if a modal OR signal is visible, this can be used to render an overlay component 387 | 388 | | Property | Type | Default | Description | 389 | |:---|:---|:---|:---| 390 | | `state` | object | - | Your application state | 391 | -------------------------------------------------------------------------------- /examples/components/App/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Button from '../Button/Button' 3 | import Logo from '../Logo/Logo' 4 | import ModalLogin from '../ModalLogin/ModalLogin' 5 | import styles from './App.scss' 6 | 7 | import { 8 | withSignal, 9 | withSignalPropTypes, 10 | SignalTypes, 11 | eventHandler 12 | } from '../../../src/index' 13 | 14 | const modalLoginId = '@app/modal-login' 15 | const KillTheWorldEvent = eventHandler() 16 | 17 | const App = ({ createSignal, showModal }) => { 18 | const onBtnKillClick = () => { 19 | createSignal({ 20 | type: SignalTypes.YES_NO, 21 | title: 'Are you sure?', 22 | message: 'You are about to kill the world, are you sure?', 23 | labels: { 24 | yes: 'Yes, kill it!', 25 | no: 'No, there is still hope' 26 | }, 27 | eventHandler: KillTheWorldEvent 28 | }) 29 | } 30 | 31 | const onBtnErrorClick = () => { 32 | createSignal({ 33 | type: SignalTypes.OK, 34 | title: 'Oeps', 35 | message: 'Something has gone wrong' 36 | }) 37 | } 38 | 39 | const onBtnLoginClick = () => { 40 | showModal(modalLoginId) 41 | } 42 | 43 | const onYes = () => { 44 | console.log('You killed everyone, you must be proud.') 45 | } 46 | 47 | const onNo = () => { 48 | console.log('That was close, we need more people like you.') 49 | } 50 | 51 | return ( 52 |
53 |
54 | 55 |
56 |
57 |
58 |

59 | Signals 60 |

61 |

62 | Quick popups that require no custom logic (confirms, notifications etc.) 63 |

64 |
65 |

66 | SignalTypes.YES_NO 67 |

68 | 73 |
74 |
75 |

76 | SignalTypes.OK 77 |

78 | 83 |
84 |
85 |
86 |

87 | Modal 88 |

89 |

90 | Fully customizable modals 91 |

92 | 97 |
98 | 101 | 102 |
103 |
104 | ) 105 | } 106 | 107 | App.propTypes = { 108 | ...withSignalPropTypes 109 | } 110 | 111 | export default withSignal(App) 112 | -------------------------------------------------------------------------------- /examples/components/App/App.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | padding: 5em 0; 6 | background-color: lighten(#6670D9, 35%); 7 | border-bottom: 1px solid lighten(#6670D9, 30%); 8 | } 9 | 10 | .content { 11 | max-width: 1040px; 12 | padding: 1em; 13 | margin: 0 auto; 14 | 15 | h1 { 16 | margin-bottom: .3em; 17 | } 18 | } 19 | 20 | .section { 21 | margin-bottom: 2em; 22 | padding-bottom: 2em; 23 | border-bottom: 1px solid #f0f0f0; 24 | 25 | &:last-child { 26 | border-bottom: 0; 27 | } 28 | } 29 | 30 | .subSection { 31 | p { 32 | color: #999; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/components/Button/Button.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styles from './Button.scss' 4 | import cx from 'classnames' 5 | 6 | const Button = ({ children, primary, reject, onClick, disabled }) => ( 7 | 17 | ) 18 | 19 | Button.propTypes = { 20 | children: PropTypes.node, 21 | primary: PropTypes.bool, 22 | reject: PropTypes.bool, 23 | disabled: PropTypes.bool, 24 | onClick: PropTypes.func 25 | } 26 | 27 | export default Button 28 | -------------------------------------------------------------------------------- /examples/components/Button/Button.scss: -------------------------------------------------------------------------------- 1 | .component { 2 | display: block; 3 | outline: 0; 4 | border: 0; 5 | padding: .5em 1em; 6 | font-family: inherit; 7 | background-color: #999; 8 | border-radius: 2px; 9 | font-size: 12px; 10 | cursor: pointer; 11 | } 12 | 13 | .primary { 14 | background-color: #6670D9; 15 | color: white; 16 | } 17 | 18 | .reject { 19 | background-color: #ee5253; 20 | color: white; 21 | } 22 | 23 | .disabled { 24 | cursor: not-allowed; 25 | opacity: .5; 26 | } 27 | -------------------------------------------------------------------------------- /examples/components/Logo/Logo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LogoImg from '../../../Logo.png' 3 | 4 | const Logo = () => ( 5 |
6 | 7 |
8 | ) 9 | 10 | export default Logo 11 | -------------------------------------------------------------------------------- /examples/components/Modal/Modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Modal as ModalConstruct } from 'react-modal-construction-kit' 4 | import styles from './Modal.scss' 5 | import cx from 'classnames' 6 | 7 | const Modal = ({ 8 | title, 9 | children, 10 | footer, 11 | dialogClassName, 12 | ...props 13 | }) => ( 14 | 20 |
21 | {title} 22 |
23 | {children && ( 24 |
25 | {children} 26 |
27 | )} 28 | {footer && ( 29 |
30 | {footer} 31 |
32 | )} 33 |
34 | ) 35 | 36 | Modal.propTypes = { 37 | title: PropTypes.string, 38 | children: PropTypes.node, 39 | footer: PropTypes.node, 40 | ...ModalConstruct.propTypes 41 | } 42 | 43 | export default Modal 44 | -------------------------------------------------------------------------------- /examples/components/Modal/Modal.scss: -------------------------------------------------------------------------------- 1 | $color-border: #f0f0f0; 2 | 3 | .dialog { 4 | padding: 1em; 5 | } 6 | 7 | .content { 8 | border-radius: 2px; 9 | } 10 | 11 | .header { 12 | padding: 1em; 13 | border-top-left-radius: 2px; 14 | border-top-right-radius: 2px; 15 | font-size: 12px; 16 | border-bottom: 1px solid $color-border; 17 | background-color: darken(white, 3%); 18 | } 19 | 20 | .body { 21 | padding: 1em; 22 | border-bottom: 1px solid $color-border; 23 | } 24 | 25 | .footer { 26 | padding: 1em; 27 | display: flex; 28 | border-radius: 0 0 2px 2px; 29 | overflow: hidden; 30 | white-space: nowrap; 31 | align-items: center; 32 | justify-content: flex-end; 33 | 34 | > :not(:last-child) { 35 | margin-right: .25rem; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/components/ModalLogin/ModalLogin.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Modal from '../Modal/Modal' 3 | import Button from '../Button/Button' 4 | import { isModal, isModalPropTypes } from '../../../src' 5 | import styles from './ModalLogin.scss' 6 | 7 | const ModalLogin = ({ 8 | modal, 9 | setBusy, 10 | close 11 | }) => { 12 | const handleSubmit = (e) => { 13 | e.preventDefault() 14 | setBusy(true) 15 | 16 | setTimeout(() => { 17 | close() 18 | }, 2000) 19 | } 20 | 21 | return ( 22 | 28 |
29 |
30 | 31 |
32 |
33 | 34 |
35 | 40 |
41 |
42 | ) 43 | } 44 | 45 | ModalLogin.propTypes = { 46 | ...isModalPropTypes 47 | } 48 | 49 | export default isModal(ModalLogin) 50 | -------------------------------------------------------------------------------- /examples/components/ModalLogin/ModalLogin.scss: -------------------------------------------------------------------------------- 1 | .modalDialog { 2 | max-width: 320px !important; 3 | } 4 | -------------------------------------------------------------------------------- /examples/containers/SignalContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Button from '../components/Button/Button' 4 | import Modal from '../components/Modal/Modal' 5 | 6 | import { 7 | createContainer, 8 | SignalEvents, 9 | SignalTypes 10 | } from '../../src/index' 11 | 12 | const SignalContainer = ({ 13 | event, 14 | destroy, 15 | close, 16 | modal 17 | }) => { 18 | // modal contains all the properties you submit when calling `createSignal`, so you have all the freedom 19 | // to do whatever you want (title, message, isRequired) only isFirst and isVisible are required. 20 | 21 | return ( 22 | event(modal, eventType))}> 27 | {modal.message} 28 | 29 | ) 30 | } 31 | 32 | SignalContainer.propTypes = { 33 | event: PropTypes.func, 34 | destroy: PropTypes.func, 35 | close: PropTypes.func, 36 | modal: PropTypes.object 37 | } 38 | 39 | function getModalLabel (modal, labelType, otherwise) { 40 | return (modal.labels && modal.labels[labelType]) || {otherwise} 41 | } 42 | 43 | function getFooter (modal, onModalEvent) { 44 | switch (modal.type) { 45 | case SignalTypes.YES_NO: 46 | return [ 47 | , 53 | 59 | ] 60 | case SignalTypes.YES_NO_CANCEL: 61 | return [ 62 | , 67 | , 73 | 79 | ] 80 | 81 | case SignalTypes.OK_CANCEL: 82 | return [ 83 | , 88 | 94 | ] 95 | case SignalTypes.OK: 96 | return ( 97 | 102 | ) 103 | } 104 | 105 | return null 106 | } 107 | 108 | export default createContainer(SignalContainer) 109 | -------------------------------------------------------------------------------- /examples/containers/SignalOverlayContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Overlay } from 'react-modal-construction-kit' 4 | import { connect } from 'react-redux' 5 | import { getHasVisibleModal } from '../../src/index' 6 | 7 | const SignalOverlayContainer = ({ isVisible }) => ( 8 | 9 | ) 10 | 11 | SignalOverlayContainer.propTypes = { 12 | isVisible: PropTypes.bool.isRequired 13 | } 14 | 15 | const mapStateToProps = state => ({ 16 | isVisible: getHasVisibleModal(state) 17 | }) 18 | 19 | export default connect( 20 | mapStateToProps 21 | )(SignalOverlayContainer) 22 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { createStore } from 'redux' 4 | import { Provider } from 'react-redux' 5 | import rootReducer from './reducers' 6 | import SignalContainer from './containers/SignalContainer' 7 | import SignalOverlay from './containers/SignalOverlayContainer' 8 | import App from './components/App/App' 9 | import './index.scss' 10 | 11 | const store = createStore( 12 | rootReducer, 13 | {}, 14 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() 15 | ) 16 | 17 | const rootElement = document.getElementById('root') 18 | 19 | render( 20 | 21 |
22 | 23 | 24 | 25 |
26 |
, 27 | rootElement 28 | ) 29 | -------------------------------------------------------------------------------- /examples/index.scss: -------------------------------------------------------------------------------- 1 | :global { 2 | body { 3 | margin: 0; 4 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif; 5 | color: #212529; 6 | text-rendering: optimizeLegibility; 7 | -webkit-font-smoothing: antialiased; 8 | } 9 | 10 | input { 11 | display: block; 12 | width: 100%; 13 | border: 1px solid darken(white, 5%); 14 | padding: .6em; 15 | border-radius: 2px; 16 | font-family: inherit; 17 | outline: 0; 18 | font-size: 14px; 19 | } 20 | 21 | form { 22 | margin-bottom: 0; 23 | } 24 | 25 | .form-field { 26 | margin-bottom: 1em; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import { reducer as signalReducer } from '../src/index' 3 | 4 | const rootReducer = combineReducers({ 5 | signal: signalReducer 6 | }) 7 | 8 | export default rootReducer 9 | -------------------------------------------------------------------------------- /examples/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | 5 | const env = process.env.NODE_ENV 6 | 7 | const rules = [] 8 | 9 | rules.push({ 10 | test: /\.js$/, 11 | loader: 'babel-loader', 12 | exclude: /node_modules/ 13 | }) 14 | 15 | rules.push({ 16 | test: /\.scss$/, 17 | use: [{ 18 | loader: "style-loader" 19 | }, { 20 | loader: "css-loader", 21 | options: { 22 | importLoaders: 1, 23 | sourceMap: true, 24 | modules: true, 25 | localIdentName: '[name]-[local]-[hash:base64:5]', 26 | minimize: false, 27 | discardComments: { 28 | removeAll: true 29 | } 30 | } 31 | }, { 32 | loader: "sass-loader" 33 | }] 34 | }) 35 | 36 | rules.push({ 37 | test: /\.(ico|jpg|jpeg|png|gif|svg)(\?.*)?$/, 38 | use: [{ 39 | loader: 'file-loader', 40 | query: { 41 | name: '[hash:8].[ext]' 42 | } 43 | }, { 44 | loader: 'img-loader', 45 | query: { 46 | options: { 47 | enabled: true 48 | } 49 | } 50 | }] 51 | }) 52 | 53 | const config = { 54 | context: __dirname, 55 | mode: 'development', 56 | entry: './index.js', 57 | module: { 58 | rules 59 | }, 60 | output: { 61 | path: path.resolve(__dirname, '../dist-examples') 62 | }, 63 | plugins: [ 64 | new webpack.DefinePlugin({ 65 | 'process.env.NODE_ENV': JSON.stringify(env) 66 | }), 67 | new HtmlWebpackPlugin({ 68 | template: './index.html' 69 | }) 70 | ] 71 | } 72 | 73 | module.exports = config 74 | -------------------------------------------------------------------------------- /gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import ghPages from 'gulp-gh-pages' 3 | 4 | gulp.task('deploy', () => { 5 | return gulp 6 | .src('./dist-examples/**/*') 7 | .pipe(ghPages()) 8 | }) 9 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikevercoelen/redux-signal/3df953d08952fdf9a197de07ae9c1199a2b5173c/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-signal", 3 | "version": "2.0.1", 4 | "description": "A scalable solution for modals using React and Redux", 5 | "main": "./lib/index.js", 6 | "module": "./es/index.js", 7 | "jsnext:main": "./es/index.js", 8 | "sideEffects": false, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/mikevercoelen/redux-signal.git" 12 | }, 13 | "scripts": { 14 | "analyze": "webpack src/index.js dist/redux-signal.js -p --bail --profile --json > stats.json && webpack-bundle-analyzer stats.json", 15 | "build": "npm run build:lib && npm run build:es && npm run build:umd && npm run build:umd:min", 16 | "build:lib": "babel src --out-dir lib --ignore __tests__", 17 | "build:es": "cross-env BABEL_ENV=es babel src --out-dir es --ignore __tests__", 18 | "build:umd": "cross-env NODE_ENV=development webpack --env.development ./src/index.js --output-filename=redux-signal.js", 19 | "build:umd:min": "cross-env NODE_ENV=production webpack --env.production ./src/index.js --output-filename=redux-signal.min.js", 20 | "dev": "npm-watch", 21 | "clean": "rimraf dist lib es", 22 | "lint": "eslint src", 23 | "lint:fix": "eslint src --fix", 24 | "precommit": "lint-staged", 25 | "prepublishOnly": "npm run lint:fix && npm run test:cov && npm run clean && npm run build && npm run deploy", 26 | "test": "cross-env NODE_ENV=test jest --runInBand", 27 | "test:watch": "npm test -- --watch", 28 | "test:cov": "npm run test -- --coverage", 29 | "examples": "cross-env NODE_ENV=development webpack-dev-server --config=./examples/webpack.config.js", 30 | "examples:build": "cross-env NODE_ENV=production webpack --config=./examples/webpack.config.js", 31 | "deploy": "npm run examples:build && gulp deploy", 32 | "prepush": "npm run test" 33 | }, 34 | "watch": { 35 | "build": "src/**/*.js" 36 | }, 37 | "keywords": [ 38 | "react", 39 | "redux", 40 | "notification", 41 | "modal", 42 | "react-modal", 43 | "redux-modal", 44 | "redux-signal", 45 | "signal", 46 | "decorator", 47 | "react-redux" 48 | ], 49 | "author": "Mike Vercoelen ", 50 | "license": "MIT", 51 | "bugs": { 52 | "url": "https://github.com/mikevercoelen/redux-signal/issues" 53 | }, 54 | "homepage": "https://github.com/mikevercoelen/redux-signal#readme", 55 | "dependencies": { 56 | "lru-memoize": "^1.0.2", 57 | "prop-types": "^15.6.1" 58 | }, 59 | "devDependencies": { 60 | "babel-cli": "^6.26.0", 61 | "babel-core": "^6.26.0", 62 | "babel-eslint": "^8.2.3", 63 | "babel-jest": "^22.4.3", 64 | "babel-loader": "^7.1.4", 65 | "babel-plugin-istanbul": "^4.1.6", 66 | "babel-plugin-syntax-async-functions": "^6.13.0", 67 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 68 | "babel-plugin-transform-react-jsx-source": "^6.22.0", 69 | "babel-plugin-transform-regenerator": "^6.26.0", 70 | "babel-preset-es2015-no-commonjs": "^0.0.2", 71 | "babel-preset-react": "^6.24.1", 72 | "babel-preset-stage-2": "^6.24.1", 73 | "babel-register": "^6.26.0", 74 | "classnames": "^2.2.5", 75 | "codecov.io": "^0.1.6", 76 | "cross-env": "^5.1.4", 77 | "css-loader": "^0.28.11", 78 | "eslint": "^4.19.1", 79 | "eslint-config-standard": "^11.0.0", 80 | "eslint-config-standard-react": "^6.0.0", 81 | "eslint-plugin-babel": "^5.0.0", 82 | "eslint-plugin-import": "^2.11.0", 83 | "eslint-plugin-jest": "^21.15.0", 84 | "eslint-plugin-node": "^6.0.1", 85 | "eslint-plugin-promise": "^3.7.0", 86 | "eslint-plugin-react": "^7.7.0", 87 | "eslint-plugin-standard": "^3.0.1", 88 | "file-loader": "^1.1.11", 89 | "flux-standard-action": "^2.0.1", 90 | "gulp": "^3.9.1", 91 | "gulp-gh-pages": "^0.5.4", 92 | "html-webpack-plugin": "^3.2.0", 93 | "husky": "^0.14.3", 94 | "img-loader": "^2.0.1", 95 | "immutable": "^3.8.2", 96 | "jest": "^22.4.3", 97 | "jest-immutable-matchers": "^2.0.1", 98 | "lint-staged": "^7.0.4", 99 | "node-sass": "^4.8.3", 100 | "npm-watch": "^0.3.0", 101 | "react": "^16.3.1", 102 | "react-dom": "^16.3.1", 103 | "react-modal-construction-kit": "^2.0.7", 104 | "react-redux": "^5.0.7", 105 | "react-transition-group": "^2.3.1", 106 | "redux": "^3.7.2", 107 | "redux-immutablejs": "^0.0.8", 108 | "reselect": "^3.0.1", 109 | "rifraf": "^2.0.3", 110 | "rimraf": "^2.6.2", 111 | "sass-loader": "^7.0.1", 112 | "stringstream": "^0.0.5", 113 | "style-loader": "^0.20.3", 114 | "tmp": "0.0.33", 115 | "url-loader": "^1.0.1", 116 | "webpack": "^4.5.0", 117 | "webpack-bundle-analyzer": "^2.11.1", 118 | "webpack-cli": "^2.0.14", 119 | "webpack-dev-server": "^3.1.3" 120 | }, 121 | "peerDependencies": { 122 | "react": "^15.0.0-0 || ^16.0.0-0", 123 | "react-redux": "^4.3.0 || ^5.0.0", 124 | "redux": "^3.0.0", 125 | "immutable": "^3.8.2", 126 | "reselect": "^3.0.1" 127 | }, 128 | "files": [ 129 | "README.md", 130 | "es", 131 | "lib", 132 | "dist" 133 | ], 134 | "lint-staged": { 135 | "*.js": [ 136 | "eslint --fix", 137 | "git add" 138 | ] 139 | }, 140 | "jest": { 141 | "collectCoverageFrom": [ 142 | "src/**/*.js", 143 | "!src/**/__tests__/**/*.js" 144 | ], 145 | "coverageReporters": [ 146 | "text", 147 | "lcov", 148 | "html" 149 | ], 150 | "coveragePathIgnorePatterns": [ 151 | "/node_modules/", 152 | "/dist/" 153 | ], 154 | "testRegex": "__tests__/.*\\.spec\\.js$", 155 | "testEnvironment": "jsdom" 156 | }, 157 | "npmName": "redux-signal", 158 | "npmFileMap": [ 159 | { 160 | "basePath": "/dist/", 161 | "files": [ 162 | "*.js" 163 | ] 164 | } 165 | ] 166 | } 167 | -------------------------------------------------------------------------------- /src/__tests__/index.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | showModal, 3 | getHasVisibleModal, 4 | withSignal, 5 | withSignalPropTypes, 6 | isModal, 7 | isModalPropTypes, 8 | eventHandler, 9 | reducer, 10 | createContainer, 11 | ModalStates, 12 | SignalEvents, 13 | SignalTypes 14 | } from '../index' 15 | 16 | describe('index', () => { 17 | it('should export getHasVisibleModal', () => { 18 | expect(typeof getHasVisibleModal).toBe('function') 19 | }) 20 | 21 | it('should export withSignal', () => { 22 | expect(typeof withSignal).toBe('function') 23 | }) 24 | 25 | it('should export withSignalPropTypes', () => { 26 | expect(typeof withSignalPropTypes).toBe('object') 27 | }) 28 | 29 | it('should export isModal', () => { 30 | expect(typeof isModal).toBe('function') 31 | }) 32 | 33 | it('should export isModalPropTypes', () => { 34 | expect(typeof isModalPropTypes).toBe('object') 35 | }) 36 | 37 | it('should export eventHandler', () => { 38 | expect(typeof eventHandler).toBe('function') 39 | }) 40 | 41 | it('should export reducer', () => { 42 | expect(typeof reducer).toBe('function') 43 | }) 44 | 45 | it('should export createContainer', () => { 46 | expect(typeof createContainer).toBe('function') 47 | }) 48 | 49 | it('should export ModalStates', () => { 50 | expect(typeof ModalStates).toBe('object') 51 | }) 52 | 53 | it('should export SignalEvents', () => { 54 | expect(typeof SignalEvents).toBe('object') 55 | }) 56 | 57 | it('should export SignalTypes', () => { 58 | expect(typeof SignalTypes).toBe('object') 59 | }) 60 | 61 | it('should export showModal', () => { 62 | expect(typeof showModal).toBe('function') 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /src/__tests__/reducer.spec.js: -------------------------------------------------------------------------------- 1 | import * as matchers from 'jest-immutable-matchers' 2 | import reducer, { initialState } from '../reducer' 3 | import { createSignal } from '../actions' 4 | import * as SignalTypes from '../constants/SignalTypes' 5 | 6 | describe('reducer', () => { 7 | beforeEach(function () { 8 | jest.addMatchers(matchers) 9 | }) 10 | 11 | it('should return the initial state', () => { 12 | const result = reducer(undefined, {}) 13 | expect(result.equals(initialState)).toBe(true) 14 | expect(result).toBeImmutable() 15 | }) 16 | 17 | it('should handle createSignal', () => { 18 | const createSignalData = { 19 | type: SignalTypes.OK, 20 | title: 'test', 21 | message: 'test' 22 | } 23 | 24 | const result = reducer(initialState, createSignal(createSignalData)) 25 | expect(result).toBeImmutable() 26 | const resultJs = result.toJS() 27 | expect(resultJs).toHaveProperty('signal') 28 | expect(resultJs).toHaveProperty('signal.data') 29 | expect(resultJs).toHaveProperty('signal.eventQueue') 30 | expect(resultJs).toHaveProperty('signal.feedbackQueue') 31 | expect(resultJs).toHaveProperty('modals') 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable' 2 | import { uid } from './utils' 3 | import * as ModalStates from './constants/ModalStates' 4 | import * as ActionTypes from './constants/ActionTypes' 5 | 6 | const __DEV__ = process.env.NODE_ENV === 'development' 7 | 8 | export const createSignal = ({ 9 | type, 10 | eventHandler, 11 | ...props 12 | }) => { 13 | if (__DEV__) { 14 | if (!type) { 15 | throw new Error('Signal requires a `type`') 16 | } 17 | } 18 | 19 | return { 20 | type: ActionTypes.SIGNAL_CREATE, 21 | modal: fromJS({ 22 | id: uid(10), 23 | type, 24 | eventHandlerId: eventHandler ? eventHandler.eventQueueId : null, 25 | state: ModalStates.CREATED, 26 | ...props 27 | }) 28 | } 29 | } 30 | 31 | export const createModal = instanceId => ({ 32 | instanceId, 33 | type: ActionTypes.MODAL_CREATE 34 | }) 35 | 36 | export const destroyModal = instanceId => ({ 37 | instanceId, 38 | type: ActionTypes.MODAL_DESTROY 39 | }) 40 | 41 | export const showModal = (instanceId, data) => ({ 42 | data, 43 | instanceId, 44 | type: ActionTypes.MODAL_SHOW 45 | }) 46 | 47 | export const hideModal = instanceId => ({ 48 | instanceId, 49 | type: ActionTypes.MODAL_HIDE 50 | }) 51 | 52 | export const setModalBusy = (instanceId, isBusy) => ({ 53 | instanceId, 54 | isBusy, 55 | type: ActionTypes.MODAL_SET_BUSY 56 | }) 57 | 58 | export const destroySignal = id => ({ 59 | type: ActionTypes.SIGNAL_DESTROY, 60 | id: id 61 | }) 62 | 63 | export const setModalState = (id, state) => ({ 64 | type: ActionTypes.MODAL_SET_STATE, 65 | id: id, 66 | value: state 67 | }) 68 | 69 | export const signalEvent = (id, type) => ({ 70 | type: ActionTypes.SIGNAL_EVENT, 71 | id: id, 72 | event: { 73 | type, 74 | modalId: id 75 | } 76 | }) 77 | 78 | export const eventQueueShift = eventQueueId => ({ 79 | eventQueueId, 80 | type: ActionTypes.SIGNAL_EVENT_QUEUE_SHIFT 81 | }) 82 | 83 | export const feedbackQueueShift = eventQueueId => ({ 84 | eventQueueId, 85 | type: ActionTypes.SIGNAL_FEEDBACK_QUEUE_SHIFT 86 | }) 87 | 88 | export const queueDestroy = eventQueueId => ({ 89 | eventQueueId, 90 | type: ActionTypes.SIGNAL_QUEUE_DESTROY 91 | }) 92 | -------------------------------------------------------------------------------- /src/constants/ActionTypes.js: -------------------------------------------------------------------------------- 1 | const prefix = '@@redux-signal/' 2 | 3 | export const MODAL_CREATE = `${prefix}MODAL_CREATE` 4 | export const MODAL_DESTROY = `${prefix}MODAL_DESTROY` 5 | export const MODAL_HIDE = `${prefix}MODAL_HIDE` 6 | export const MODAL_SHOW = `${prefix}MODAL_SHOW` 7 | export const MODAL_SET_BUSY = `${prefix}MODAL_SET_BUSY` 8 | export const MODAL_SET_STATE = `${prefix}MODAL_SET_STATE` 9 | 10 | export const SIGNAL_CREATE = `${prefix}SIGNAL_CREATE` 11 | export const SIGNAL_DESTROY = `${prefix}SIGNAL_DESTROY` 12 | export const SIGNAL_EVENT = `${prefix}SIGNAL_EVENT` 13 | export const SIGNAL_EVENT_QUEUE_SHIFT = `${prefix}SIGNAL_EVENT_QUEUE_SHIFT` 14 | export const SIGNAL_FEEDBACK_QUEUE_SHIFT = `${prefix}SIGNAL_FEEDBACK_QUEUE_SHIFT` 15 | export const SIGNAL_QUEUE_DESTROY = `${prefix}SIGNAL_QUEUE_DESTROY` 16 | -------------------------------------------------------------------------------- /src/constants/ModalStates.js: -------------------------------------------------------------------------------- 1 | export const CREATED = 'CREATED' 2 | export const VISIBLE = 'VISIBLE' 3 | export const DESTROYED = 'DESTROYED' 4 | -------------------------------------------------------------------------------- /src/constants/SignalEvents.js: -------------------------------------------------------------------------------- 1 | export const CLOSE = 'CLOSE' 2 | export const BTN_CANCEL = 'BTN_CANCEL' 3 | export const BTN_OK = 'BTN_OK' 4 | export const BTN_YES = 'BTN_YES' 5 | export const BTN_NO = 'BTN_NO' 6 | -------------------------------------------------------------------------------- /src/constants/SignalTypes.js: -------------------------------------------------------------------------------- 1 | export const OK = 'OK' 2 | export const OK_CANCEL = 'OK_CANCEL' 3 | export const YES_NO = 'YES_NO' 4 | export const YES_NO_CANCEL = 'YES_NO_CANCEL' 5 | -------------------------------------------------------------------------------- /src/createContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { Map, List } from 'immutable' 5 | import { getModal, getSignal } from './selectors' 6 | import { getSignalModalId } from './utils' 7 | 8 | import { 9 | destroySignal, 10 | signalEvent, 11 | setModalState, 12 | feedbackQueueShift 13 | } from './actions' 14 | 15 | import * as ModalEvents from './constants/SignalEvents' 16 | import * as ModalStates from './constants/ModalStates' 17 | 18 | const createContainer = Modal => { 19 | class SignalContainer extends React.Component { 20 | static propTypes = { 21 | updateModals: PropTypes.func.isRequired, 22 | handleEventFeedback: PropTypes.func.isRequired, 23 | onModalExited: PropTypes.func.isRequired, 24 | onModalClose: PropTypes.func.isRequired, 25 | onModalEvent: PropTypes.func.isRequired, 26 | modals: PropTypes.instanceOf(List), 27 | rawModal: PropTypes.instanceOf(Map) 28 | } 29 | 30 | componentWillMount () { 31 | this.props.updateModals() 32 | } 33 | 34 | componentWillReceiveProps (nextProps) { 35 | nextProps.updateModals() 36 | nextProps.handleEventFeedback() 37 | } 38 | 39 | render () { 40 | const { 41 | modals, 42 | rawModal, 43 | onModalExited, 44 | onModalClose, 45 | onModalEvent 46 | } = this.props 47 | 48 | return modals.map(modal => { 49 | const id = modal.get('id') 50 | const currentRawModal = rawModal.get(id) 51 | 52 | return ( 53 | onModalExited(id)} 57 | close={() => onModalClose(id)} 58 | modal={{ 59 | ...modal.toJS(), 60 | ...currentRawModal 61 | }} 62 | /> 63 | ) 64 | }) 65 | } 66 | } 67 | 68 | const mapStateToProps = state => { 69 | const modals = getSignal(state) 70 | 71 | const rawModal = Map(modals.map(modal => [ 72 | modal.get('id'), 73 | getModal(getSignalModalId(modal.get('id')))(state) 74 | ])) 75 | 76 | const eventFeedback = Map( 77 | modals.map(modal => { 78 | let feedback = null 79 | 80 | const eventQueueId = modal.get('eventHandlerId') 81 | if (eventQueueId) { 82 | const events = state.signal.getIn([ 83 | 'signal', 84 | 'feedbackQueue', 85 | eventQueueId 86 | ]) 87 | 88 | if (events && events.size > 0) { 89 | feedback = events.first() 90 | } 91 | } 92 | 93 | return [ 94 | modal.get('id'), 95 | feedback 96 | ] 97 | }) 98 | ) 99 | 100 | return { 101 | eventFeedback, 102 | modals, 103 | rawModal 104 | } 105 | } 106 | 107 | const mapDispatchToProps = dispatch => { 108 | return { 109 | dispatch, 110 | 111 | onModalExited: modalId => { 112 | dispatch(destroySignal(modalId)) 113 | }, 114 | 115 | onModalClose: modalId => { 116 | dispatch(signalEvent(modalId, ModalEvents.CLOSE)) 117 | dispatch(setModalState(modalId, ModalStates.DESTROYED)) 118 | } 119 | } 120 | } 121 | 122 | const mergeProps = (stateProps, dispatchProps) => { 123 | const { eventFeedback, modals, rawModal } = stateProps 124 | const { dispatch } = dispatchProps 125 | 126 | return { 127 | ...stateProps, 128 | ...dispatchProps, 129 | 130 | handleEventFeedback: () => { 131 | modals.forEach(modal => { 132 | const event = eventFeedback.get(modal.get('id')) 133 | 134 | if (!event) { 135 | return 136 | } 137 | 138 | const modalId = modal.get('id') 139 | 140 | dispatch(feedbackQueueShift(modal.get('eventHandlerId'))) 141 | 142 | // By default, BTN_* events trigger modal close 143 | if (event.type.startsWith('BTN_')) { 144 | dispatch(signalEvent(modalId, ModalEvents.CLOSE)) 145 | dispatch(setModalState(modalId, ModalStates.DESTROYED)) 146 | } 147 | }) 148 | }, 149 | 150 | onModalEvent: (modal, eventType) => { 151 | const modalId = modal.id 152 | 153 | if (modal.eventHandlerId) { 154 | dispatch(signalEvent(modalId, eventType)) 155 | } else { 156 | // If we do not have an event handler, just close the modal on any button 157 | if (eventType.startsWith('BTN_')) { 158 | dispatch(setModalState(modalId, ModalStates.DESTROYED)) 159 | } 160 | } 161 | }, 162 | 163 | updateModals: () => { 164 | modals.forEach(modal => { 165 | const modalId = modal.get('id') 166 | const state = rawModal.getIn([modalId]).state 167 | 168 | switch (state) { 169 | case ModalStates.CREATED: 170 | dispatch(setModalState(modalId, ModalStates.VISIBLE)) 171 | break 172 | } 173 | }) 174 | } 175 | } 176 | } 177 | 178 | return connect( 179 | mapStateToProps, 180 | mapDispatchToProps, 181 | mergeProps 182 | )(SignalContainer) 183 | } 184 | 185 | export default createContainer 186 | -------------------------------------------------------------------------------- /src/eventHandler.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | import { uid } from './utils' 5 | 6 | import { eventQueueShift, queueDestroy } from './actions' 7 | import { getModalEvents, getModalByEventQueueId } from './selectors' 8 | import * as ModalEvents from './constants/SignalEvents' 9 | 10 | const eventHandler = () => { 11 | const eventQueueId = uid(10) 12 | 13 | const Component = class extends React.Component { 14 | static propTypes = { 15 | destroyQueue: PropTypes.func.isRequired, 16 | 17 | /* eslint-disable react/no-unused-prop-types */ 18 | handledEvent: PropTypes.func.isRequired, 19 | onOk: PropTypes.func, 20 | onCancel: PropTypes.func, 21 | onYes: PropTypes.func, 22 | onNo: PropTypes.func, 23 | modal: PropTypes.object 24 | /* eslint-enable */ 25 | } 26 | 27 | componentWillMount () { 28 | this.processEvents(this.props) 29 | } 30 | 31 | componentWillReceiveProps (nextProps) { 32 | this.processEvents(nextProps) 33 | } 34 | 35 | componentWillUnmount () { 36 | this.props.destroyQueue() 37 | } 38 | 39 | callHandler = (handler, props) => { 40 | if (handler) { 41 | handler(props.modal.toJS()) 42 | } 43 | } 44 | 45 | processEvents (props) { 46 | if (props.event) { 47 | const handlers = { 48 | [ModalEvents.BTN_OK]: props.onOk, 49 | [ModalEvents.BTN_CANCEL]: props.onCancel, 50 | [ModalEvents.BTN_YES]: props.onYes, 51 | [ModalEvents.BTN_NO]: props.onNo, 52 | [ModalEvents.CLOSE]: props.onClose 53 | } 54 | 55 | this.callHandler(handlers[props.event.type], props) 56 | props.handledEvent() 57 | } 58 | } 59 | 60 | render () { 61 | return null 62 | } 63 | } 64 | 65 | const mapStateToProps = state => { 66 | const events = getModalEvents(eventQueueId)(state) 67 | const event = events ? events.first() : null 68 | 69 | return { 70 | event, 71 | modal: getModalByEventQueueId(eventQueueId)(state) 72 | } 73 | } 74 | 75 | const mapDispatchToProps = dispatch => ({ 76 | handledEvent: () => dispatch(eventQueueShift(eventQueueId)), 77 | destroyQueue: () => dispatch(queueDestroy(eventQueueId)) 78 | }) 79 | 80 | const Connected = connect(mapStateToProps, mapDispatchToProps)(Component) 81 | 82 | Connected.eventQueueId = eventQueueId 83 | 84 | return Connected 85 | } 86 | 87 | export default eventHandler 88 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as SignalEvents from './constants/SignalEvents' 2 | import * as SignalTypes from './constants/SignalTypes' 3 | import * as ModalStates from './constants/ModalStates' 4 | 5 | export { 6 | showModal, 7 | hideModal, 8 | setModalBusy, 9 | destroySignal, 10 | signalEvent, 11 | setModalState 12 | } from './actions' 13 | 14 | export { 15 | getModal, 16 | getSignal, 17 | getModalByEventQueueId, 18 | getModalEvents, 19 | getHasVisibleModal 20 | } from './selectors' 21 | 22 | export { 23 | default as withSignal, 24 | withSignalPropTypes 25 | } from './withSignal' 26 | 27 | export { 28 | default as isModal, 29 | isModalPropTypes 30 | } from './isModal' 31 | 32 | export { 33 | getSignalModalId 34 | } from './utils' 35 | 36 | export { default as eventHandler } from './eventHandler' 37 | export { default as reducer } from './reducer' 38 | export { default as createContainer } from './createContainer' 39 | 40 | export { 41 | SignalEvents, 42 | SignalTypes, 43 | ModalStates 44 | } 45 | -------------------------------------------------------------------------------- /src/isModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { connect } from 'react-redux' 4 | 5 | import { 6 | createModal, 7 | destroyModal, 8 | hideModal, 9 | setModalBusy 10 | } from './actions' 11 | 12 | import { getModal } from './selectors' 13 | 14 | export const isModalPropTypes = { 15 | instanceId: PropTypes.string.isRequired, 16 | close: PropTypes.func.isRequired, 17 | setBusy: PropTypes.func.isRequired, 18 | modal: PropTypes.object.isRequired 19 | } 20 | 21 | const isModal = WrappedComponent => { 22 | const Component = class extends React.Component { 23 | static propTypes = { 24 | ...isModalPropTypes, 25 | create: PropTypes.func.isRequired, 26 | destroy: PropTypes.func.isRequired 27 | } 28 | 29 | componentWillMount () { 30 | this.props.create() 31 | } 32 | 33 | componentWillUnmount () { 34 | this.props.destroy() 35 | } 36 | 37 | render () { 38 | const { 39 | instanceId, 40 | close, 41 | setBusy, 42 | modal 43 | } = this.props 44 | 45 | const componentProps = { 46 | instanceId, 47 | close, 48 | setBusy, 49 | modal 50 | } 51 | 52 | return 53 | } 54 | } 55 | 56 | const mapStateToProps = (state, { instanceId }) => ({ 57 | modal: getModal(instanceId)(state) 58 | }) 59 | 60 | const mapDispatchToProps = (dispatch, { instanceId }) => ({ 61 | create: () => dispatch(createModal(instanceId)), 62 | destroy: () => dispatch(destroyModal(instanceId)), 63 | close: () => dispatch(hideModal(instanceId)), 64 | setBusy: isBusy => dispatch(setModalBusy(instanceId, isBusy)) 65 | }) 66 | 67 | return connect( 68 | mapStateToProps, 69 | mapDispatchToProps 70 | )(Component) 71 | } 72 | 73 | export default isModal 74 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import { fromJS, List, Map } from 'immutable' 2 | import * as ActionTypes from './constants/ActionTypes' 3 | import * as ModalStates from './constants/ModalStates' 4 | import { getSignalModalId } from './utils' 5 | 6 | export const initialState = fromJS({ 7 | signal: { 8 | data: {}, 9 | eventQueue: {}, 10 | feedbackQueue: {}, 11 | order: [] 12 | }, 13 | modals: {} 14 | }) 15 | 16 | const emptyModal = Map({ 17 | isBusy: false, 18 | state: ModalStates.CREATED 19 | }) 20 | 21 | function signalCreate (state, action) { 22 | const modalInstanceId = getSignalModalId(action.modal.get('id')) 23 | 24 | return state 25 | .setIn(['modals', modalInstanceId], emptyModal) 26 | .setIn(['signal', 'data', action.modal.get('id')], action.modal) 27 | .updateIn(['signal', 'order'], e => e.push(action.modal.get('id'))) 28 | .update('signal', state => { 29 | const eventHandlerId = action.modal.get('eventHandlerId') 30 | 31 | if (!eventHandlerId) { 32 | return state 33 | } 34 | 35 | return state 36 | .setIn(['eventQueue', eventHandlerId], List()) 37 | .setIn(['feedbackQueue', eventHandlerId], List()) 38 | }) 39 | } 40 | 41 | function signalDestroy (state, action) { 42 | const modalInstanceId = getSignalModalId(action.id) 43 | 44 | return state 45 | .deleteIn(['signal', 'data', action.id]) 46 | .updateIn(['signal', 'order'], e => e.delete(e.indexOf(action.id))) 47 | .deleteIn(['modals', modalInstanceId]) 48 | } 49 | 50 | function signalSetState (state, action) { 51 | const translatedAction = { 52 | instanceId: getSignalModalId(action.id), 53 | value: action.value 54 | } 55 | 56 | return state.setIn( 57 | ['modals', translatedAction.instanceId, 'state'], 58 | translatedAction.value 59 | ) 60 | } 61 | 62 | function signalEvent (state, action) { 63 | const eventHandlerId = state.getIn([ 64 | 'signal', 65 | 'data', 66 | action.id, 67 | 'eventHandlerId' 68 | ]) 69 | 70 | if (eventHandlerId !== null) { 71 | return state.updateIn(['signal', 'eventQueue', eventHandlerId], e => 72 | e.push(action.event) 73 | ) 74 | } 75 | 76 | return state 77 | } 78 | 79 | function signalEventQueueShift (state, action) { 80 | const event = state 81 | .getIn(['signal', 'eventQueue', action.eventQueueId]) 82 | .first() 83 | 84 | // Move event from eventQueue to feedbackQueue 85 | return state 86 | .updateIn(['signal', 'eventQueue', action.eventQueueId], e => e.shift()) 87 | .updateIn(['signal', 'feedbackQueue', action.eventQueueId], e => 88 | e.push(event) 89 | ) 90 | } 91 | 92 | export default function reduxSignalReducer (state = initialState, action) { 93 | switch (action.type) { 94 | case ActionTypes.MODAL_CREATE: 95 | case ActionTypes.MODAL_HIDE: 96 | return state.setIn(['modals', action.instanceId], emptyModal) 97 | case ActionTypes.MODAL_DESTROY: 98 | return state.deleteIn(['modals', action.instanceId]) 99 | case ActionTypes.MODAL_SHOW: 100 | return state.updateIn(['modals', action.instanceId], modal => { 101 | return modal 102 | .set('isBusy', false) 103 | .set('state', ModalStates.VISIBLE) 104 | .set('data', action.data) 105 | }) 106 | case ActionTypes.MODAL_SET_BUSY: 107 | return state.setIn(['modals', action.instanceId, 'isBusy'], action.isBusy) 108 | case ActionTypes.SIGNAL_CREATE: 109 | return signalCreate(state, action) 110 | case ActionTypes.SIGNAL_DESTROY: 111 | return signalDestroy(state, action) 112 | case ActionTypes.MODAL_SET_STATE: 113 | return signalSetState(state, action) 114 | case ActionTypes.SIGNAL_EVENT: 115 | return signalEvent(state, action) 116 | case ActionTypes.SIGNAL_EVENT_QUEUE_SHIFT: 117 | return signalEventQueueShift(state, action) 118 | case ActionTypes.SIGNAL_FEEDBACK_QUEUE_SHIFT: 119 | return state.updateIn( 120 | ['signal', 'feedbackQueue', action.eventQueueId], 121 | e => e.shift() 122 | ) 123 | case ActionTypes.SIGNAL_QUEUE_DESTROY: 124 | return state 125 | .deleteIn(['signal', 'eventQueue', action.eventQueueId]) 126 | .deleteIn(['signal', 'feedbackQueue', action.eventQueueId]) 127 | default: 128 | return state 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector, createSelectorCreator } from 'reselect' 2 | import lrumemoize from 'lru-memoize' 3 | 4 | import * as ModalStates from './constants/ModalStates' 5 | 6 | // TODO: make this configurable? 8 seems to be a reasonable default 7 | const customSelectorCreator = createSelectorCreator( 8 | (s, n) => lrumemoize(n)(s), 9 | 8 10 | ) 11 | 12 | export const getModal = customSelectorCreator( 13 | instanceId => instanceId, 14 | instanceId => 15 | createSelector( 16 | state => state.signal.get('modals'), 17 | modals => { 18 | const modalState = modals.get(instanceId) 19 | 20 | if (!modalState) { 21 | return { 22 | data: null, 23 | isBusy: false, 24 | isFirst: false, 25 | isVisible: false, 26 | state: null 27 | } 28 | } 29 | 30 | let firstVisibleModal = null 31 | for (const [key, value] of modals.entries()) { 32 | if (value.get('state') === ModalStates.VISIBLE) { 33 | firstVisibleModal = key 34 | break 35 | } 36 | } 37 | 38 | return { 39 | instanceId, 40 | data: modalState.get('data'), 41 | isBusy: modalState.get('isBusy'), 42 | isFirst: firstVisibleModal === instanceId, 43 | isVisible: modalState.get('state') === ModalStates.VISIBLE, 44 | state: modalState.get('state') 45 | } 46 | } 47 | ) 48 | ) 49 | 50 | export const getHasVisibleModal = createSelector( 51 | state => state.signal.get('modals'), 52 | modals => { 53 | for (const value of modals.valueSeq()) { 54 | if (value.get('state') === ModalStates.VISIBLE) { 55 | return true 56 | } 57 | } 58 | 59 | return false 60 | } 61 | ) 62 | 63 | export const getSignal = createSelector( 64 | state => state.signal.get('signal'), 65 | signal => signal.get('order').map(id => signal.getIn(['data', id])) 66 | ) 67 | 68 | export const getModalEvents = createSelector( 69 | eventQueueId => eventQueueId, 70 | eventQueueId => 71 | createSelector( 72 | state => state.signal.getIn(['signal', 'eventQueue']), 73 | eventQueue => eventQueue.get(eventQueueId) 74 | ) 75 | ) 76 | 77 | export const getModalByEventQueueId = createSelector( 78 | eventQueueId => eventQueueId, 79 | eventQueueId => 80 | createSelector( 81 | state => state.signal.getIn(['signal', 'data']), 82 | data => { 83 | if (!data) { 84 | return null 85 | } 86 | 87 | for (const value of data.valueSeq()) { 88 | if (value.get('eventHandlerId') === eventQueueId) { 89 | return value 90 | } 91 | } 92 | 93 | return null 94 | } 95 | ) 96 | ) 97 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const getSignalModalId = id => `signal-${id}` 2 | 3 | export function uid (len) { 4 | len = len || 7 5 | return Math.random() 6 | .toString(35) 7 | .substr(2, len) 8 | } 9 | -------------------------------------------------------------------------------- /src/withSignal.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { connect } from 'react-redux' 3 | import { 4 | createSignal, 5 | setModalState, 6 | signalEvent, 7 | showModal, 8 | hideModal 9 | } from './actions' 10 | 11 | export const withSignalPropTypes = { 12 | createSignal: PropTypes.func.isRequired, 13 | setModalState: PropTypes.func.isRequired, 14 | signalEvent: PropTypes.func.isRequired, 15 | showModal: PropTypes.func.isRequired, 16 | hideModal: PropTypes.func.isRequired 17 | } 18 | 19 | const withSignal = Component => { 20 | const mapDispatchToProps = { 21 | createSignal, 22 | setModalState, 23 | signalEvent, 24 | showModal, 25 | hideModal 26 | } 27 | 28 | return connect( 29 | null, 30 | mapDispatchToProps 31 | )(Component) 32 | } 33 | 34 | export default withSignal 35 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | 3 | const env = process.env.NODE_ENV 4 | 5 | const reactExternal = { 6 | root: 'React', 7 | commonjs2: 'react', 8 | commonjs: 'react', 9 | amd: 'react' 10 | } 11 | 12 | const propTypesExternal = { 13 | root: 'PropTypes', 14 | commonjs2: 'prop-types', 15 | commonjs: 'prop-types', 16 | amd: 'prop-types' 17 | } 18 | 19 | const reduxExternal = { 20 | root: 'Redux', 21 | commonjs2: 'redux', 22 | commonjs: 'redux', 23 | amd: 'redux' 24 | } 25 | 26 | const reactReduxExternal = { 27 | root: 'ReactRedux', 28 | commonjs2: 'react-redux', 29 | commonjs: 'react-redux', 30 | amd: 'react-redux' 31 | } 32 | 33 | const immutableExternal = { 34 | root: 'Immutable', 35 | commonjs2: 'immutable', 36 | commonjs: 'immutable', 37 | amd: 'immutable' 38 | } 39 | 40 | const reselectExternal = { 41 | commonjs2: 'reselect', 42 | commonjs: 'reselect', 43 | amd: 'reselect' 44 | } 45 | 46 | const rules = [] 47 | 48 | rules.push({ 49 | test: /\.js$/, 50 | loader: 'babel-loader', 51 | exclude: /node_modules/ 52 | }) 53 | 54 | const config = { 55 | mode: env, 56 | externals: { 57 | react: reactExternal, 58 | redux: reduxExternal, 59 | immutable: immutableExternal, 60 | reselect: reselectExternal, 61 | 'react-redux': reactReduxExternal, 62 | 'prop-types': propTypesExternal 63 | }, 64 | module: { 65 | rules 66 | }, 67 | output: { 68 | library: 'ReduxSignal', 69 | libraryTarget: 'umd' 70 | }, 71 | plugins: [ 72 | new webpack.DefinePlugin({ 73 | 'process.env.NODE_ENV': JSON.stringify(env) 74 | }) 75 | ] 76 | } 77 | 78 | module.exports = config 79 | --------------------------------------------------------------------------------