├── .babelrc ├── .gitignore ├── DISCLAIMER ├── LICENSE ├── README.md ├── TODO.md ├── Testing.md ├── ___webpack.config.js ├── __tests__ └── app.specs.js ├── assets ├── Image search scenario with fsm with mandatory options.txt ├── Image search scenario with fsm.png ├── Image search scenario with fsm.txt ├── Image search scenario with mandatory options.png ├── Image search scenario.png ├── Image search scenario.txt ├── image gallery state cat.png ├── image gallery state cat.txt ├── mvp architecture.graphml ├── mvp architecture.png ├── view-mediator-state-command architecture.graphml └── view-mediator-state-command architecture.png ├── package-lock.json ├── package.json ├── pkg ├── LICENSE ├── README.md ├── dist-node │ └── index.js ├── dist-src │ ├── Machine.js │ ├── helpers.js │ ├── index.js │ └── properties.js ├── dist-web │ └── index.js └── package.json ├── rollup.config.js ├── src ├── Machine.js ├── helpers.js ├── index.js └── properties.js ├── test-utils.js ├── tests ├── assets │ └── test-generation.js ├── css │ └── qunit-1.20.0.css ├── fixtures │ ├── MovieSearch.js │ ├── components.js │ ├── fake.js │ ├── helpers.js │ ├── machines.js │ ├── movieSearchApp.js │ ├── movieSearchFsm.js │ ├── properties.js │ └── test-ids.js ├── helpers.js ├── image_gallery_component.specs.js ├── image_gallery_machine.specs.js ├── index.html ├── index.js ├── qunit-2.8.0.js └── webpack.config.js └── types ├── fsm.js └── react-fsm-integration.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false, 7 | "loose": true 8 | } 9 | ], 10 | "@babel/preset-react" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .idea 4 | -------------------------------------------------------------------------------- /DISCLAIMER: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brucou/react-state-driven/04b2ba782a57d30415f5e5e9aeb6cfe12fac6edb/DISCLAIMER -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 brucou 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. 22 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | - refactor image_gallery_component_specs 3 | - it is worth showing tests running live in the browsers 4 | - remove dependency on staet-transducer and entry actions! 5 | - also don't do the tests by recomputing stuff but with PBT!!! 6 | # API 7 | - simplify 8 | - preprocessor default x => x 9 | - effect handlers default nothing 10 | - no initial event: 11 | 12 | 13 | 14 | - try to see how to hve the renderWith component as a slot/child of 15 | # Build 16 | - isntall pkg2 and build with that 17 | - use the codesandbox as a test - may be difficult to debug BUT I have the devtool!! 18 | 19 | - BEST PRACTICE: three possibilities for reusing pure components: 20 | - inject a next into the component interface. That means the event handler in the component 21 | MUST use `next` to pass events. Which means they are not completely pure isnt' it. That means 22 | the component DEPENDS on `next`, though the dependency is INJECTED 23 | - the `next` becomes a part of the specification of the component 24 | - there could be cases where we don't want that, or we CAN"T do that. ChessBoard is a good 25 | example 26 | - the component exposes an interface to pass events, just like the DOM. onclick etc. Cf. 27 | ChessBoard example. In that case, a wrapper around the component that write onclick as a 28 | function of next can be easily written. The component changes NOT AT ALL but we have to write 29 | the glue code out of it (we have to write it anyways!). So this might be a best case? 30 | - the component deals with no events handlers - pure render. We then have to capture the events 31 | out of the component, like cycle does. However, this event capture is COUPLED to the component 32 | (through selectors or logic). So modifying the component may mean modifying the capture code 33 | ... Selectors is an implementation detail of the component. Not ideal. Best would be to write 34 | the event handling logic based on the component specification. For example, selector.onclik <- 35 | next (...), where selector is computed as find(.button with text...) under the element `el`.. 36 | . complex. Can't think of a case where it is worth the trouble. 37 | - SO 1 and 2 are acceptable with 2 the preferred option when possible. In 2, the component 38 | needs not know ANYTHING about its context - zero dependency. But it needs carefully anticipate 39 | the needs of its context/consumers and surface that in its API 40 | - change build: pkg does not publish anymore, reinstall nd rety 41 | - does not work with codesndbox playground (CORS error having nothing to do with CORS) 42 | - if it does not work, reverse to a rollup build... 43 | - change API: 44 | - have renderWith 45 | - have props + isVisible[=false] as state of components 46 | - toggle isVisible on the first render 47 | - when COMMAND_RENDER, merge the params in the props property 48 | - rerender renderWith with the new merged props 49 | - NOTE: can already be done with 50 | [COMMAND_RENDER]: (machineComponent, renderWith, params, next) => { 51 | // Applying flipping animations : read DOM before render, and flip after render 52 | flipping.read(); 53 | const newProps = merge(machineComponent.props, params); // merge be optional user defined 54 | props of 55 | machineComponent.setState( 56 | { 57 | render: React.createElement(renderWith, Object.assign({}, newProps, { next }), []), 58 | props: newProps 59 | }, 60 | () => flipping.flip() 61 | ); 62 | } 63 | // TODO : test actions are run in order.. 64 | - EXAMPLE OF SUBSTITUTION : 65 | - > any state machine implementation can be substituted to our library provided that it respects 66 | the machine interface and contracts: 67 | - give an example of state machine written as a standard function - no library 68 | - the password example is great for that 69 | - NTH: options: event emitter property name (next by default) - so interface with renderWith can 70 | be customized! 71 | - DOC!! procss all the DOC it notes in the code 72 | - incorporate in codesandbox AND demoboard link for article then PUBLISH the motherfucking article 73 | - idea is stat machine will be the same among all ui libraries 74 | - try with initial control state and new version of state transducer 75 | - UPDATE README! clean code. new article will send lots of people 76 | - when finished, update state transducer to remove the event handler library of options! cf code 77 | TODOs 78 | - actually might even have meta data in observe and subject interface (give it a name for 79 | tracing?) 80 | - if no eventHandler passed, then use internal event handling library which is just 81 | eventEmitter and listeners. Then leave transducers out 82 | - make preprocessor an object : 83 | - {rawEvent : (rawEventData, ref) => ...} 84 | - if not a function then use the object format 85 | - do a test generation library also for testing the FSM cd. movie-search-app, for now I only 86 | generate the inputs 87 | - then do a library also for running the tests in DOM real browser 88 | - include this as observable library in connection with the event emitter : 4 Kb all included!!! 89 | and that means all operators which can be tree shaken -- and performant!! 90 | - have a rollup config for min and one without, and add source map 91 | - write same movie search app for hyperHTML, svelte and angular! 92 | - change API to have observable, observer, and pipe API for event handler!! 93 | - change subject API to next instead of `emit`, this is observable standard 94 | - update demo code 95 | - make a esm version of penpal to decrease size (right now I import the whole thing...) 96 | - in demos, add options with initial event [INIT_EVENT, 0] cf. ipage_gallery_component_spcs 97 | - update README with nice drawing 98 | - event seq. => input seq. => fsm => output seq. => assertion seq. 99 | - graphs about controller vs. mediator 100 | - mocks, category computation <- machine some graph to explain that the command handler effect 101 | handler -> add to the LSC graph!! 102 | - look at that plan-oriented-testing (recoup with the improvement in the testing generation) 103 | - explain the resulting architecture 104 | - communicate outputs by callback : similar to onClick for instance 105 | - how to receive inputs?? pass an event source as parameter!! 106 | - this architecture allow to implement any effectful component!! communicating through in and 107 | out sources 108 | - can those components be synchronized via a monitor?? to investigate but the idea would be to 109 | copy Erlang here. If I want to implement a restart/stop for instance, then I should have a 110 | protocol that gives components the possibility to clean up, and also a way to restart (that 111 | should be restarting the machine right?) The point is this is independent from the chart. The 112 | monitor would also manage error (using react boundaries, or having an error callback on each 113 | component?) 114 | - I could use a monitor which implement an actor-based protocol, with actors being React 115 | component based on state machines!! 116 | - implement nice examples 117 | - could add a machine.stop in the interface, that we would run in the onComplete, or on 118 | ErrorHandler (no need for xstate integration but maybe for others) 119 | - add debugging support..., an overlay tipicamente would be good 120 | 121 | 122 | # Actor model 123 | ## Basics 124 | - a machine has a receiving source which receives : 125 | - next messages : message to be processed 126 | - exit messages : from linked/dependent machines 127 | - a machine has a emitting source which emits : 128 | - next messages 129 | - exit message with reason 130 | - addressing to think about 131 | - a machine can create other machines and pass them a receiving source and an exit source 132 | - the receiving source receives events delegated by/from the parent machine 133 | - the receiving source receives events delegated by the parent machine 134 | - the exit source can be connected back to the machine 135 | - or to other machine's source that the parent machine know of?? 136 | 137 | ## Links 138 | - a parent machine can create another machine and pass sources so that: 139 | - the child machine receives next message 140 | - error messages received from the child machine triggers a forwarding of the error to its exit 141 | source 142 | - conversely, an exit message from the parent machine is propagated to its children 143 | - the exit message forwarded are of the same nature : if normal, then normal, if other 144 | reasons then that 145 | - this means links are bidirectional 146 | 147 | > Terminating processes will emit exit signals to all linked processes, which may terminate as 148 | well or handle the exit in some way. 149 | > The default behaviour when a process receives an exit signal with an exit reason other than normal, is to terminate and in turn emit exit signals with the same exit reason to its linked processes. 150 | An exit signal with reason normal is ignored. 151 | 152 | ## Monitor 153 | - same as links, BUT : 154 | - an exception (i.e. not normal exit) is turned into receiving a down message 155 | 156 | ## Supervisors 157 | - list children 158 | - 4 strategies, did not understand the last one 159 | - supervisor is about restarting! 160 | 161 | > The supervisor is responsible for starting, stopping, and monitoring its child processes. The basic idea of a supervisor is that it must keep its child processes alive by restarting them when necessary. 162 | 163 | 164 | # State machine debugger 165 | ## Goals 166 | Memorize the trace of execution of a state machine and propose a navigation interface allowing to 167 | rewind and forward past events to see their effects on the machine. Desirable features are a 168 | clipboard and diff functionality, for instance compare two events and look at the diff. 169 | ## UX/UI 170 | - drawer, overlay, sidebar... Basically show and hide on demand, is seen over given content 171 | - could be transparent when far, opaque when close (mouse) 172 | - activable/deactivable 173 | - 174 | 175 | --------> 176 | data display | vertical ev -> eff | zoom machine viz | data display (extended state) 177 | (ev|pre|output|eff) 178 | 179 | prev/next, fast prev/fast next, counter with index x/y 180 | 181 | ## Features 182 | - live connection to the `` component 183 | - keeps trace of events, to the machine, outputs to the machine, and extended state 184 | - probably should also keep track of preprocessor input?, and also effect handlers call, and 185 | trigger call 186 | - should/could render COMMAND_RENDER (or not? just html?) 187 | - visualize state machine and color active state and previous transition 188 | - should keep track of expected output sequence too 189 | - allow memorization of stuff, and diff between any two of those 190 | - grouped by type? diff events? diff command? etc. 191 | - should display a navigator 192 | - one step could be broken down as 193 | - event 194 | - input 195 | - command 196 | - effect 197 | - navigation between steps, and within steps 198 | 199 | ## API design 200 | - event source 201 | - event emitter (callback) 202 | 203 | # Choice of window manager 204 | - [subdivide](https://github.com/philholden/subdivide) 205 | - 3 years not updated, not maintained, 3 contributors 206 | - you have to have a list of applications before hand to pick the window 207 | - nice videos - have a look 208 | - nice idea though, user-driven layout, the top in customizability 209 | - may be useful for language learning system 210 | 211 | # Choice of window tech 212 | - [same-domain window](https://github.com/ryanseddon/react-frame-component) 213 | - easiest 214 | - BUT requires me to ship the debug window code with react-state-driven and can't be tree-shaken 215 | - so potentially turning it from 8K gzip to ?? gzip? 216 | - doing this with injecting debug app as prop won't work 217 | - how to use the same css style?? can use style prop 218 | - cf. also https://medium.com/@ryanseddon/rendering-to-iframes-in-react-d1cb92274f86 219 | - license is weird, ask!! 220 | - iframe and communicate via postMessage 221 | - [postmate](https://github.com/dollarshaveclub/postmate) looks amazing and low level and 222 | easier API 223 | - [zoid](https://github.com/krakenjs/zoid) seems like a rich solution, but haven't understood it 224 | all yet. Seems to integrate pretty well with react 225 | - size?? 226 | - understand [post-robot](https://github.com/krakenjs/post-robot) 227 | - copy styling in new window just after opening (no need to be ready) 228 | - [react new window](https://hackernoon.com/using-a-react-16-portal-to-do-something-cool-2a2d627b0202) 229 | - postmate looks promising, 1.5KB vs. 10+/20+kb for post-robot/zoid 230 | - however no timeout management! or error management! 231 | - possibilities 232 | - [flex layout](https://github.com/caplin/FlexLayout) 233 | - API seems simple, documentation understandable(!) 234 | - 81 stars, 6 contributors 235 | - not too many demos, and not too beautiful 236 | - tabs can close and expand 237 | - some layout configuration are hard to do manually for the user (spanning two widgets) 238 | - but unique and very nice system for border tab handles 239 | - so you have a center area, and four borders 240 | - [golden layout](https://github.com/golden-layout/golden-layout) 241 | - 4035 stars, 45 ccontributors 242 | - last updated 7 months ago, lots of issues unresolved (more like Q&A though) 243 | - so probably not very maintained 244 | - allows to pop out and back windows 245 | - does tabs, window controls, but no border handles 246 | - LOTS of examples!! and doc not too bad 247 | - [react mosaic](https://github.com/palantir/react-mosaic) 248 | - 1569 stars, 7 contributors 249 | - uses a binary tree (?) to describe the layout structure 250 | - does not do tabs and border handles 251 | - demos but not too much in form of documentation 252 | - annoying typescript and tsx code with poor readability 253 | - [React-Grid-Layout](https://github.com/STRML/react-grid-layout) 254 | - 7571 stars, 47 contributors 255 | - used in top companies 256 | - only does layouting 257 | - no tabs/stacks... 258 | - slick and clean 259 | - maintained 260 | - issues dealt with 261 | - http://demo.thorpora.fr/ez-dashing/ 262 | - https://camo.githubusercontent.com/8c68a2e6d6e01364247232267a5698ac0d9b63c6/687474703a2f2f692e696d6775722e636f6d2f6f6f314e5436632e676966 263 | - https://strml.github.io/react-grid-layout/examples/0-showcase.html 264 | 265 | OK for now, it seems `flex layout` is what we need. This is the closest to webstorm window system 266 | . React-Grid-Layout seems the most user friendly slick but it would be necessary to customize a 267 | lot to add features (tabs, border handles) in a slick way. 268 | 269 | # API subject + transducer 270 | - try catch and error processing 271 | - write adapter for event emitter 272 | - beware of error and completion semantics: should I close the subscription a la rxjs?? 273 | to think about 274 | - in example, I will have to write flatMapLatest myself! 275 | - DOCs!! 276 | - will have to write concatMap also as transducer... mmm will be concatMapPromise 277 | 278 | In order: 279 | - make it work with Rx with the machine example without flatMapLatest 280 | - add flatMapLatest 281 | - make it work with event emitter 282 | - do try catch error processing 283 | -------------------------------------------------------------------------------- /___webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin') 2 | const path = require('path') 3 | const webpack = require('webpack') 4 | 5 | module.exports = { 6 | entry: './playground/index.js', 7 | 8 | devServer: { 9 | contentBase: './dist', 10 | hot: true, 11 | }, 12 | 13 | mode: 'development', 14 | 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.js$/, 19 | exclude: /node_modules/, 20 | use: { 21 | loader: 'babel-loader', 22 | options: { 23 | plugins: ['react-hot-loader/babel'], 24 | }, 25 | }, 26 | }, 27 | ], 28 | }, 29 | 30 | output: { 31 | path: path.resolve(__dirname, './dist'), 32 | filename: 'bundle.js', 33 | }, 34 | 35 | plugins: [ 36 | new HtmlWebpackPlugin(), 37 | new webpack.NamedModulesPlugin(), 38 | new webpack.HotModuleReplacementPlugin(), 39 | ], 40 | } 41 | -------------------------------------------------------------------------------- /__tests__/app.specs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import sinon from 'sinon'; 4 | import {Machine} from '../src' 5 | import { 6 | getByLabelText, 7 | getByText, 8 | getByTestId, 9 | queryByTestId, 10 | // Tip: all queries are also exposed on an object 11 | // called "queries" which you could import here as well 12 | wait, 13 | } from 'dom-testing-library' 14 | // import Enzyme, { mount } from 'enzyme'; 15 | // import Adapter from 'enzyme-adapter-react-16'; 16 | // Enzyme.configure({ adapter: new Adapter() }); 17 | 18 | test('Link changes the class when hovered', () => { 19 | // const component = renderer.create(
"Hello"
); 20 | // let tree = component.toJSON(); 21 | // expect(tree).toMatchSnapshot(); 22 | const onButtonClick = sinon.spy(); 23 | const wrapper = mount(( 24 |
"Hello"
25 | )); 26 | wrapper.find('div').simulate('click'); 27 | expect(onButtonClick).to.have.property('callCount', 1); 28 | }); 29 | 30 | // TODO : use Qunit to test in the browser 31 | // qunit index.html to add id for test location (with data-id : module name) 32 | // render the react component in the DOM 33 | // use dom-testing-library to do the input simulation and wait for outputs 34 | // test output vs expected outputs 35 | // consume all input sequence! 36 | // first try set up with a small trivial example 37 | // then with one input sequence by hand 38 | // then automate it for any input sequence 39 | 40 | -------------------------------------------------------------------------------- /assets/Image search scenario with fsm with mandatory options.txt: -------------------------------------------------------------------------------- 1 | title Image search scenario with mandatory options 2 | 3 | User -> User interface : 'c' 4 | User -> User interface : 'a' 5 | User -> User interface : 't' 6 | User -> User interface : Search 7 | note right of User interface : User searches for 'cat' images 8 | User interface -> +Mediator : button click 9 | Mediator -> +FSM : {SEARCH : 'cat'} 10 | FSM -> -Mediator : [ query 'cat' images, render ] 11 | Mediator -> +Command handler : query 'cat' images 12 | Command handler -> +External systems : fetch https://api.flickr.com/... 13 | External systems -> -Command handler : 14 | Command handler -> -Mediator : 15 | Mediator -> -User interface : render 16 | User interface -> User : 17 | note right of External systems : successful search 18 | External systems -> Command handler : query results 19 | Command handler -> +Mediator : queried 'cat' images 20 | Mediator -> +FSM : {SEARCH_SUCCESS : { items: ... }} 21 | FSM -> -Mediator : [render] 22 | note right of User interface : Application displays cat images 23 | Mediator -> -User interface : render 24 | User interface -> User : 25 | -------------------------------------------------------------------------------- /assets/Image search scenario with fsm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brucou/react-state-driven/04b2ba782a57d30415f5e5e9aeb6cfe12fac6edb/assets/Image search scenario with fsm.png -------------------------------------------------------------------------------- /assets/Image search scenario with fsm.txt: -------------------------------------------------------------------------------- 1 | title Image search scenario 2 | 3 | User -> User interface : 'c' 4 | User -> User interface : 'a' 5 | User -> User interface : 't' 6 | User -> User interface : Search 7 | note right of User interface : User searches for 'cat' images 8 | User interface -> +Mediator : button click 9 | Mediator -> +Preprocessor : button click 10 | Preprocessor -> -Mediator : {Search : 'cat'} 11 | Mediator -> +FSM : {SEARCH : 'cat'} 12 | FSM -> -Mediator : [ query 'cat' images, render ] 13 | Mediator -> +Command handler : query 'cat' images 14 | Command handler -> +Effect handler : query flickr 15 | Effect handler -> +External systems : fetch https://api.flickr.com/... 16 | External systems -> -Effect handler : 17 | Effect handler -> -Command handler : 18 | Command handler -> -Mediator : 19 | Mediator -> -User interface : render 20 | User interface -> User : 21 | note right of External systems : successful search 22 | External systems -> Effect handler : API response 23 | Effect handler -> Command handler : query results 24 | Command handler -> +Mediator : queried 'cat' images 25 | Mediator -> +FSM : {SEARCH_SUCCESS : { items: ... }} 26 | FSM -> -Mediator : [render] 27 | note right of User interface : Application displays cat images 28 | Mediator -> -User interface : render 29 | User interface -> User : 30 | -------------------------------------------------------------------------------- /assets/Image search scenario with mandatory options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brucou/react-state-driven/04b2ba782a57d30415f5e5e9aeb6cfe12fac6edb/assets/Image search scenario with mandatory options.png -------------------------------------------------------------------------------- /assets/Image search scenario.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brucou/react-state-driven/04b2ba782a57d30415f5e5e9aeb6cfe12fac6edb/assets/Image search scenario.png -------------------------------------------------------------------------------- /assets/Image search scenario.txt: -------------------------------------------------------------------------------- 1 | title Image search scenario 2 | 3 | User -> User interface : 'c' 4 | User -> User interface : 'a' 5 | User -> User interface : 't' 6 | User -> User interface : Search 7 | note right of User interface : User searches for 'cat' images 8 | User interface -> +Application : button click 9 | Application -> External systems : API call 10 | Application -> -User interface : 11 | User interface -> User : 12 | note right of External systems : successful search 13 | External systems -> +Application : API response 14 | Application -> -User interface : render 15 | note right of User interface : Application displays cat images 16 | User interface -> -User : 17 | 18 | -------------------------------------------------------------------------------- /assets/image gallery state cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brucou/react-state-driven/04b2ba782a57d30415f5e5e9aeb6cfe12fac6edb/assets/image gallery state cat.png -------------------------------------------------------------------------------- /assets/image gallery state cat.txt: -------------------------------------------------------------------------------- 1 | initial, 2 | 3 | start: 4 | entry/ render, 5 | gallery: 6 | entry/ render, 7 | error: 8 | entry/ render, 9 | photo: 10 | entry/ render | setPhoto, 11 | 12 | loading: 13 | entry/ render | search; 14 | 15 | initial => start; 16 | start => loading : SEARCH; 17 | loading=> error : SEARCH FAILURE; 18 | error => loading: SEARCH; 19 | loading=> gallery : CANCEL SEARCH; 20 | loading=> gallery : SEARCH SUCCESS 21 | / update items; 22 | gallery => loading : SEARCH; 23 | gallery => photo : SELECT PHOTO; 24 | photo => gallery : EXIT PHOTO; 25 | 26 | -------------------------------------------------------------------------------- /assets/mvp architecture.graphml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | User 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | External 42 | systems 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | User interface 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | Folder 1 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | View 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | Presenter 109 | (Mediator) 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /assets/mvp architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brucou/react-state-driven/04b2ba782a57d30415f5e5e9aeb6cfe12fac6edb/assets/mvp architecture.png -------------------------------------------------------------------------------- /assets/view-mediator-state-command architecture.graphml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | User 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | External 42 | systems 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | User interface 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | Folder 1 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | View 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | Mediator 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | Command 126 | handler 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | FSM 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | Preprocessor 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | -------------------------------------------------------------------------------- /assets/view-mediator-state-command architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brucou/react-state-driven/04b2ba782a57d30415f5e5e9aeb6cfe12fac6edb/assets/view-mediator-state-command architecture.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "git@github.com:brucou/react-state-driven.git", 3 | "name": "react-state-driven", 4 | "sideEffects": false, 5 | "author": "brucou", 6 | "version": "0.11.1", 7 | "license": "MIT", 8 | "description": "A state machine abstraction for React", 9 | "main": "dist/react-state-driven.js", 10 | "module": "dist/react-state-driven.es.js", 11 | "files": [ 12 | "DISCLAIMER", 13 | "dist", 14 | "pkg" 15 | ], 16 | "@pika/pack": { 17 | "pipeline": [ 18 | [ 19 | "@pika/plugin-standard-pkg", 20 | { 21 | "exclude": [ 22 | "__tests__/**/*", 23 | "tests/**/*" 24 | ] 25 | } 26 | ], 27 | [ 28 | "@pika/plugin-build-node" 29 | ], 30 | [ 31 | "@pika/plugin-build-web" 32 | ], 33 | [ 34 | "@pika/plugin-build-types" 35 | ] 36 | ] 37 | }, 38 | "scripts": { 39 | "build": "rollup -c", 40 | "pack": "pack build", 41 | "prepublish": "npm run build && npm run pack", 42 | "start": "webpack-dev-server --open", 43 | "test": "webpack-dev-server --config tests/webpack.config.js --open" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.10.2", 47 | "@babel/preset-react": "^7.10.1", 48 | "@pika/plugin-build-node": "^0.9.2", 49 | "@pika/plugin-build-types": "^0.9.2", 50 | "@pika/plugin-build-web": "^0.9.2", 51 | "@pika/plugin-standard-pkg": "^0.9.2", 52 | "babel-loader": "^8.1.0", 53 | "dom-testing-library": "^3.19.4", 54 | "fetch-jsonp": "^1.1.3", 55 | "flipping": "1.1.0", 56 | "fp-rosetree": "^0.6.2", 57 | "html-parse-stringify": "^1.0.3", 58 | "html-webpack-plugin": "^3.2.0", 59 | "hyperscript-helpers": "3.0.3", 60 | "idx": "^2.5.6", 61 | "immer": "1.7.4", 62 | "json-patch-es6": "^2.0.9", 63 | "prettier": "^1.19.1", 64 | "pretty-format": "^23.6.0", 65 | "ramda": "^0.26.1", 66 | "react": "^16.13.1", 67 | "react-dom": "^16.13.1", 68 | "react-hot-loader": "4.3.4", 69 | "react-hyperscript": "3.2.0", 70 | "react-test-renderer": "^16.13.1", 71 | "react-testing-library": "^5.9.0", 72 | "rimraf": "^2.7.1", 73 | "rollup": "^0.64.1", 74 | "rollup-plugin-babel": "^4.4.0", 75 | "rollup-plugin-commonjs": "^9.3.4", 76 | "rollup-plugin-node-resolve": "^3.3.0", 77 | "rollup-plugin-terser": "^1.0.1", 78 | "rxjs": "^6.5.5", 79 | "rxjs-compat": "^6.5.5", 80 | "sinon": "^7.5.0", 81 | "snowpack": "^2.3.1", 82 | "superagent": "^4.1.0", 83 | "webpack": "^4.43.0", 84 | "webpack-cli": "^3.3.11", 85 | "webpack-dev-server": "^3.11.0" 86 | }, 87 | "dependencies": { 88 | "@pika/pack": "^0.5.0", 89 | "kingly": "^0.28.3", 90 | "emitonoff": "^0.1.0" 91 | }, 92 | "peerDependencies": { 93 | "react": ">=16.3" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 brucou 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. 22 | -------------------------------------------------------------------------------- /pkg/dist-node/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { value: true }); 4 | 5 | function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } 6 | 7 | var React = require('react'); 8 | var React__default = _interopDefault(React); 9 | var emitonoff = _interopDefault(require('emitonoff')); 10 | 11 | var noop = function noop() {}; 12 | var emptyConsole = { 13 | log: noop, 14 | warn: noop, 15 | info: noop, 16 | debug: noop, 17 | error: noop, 18 | trace: noop 19 | }; 20 | var COMMAND_RENDER = 'render'; 21 | var NO_STATE_UPDATE = []; 22 | 23 | function tryCatch(fn, errCb) { 24 | return function tryCatch() { 25 | for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { 26 | args[_key] = arguments[_key]; 27 | } 28 | 29 | try { 30 | return fn.apply(fn, args); 31 | } catch (e) { 32 | return errCb(e, args); 33 | } 34 | }; 35 | } 36 | /** 37 | * 38 | * @param {{console, debugEmitter, connection}} debug 39 | * @param errMsg 40 | * @returns {logError} 41 | */ 42 | 43 | var logError = function logErrorCurried(debug, errMsg) { 44 | return function logError(e, args) { 45 | debug && debug.console && debug.console.error("An error occurred while executing: ", errMsg, args, e); 46 | }; 47 | }; 48 | var getEventEmitterAdapter = function getEventEmitterAdapter(emitonoff) { 49 | var eventEmitter = emitonoff(); 50 | var DUMMY_NAME_SPACE = "_"; 51 | var subscribers = []; 52 | var subject = { 53 | next: function next(x) { 54 | try { 55 | eventEmitter.emit(DUMMY_NAME_SPACE, x); 56 | } catch (e) { 57 | subject.error(e); 58 | } 59 | }, 60 | error: function error(e) { 61 | throw e; 62 | }, 63 | complete: function complete() { 64 | return subscribers.forEach(function (f) { 65 | return eventEmitter.off(DUMMY_NAME_SPACE, f); 66 | }); 67 | }, 68 | subscribe: function subscribe(_ref) { 69 | var f = _ref.next, 70 | errFn = _ref.error, 71 | __ = _ref.complete; 72 | subscribers.push(f); 73 | eventEmitter.on(DUMMY_NAME_SPACE, f); 74 | subject.error = errFn; 75 | return { 76 | unsubscribe: subject.complete 77 | }; 78 | } 79 | }; 80 | return subject; 81 | }; 82 | 83 | function _inheritsLoose(subClass, superClass) { 84 | subClass.prototype = Object.create(superClass.prototype); 85 | subClass.prototype.constructor = subClass; 86 | subClass.__proto__ = superClass; 87 | } 88 | var MOUNTED = "mounted"; 89 | 90 | var COMMAND_HANDLER_EXEC_ERR = function COMMAND_HANDLER_EXEC_ERR(command) { 91 | return "handler for command " + command; 92 | }; 93 | 94 | function defaultRenderHandler(machineComponent, renderWith, params, next) { 95 | return machineComponent.setState({ 96 | render: /*#__PURE__*/React__default.createElement(renderWith, Object.assign({}, params, { 97 | next: next 98 | }), []) 99 | }, // DOC : callback for the react default render function in options 100 | params.postRenderCallback); 101 | } 102 | var Machine = /*#__PURE__*/function (_Component) { 103 | _inheritsLoose(Machine, _Component); 104 | 105 | function Machine(props) { 106 | var _this; 107 | 108 | _this = _Component.call(this, props) || this; 109 | _this.state = { 110 | render: null 111 | }; 112 | _this.rawEventSource = null; 113 | _this.subscription = null; 114 | return _this; 115 | } // NOTE: An interface like is 116 | // not possible in React/jsx syntax. When passed as part of a `props.children`, 117 | // the function component would be transformed into a React element, 118 | // and hence can no longer be used. We do not want the React element, we want 119 | // the react element factory... It is thereforth necessary to pass the 120 | // render component as a property (or use a render prop pattern) 121 | 122 | 123 | var _proto = Machine.prototype; 124 | 125 | _proto.componentDidMount = function componentDidMount() { 126 | var _ref, _Object$assign, _Object$assign2; 127 | 128 | var machineComponent = this; // TODO: I should use React props checking mechanism for this 129 | // try {assertPropsContract(machineComponent.props);} catch (e) {console.error(e); return} 130 | 131 | var _machineComponent$pro = machineComponent.props, 132 | _fsm = _machineComponent$pro.fsm, 133 | eventHandler = _machineComponent$pro.eventHandler, 134 | preprocessor = _machineComponent$pro.preprocessor, 135 | commandHandlers = _machineComponent$pro.commandHandlers, 136 | effectHandlers = _machineComponent$pro.effectHandlers, 137 | options = _machineComponent$pro.options, 138 | renderWith = _machineComponent$pro.renderWith; // initial event is optional. Use it for instance if you want to pass data with the event 139 | // or if you use the "mounted" string of characters for other purposes 140 | // or if simply you want to completely decouple the machine from the component 141 | 142 | var initialEvent = options && options.initialEvent || (_ref = {}, _ref[MOUNTED] = void 0, _ref); // `debug` is optional. As of now, includes the console to log debugging info 143 | 144 | var debug = options && options.debug || null; 145 | 146 | var _console = debug && debug.console || emptyConsole; // Wrapping the user-provided API with tryCatch to detect error early 147 | 148 | 149 | var wrappedFsm = tryCatch(_fsm, logError(debug, "the state machine!")); 150 | this.rawEventSource = eventHandler || getEventEmitterAdapter(emitonoff); 151 | 152 | var _next = tryCatch(this.rawEventSource.next.bind(this.rawEventSource), logError(debug, "the event handler's 'next' function!")); 153 | 154 | var commandHandlersWithRenderHandler = Object.assign({}, commandHandlers, (_Object$assign = {}, _Object$assign[COMMAND_RENDER] = function renderHandler(next, params, effectHandlersWithRender) { 155 | effectHandlersWithRender[COMMAND_RENDER](machineComponent, renderWith, params, next); 156 | }, _Object$assign)); 157 | var effectHandlersWithRender = effectHandlers && effectHandlers[COMMAND_RENDER] ? effectHandlers : Object.assign((_Object$assign2 = {}, _Object$assign2[COMMAND_RENDER] = defaultRenderHandler, _Object$assign2), effectHandlers || {}); 158 | var preprocessedEventSource = tryCatch(preprocessor || function (x) { 159 | return x; 160 | }, logError(debug, "the preprocessor!"))(this.rawEventSource); 161 | this.subscription = preprocessedEventSource.subscribe({ 162 | next: function next(event) { 163 | // 1. Run the input on the machine to obtain the actions to perform 164 | var actions = wrappedFsm(event); // 2. Execute the actions, if any 165 | 166 | if (actions === null) { 167 | return void 0; 168 | } else { 169 | actions.filter(function (action) { 170 | return action !== null; 171 | }).forEach(function (action) { 172 | var command = action.command, 173 | params = action.params; 174 | var commandHandler = commandHandlersWithRenderHandler[command]; 175 | 176 | if (!commandHandler || typeof commandHandler !== "function") { 177 | throw new Error("Could not find " + COMMAND_HANDLER_EXEC_ERR(command)); 178 | } 179 | 180 | tryCatch(commandHandler, logError(debug, COMMAND_HANDLER_EXEC_ERR(command)))(_next, params, effectHandlersWithRender); // NOTE : generally command handlers won't return values synchronously 181 | // It is however possible and we should trace that 182 | }); 183 | return void 0; 184 | } 185 | }, 186 | error: function error(_error) { 187 | // We may get there for instance if there was a preprocessor throwing an exception 188 | _console.error( // `Machine > Mediator: an error in the event processing chain! The machine will not process any additional events. Remember that command handlers ought never throw, but should pass errors as events back to the mediator.`, 189 | _error); 190 | }, 191 | complete: function complete() {} 192 | }); // DOC : we do not trace effectHandlers 193 | // DOC CONTRACT: no command handler should throw! but pass errors as messages or events 194 | // DOC: error behavior. Errors should be captured by the event emitter and forwarded to the error method 195 | // It is up to the API user to decide if to complete the subject or not 196 | // DOC: we no longer throw - log the errors on console, if console is set 197 | // DOC: preprocessor can be undefined and default to x => x 198 | // Start with the initial event if any 199 | 200 | initialEvent && this.rawEventSource.next(initialEvent); 201 | }; 202 | 203 | _proto.componentWillUnmount = function componentWillUnmount() { 204 | this.subscription.unsubscribe(); 205 | this.rawEventSource.complete(); 206 | }; 207 | 208 | _proto.render = function render() { 209 | var machineComponent = this; 210 | return machineComponent.state.render || null; 211 | }; 212 | 213 | return Machine; 214 | }(React.Component); // function assertPropsContract(props) { 215 | // const {fsm, eventHandler, preprocessor, commandHandlers, effectHandlers, options} = props; 216 | // if (!eventHandler) throw new Error(` : eventHandler prop has a falsy value!`); 217 | // if (!fsm) throw new Error(` : fsm prop has a falsy value! Should be specifications for the state machine!`); 218 | // } 219 | 220 | exports.COMMAND_RENDER = COMMAND_RENDER; 221 | exports.MOUNTED = MOUNTED; 222 | exports.Machine = Machine; 223 | exports.NO_STATE_UPDATE = NO_STATE_UPDATE; 224 | //# sourceMappingURL=index.js.map 225 | -------------------------------------------------------------------------------- /pkg/dist-src/Machine.js: -------------------------------------------------------------------------------- 1 | function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; subClass.__proto__ = superClass; } 2 | 3 | import React, { Component } from "react"; 4 | import { emptyConsole, COMMAND_RENDER } from "./properties.js"; 5 | import { getEventEmitterAdapter, logError, tryCatch } from "./helpers.js"; 6 | import emitonoff from "emitonoff"; 7 | export var MOUNTED = "mounted"; 8 | 9 | var COMMAND_HANDLER_EXEC_ERR = function COMMAND_HANDLER_EXEC_ERR(command) { 10 | return "handler for command " + command; 11 | }; 12 | 13 | function defaultRenderHandler(machineComponent, renderWith, params, next) { 14 | return machineComponent.setState({ 15 | render: /*#__PURE__*/React.createElement(renderWith, Object.assign({}, params, { 16 | next: next 17 | }), []) 18 | }, // DOC : callback for the react default render function in options 19 | params.postRenderCallback); 20 | } 21 | 22 | ; 23 | export var Machine = /*#__PURE__*/function (_Component) { 24 | _inheritsLoose(Machine, _Component); 25 | 26 | function Machine(props) { 27 | var _this; 28 | 29 | _this = _Component.call(this, props) || this; 30 | _this.state = { 31 | render: null 32 | }; 33 | _this.rawEventSource = null; 34 | _this.subscription = null; 35 | return _this; 36 | } // NOTE: An interface like is 37 | // not possible in React/jsx syntax. When passed as part of a `props.children`, 38 | // the function component would be transformed into a React element, 39 | // and hence can no longer be used. We do not want the React element, we want 40 | // the react element factory... It is thereforth necessary to pass the 41 | // render component as a property (or use a render prop pattern) 42 | 43 | 44 | var _proto = Machine.prototype; 45 | 46 | _proto.componentDidMount = function componentDidMount() { 47 | var _ref, _Object$assign, _Object$assign2; 48 | 49 | var machineComponent = this; // TODO: I should use React props checking mechanism for this 50 | // try {assertPropsContract(machineComponent.props);} catch (e) {console.error(e); return} 51 | 52 | var _machineComponent$pro = machineComponent.props, 53 | _fsm = _machineComponent$pro.fsm, 54 | eventHandler = _machineComponent$pro.eventHandler, 55 | preprocessor = _machineComponent$pro.preprocessor, 56 | commandHandlers = _machineComponent$pro.commandHandlers, 57 | effectHandlers = _machineComponent$pro.effectHandlers, 58 | options = _machineComponent$pro.options, 59 | renderWith = _machineComponent$pro.renderWith; // initial event is optional. Use it for instance if you want to pass data with the event 60 | // or if you use the "mounted" string of characters for other purposes 61 | // or if simply you want to completely decouple the machine from the component 62 | 63 | var initialEvent = options && options.initialEvent || (_ref = {}, _ref[MOUNTED] = void 0, _ref); // `debug` is optional. As of now, includes the console to log debugging info 64 | 65 | var debug = options && options.debug || null; 66 | 67 | var _console = debug && debug.console || emptyConsole; // Wrapping the user-provided API with tryCatch to detect error early 68 | 69 | 70 | var wrappedFsm = tryCatch(_fsm, logError(debug, "the state machine!")); 71 | this.rawEventSource = eventHandler || getEventEmitterAdapter(emitonoff); 72 | 73 | var _next = tryCatch(this.rawEventSource.next.bind(this.rawEventSource), logError(debug, "the event handler's 'next' function!")); 74 | 75 | var commandHandlersWithRenderHandler = Object.assign({}, commandHandlers, (_Object$assign = {}, _Object$assign[COMMAND_RENDER] = function renderHandler(next, params, effectHandlersWithRender) { 76 | effectHandlersWithRender[COMMAND_RENDER](machineComponent, renderWith, params, next); 77 | }, _Object$assign)); 78 | var effectHandlersWithRender = effectHandlers && effectHandlers[COMMAND_RENDER] ? effectHandlers : Object.assign((_Object$assign2 = {}, _Object$assign2[COMMAND_RENDER] = defaultRenderHandler, _Object$assign2), effectHandlers || {}); 79 | var preprocessedEventSource = tryCatch(preprocessor || function (x) { 80 | return x; 81 | }, logError(debug, "the preprocessor!"))(this.rawEventSource); 82 | this.subscription = preprocessedEventSource.subscribe({ 83 | next: function next(event) { 84 | // 1. Run the input on the machine to obtain the actions to perform 85 | var actions = wrappedFsm(event); // 2. Execute the actions, if any 86 | 87 | if (actions === null) { 88 | return void 0; 89 | } else { 90 | actions.filter(function (action) { 91 | return action !== null; 92 | }).forEach(function (action) { 93 | var command = action.command, 94 | params = action.params; 95 | var commandHandler = commandHandlersWithRenderHandler[command]; 96 | 97 | if (!commandHandler || typeof commandHandler !== "function") { 98 | throw new Error("Could not find " + COMMAND_HANDLER_EXEC_ERR(command)); 99 | } 100 | 101 | tryCatch(commandHandler, logError(debug, COMMAND_HANDLER_EXEC_ERR(command)))(_next, params, effectHandlersWithRender); // NOTE : generally command handlers won't return values synchronously 102 | // It is however possible and we should trace that 103 | }); 104 | return void 0; 105 | } 106 | }, 107 | error: function error(_error) { 108 | // We may get there for instance if there was a preprocessor throwing an exception 109 | _console.error( // `Machine > Mediator: an error in the event processing chain! The machine will not process any additional events. Remember that command handlers ought never throw, but should pass errors as events back to the mediator.`, 110 | _error); 111 | }, 112 | complete: function complete() {} 113 | }); // DOC : we do not trace effectHandlers 114 | // DOC CONTRACT: no command handler should throw! but pass errors as messages or events 115 | // DOC: error behavior. Errors should be captured by the event emitter and forwarded to the error method 116 | // It is up to the API user to decide if to complete the subject or not 117 | // DOC: we no longer throw - log the errors on console, if console is set 118 | // DOC: preprocessor can be undefined and default to x => x 119 | // Start with the initial event if any 120 | 121 | initialEvent && this.rawEventSource.next(initialEvent); 122 | }; 123 | 124 | _proto.componentWillUnmount = function componentWillUnmount() { 125 | this.subscription.unsubscribe(); 126 | this.rawEventSource.complete(); 127 | }; 128 | 129 | _proto.render = function render() { 130 | var machineComponent = this; 131 | return machineComponent.state.render || null; 132 | }; 133 | 134 | return Machine; 135 | }(Component); // function assertPropsContract(props) { 136 | // const {fsm, eventHandler, preprocessor, commandHandlers, effectHandlers, options} = props; 137 | // if (!eventHandler) throw new Error(` : eventHandler prop has a falsy value!`); 138 | // if (!fsm) throw new Error(` : fsm prop has a falsy value! Should be specifications for the state machine!`); 139 | // } -------------------------------------------------------------------------------- /pkg/dist-src/helpers.js: -------------------------------------------------------------------------------- 1 | export function identity(x) { 2 | return x; 3 | } 4 | export function tryCatch(fn, errCb) { 5 | return function tryCatch() { 6 | for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { 7 | args[_key] = arguments[_key]; 8 | } 9 | 10 | try { 11 | return fn.apply(fn, args); 12 | } catch (e) { 13 | return errCb(e, args); 14 | } 15 | }; 16 | } 17 | /** 18 | * 19 | * @param {{console, debugEmitter, connection}} debug 20 | * @param errMsg 21 | * @returns {logError} 22 | */ 23 | 24 | export var logError = function logErrorCurried(debug, errMsg) { 25 | return function logError(e, args) { 26 | debug && debug.console && debug.console.error("An error occurred while executing: ", errMsg, args, e); 27 | }; 28 | }; 29 | export var getStateTransducerRxAdapter = function getStateTransducerRxAdapter(RxApi) { 30 | var Subject = RxApi.Subject; 31 | return new Subject(); 32 | }; 33 | export var getEventEmitterAdapter = function getEventEmitterAdapter(emitonoff) { 34 | var eventEmitter = emitonoff(); 35 | var DUMMY_NAME_SPACE = "_"; 36 | var subscribers = []; 37 | var subject = { 38 | next: function next(x) { 39 | try { 40 | eventEmitter.emit(DUMMY_NAME_SPACE, x); 41 | } catch (e) { 42 | subject.error(e); 43 | } 44 | }, 45 | error: function error(e) { 46 | throw e; 47 | }, 48 | complete: function complete() { 49 | return subscribers.forEach(function (f) { 50 | return eventEmitter.off(DUMMY_NAME_SPACE, f); 51 | }); 52 | }, 53 | subscribe: function subscribe(_ref) { 54 | var f = _ref.next, 55 | errFn = _ref.error, 56 | __ = _ref.complete; 57 | subscribers.push(f); 58 | eventEmitter.on(DUMMY_NAME_SPACE, f); 59 | subject.error = errFn; 60 | return { 61 | unsubscribe: subject.complete 62 | }; 63 | } 64 | }; 65 | return subject; 66 | }; -------------------------------------------------------------------------------- /pkg/dist-src/index.js: -------------------------------------------------------------------------------- 1 | export { Machine, MOUNTED } from "./Machine.js"; 2 | export { NO_STATE_UPDATE, COMMAND_RENDER } from "./properties.js"; -------------------------------------------------------------------------------- /pkg/dist-src/properties.js: -------------------------------------------------------------------------------- 1 | export var noop = function noop() {}; 2 | export var emptyConsole = { 3 | log: noop, 4 | warn: noop, 5 | info: noop, 6 | debug: noop, 7 | error: noop, 8 | trace: noop 9 | }; 10 | export var COMMAND_RENDER = 'render'; 11 | export var CONTRACT_MODEL_UPDATE_FN_RETURN_VALUE = "Model update function must return valid update operations!"; 12 | export var NO_STATE_UPDATE = []; 13 | export var COMMAND_SEARCH = 'command_search'; -------------------------------------------------------------------------------- /pkg/dist-web/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import emitonoff from 'emitonoff'; 3 | 4 | var noop = function noop() {}; 5 | var emptyConsole = { 6 | log: noop, 7 | warn: noop, 8 | info: noop, 9 | debug: noop, 10 | error: noop, 11 | trace: noop 12 | }; 13 | var COMMAND_RENDER = 'render'; 14 | var NO_STATE_UPDATE = []; 15 | 16 | function tryCatch(fn, errCb) { 17 | return function tryCatch() { 18 | for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { 19 | args[_key] = arguments[_key]; 20 | } 21 | 22 | try { 23 | return fn.apply(fn, args); 24 | } catch (e) { 25 | return errCb(e, args); 26 | } 27 | }; 28 | } 29 | /** 30 | * 31 | * @param {{console, debugEmitter, connection}} debug 32 | * @param errMsg 33 | * @returns {logError} 34 | */ 35 | 36 | var logError = function logErrorCurried(debug, errMsg) { 37 | return function logError(e, args) { 38 | debug && debug.console && debug.console.error("An error occurred while executing: ", errMsg, args, e); 39 | }; 40 | }; 41 | var getEventEmitterAdapter = function getEventEmitterAdapter(emitonoff) { 42 | var eventEmitter = emitonoff(); 43 | var DUMMY_NAME_SPACE = "_"; 44 | var subscribers = []; 45 | var subject = { 46 | next: function next(x) { 47 | try { 48 | eventEmitter.emit(DUMMY_NAME_SPACE, x); 49 | } catch (e) { 50 | subject.error(e); 51 | } 52 | }, 53 | error: function error(e) { 54 | throw e; 55 | }, 56 | complete: function complete() { 57 | return subscribers.forEach(function (f) { 58 | return eventEmitter.off(DUMMY_NAME_SPACE, f); 59 | }); 60 | }, 61 | subscribe: function subscribe(_ref) { 62 | var f = _ref.next, 63 | errFn = _ref.error, 64 | __ = _ref.complete; 65 | subscribers.push(f); 66 | eventEmitter.on(DUMMY_NAME_SPACE, f); 67 | subject.error = errFn; 68 | return { 69 | unsubscribe: subject.complete 70 | }; 71 | } 72 | }; 73 | return subject; 74 | }; 75 | 76 | function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; subClass.__proto__ = superClass; } 77 | var MOUNTED = "mounted"; 78 | 79 | var COMMAND_HANDLER_EXEC_ERR = function COMMAND_HANDLER_EXEC_ERR(command) { 80 | return "handler for command " + command; 81 | }; 82 | 83 | function defaultRenderHandler(machineComponent, renderWith, params, next) { 84 | return machineComponent.setState({ 85 | render: /*#__PURE__*/React.createElement(renderWith, Object.assign({}, params, { 86 | next: next 87 | }), []) 88 | }, // DOC : callback for the react default render function in options 89 | params.postRenderCallback); 90 | } 91 | var Machine = /*#__PURE__*/function (_Component) { 92 | _inheritsLoose(Machine, _Component); 93 | 94 | function Machine(props) { 95 | var _this; 96 | 97 | _this = _Component.call(this, props) || this; 98 | _this.state = { 99 | render: null 100 | }; 101 | _this.rawEventSource = null; 102 | _this.subscription = null; 103 | return _this; 104 | } // NOTE: An interface like is 105 | // not possible in React/jsx syntax. When passed as part of a `props.children`, 106 | // the function component would be transformed into a React element, 107 | // and hence can no longer be used. We do not want the React element, we want 108 | // the react element factory... It is thereforth necessary to pass the 109 | // render component as a property (or use a render prop pattern) 110 | 111 | 112 | var _proto = Machine.prototype; 113 | 114 | _proto.componentDidMount = function componentDidMount() { 115 | var _ref, _Object$assign, _Object$assign2; 116 | 117 | var machineComponent = this; // TODO: I should use React props checking mechanism for this 118 | // try {assertPropsContract(machineComponent.props);} catch (e) {console.error(e); return} 119 | 120 | var _machineComponent$pro = machineComponent.props, 121 | _fsm = _machineComponent$pro.fsm, 122 | eventHandler = _machineComponent$pro.eventHandler, 123 | preprocessor = _machineComponent$pro.preprocessor, 124 | commandHandlers = _machineComponent$pro.commandHandlers, 125 | effectHandlers = _machineComponent$pro.effectHandlers, 126 | options = _machineComponent$pro.options, 127 | renderWith = _machineComponent$pro.renderWith; // initial event is optional. Use it for instance if you want to pass data with the event 128 | // or if you use the "mounted" string of characters for other purposes 129 | // or if simply you want to completely decouple the machine from the component 130 | 131 | var initialEvent = options && options.initialEvent || (_ref = {}, _ref[MOUNTED] = void 0, _ref); // `debug` is optional. As of now, includes the console to log debugging info 132 | 133 | var debug = options && options.debug || null; 134 | 135 | var _console = debug && debug.console || emptyConsole; // Wrapping the user-provided API with tryCatch to detect error early 136 | 137 | 138 | var wrappedFsm = tryCatch(_fsm, logError(debug, "the state machine!")); 139 | this.rawEventSource = eventHandler || getEventEmitterAdapter(emitonoff); 140 | 141 | var _next = tryCatch(this.rawEventSource.next.bind(this.rawEventSource), logError(debug, "the event handler's 'next' function!")); 142 | 143 | var commandHandlersWithRenderHandler = Object.assign({}, commandHandlers, (_Object$assign = {}, _Object$assign[COMMAND_RENDER] = function renderHandler(next, params, effectHandlersWithRender) { 144 | effectHandlersWithRender[COMMAND_RENDER](machineComponent, renderWith, params, next); 145 | }, _Object$assign)); 146 | var effectHandlersWithRender = effectHandlers && effectHandlers[COMMAND_RENDER] ? effectHandlers : Object.assign((_Object$assign2 = {}, _Object$assign2[COMMAND_RENDER] = defaultRenderHandler, _Object$assign2), effectHandlers || {}); 147 | var preprocessedEventSource = tryCatch(preprocessor || function (x) { 148 | return x; 149 | }, logError(debug, "the preprocessor!"))(this.rawEventSource); 150 | this.subscription = preprocessedEventSource.subscribe({ 151 | next: function next(event) { 152 | // 1. Run the input on the machine to obtain the actions to perform 153 | var actions = wrappedFsm(event); // 2. Execute the actions, if any 154 | 155 | if (actions === null) { 156 | return void 0; 157 | } else { 158 | actions.filter(function (action) { 159 | return action !== null; 160 | }).forEach(function (action) { 161 | var command = action.command, 162 | params = action.params; 163 | var commandHandler = commandHandlersWithRenderHandler[command]; 164 | 165 | if (!commandHandler || typeof commandHandler !== "function") { 166 | throw new Error("Could not find " + COMMAND_HANDLER_EXEC_ERR(command)); 167 | } 168 | 169 | tryCatch(commandHandler, logError(debug, COMMAND_HANDLER_EXEC_ERR(command)))(_next, params, effectHandlersWithRender); // NOTE : generally command handlers won't return values synchronously 170 | // It is however possible and we should trace that 171 | }); 172 | return void 0; 173 | } 174 | }, 175 | error: function error(_error) { 176 | // We may get there for instance if there was a preprocessor throwing an exception 177 | _console.error( // `Machine > Mediator: an error in the event processing chain! The machine will not process any additional events. Remember that command handlers ought never throw, but should pass errors as events back to the mediator.`, 178 | _error); 179 | }, 180 | complete: function complete() {} 181 | }); // DOC : we do not trace effectHandlers 182 | // DOC CONTRACT: no command handler should throw! but pass errors as messages or events 183 | // DOC: error behavior. Errors should be captured by the event emitter and forwarded to the error method 184 | // It is up to the API user to decide if to complete the subject or not 185 | // DOC: we no longer throw - log the errors on console, if console is set 186 | // DOC: preprocessor can be undefined and default to x => x 187 | // Start with the initial event if any 188 | 189 | initialEvent && this.rawEventSource.next(initialEvent); 190 | }; 191 | 192 | _proto.componentWillUnmount = function componentWillUnmount() { 193 | this.subscription.unsubscribe(); 194 | this.rawEventSource.complete(); 195 | }; 196 | 197 | _proto.render = function render() { 198 | var machineComponent = this; 199 | return machineComponent.state.render || null; 200 | }; 201 | 202 | return Machine; 203 | }(Component); // function assertPropsContract(props) { 204 | // const {fsm, eventHandler, preprocessor, commandHandlers, effectHandlers, options} = props; 205 | // if (!eventHandler) throw new Error(` : eventHandler prop has a falsy value!`); 206 | // if (!fsm) throw new Error(` : fsm prop has a falsy value! Should be specifications for the state machine!`); 207 | // } 208 | 209 | export { COMMAND_RENDER, MOUNTED, Machine, NO_STATE_UPDATE }; 210 | //# sourceMappingURL=index.js.map 211 | -------------------------------------------------------------------------------- /pkg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-state-driven", 3 | "description": "A state machine abstraction for React", 4 | "version": "0.11.1", 5 | "license": "MIT", 6 | "files": [ 7 | "dist-*/", 8 | "bin/" 9 | ], 10 | "pika": true, 11 | "sideEffects": false, 12 | "repository": "git@github.com:brucou/react-state-driven.git", 13 | "dependencies": { 14 | "@pika/pack": "^0.5.0", 15 | "kingly": "^0.28.3", 16 | "emitonoff": "^0.1.0" 17 | }, 18 | "peerDependencies": { 19 | "react": ">=16.3" 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "^7.10.2", 23 | "@babel/preset-react": "^7.10.1", 24 | "@pika/plugin-build-node": "^0.9.2", 25 | "@pika/plugin-build-types": "^0.9.2", 26 | "@pika/plugin-build-web": "^0.9.2", 27 | "@pika/plugin-standard-pkg": "^0.9.2", 28 | "babel-loader": "^8.1.0", 29 | "dom-testing-library": "^3.19.4", 30 | "fetch-jsonp": "^1.1.3", 31 | "flipping": "1.1.0", 32 | "fp-rosetree": "^0.6.2", 33 | "html-parse-stringify": "^1.0.3", 34 | "html-webpack-plugin": "^3.2.0", 35 | "hyperscript-helpers": "3.0.3", 36 | "idx": "^2.5.6", 37 | "immer": "1.7.4", 38 | "json-patch-es6": "^2.0.9", 39 | "prettier": "^1.19.1", 40 | "pretty-format": "^23.6.0", 41 | "ramda": "^0.26.1", 42 | "react": "^16.13.1", 43 | "react-dom": "^16.13.1", 44 | "react-hot-loader": "4.3.4", 45 | "react-hyperscript": "3.2.0", 46 | "react-test-renderer": "^16.13.1", 47 | "react-testing-library": "^5.9.0", 48 | "rimraf": "^2.7.1", 49 | "rollup": "^0.64.1", 50 | "rollup-plugin-babel": "^4.4.0", 51 | "rollup-plugin-commonjs": "^9.3.4", 52 | "rollup-plugin-node-resolve": "^3.3.0", 53 | "rollup-plugin-terser": "^1.0.1", 54 | "rxjs": "^6.5.5", 55 | "rxjs-compat": "^6.5.5", 56 | "sinon": "^7.5.0", 57 | "snowpack": "^2.3.1", 58 | "superagent": "^4.1.0", 59 | "webpack": "^4.43.0", 60 | "webpack-cli": "^3.3.11", 61 | "webpack-dev-server": "^3.11.0" 62 | }, 63 | "esnext": "dist-src/index.js", 64 | "main": "dist-node/index.js", 65 | "module": "dist-web/index.js", 66 | "types": "dist-types/index.d.ts" 67 | } 68 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import { terser } from "rollup-plugin-terser"; 4 | import babel from 'rollup-plugin-babel' 5 | import pkg from './package.json' 6 | 7 | const makeExternalPredicate = externalArr => { 8 | if (externalArr.length === 0) { 9 | return () => false 10 | } 11 | const pattern = new RegExp(`^(${externalArr.join('|')})($|/)`) 12 | return id => pattern.test(id) 13 | } 14 | 15 | export default { 16 | input: 'src/index.js', 17 | 18 | output: [ 19 | { file: pkg.main, format: 'cjs' }, 20 | { file: pkg.module, format: 'es' }, 21 | ], 22 | 23 | external: makeExternalPredicate([ 24 | // ...Object.keys(pkg.dependencies || {}), 25 | ...Object.keys(pkg.peerDependencies || {}), 26 | ]), 27 | 28 | plugins: [ 29 | // TODO: why was that there? 30 | // babel({ plugins: ['external-helpers'] }), 31 | resolve({ 32 | // use "module" field for ES6 module if possible 33 | module: true, // Default: true 34 | 35 | // use "jsnext:main" if possible 36 | // – see https://github.com/rollup/rollup/wiki/jsnext:main 37 | jsnext: false, // Default: false 38 | 39 | // use "main" field or index.js, even if it's not an ES6 module 40 | // (needs to be converted from CommonJS to ES6 41 | // – see https://github.com/rollup/rollup-plugin-commonjs 42 | main: true, // Default: true 43 | 44 | // some package.json files have a `browser` field which 45 | // specifies alternative files to load for people bundling 46 | // for the browser. If that's you, use this option, otherwise 47 | // pkg.browser will be ignored 48 | browser: true, // Default: false 49 | 50 | // not all files you want to resolve are .js files 51 | extensions: [ '.mjs', '.js', '.jsx', '.json' ], // Default: [ '.mjs', '.js', '.json', '.node' ] 52 | 53 | // whether to prefer built-in modules (e.g. `fs`, `path`) or 54 | // local ones with the same names 55 | preferBuiltins: false, // Default: true 56 | 57 | // Lock the module search in this path (like a chroot). Module defined 58 | // outside this path will be marked as external 59 | // jail: '/my/jail/path', // Default: '/' 60 | 61 | // Set to an array of strings and/or regexps to lock the module search 62 | // to modules that match at least one entry. Modules not matching any 63 | // entry will be marked as external 64 | // only: [ 65 | // /^state-transducer/, 66 | // /^penpal/, 67 | // ], // Default: null 68 | 69 | // If true, inspect resolved files to check that they are 70 | // ES2015 modules 71 | modulesOnly: false, // Default: false 72 | 73 | // Any additional options that should be passed through 74 | // to node-resolve 75 | customResolveOptions: { 76 | moduleDirectory: 'node_modules' 77 | } 78 | }), 79 | commonjs({ 80 | include: ['node_modules/**'], 81 | // // non-CommonJS modules will be ignored, but you can also 82 | // // specifically include/exclude files 83 | // include: 'node_modules/fast-json-patch/lib/duplex.js', // Default: undefined 84 | // // exclude: [ 'node_modules/foo/**', 'node_modules/bar/**' ], // Default: undefined 85 | // // these values can also be regular expressions 86 | // // include: /node_modules/ 87 | // // exclude: [ /node_modules\/[a-f].*/], mined 88 | // 89 | // // search for files other than .js files (must already 90 | // // be transpiled by a previous plugin!) 91 | // // extensions: [ '.js', '.coffee' ], // Default: [ '.js' ] 92 | // 93 | // // if true then uses of `global` won't be dealt with by this plugin 94 | // ignoreGlobal: false, // Default: false 95 | // 96 | // // if false then skip sourceMap generation for CommonJS modules 97 | // // sourceMap: false, // Default: true 98 | // 99 | // // explicitly specify unresolvable named exports 100 | // // (see below for more details) 101 | // namedExports: { './module.js': ['foo', 'bar' ] }, // Default: undefined 102 | // 103 | // // sometimes you have to leave require statements 104 | // // unconverted. Pass an array containing the IDs 105 | // // or a `id => boolean` function. Only use this 106 | // // option if you know what you're doing! 107 | // ignore: [ 'conditional-runtime-dependency' ] 108 | }), 109 | terser() 110 | ], 111 | } 112 | -------------------------------------------------------------------------------- /src/Machine.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from "react"; 2 | import {emptyConsole, COMMAND_RENDER} from "./properties"; 3 | import {getEventEmitterAdapter, logError, tryCatch} from "./helpers"; 4 | import emitonoff from "emitonoff"; 5 | 6 | export const MOUNTED="mounted"; 7 | const COMMAND_HANDLER_EXEC_ERR = command => `handler for command ${command}`; 8 | 9 | function defaultRenderHandler(machineComponent, renderWith, params, next) { 10 | return machineComponent.setState( 11 | {render: React.createElement(renderWith, Object.assign({}, params, {next}), [])}, 12 | // DOC : callback for the react default render function in options 13 | params.postRenderCallback 14 | ); 15 | }; 16 | 17 | export class Machine extends Component { 18 | constructor(props) { 19 | super(props); 20 | this.state = {render: null}; 21 | this.rawEventSource = null; 22 | this.subscription = null; 23 | } 24 | 25 | // NOTE: An interface like is 26 | // not possible in React/jsx syntax. When passed as part of a `props.children`, 27 | // the function component would be transformed into a React element, 28 | // and hence can no longer be used. We do not want the React element, we want 29 | // the react element factory... It is thereforth necessary to pass the 30 | // render component as a property (or use a render prop pattern) 31 | componentDidMount() { 32 | const machineComponent = this; 33 | // TODO: I should use React props checking mechanism for this 34 | // try {assertPropsContract(machineComponent.props);} catch (e) {console.error(e); return} 35 | 36 | const {fsm: _fsm, eventHandler, preprocessor, commandHandlers, effectHandlers, options, renderWith} 37 | = machineComponent.props; 38 | 39 | // initial event is optional. Use it for instance if you want to pass data with the event 40 | // or if you use the "mounted" string of characters for other purposes 41 | // or if simply you want to completely decouple the machine from the component 42 | const initialEvent = options && options.initialEvent || {[MOUNTED]: void 0}; 43 | // `debug` is optional. As of now, includes the console to log debugging info 44 | const debug = options && options.debug || null; 45 | const _console = debug && debug.console || emptyConsole; 46 | 47 | // Wrapping the user-provided API with tryCatch to detect error early 48 | const wrappedFsm = tryCatch(_fsm, logError(debug, `the state machine!`)); 49 | 50 | this.rawEventSource = eventHandler || getEventEmitterAdapter(emitonoff); 51 | const next = tryCatch(this.rawEventSource.next.bind(this.rawEventSource), logError(debug, `the event handler's 'next' function!`)); 52 | 53 | const commandHandlersWithRenderHandler = Object.assign({}, commandHandlers, { 54 | [COMMAND_RENDER]: function renderHandler(next, params, effectHandlersWithRender) { 55 | effectHandlersWithRender[COMMAND_RENDER](machineComponent, renderWith, params, next); 56 | } 57 | }); 58 | 59 | const effectHandlersWithRender = 60 | effectHandlers && effectHandlers[COMMAND_RENDER] 61 | ? effectHandlers 62 | : Object.assign({[COMMAND_RENDER]: defaultRenderHandler}, effectHandlers || {}); 63 | 64 | const preprocessedEventSource = tryCatch(preprocessor || (x => x), logError(debug, `the preprocessor!`))( 65 | this.rawEventSource 66 | ); 67 | 68 | this.subscription = preprocessedEventSource.subscribe({ 69 | next: event => { 70 | // 1. Run the input on the machine to obtain the actions to perform 71 | const actions = wrappedFsm(event); 72 | 73 | // 2. Execute the actions, if any 74 | if (actions === null) { 75 | return void 0; 76 | } 77 | else { 78 | actions.filter(action => action !== null) 79 | .forEach(action => { 80 | const {command, params} = action; 81 | 82 | const commandHandler = commandHandlersWithRenderHandler[command]; 83 | if (!commandHandler || typeof commandHandler !== "function") { 84 | throw new Error( 85 | `Could not find ${COMMAND_HANDLER_EXEC_ERR(command)}` 86 | ); 87 | } 88 | 89 | tryCatch( 90 | commandHandler, 91 | logError(debug, COMMAND_HANDLER_EXEC_ERR(command)) 92 | )(next, params, effectHandlersWithRender); 93 | 94 | // NOTE : generally command handlers won't return values synchronously 95 | // It is however possible and we should trace that 96 | }); 97 | 98 | return void 0; 99 | } 100 | }, 101 | error: error => { 102 | // We may get there for instance if there was a preprocessor throwing an exception 103 | _console.error( 104 | // `Machine > Mediator: an error in the event processing chain! The machine will not process any additional events. Remember that command handlers ought never throw, but should pass errors as events back to the mediator.`, 105 | error 106 | ); 107 | }, 108 | complete: () => { 109 | } 110 | } 111 | ); 112 | // DOC : we do not trace effectHandlers 113 | // DOC CONTRACT: no command handler should throw! but pass errors as messages or events 114 | // DOC: error behavior. Errors should be captured by the event emitter and forwarded to the error method 115 | // It is up to the API user to decide if to complete the subject or not 116 | // DOC: we no longer throw - log the errors on console, if console is set 117 | // DOC: preprocessor can be undefined and default to x => x 118 | 119 | // Start with the initial event if any 120 | initialEvent && this.rawEventSource.next(initialEvent); 121 | } 122 | 123 | componentWillUnmount() { 124 | this.subscription.unsubscribe(); 125 | this.rawEventSource.complete(); 126 | } 127 | 128 | render() { 129 | const machineComponent = this; 130 | return machineComponent.state.render || null; 131 | } 132 | } 133 | 134 | // function assertPropsContract(props) { 135 | // const {fsm, eventHandler, preprocessor, commandHandlers, effectHandlers, options} = props; 136 | // if (!eventHandler) throw new Error(` : eventHandler prop has a falsy value!`); 137 | // if (!fsm) throw new Error(` : fsm prop has a falsy value! Should be specifications for the state machine!`); 138 | // } 139 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | export function identity(x) {return x;} 2 | 3 | export function tryCatch(fn, errCb) { 4 | return function tryCatch(...args) { 5 | try {return fn.apply(fn, args);} 6 | catch (e) { 7 | return errCb(e, args); 8 | } 9 | }; 10 | } 11 | 12 | /** 13 | * 14 | * @param {{console, debugEmitter, connection}} debug 15 | * @param errMsg 16 | * @returns {logError} 17 | */ 18 | export const logError = function logErrorCurried(debug, errMsg) { 19 | return function logError(e, args) { 20 | debug && 21 | debug.console && 22 | debug.console.error(`An error occurred while executing: `, errMsg, args, e); 23 | }; 24 | }; 25 | 26 | export const getStateTransducerRxAdapter = RxApi => { 27 | const { Subject } = RxApi; 28 | 29 | return new Subject(); 30 | }; 31 | 32 | export const getEventEmitterAdapter = emitonoff => { 33 | const eventEmitter = emitonoff(); 34 | const DUMMY_NAME_SPACE = "_"; 35 | const subscribers = []; 36 | 37 | const subject = { 38 | next: x => { 39 | try { 40 | eventEmitter.emit(DUMMY_NAME_SPACE, x); 41 | } catch (e) { 42 | subject.error(e); 43 | } 44 | }, 45 | error: e => { 46 | throw e; 47 | }, 48 | complete: () => 49 | subscribers.forEach(f => eventEmitter.off(DUMMY_NAME_SPACE, f)), 50 | subscribe: ({ next: f, error: errFn, complete: __ }) => { 51 | subscribers.push(f); 52 | eventEmitter.on(DUMMY_NAME_SPACE, f); 53 | subject.error = errFn; 54 | return { unsubscribe: subject.complete }; 55 | } 56 | }; 57 | return subject; 58 | }; 59 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { Machine, MOUNTED } from "./Machine"; 2 | export { NO_STATE_UPDATE, COMMAND_RENDER } from "./properties"; 3 | -------------------------------------------------------------------------------- /src/properties.js: -------------------------------------------------------------------------------- 1 | export const noop = () => {}; 2 | export const emptyConsole = { log: noop, warn: noop, info: noop, debug: noop, error: noop, trace: noop }; 3 | export const COMMAND_RENDER = 'render'; 4 | 5 | export const CONTRACT_MODEL_UPDATE_FN_RETURN_VALUE = `Model update function must return valid update operations!`; 6 | export const NO_STATE_UPDATE = []; 7 | export const COMMAND_SEARCH = 'command_search'; 8 | -------------------------------------------------------------------------------- /test-utils.js: -------------------------------------------------------------------------------- 1 | // Test framework helpers 2 | import { logError, tryCatch } from "./src/helpers"; 3 | const SIMULATE_INPUT_ERR = `An error occurred while simulating inputs when testing a component!`; 4 | 5 | function mock(sinonAPI, effectHandlers, mocks, inputSequence) { 6 | const effects = Object.keys(effectHandlers); 7 | return effects.reduce((acc, effect) => { 8 | acc[effect] = sinonAPI.spy(mocks[effect](inputSequence)); 9 | return acc; 10 | }, {}); 11 | } 12 | 13 | function forEachOutput(expectedOutput, fn) { 14 | if (!expectedOutput) return void 0; 15 | 16 | expectedOutput.forEach((output, index) => { 17 | if (output === NO_OUTPUT) return void 0; 18 | fn(output, index); 19 | }); 20 | } 21 | 22 | function checkOutputs(testHarness, testCase, imageGallery, container, expectedOutput) { 23 | return forEachOutput(expectedOutput, output => { 24 | const {then} = testCase; 25 | const {command, params} = output; 26 | const matcher = then[command]; 27 | 28 | if (matcher === undefined) { 29 | console.error( 30 | new Error( 31 | `test case > ${ 32 | testCase.eventName 33 | } :: did not find matcher for command ${command}. Please review the 'then' object:` 34 | ), 35 | then 36 | ); 37 | throw `test case > ${testCase.eventName} :: did not find matcher for command ${command}.`; 38 | } else { 39 | matcher(testHarness, testCase, imageGallery, container, output); 40 | } 41 | }); 42 | } 43 | 44 | export function testMachineComponent(testAPI, testScenario, machineDef) { 45 | const {testCases, mocks, when, then, container, mockedMachineFactory} = testScenario; 46 | const {sinonAPI, test, rtl, debug} = testAPI; 47 | 48 | // TODO : add some contracts here : like same size for input sequence and output sequence 49 | testCases.forEach(testCase => { 50 | test(`${testCase.controlStateSequence.join(" -> ")}`, function exec_test(assert) { 51 | const inputSequence = testCase.inputSequence; 52 | // NOTE : by construction of the machine, length of input and output sequence are the same!! 53 | const expectedFsmOutputSequence = testCase.outputSequence; 54 | const expectedOutputSequence = expectedFsmOutputSequence; 55 | const mockedEffectHandlers = mock(sinonAPI, machineDef.effectHandlers, mocks, inputSequence); 56 | const mockedFsm = mockedMachineFactory(machineDef, mockedEffectHandlers); 57 | const done = assert.async(inputSequence.length); 58 | 59 | inputSequence.reduce((acc, input, index) => { 60 | const eventName = Object.keys(input)[0]; 61 | const eventData = input[eventName]; 62 | const testHarness = {assert, rtl}; 63 | const testCase = { 64 | eventName, 65 | eventData, 66 | expectedOutput: expectedOutputSequence[index], 67 | inputSequence, 68 | expectedOutputSequence, 69 | mockedEffectHandlers, 70 | when, 71 | then, 72 | mocks 73 | }; 74 | const simulateInput = when[eventName]; 75 | 76 | return acc 77 | .then(() => { 78 | if (!simulateInput) throw `Cannot find what to do to simulate event ${eventName}!`; 79 | if (typeof simulateInput !== "function") { 80 | console.error( 81 | new Error(`Simulation for event ${eventName} must be defined through a function! Review received ::`), 82 | simulateInput 83 | ); 84 | throw `Simulation for event ${eventName} must be defined through a function!`; 85 | } 86 | 87 | const simulatedInput = tryCatch(simulateInput, logError(debug, SIMULATE_INPUT_ERR))( 88 | testHarness, 89 | testCase, 90 | mockedFsm, 91 | container 92 | ); 93 | if (simulatedInput instanceof Promise) { 94 | return simulatedInput.then(() => 95 | checkOutputs(testHarness, testCase, mockedFsm, container, expectedOutputSequence[index]) 96 | ); 97 | } else { 98 | checkOutputs(testHarness, testCase, mockedFsm, container, expectedOutputSequence[index]); 99 | } 100 | }) 101 | .then(done) 102 | .catch(e => { 103 | console.log(`Error`, e); 104 | assert.ok(false, e); 105 | done(e); 106 | }); 107 | }, Promise.resolve()); 108 | }); 109 | }); 110 | } 111 | 112 | -------------------------------------------------------------------------------- /tests/css/qunit-1.20.0.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * QUnit 1.20.0 3 | * http://qunitjs.com/ 4 | * 5 | * Copyright jQuery Foundation and other contributors 6 | * Released under the MIT license 7 | * http://jquery.org/license 8 | * 9 | * Date: 2015-10-27T17:53Z 10 | */ 11 | 12 | /** Font Family and Sizes */ 13 | 14 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult { 15 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif; 16 | } 17 | 18 | #qunit-testrunner-toolbar, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; } 19 | #qunit-tests { font-size: smaller; } 20 | 21 | 22 | /** Resets */ 23 | 24 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter { 25 | margin: 0; 26 | padding: 0; 27 | } 28 | 29 | 30 | /** Header */ 31 | 32 | #qunit-header { 33 | padding: 0.5em 0 0.5em 1em; 34 | 35 | color: #8699A4; 36 | background-color: #0D3349; 37 | 38 | font-size: 1.5em; 39 | line-height: 1em; 40 | font-weight: 400; 41 | 42 | border-radius: 5px 5px 0 0; 43 | } 44 | 45 | #qunit-header a { 46 | text-decoration: none; 47 | color: #C2CCD1; 48 | } 49 | 50 | #qunit-header a:hover, 51 | #qunit-header a:focus { 52 | color: #FFF; 53 | } 54 | 55 | #qunit-testrunner-toolbar label { 56 | display: inline-block; 57 | padding: 0 0.5em 0 0.1em; 58 | } 59 | 60 | #qunit-banner { 61 | height: 5px; 62 | } 63 | 64 | #qunit-testrunner-toolbar { 65 | padding: 0.5em 1em 0.5em 1em; 66 | color: #5E740B; 67 | background-color: #EEE; 68 | overflow: hidden; 69 | } 70 | 71 | #qunit-filteredTest { 72 | padding: 0.5em 1em 0.5em 1em; 73 | background-color: #F4FF77; 74 | color: #366097; 75 | } 76 | 77 | #qunit-userAgent { 78 | padding: 0.5em 1em 0.5em 1em; 79 | background-color: #2B81AF; 80 | color: #FFF; 81 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px; 82 | } 83 | 84 | #qunit-modulefilter-container { 85 | float: right; 86 | padding: 0.2em; 87 | } 88 | 89 | .qunit-url-config { 90 | display: inline-block; 91 | padding: 0.1em; 92 | } 93 | 94 | .qunit-filter { 95 | display: block; 96 | float: right; 97 | margin-left: 1em; 98 | } 99 | 100 | /** Tests: Pass/Fail */ 101 | 102 | #qunit-tests { 103 | list-style-position: inside; 104 | } 105 | 106 | #qunit-tests li { 107 | padding: 0.4em 1em 0.4em 1em; 108 | border-bottom: 1px solid #FFF; 109 | list-style-position: inside; 110 | } 111 | 112 | #qunit-tests > li { 113 | display: none; 114 | } 115 | 116 | #qunit-tests li.running, 117 | #qunit-tests li.pass, 118 | #qunit-tests li.fail, 119 | #qunit-tests li.skipped { 120 | display: list-item; 121 | } 122 | 123 | #qunit-tests.hidepass li.running, 124 | #qunit-tests.hidepass li.pass { 125 | visibility: hidden; 126 | position: absolute; 127 | width: 0; 128 | height: 0; 129 | padding: 0; 130 | border: 0; 131 | margin: 0; 132 | } 133 | 134 | #qunit-tests li strong { 135 | cursor: pointer; 136 | } 137 | 138 | #qunit-tests li.skipped strong { 139 | cursor: default; 140 | } 141 | 142 | #qunit-tests li a { 143 | padding: 0.5em; 144 | color: #C2CCD1; 145 | text-decoration: none; 146 | } 147 | 148 | #qunit-tests li p a { 149 | padding: 0.25em; 150 | color: #6B6464; 151 | } 152 | #qunit-tests li a:hover, 153 | #qunit-tests li a:focus { 154 | color: #000; 155 | } 156 | 157 | #qunit-tests li .runtime { 158 | float: right; 159 | font-size: smaller; 160 | } 161 | 162 | .qunit-assert-list { 163 | margin-top: 0.5em; 164 | padding: 0.5em; 165 | 166 | background-color: #FFF; 167 | 168 | border-radius: 5px; 169 | } 170 | 171 | .qunit-source { 172 | margin: 0.6em 0 0.3em; 173 | } 174 | 175 | .qunit-collapsed { 176 | display: none; 177 | } 178 | 179 | #qunit-tests table { 180 | border-collapse: collapse; 181 | margin-top: 0.2em; 182 | } 183 | 184 | #qunit-tests th { 185 | text-align: right; 186 | vertical-align: top; 187 | padding: 0 0.5em 0 0; 188 | } 189 | 190 | #qunit-tests td { 191 | vertical-align: top; 192 | } 193 | 194 | #qunit-tests pre { 195 | margin: 0; 196 | white-space: pre-wrap; 197 | word-wrap: break-word; 198 | } 199 | 200 | #qunit-tests del { 201 | background-color: #E0F2BE; 202 | color: #374E0C; 203 | text-decoration: none; 204 | } 205 | 206 | #qunit-tests ins { 207 | background-color: #FFCACA; 208 | color: #500; 209 | text-decoration: none; 210 | } 211 | 212 | /*** Test Counts */ 213 | 214 | #qunit-tests b.counts { color: #000; } 215 | #qunit-tests b.passed { color: #5E740B; } 216 | #qunit-tests b.failed { color: #710909; } 217 | 218 | #qunit-tests li li { 219 | padding: 5px; 220 | background-color: #FFF; 221 | border-bottom: none; 222 | list-style-position: inside; 223 | } 224 | 225 | /*** Passing Styles */ 226 | 227 | #qunit-tests li li.pass { 228 | color: #3C510C; 229 | background-color: #FFF; 230 | border-left: 10px solid #C6E746; 231 | } 232 | 233 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; } 234 | #qunit-tests .pass .test-name { color: #366097; } 235 | 236 | #qunit-tests .pass .test-actual, 237 | #qunit-tests .pass .test-expected { color: #999; } 238 | 239 | #qunit-banner.qunit-pass { background-color: #C6E746; } 240 | 241 | /*** Failing Styles */ 242 | 243 | #qunit-tests li li.fail { 244 | color: #710909; 245 | background-color: #FFF; 246 | border-left: 10px solid #EE5757; 247 | white-space: pre; 248 | } 249 | 250 | #qunit-tests > li:last-child { 251 | border-radius: 0 0 5px 5px; 252 | } 253 | 254 | #qunit-tests .fail { color: #000; background-color: #EE5757; } 255 | #qunit-tests .fail .test-name, 256 | #qunit-tests .fail .module-name { color: #000; } 257 | 258 | #qunit-tests .fail .test-actual { color: #EE5757; } 259 | #qunit-tests .fail .test-expected { color: #008000; } 260 | 261 | #qunit-banner.qunit-fail { background-color: #EE5757; } 262 | 263 | /*** Skipped tests */ 264 | 265 | #qunit-tests .skipped { 266 | background-color: #EBECE9; 267 | } 268 | 269 | #qunit-tests .qunit-skipped-label { 270 | background-color: #F4FF77; 271 | display: inline-block; 272 | font-style: normal; 273 | color: #366097; 274 | line-height: 1.8em; 275 | padding: 0 0.5em; 276 | margin: -0.4em 0.4em -0.4em 0; 277 | } 278 | 279 | /** Result */ 280 | 281 | #qunit-testresult { 282 | padding: 0.5em 1em 0.5em 1em; 283 | 284 | color: #2B81AF; 285 | background-color: #D2E0E6; 286 | 287 | border-bottom: 1px solid #FFF; 288 | } 289 | #qunit-testresult .module-name { 290 | font-weight: 700; 291 | } 292 | 293 | /** Fixture */ 294 | 295 | #qunit-fixture { 296 | position: absolute; 297 | top: -10000px; 298 | left: -10000px; 299 | width: 1000px; 300 | height: 1000px; 301 | } 302 | -------------------------------------------------------------------------------- /tests/fixtures/MovieSearch.js: -------------------------------------------------------------------------------- 1 | import { 2 | events, 3 | IMAGE_TMDB_PREFIX, 4 | LOADING, 5 | NETWORK_ERROR, 6 | POPULAR_NOW, 7 | PROMPT, 8 | screens as screenIds, 9 | SEARCH_RESULTS_FOR, 10 | testIds 11 | } from "./properties"; 12 | import h from "react-hyperscript"; 13 | import hyperscript from "hyperscript-helpers"; 14 | 15 | const { 16 | PROMPT_TESTID, 17 | RESULTS_HEADER_TESTID, 18 | RESULTS_CONTAINER_TESTID, 19 | QUERY_FIELD_TESTID, 20 | MOVIE_IMG_SRC_TESTID, 21 | MOVIE_TITLE_TESTID, 22 | NETWORK_ERROR_TESTID 23 | } = testIds; 24 | const { 25 | LOADING_SCREEN, 26 | SEARCH_ERROR_SCREEN, 27 | SEARCH_RESULTS_AND_LOADING_SCREEN, 28 | SEARCH_RESULTS_SCREEN, 29 | SEARCH_RESULTS_WITH_MOVIE_DETAILS, 30 | SEARCH_RESULTS_WITH_MOVIE_DETAILS_AND_LOADING_SCREEN, 31 | SEARCH_RESULTS_WITH_MOVIE_DETAILS_ERROR 32 | } = screenIds; 33 | const { QUERY_RESETTED, QUERY_CHANGED, MOVIE_DETAILS_DESELECTED, MOVIE_SELECTED } = events; 34 | const { div, a, ul, li, input, h1, h3, legend, img, dl, dt, dd } = hyperscript(h); 35 | 36 | // TODO : update the trigger(..) for next({[eventName]: eventData}) - no preprocessor this time, I already did that 37 | // before, look for it. In ivi maybe?? 38 | const screens = next => ({ 39 | [LOADING_SCREEN]: () => 40 | div(".App.uk-light.uk-background-secondary", { "data-active-page": "home" }, [ 41 | div(".App__view-container", [ 42 | div(".App__view.uk-margin-top-small.uk-margin-left.uk-margin-right", { "data-page": "home" }, [ 43 | div(".HomePage", [ 44 | h1([`TMDb UI – Home`]), 45 | legend(".uk-legend", { "data-testid": PROMPT_TESTID }, [PROMPT]), 46 | div(".SearchBar.uk-inline.uk-margin-bottom", [ 47 | a(".uk-form-icon.uk-form-icon-flip.js-clear", { 48 | "uk-icon": "icon:search" 49 | }), 50 | input(".SearchBar__input.uk-input.js-input", { 51 | type: "text", 52 | value: "", 53 | onChange: eventHandlersFactory(next)[QUERY_CHANGED], 54 | "data-testid": QUERY_FIELD_TESTID 55 | }) 56 | ]), 57 | h3(".uk-heading-bullet.uk-margin-remove-top", { "data-testid": RESULTS_HEADER_TESTID }, [POPULAR_NOW]), 58 | div(".ResultsContainer", { "data-testid": RESULTS_CONTAINER_TESTID }, [div([LOADING])]) 59 | ]) 60 | ]) 61 | ]) 62 | ]), 63 | [SEARCH_RESULTS_SCREEN]: ({ results, query }) => 64 | div(".App.uk-light.uk-background-secondary", { "data-active-page": "home" }, [ 65 | div(".App__view-container", [ 66 | div(".App__view.uk-margin-top-small.uk-margin-left.uk-margin-right", { "data-page": "home" }, [ 67 | div(".HomePage", [ 68 | h1([`TMDb UI – Home`]), 69 | legend(".uk-legend", { "data-testid": PROMPT_TESTID }, [PROMPT]), 70 | div(".SearchBar.uk-inline.uk-margin-bottom", [ 71 | a(".uk-form-icon.uk-form-icon-flip.js-clear", { 72 | "uk-icon": query.length > 0 ? "icon:close" : "icon:search", 73 | onClick: eventHandlersFactory(next)[QUERY_RESETTED] 74 | }), 75 | input(".SearchBar__input.uk-input.js-input", { 76 | type: "text", 77 | value: query, 78 | onChange: eventHandlersFactory(next)[QUERY_CHANGED], 79 | "data-testid": QUERY_FIELD_TESTID 80 | }) 81 | ]), 82 | h3(".uk-heading-bullet.uk-margin-remove-top", { "data-testid": RESULTS_HEADER_TESTID }, [ 83 | query.length === 0 ? POPULAR_NOW : SEARCH_RESULTS_FOR(query) 84 | ]), 85 | div(".ResultsContainer", { "data-testid": RESULTS_CONTAINER_TESTID }, [ 86 | ul(".uk-thumbnav", [ 87 | results && 88 | results 89 | .filter(result => result.backdrop_path) 90 | .map(result => 91 | li(".uk-margin-bottom", { key: result.id }, [ 92 | a( 93 | ".ResultsContainer__result-item.js-result-click", 94 | { 95 | href: "#", 96 | onClick: ev => eventHandlersFactory(next)[MOVIE_SELECTED](ev, result), 97 | "data-id": result.id 98 | }, 99 | [ 100 | div(".ResultsContainer__thumbnail-holder", [ 101 | img({ 102 | src: `${IMAGE_TMDB_PREFIX}${result.backdrop_path}`, 103 | alt: "", 104 | "data-testid": MOVIE_IMG_SRC_TESTID 105 | }) 106 | ]), 107 | div( 108 | ".ResultsContainer__caption.uk-text-small.uk-text-muted", 109 | { "data-testid": MOVIE_TITLE_TESTID }, 110 | [result.title] 111 | ) 112 | ] 113 | ) 114 | ]) 115 | ) 116 | ]) 117 | ]) 118 | ]) 119 | ]) 120 | ]) 121 | ]), 122 | [SEARCH_ERROR_SCREEN]: ({ query }) => 123 | div(".App.uk-light.uk-background-secondary", { "data-active-page": "home" }, [ 124 | div(".App__view-container", [ 125 | div(".App__view.uk-margin-top-small.uk-margin-left.uk-margin-right", { "data-page": "home" }, [ 126 | div(".HomePage", [ 127 | h1([`TMDb UI – Home`]), 128 | legend(".uk-legend", { "data-testid": PROMPT_TESTID }, [PROMPT]), 129 | div(".SearchBar.uk-inline.uk-margin-bottom", [ 130 | a(".uk-form-icon.uk-form-icon-flip.js-clear", { 131 | "uk-icon": query.length > 0 ? "icon:close" : "icon:search", 132 | onClick: eventHandlersFactory(next)[QUERY_RESETTED] 133 | }), 134 | input(".SearchBar__input.uk-input.js-input", { 135 | type: "text", 136 | value: query, 137 | onChange: eventHandlersFactory(next)[QUERY_CHANGED], 138 | "data-testid": QUERY_FIELD_TESTID 139 | }) 140 | ]), 141 | h3(".uk-heading-bullet.uk-margin-remove-top", { "data-testid": RESULTS_HEADER_TESTID }, [POPULAR_NOW]), 142 | div(".ResultsContainer", { "data-testid": RESULTS_CONTAINER_TESTID }, [ 143 | div({ "data-testid": NETWORK_ERROR_TESTID }, [NETWORK_ERROR]) 144 | ]) 145 | ]) 146 | ]) 147 | ]) 148 | ]), 149 | [SEARCH_RESULTS_AND_LOADING_SCREEN]: ({ results, query }) => 150 | div(".App.uk-light.uk-background-secondary", { "data-active-page": "home" }, [ 151 | div(".App__view-container", [ 152 | div(".App__view.uk-margin-top-small.uk-margin-left.uk-margin-right", { "data-page": "home" }, [ 153 | div(".HomePage", [ 154 | h1([`TMDb UI – Home`]), 155 | legend(".uk-legend", { "data-testid": PROMPT_TESTID }, [PROMPT]), 156 | div(".SearchBar.uk-inline.uk-margin-bottom", [ 157 | a(".uk-form-icon.uk-form-icon-flip.js-clear", { 158 | "uk-icon": query.length > 0 ? "icon:close" : "icon:search", 159 | onClick: eventHandlersFactory(next)[QUERY_RESETTED] 160 | }), 161 | input(".SearchBar__input.uk-input.js-input", { 162 | type: "text", 163 | value: query, 164 | onChange: eventHandlersFactory(next)[QUERY_CHANGED], 165 | "data-testid": QUERY_FIELD_TESTID 166 | }) 167 | ]), 168 | h3(".uk-heading-bullet.uk-margin-remove-top", { "data-testid": RESULTS_HEADER_TESTID }, [ 169 | query.length === 0 ? POPULAR_NOW : SEARCH_RESULTS_FOR(query) 170 | ]), 171 | div(".ResultsContainer", { "data-testid": RESULTS_CONTAINER_TESTID }, [div([`Loading...`])]) 172 | ]) 173 | ]) 174 | ]) 175 | ]), 176 | [SEARCH_RESULTS_WITH_MOVIE_DETAILS_AND_LOADING_SCREEN]: ({ results, query, title }) => 177 | div(".App.uk-light.uk-background-secondary", { "data-active-page": "item" }, [ 178 | div(".App__view-container", [ 179 | div(".App__view.uk-margin-top-small.uk-margin-left.uk-margin-right", { "data-page": "home" }, [ 180 | div(".HomePage", [ 181 | h1([`TMDb UI – Home`]), 182 | legend(".uk-legend", { "data-testid": PROMPT_TESTID }, [PROMPT]), 183 | div(".SearchBar.uk-inline.uk-margin-bottom", [ 184 | a(".uk-form-icon.uk-form-icon-flip.js-clear", { 185 | "uk-icon": query.length > 0 ? "icon:close" : "icon:search", 186 | onClick: eventHandlersFactory(next)[QUERY_RESETTED] 187 | }), 188 | input(".SearchBar__input.uk-input.js-input", { 189 | type: "text", 190 | value: query, 191 | onChange: eventHandlersFactory(next)[QUERY_CHANGED], 192 | "data-testid": QUERY_FIELD_TESTID 193 | }) 194 | ]), 195 | h3(".uk-heading-bullet.uk-margin-remove-top", { "data-testid": RESULTS_HEADER_TESTID }, [ 196 | query.length === 0 ? POPULAR_NOW : SEARCH_RESULTS_FOR(query) 197 | ]), 198 | div(".ResultsContainer", { "data-testid": RESULTS_CONTAINER_TESTID }, [ 199 | ul(".uk-thumbnav", [ 200 | results && 201 | results 202 | .filter(result => result.backdrop_path) 203 | .map(result => 204 | li( 205 | ".uk-margin-bottom", 206 | { 207 | key: result.id, 208 | onClick: ev => eventHandlersFactory(next)[MOVIE_SELECTED](ev, result) 209 | }, 210 | [ 211 | a( 212 | ".ResultsContainer__result-item.js-result-click", 213 | { 214 | href: null, 215 | "data-id": result.id 216 | }, 217 | [ 218 | div(".ResultsContainer__thumbnail-holder", [ 219 | img({ 220 | src: `${IMAGE_TMDB_PREFIX}${result.backdrop_path}`, 221 | alt: "", 222 | "data-testid": MOVIE_IMG_SRC_TESTID 223 | }) 224 | ]), 225 | div( 226 | ".ResultsContainer__caption.uk-text-small.uk-text-muted", 227 | { "data-testid": MOVIE_TITLE_TESTID }, 228 | [result.title] 229 | ) 230 | ] 231 | ) 232 | ] 233 | ) 234 | ) 235 | ]) 236 | ]) 237 | ]) 238 | ]), 239 | div(".App__view.uk-margin-top-small.uk-margin-left.uk-margin-right", { "data-page": "item" }, [ 240 | div([h1([title]), div(["Loading..."])]) 241 | ]) 242 | ]) 243 | ]), 244 | [SEARCH_RESULTS_WITH_MOVIE_DETAILS]: ({ results, query, details, cast }) => 245 | div(".App.uk-light.uk-background-secondary", { "data-active-page": "item" }, [ 246 | div(".App__view-container", { onClick: eventHandlersFactory(next)[MOVIE_DETAILS_DESELECTED] }, [ 247 | div(".App__view.uk-margin-top-small.uk-margin-left.uk-margin-right", { "data-page": "home" }, [ 248 | div(".HomePage", [ 249 | h1([`TMDb UI – Home`]), 250 | legend(".uk-legend", { "data-testid": PROMPT_TESTID }, [PROMPT]), 251 | div(".SearchBar.uk-inline.uk-margin-bottom", [ 252 | a(".uk-form-icon.uk-form-icon-flip.js-clear", { 253 | "uk-icon": query.length > 0 ? "icon:close" : "icon:search", 254 | onClick: eventHandlersFactory(next)[QUERY_RESETTED] 255 | }), 256 | input(".SearchBar__input.uk-input.js-input", { 257 | type: "text", 258 | value: query, 259 | onChange: eventHandlersFactory(next)[QUERY_CHANGED], 260 | "data-testid": QUERY_FIELD_TESTID 261 | }) 262 | ]), 263 | h3(".uk-heading-bullet.uk-margin-remove-top", { "data-testid": RESULTS_HEADER_TESTID }, [ 264 | query.length === 0 ? POPULAR_NOW : SEARCH_RESULTS_FOR(query) 265 | ]), 266 | div(".ResultsContainer", { "data-testid": RESULTS_CONTAINER_TESTID }, [ 267 | ul(".uk-thumbnav", [ 268 | results && 269 | results 270 | .filter(result => result.backdrop_path) 271 | .map(result => 272 | li(".uk-margin-bottom", { key: result.id }, [ 273 | a( 274 | ".ResultsContainer__result-item.js-result-click", 275 | { 276 | href: "#", 277 | onClick: ev => eventHandlersFactory(next)[MOVIE_SELECTED](ev, result), 278 | "data-id": result.id 279 | }, 280 | [ 281 | div(".ResultsContainer__thumbnail-holder", [ 282 | img({ 283 | src: `${IMAGE_TMDB_PREFIX}${result.backdrop_path}`, 284 | alt: "", 285 | "data-testid": MOVIE_IMG_SRC_TESTID 286 | }) 287 | ]), 288 | div( 289 | ".ResultsContainer__caption.uk-text-small.uk-text-muted", 290 | { "data-testid": MOVIE_TITLE_TESTID }, 291 | [result.title] 292 | ) 293 | ] 294 | ) 295 | ]) 296 | ) 297 | ]) 298 | ]) 299 | ]) 300 | ]), 301 | div(".App__view.uk-margin-top-small.uk-margin-left.uk-margin-right", { "data-page": "item" }, [ 302 | div([ 303 | h1([details.title || ""]), 304 | div(".MovieDetailsPage", [ 305 | div( 306 | ".MovieDetailsPage__img-container.uk-margin-right", 307 | { 308 | style: { float: "left" } 309 | }, 310 | [ 311 | img({ 312 | src: `http://image.tmdb.org/t/p/w342${details.poster_path}`, 313 | alt: "" 314 | }) 315 | ] 316 | ), 317 | dl(".uk-description-list", [ 318 | dt([`Popularity`]), 319 | dd([details.vote_average]), 320 | dt([`Overview`]), 321 | dd([details.overview]), 322 | dt([`Genres`]), 323 | dd([details.genres.map(g => g.name).join(", ")]), 324 | dt([`Starring`]), 325 | dd([ 326 | cast.cast 327 | .slice(0, 3) 328 | .map(cast => cast.name) 329 | .join(", ") 330 | ]), 331 | dt([`Languages`]), 332 | dd([details.spoken_languages.map(g => g.name).join(", ")]), 333 | dt([`Original Title`]), 334 | dd([details.original_title]), 335 | dt([`Release Date`]), 336 | dd([details.release_date]), 337 | details.imdb_id && dt([`IMDb URL`]), 338 | details.imdb_id && 339 | dd([ 340 | a( 341 | { 342 | href: `https://www.imdb.com/title/${details.imdb_id}/` 343 | }, 344 | [`https://www.imdb.com/title/${details.imdb_id}/`] 345 | ) 346 | ]) 347 | ]) 348 | ]) 349 | ]) 350 | ]) 351 | ]) 352 | ]), 353 | [SEARCH_RESULTS_WITH_MOVIE_DETAILS_ERROR]: ({ results, query, title }) => 354 | div(".App.uk-light.uk-background-secondary", { "data-active-page": "item" }, [ 355 | div(".App__view-container", { onClick: eventHandlersFactory(next)[MOVIE_DETAILS_DESELECTED] }, [ 356 | div(".App__view.uk-margin-top-small.uk-margin-left.uk-margin-right", { "data-page": "home" }, [ 357 | div(".HomePage", [ 358 | h1([`TMDb UI – Home`]), 359 | legend(".uk-legend", { "data-testid": PROMPT_TESTID }, [PROMPT]), 360 | div(".SearchBar.uk-inline.uk-margin-bottom", [ 361 | a(".uk-form-icon.uk-form-icon-flip.js-clear", { 362 | "uk-icon": query.length > 0 ? "icon:close" : "icon:search", 363 | onClick: eventHandlersFactory(next)[QUERY_RESETTED] 364 | }), 365 | input(".SearchBar__input.uk-input.js-input", { 366 | type: "text", 367 | value: query, 368 | onChange: eventHandlersFactory(next)[QUERY_CHANGED], 369 | "data-testid": QUERY_FIELD_TESTID 370 | }) 371 | ]), 372 | h3(".uk-heading-bullet.uk-margin-remove-top", { "data-testid": RESULTS_HEADER_TESTID }, [ 373 | query.length === 0 ? POPULAR_NOW : SEARCH_RESULTS_FOR(query) 374 | ]), 375 | div(".ResultsContainer", { "data-testid": RESULTS_CONTAINER_TESTID }, [ 376 | ul(".uk-thumbnav", [ 377 | results && 378 | results 379 | .filter(result => result.backdrop_path) 380 | .map(result => 381 | li(".uk-margin-bottom", { key: result.id }, [ 382 | a( 383 | ".ResultsContainer__result-item.js-result-click", 384 | { 385 | href: "#", 386 | onClick: ev => eventHandlersFactory(next)[MOVIE_SELECTED](ev, result), 387 | "data-id": result.id 388 | }, 389 | [ 390 | div(".ResultsContainer__thumbnail-holder", [ 391 | img({ 392 | src: `${IMAGE_TMDB_PREFIX}${result.backdrop_path}`, 393 | alt: "", 394 | "data-testid": MOVIE_IMG_SRC_TESTID 395 | }) 396 | ]), 397 | div( 398 | ".ResultsContainer__caption.uk-text-small.uk-text-muted", 399 | { "data-testid": MOVIE_TITLE_TESTID }, 400 | [result.title] 401 | ) 402 | ] 403 | ) 404 | ]) 405 | ) 406 | ]) 407 | ]) 408 | ]) 409 | ]), 410 | div(".App__view.uk-margin-top-small.uk-margin-left.uk-margin-right", { "data-page": "item" }, [ 411 | div([h1([title]), div({ "data-testid": NETWORK_ERROR_TESTID }, [NETWORK_ERROR])]) 412 | ]) 413 | ]) 414 | ]) 415 | }); 416 | 417 | export function MovieSearch(props) { 418 | const { screen, query, results, title, details, cast, next } = props; 419 | 420 | return screens(next)[screen](props); 421 | } 422 | 423 | // TODO: include 424 | const eventHandlersFactory = next => ({ 425 | [QUERY_CHANGED]: ev => next({ [QUERY_CHANGED]: ev.target.value }), 426 | [QUERY_RESETTED]: ev => next({ [QUERY_CHANGED]: "" }), 427 | [MOVIE_SELECTED]: (ev, result) => next({ [MOVIE_SELECTED]: { movie: result } }), 428 | [MOVIE_DETAILS_DESELECTED]: ev => next({ [MOVIE_DETAILS_DESELECTED]: void 0 }) 429 | }); 430 | -------------------------------------------------------------------------------- /tests/fixtures/components.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import h from "react-hyperscript"; 3 | import hyperscript from "hyperscript-helpers"; 4 | import { SEARCH, CANCEL_SEARCH, PHOTO, PHOTO_DETAIL, SEARCH_INPUT, SEARCH_ERROR } from "./test-ids"; 5 | 6 | const { div, button, span, input, form, section, img, h1 } = hyperscript(h); 7 | 8 | export class Form extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | this.formRef = React.createRef(); 12 | } 13 | 14 | render() { 15 | const Component = this; 16 | const { galleryState, onSubmit, onClick } = Component.props; 17 | 18 | const searchText = { 19 | loading: "Searching...", 20 | error: "Try search again", 21 | start: "Search" 22 | }[galleryState] || "Search"; 23 | const isLoading = galleryState === "loading"; 24 | 25 | return ( 26 | form(".ui-form", { onSubmit: ev => onSubmit(ev, this.formRef), "data-testid": SEARCH }, [ 27 | input(".ui-input", { 28 | ref: this.formRef, 29 | type: "search", 30 | placeholder: "Search Flickr for photos...", 31 | disabled: isLoading, 32 | "data-testid": SEARCH_INPUT 33 | }), 34 | div(".ui-buttons", [ 35 | button(".ui-button", { disabled: isLoading, "data-flip-key": "search" }, searchText), 36 | isLoading && button(".ui-button", { type: "button", onClick: onClick, "data-testid": CANCEL_SEARCH }, "Cancel") 37 | ]) 38 | ]) 39 | ); 40 | } 41 | } 42 | 43 | export class Gallery extends React.Component { 44 | constructor(props) { 45 | super(props); 46 | } 47 | 48 | render() { 49 | const { galleryState, items, onClick } = this.props; 50 | const isError = galleryState === "error"; 51 | 52 | return ( 53 | section(".ui-items", { "data-state": galleryState }, [ 54 | isError 55 | ? span(".ui-error", {"data-testid": SEARCH_ERROR}, `Uh oh, search failed.`) 56 | : items.map((item, i) => img(".ui-item", { 57 | src: item.media.m, 58 | style: { "--i": i }, 59 | key: item.link, 60 | onClick: ev => onClick(item), 61 | "data-testid": PHOTO 62 | })) 63 | ]) 64 | ); 65 | } 66 | } 67 | 68 | export class Photo extends React.Component { 69 | constructor(props) { 70 | super(props); 71 | } 72 | 73 | render() { 74 | // NOTE: by machine construction, `photo` exists and is not null 75 | const { galleryState, onClick, photo } = this.props; 76 | 77 | if (galleryState !== "photo") return null; 78 | 79 | return ( 80 | section(".ui-photo-detail", { onClick , "data-testid": PHOTO_DETAIL}, [ 81 | img(".ui-photo", { src: photo.media.m }) 82 | ]) 83 | ); 84 | } 85 | } 86 | 87 | export class GalleryApp extends React.Component { 88 | constructor(props) { 89 | super(props); 90 | } 91 | 92 | render() { 93 | const { query, photo, items, next, gallery: galleryState } = this.props; 94 | 95 | const trigger = triggerFnFactory({next}); 96 | 97 | return div(".ui-app", { "data-state": galleryState }, [ 98 | h(Form, { galleryState, onSubmit: trigger("onSubmit"), onClick: trigger("onCancelClick") }, []), 99 | h(Gallery, { galleryState, items, onClick: trigger("onGalleryClick") }, []), 100 | h(Photo, { galleryState, photo, onClick: trigger("onPhotoClick") }, []) 101 | ]); 102 | } 103 | } 104 | 105 | export function triggerFnFactory(rawEventSource) { 106 | return rawEventName => { 107 | // DOC : by convention, [rawEventName, rawEventData, ref (optional), ...anything else] 108 | // DOC : rawEventData is generally the raw event passed by the event handler 109 | // DOC : `ref` here is :: React.ElementRef and is generally used to pass `ref`s for uncontrolled component 110 | return function eventHandler(...args) { 111 | return rawEventSource.next([rawEventName].concat(args)); 112 | }; 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /tests/fixtures/fake.js: -------------------------------------------------------------------------------- 1 | const linksForCatheter = [ 2 | "https://www.flickr.com/photos/155010203@N06/31741086078/", 3 | "https://www.flickr.com/photos/159915559@N02/30547921577/", 4 | "https://www.flickr.com/photos/155010203@N06/44160499005/", 5 | "https://www.flickr.com/photos/139230693@N02/28991566557/" 6 | ]; 7 | const imgSrcForCathether = [ 8 | "https://farm2.staticflickr.com/1928/31741086078_8757b4913d_m.jpg", 9 | "https://farm2.staticflickr.com/1978/30547921577_f8cbee76f1_m.jpg", 10 | "https://farm2.staticflickr.com/1939/44160499005_7c34c4326d_m.jpg", 11 | "https://farm2.staticflickr.com/1833/42224900930_360debd33e_m.jpg" 12 | ]; 13 | const resultSearchForCathether = [0, 1, 2, 3].map(index => ({ 14 | link: linksForCatheter[index], 15 | media: { m: imgSrcForCathether[index] } 16 | })); 17 | const linksForCat = [ 18 | "https://www.flickr.com/photos/155010203@N06/31741086079/", 19 | "https://www.flickr.com/photos/159915559@N02/30547921579/", 20 | "https://www.flickr.com/photos/155010203@N06/44160499009/", 21 | "https://www.flickr.com/photos/139230693@N02/28991566559/" 22 | ]; 23 | const imgSrcForCat = [ 24 | "https://farm2.staticflickr.com/1811/28991566557_7373bf3b87_m.jpg", 25 | "https://farm1.staticflickr.com/838/43264055412_0758887829_m.jpg", 26 | "https://farm2.staticflickr.com/1760/28041185847_16008b600a_m.jpg", 27 | "https://farm2.staticflickr.com/1744/41656558545_d4e0eec5d3_m.jpg" 28 | ]; 29 | const resultSearchForCat = [0, 1, 2, 3].map(index => ({ 30 | link: linksForCat[index], 31 | media: { m: imgSrcForCat[index] } 32 | })); 33 | export const searchFixtures = { 34 | "cathether": resultSearchForCathether, 35 | "cat": resultSearchForCat 36 | }; 37 | -------------------------------------------------------------------------------- /tests/fixtures/helpers.js: -------------------------------------------------------------------------------- 1 | import superagent from "superagent"; 2 | 3 | // Helpers 4 | export const SvcUrl = relativeUrl => 5 | relativeUrl 6 | .replace(/^/, "https://api.themoviedb.org/3") 7 | .replace(/(\?|$)/, "?api_key=bf6b860ab05ac2d94054ba9ca96cf1fa&"); 8 | 9 | export function runMovieSearchQuery(query) { 10 | return superagent.get(SvcUrl(query)).then(res => { 11 | return res.body; 12 | }); 13 | } 14 | export function runMovieDetailQuery(movieId) { 15 | return Promise.all([runMovieSearchQuery(`/movie/${movieId}`), runMovieSearchQuery(`/movie/${movieId}/credits`)]); 16 | } 17 | 18 | export function makeQuerySlug(query) { 19 | return query.length === 0 ? `/movie/popular?language=en-US&page=1` : `/search/movie?query=${query}`; 20 | } 21 | 22 | // Utils 23 | export function destructureEvent(eventStruct) { 24 | return { 25 | rawEventName: eventStruct[0], 26 | rawEventData: eventStruct[1], 27 | ref: eventStruct[2] 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /tests/fixtures/machines.js: -------------------------------------------------------------------------------- 1 | import { INIT_EVENT, NO_OUTPUT } from "state-transducer"; 2 | import { destructureEvent, NO_ACTIONS, NO_INTENT, renderAction, runSearchQuery } from "../helpers"; 3 | import h from "react-hyperscript"; 4 | import Flipping from "flipping"; 5 | import { concatMap, filter, flatMap, map, shareReplay, startWith } from "rxjs/operators"; 6 | import { merge, Observable, Subject } from "rxjs"; 7 | import { GalleryApp, triggerFnFactory } from "./components"; 8 | import { getStateTransducerRxAdapter } from "../../src/Machine"; 9 | import { COMMAND_RENDER } from "../../src"; 10 | 11 | export const BUTTON_CLICKED = "button_clicked"; 12 | export const KEY_PRESSED = "key_pressed"; 13 | export const INPUT_KEY_PRESSED = "input_key_pressed"; 14 | export const ENTER_KEY_PRESSED = "enter_key_pressed"; 15 | export const INPUT_CHANGED = "input_changed"; 16 | export const KEY_ENTER = `Enter`; 17 | export const COMMAND_SEARCH = "command_search"; 18 | 19 | const RxApi = { Subject, Observable, merge, filter, flatMap, concatMap, map, startWith, shareReplay }; 20 | 21 | function makeRenderCommand(gallery) { 22 | return function(extendedState, eventData, fsmSettings) { 23 | const { query, items, photo } = extendedState; 24 | debugger 25 | return { 26 | outputs: [{ 27 | command: COMMAND_RENDER, 28 | params: { query, photo, items, gallery } 29 | }], 30 | updates: [] 31 | }; 32 | }; 33 | } 34 | 35 | export const imageGallery = { 36 | options: { debug: { console } }, 37 | initialExtendedState: { query: "", items: [], photo: undefined, gallery: "" }, 38 | initialControlState: "init", 39 | states: { init: "", start: "", loading: "", gallery: "", error: "", photo: "" }, 40 | events: ["START", "SEARCH", "SEARCH_SUCCESS", "SEARCH_FAILURE", "CANCEL_SEARCH", "SELECT_PHOTO", "EXIT_PHOTO"], 41 | eventHandler: getStateTransducerRxAdapter(RxApi), 42 | preprocessor: rawEventSource => rawEventSource.pipe( 43 | map(ev => { 44 | const { rawEventName, rawEventData: e, ref } = destructureEvent(ev); 45 | 46 | if (rawEventName === INIT_EVENT) { 47 | return { [INIT_EVENT]: void 0 }; 48 | } 49 | // Form raw events 50 | else if (rawEventName === "onSubmit") { 51 | e.persist(); 52 | e.preventDefault(); 53 | return { SEARCH: ref.current.value }; 54 | } 55 | else if (rawEventName === "onCancelClick") { 56 | return { CANCEL_SEARCH: void 0 }; 57 | } 58 | // Gallery 59 | else if (rawEventName === "onGalleryClick") { 60 | const item = e; 61 | return { SELECT_PHOTO: item }; 62 | } 63 | // Photo detail 64 | else if (rawEventName === "onPhotoClick") { 65 | return { EXIT_PHOTO: void 0 }; 66 | } 67 | // System events 68 | else if (rawEventName === "SEARCH_SUCCESS") { 69 | const items = e; 70 | return { SEARCH_SUCCESS: items }; 71 | } 72 | else if (rawEventName === "SEARCH_FAILURE") { 73 | return { SEARCH_FAILURE: void 0 }; 74 | } 75 | 76 | return NO_INTENT; 77 | }), 78 | filter(x => x !== NO_INTENT), 79 | startWith({ START: void 0 }) 80 | ), 81 | renderWith: GalleryApp, 82 | transitions: [ 83 | { from: "init", event: "START", to: "start", action: NO_ACTIONS }, 84 | { from: "start", event: "SEARCH", to: "loading", action: NO_ACTIONS }, 85 | { 86 | from: "loading", event: "SEARCH_SUCCESS", to: "gallery", action: (extendedState, eventData, fsmSettings) => { 87 | const items = eventData; 88 | 89 | return { 90 | updates: [{ op: "add", path: "/items", value: items }], 91 | outputs: NO_OUTPUT 92 | }; 93 | } 94 | }, 95 | { from: "loading", event: "SEARCH_FAILURE", to: "error", action: NO_ACTIONS }, 96 | { from: "loading", event: "CANCEL_SEARCH", to: "gallery", action: NO_ACTIONS }, 97 | { from: "error", event: "SEARCH", to: "loading", action: NO_ACTIONS }, 98 | { from: "gallery", event: "SEARCH", to: "loading", action: NO_ACTIONS }, 99 | { 100 | from: "gallery", event: "SELECT_PHOTO", to: "photo", action: (extendedState, eventData, fsmSettings) => { 101 | const item = eventData; 102 | 103 | return { 104 | updates: [{ op: "add", path: "/photo", value: item }], 105 | outputs: NO_OUTPUT 106 | }; 107 | } 108 | }, 109 | { from: "photo", event: "EXIT_PHOTO", to: "gallery", action: NO_ACTIONS } 110 | ], 111 | entryActions: { 112 | loading: (extendedState, eventData, fsmSettings) => { 113 | const { items, photo } = extendedState; 114 | const query = eventData; 115 | const searchCommand = { command: COMMAND_SEARCH, params: { query } }; 116 | const renderGalleryAction = makeRenderCommand("loading")({query, items, photo}, eventData, fsmSettings); 117 | 118 | return { 119 | outputs: [searchCommand].concat(renderGalleryAction.outputs), 120 | updates: [] 121 | }; 122 | }, 123 | photo: makeRenderCommand("photo"), 124 | gallery: makeRenderCommand("gallery"), 125 | error: makeRenderCommand("error"), 126 | start: makeRenderCommand("start") 127 | }, 128 | effectHandlers: { runSearchQuery }, 129 | commandHandlers: { 130 | [COMMAND_SEARCH]: (next, params, effectHandlersWithRender) => { 131 | const { runSearchQuery } = effectHandlersWithRender; 132 | const {query} = params; 133 | return runSearchQuery(query) 134 | .then(data => { 135 | // The preprocessor expect arguments in form of an array! 136 | next(["SEARCH_SUCCESS", data.items]); 137 | }) 138 | .catch(error => { 139 | next(["SEARCH_FAILURE", void 0]); 140 | }); 141 | } 142 | }, 143 | inject: new Flipping(), 144 | componentWillUpdate: flipping => (machineComponent, prevProps, prevState, snapshot, settings) => {flipping.read();}, 145 | componentDidUpdate: flipping => (machineComponent, nextProps, nextState, settings) => {flipping.flip();} 146 | }; 147 | 148 | -------------------------------------------------------------------------------- /tests/fixtures/movieSearchApp.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import h from "react-hyperscript"; 3 | import { Machine } from "../../src"; 4 | import { createStateMachine } from "state-transducer"; 5 | import { applyPatch } from "json-patch-es6"; 6 | import { movieSearchFsmDef } from "./movieSearchFsm"; 7 | 8 | /** 9 | * 10 | * @param {ExtendedState} extendedState 11 | * @param {Operation[]} extendedStateUpdateOperations 12 | * @returns {ExtendedState} 13 | */ 14 | export function applyJSONpatch(extendedState, extendedStateUpdateOperations) { 15 | return applyPatch( 16 | extendedState, 17 | extendedStateUpdateOperations || [], 18 | false, 19 | false 20 | ).newDocument; 21 | } 22 | 23 | const fsm = createStateMachine(movieSearchFsmDef, { 24 | updateState: applyJSONpatch 25 | }); 26 | 27 | const App = h( 28 | Machine, 29 | { 30 | // TODO : write it with transducers, and emitonoff emitter, will have to do lots of API surfacing, and change 31 | // emit to next in state-transducer -? new version pass it to master 32 | // TODO : mmm but I must have implementation on th 7.1 though, will that not delay me a lot? should not 33 | // worse case use rx 34 | eventHandler: movieSearchFsmDef.eventHandler, 35 | preprocessor: movieSearchFsmDef.preprocessor, 36 | fsm: fsm, 37 | commandHandlers: movieSearchFsmDef.commandHandlers, 38 | effectHandlers: movieSearchFsmDef.effectHandlers 39 | }, 40 | [] 41 | ); 42 | 43 | export default App; 44 | -------------------------------------------------------------------------------- /tests/fixtures/movieSearchFsm.js: -------------------------------------------------------------------------------- 1 | import { NO_OUTPUT, NO_STATE_UPDATE } from "state-transducer"; 2 | import {COMMAND_RENDER} from "../../src/properties"; 3 | import { 4 | COMMAND_MOVIE_DETAILS_SEARCH, 5 | COMMAND_MOVIE_SEARCH, 6 | DISCOVERY_REQUEST, 7 | events, 8 | MOVIE_DETAIL_QUERYING, 9 | MOVIE_DETAIL_SELECTION, 10 | MOVIE_DETAIL_SELECTION_ERROR, 11 | MOVIE_QUERYING, 12 | MOVIE_SELECTION, 13 | MOVIE_SELECTION_ERROR, 14 | screens as screenIds, 15 | START 16 | } from "./properties"; 17 | import { makeQuerySlug, runMovieDetailQuery, runMovieSearchQuery } from "./helpers"; 18 | 19 | const NO_ACTIONS = () => ({ outputs: NO_OUTPUT, updates: NO_STATE_UPDATE }); 20 | 21 | const initialControlState = START; 22 | const initialExtendedState = { 23 | queryFieldHasChanged: false, 24 | movieQuery: "", 25 | results: null, 26 | movieTitle: null, 27 | movieDetails: null, 28 | cast: null 29 | }; 30 | const states = { 31 | [START]: "", 32 | [MOVIE_QUERYING]: "", 33 | [MOVIE_SELECTION]: "", 34 | [MOVIE_SELECTION_ERROR]: "", 35 | [MOVIE_DETAIL_QUERYING]: "", 36 | [MOVIE_DETAIL_SELECTION]: "", 37 | [MOVIE_DETAIL_SELECTION_ERROR]: "" 38 | }; 39 | const { 40 | SEARCH_ERROR_MOVIE_RECEIVED, 41 | USER_NAVIGATED_TO_APP, 42 | QUERY_CHANGED, 43 | MOVIE_DETAILS_DESELECTED, 44 | MOVIE_SELECTED, 45 | SEARCH_ERROR_RECEIVED, 46 | SEARCH_RESULTS_MOVIE_RECEIVED, 47 | SEARCH_RESULTS_RECEIVED 48 | } = events; 49 | const { 50 | LOADING_SCREEN, 51 | SEARCH_ERROR_SCREEN, 52 | SEARCH_RESULTS_AND_LOADING_SCREEN, 53 | SEARCH_RESULTS_SCREEN, 54 | SEARCH_RESULTS_WITH_MOVIE_DETAILS, 55 | SEARCH_RESULTS_WITH_MOVIE_DETAILS_AND_LOADING_SCREEN, 56 | SEARCH_RESULTS_WITH_MOVIE_DETAILS_ERROR 57 | } = screenIds; 58 | const transitions = [ 59 | // { from: INIT_STATE, event: INIT_EVENT, to: START, action: NO_ACTIONS }, 60 | { 61 | from: START, 62 | event: USER_NAVIGATED_TO_APP, 63 | to: MOVIE_QUERYING, 64 | action: displayLoadingScreenAndQueryDb 65 | }, 66 | { 67 | from: MOVIE_QUERYING, 68 | event: SEARCH_RESULTS_RECEIVED, 69 | guards: [ 70 | { 71 | predicate: isExpectedMovieResults, 72 | to: MOVIE_SELECTION, 73 | action: displayMovieSearchResultsScreen 74 | }, 75 | { 76 | predicate: isNotExpectedMovieResults, 77 | to: MOVIE_QUERYING, 78 | action: NO_ACTIONS 79 | } 80 | ] 81 | }, 82 | { 83 | from: MOVIE_QUERYING, 84 | event: QUERY_CHANGED, 85 | to: MOVIE_QUERYING, 86 | action: displayLoadingScreenAndQueryNonEmpty 87 | }, 88 | { 89 | from: MOVIE_SELECTION, 90 | event: QUERY_CHANGED, 91 | to: MOVIE_QUERYING, 92 | action: displayLoadingScreenAndQueryNonEmpty 93 | }, 94 | { 95 | from: MOVIE_QUERYING, 96 | event: SEARCH_ERROR_RECEIVED, 97 | guards: [ 98 | { 99 | predicate: isExpectedMovieResults, 100 | to: MOVIE_SELECTION_ERROR, 101 | action: displayMovieSearchErrorScreen 102 | }, 103 | { 104 | predicate: isNotExpectedMovieResults, 105 | to: MOVIE_QUERYING, 106 | action: NO_ACTIONS 107 | } 108 | ] 109 | }, 110 | { 111 | from: MOVIE_SELECTION_ERROR, 112 | event: QUERY_CHANGED, 113 | to: MOVIE_QUERYING, 114 | action: displayLoadingScreenAndQueryNonEmpty 115 | }, 116 | { 117 | from: MOVIE_SELECTION, 118 | event: MOVIE_SELECTED, 119 | to: MOVIE_DETAIL_QUERYING, 120 | action: displayDetailsLoadingScreenAndQueryDetailsDb 121 | }, 122 | { 123 | from: MOVIE_DETAIL_QUERYING, 124 | event: SEARCH_RESULTS_MOVIE_RECEIVED, 125 | to: MOVIE_DETAIL_SELECTION, 126 | action: displayMovieDetailsSearchResultsScreen 127 | }, 128 | { 129 | from: MOVIE_DETAIL_QUERYING, 130 | event: SEARCH_ERROR_MOVIE_RECEIVED, 131 | to: MOVIE_DETAIL_SELECTION_ERROR, 132 | action: displayMovieDetailsSearchErrorScreen 133 | }, 134 | { 135 | from: MOVIE_DETAIL_SELECTION_ERROR, 136 | event: MOVIE_DETAILS_DESELECTED, 137 | to: MOVIE_SELECTION, 138 | action: displayCurrentMovieSearchResultsScreen 139 | }, 140 | { 141 | from: MOVIE_DETAIL_SELECTION, 142 | event: MOVIE_DETAILS_DESELECTED, 143 | to: MOVIE_SELECTION, 144 | action: displayCurrentMovieSearchResultsScreen 145 | } 146 | ]; 147 | 148 | export const commandHandlers = { 149 | [COMMAND_MOVIE_SEARCH]: (next, _query, effectHandlers) => { 150 | const querySlug = _query === "" ? DISCOVERY_REQUEST : makeQuerySlug(_query); 151 | 152 | effectHandlers 153 | .runMovieSearchQuery(querySlug) 154 | .then(data => { 155 | next({ 156 | [SEARCH_RESULTS_RECEIVED]: { 157 | results: data.results, 158 | query: _query 159 | } 160 | }); 161 | }) 162 | .catch(error => { 163 | next({ [SEARCH_ERROR_RECEIVED]: { query: _query } }); 164 | }); 165 | }, 166 | [COMMAND_MOVIE_DETAILS_SEARCH]: (next, movieId, effectHandlers) => { 167 | effectHandlers 168 | .runMovieDetailQuery(movieId) 169 | .then(([details, cast]) => next({ [SEARCH_RESULTS_MOVIE_RECEIVED]: [details, cast] })) 170 | .catch(err => next({ [SEARCH_ERROR_MOVIE_RECEIVED]: err })); 171 | } 172 | }; 173 | 174 | export const effectHandlers = { 175 | runMovieSearchQuery: runMovieSearchQuery, 176 | runMovieDetailQuery: runMovieDetailQuery 177 | }; 178 | 179 | function displayLoadingScreenAndQueryDb(extendedState, eventData, fsmSettings) { 180 | const searchCommand = { 181 | command: COMMAND_MOVIE_SEARCH, 182 | params: "" 183 | }; 184 | const renderCommand = { 185 | command: COMMAND_RENDER, 186 | params: { screen: LOADING_SCREEN } 187 | }; 188 | return { 189 | updates: NO_STATE_UPDATE, 190 | outputs: [renderCommand, searchCommand] 191 | }; 192 | } 193 | 194 | function displayLoadingScreenAndQueryNonEmpty(extendedState, eventData, fsmSettings) { 195 | const { queryFieldHasChanged, movieQuery, results, movieTitle } = extendedState; 196 | const query = eventData; 197 | const searchCommand = { 198 | command: COMMAND_MOVIE_SEARCH, 199 | params: query 200 | }; 201 | const renderCommand = { 202 | command: COMMAND_RENDER, 203 | params: { 204 | screen: SEARCH_RESULTS_AND_LOADING_SCREEN, 205 | results, 206 | query 207 | } 208 | }; 209 | return { 210 | updates: [ 211 | { op: "add", path: "/queryFieldHasChanged", value: true }, 212 | { op: "add", path: "/movieQuery", value: query } 213 | ], 214 | outputs: [renderCommand, searchCommand] 215 | }; 216 | } 217 | 218 | function displayMovieSearchResultsScreen(extendedState, eventData, fsmSettings) { 219 | const searchResults = eventData; 220 | const { results, query } = searchResults; 221 | const renderCommand = { 222 | command: COMMAND_RENDER, 223 | params: { 224 | screen: SEARCH_RESULTS_SCREEN, 225 | results, 226 | query: query || "" 227 | } 228 | }; 229 | 230 | return { 231 | updates: [{ op: "add", path: "/results", value: results }], 232 | outputs: [renderCommand] 233 | }; 234 | } 235 | 236 | function displayCurrentMovieSearchResultsScreen(extendedState, eventData, fsmSettings) { 237 | const { movieQuery, results } = extendedState; 238 | const renderCommand = { 239 | command: COMMAND_RENDER, 240 | params: { 241 | screen: SEARCH_RESULTS_SCREEN, 242 | results, 243 | query: movieQuery || "" 244 | } 245 | }; 246 | 247 | return { 248 | updates: NO_STATE_UPDATE, 249 | outputs: [renderCommand] 250 | }; 251 | } 252 | 253 | function displayMovieSearchErrorScreen(extendedState, eventData, fsmSettings) { 254 | const { queryFieldHasChanged, movieQuery, results, movieTitle } = extendedState; 255 | const renderCommand = { 256 | command: COMMAND_RENDER, 257 | params: { 258 | screen: SEARCH_ERROR_SCREEN, 259 | query: queryFieldHasChanged ? movieQuery : "" 260 | } 261 | }; 262 | 263 | return { 264 | updates: NO_STATE_UPDATE, 265 | outputs: [renderCommand] 266 | }; 267 | } 268 | 269 | function displayDetailsLoadingScreenAndQueryDetailsDb(extendedState, eventData, fsmSettings) { 270 | const { movie } = eventData; 271 | const movieId = movie.id; 272 | const { movieQuery, results } = extendedState; 273 | 274 | const searchCommand = { 275 | command: COMMAND_MOVIE_DETAILS_SEARCH, 276 | params: movieId 277 | }; 278 | const renderCommand = { 279 | command: COMMAND_RENDER, 280 | params: { 281 | screen: SEARCH_RESULTS_WITH_MOVIE_DETAILS_AND_LOADING_SCREEN, 282 | results, 283 | query: movieQuery, 284 | title: movie.title 285 | } 286 | }; 287 | 288 | return { 289 | updates: [{ op: "add", path: "/movieTitle", value: movie.title }], 290 | outputs: [renderCommand, searchCommand] 291 | }; 292 | } 293 | 294 | function displayMovieDetailsSearchResultsScreen(extendedState, eventData, fsmSettings) { 295 | const [movieDetails, cast] = eventData; 296 | const { queryFieldHasChanged, movieQuery, results, movieTitle } = extendedState; 297 | 298 | const renderCommand = { 299 | command: COMMAND_RENDER, 300 | params: { 301 | screen: SEARCH_RESULTS_WITH_MOVIE_DETAILS, 302 | results, 303 | query: movieQuery, 304 | title: movieTitle, 305 | details: movieDetails, 306 | cast 307 | } 308 | }; 309 | 310 | return { 311 | updates: [{ op: "add", path: "/movieDetails", value: movieDetails }, { op: "add", path: "/cast", value: cast }], 312 | outputs: [renderCommand] 313 | }; 314 | } 315 | 316 | function displayMovieDetailsSearchErrorScreen(extendedState, eventData, fsmSettings) { 317 | const { queryFieldHasChanged, movieQuery, results, movieTitle } = extendedState; 318 | 319 | const renderCommand = { 320 | command: COMMAND_RENDER, 321 | params: { 322 | screen: SEARCH_RESULTS_WITH_MOVIE_DETAILS_ERROR, 323 | results, 324 | query: movieQuery, 325 | title: movieTitle 326 | } 327 | }; 328 | 329 | return { 330 | updates: NO_STATE_UPDATE, 331 | outputs: [renderCommand] 332 | }; 333 | } 334 | 335 | // Guards 336 | function isExpectedMovieResults(extendedState, eventData, settings) { 337 | const { query: fetched } = eventData; 338 | const { movieQuery: expected } = extendedState; 339 | return fetched === expected; 340 | } 341 | 342 | function isNotExpectedMovieResults(extendedState, eventData, settings) { 343 | return !isExpectedMovieResults(extendedState, eventData, settings); 344 | } 345 | 346 | const movieSearchFsmDef = { 347 | initialControlState, 348 | initialExtendedState, 349 | states, 350 | events: Object.values(events), 351 | transitions 352 | }; 353 | 354 | export { movieSearchFsmDef }; 355 | -------------------------------------------------------------------------------- /tests/fixtures/properties.js: -------------------------------------------------------------------------------- 1 | export const NO_INTENT = null; 2 | 3 | // Test ids 4 | export const testIds = { 5 | PROMPT_TESTID: "PROMPT_TESTID", 6 | RESULTS_HEADER_TESTID: "RESULTS_HEADER_TESTID", 7 | QUERY_FIELD_TESTID: "QUERY_FIELD_TESTID", 8 | LOADING_TESTID: "LOADING_TESTID", 9 | RESULTS_CONTAINER_TESTID: "RESULTS_CONTAINER_TESTID", 10 | MOVIE_IMG_SRC_TESTID: "MOVIE_IMG_SRC_TESTID", 11 | MOVIE_TITLE_TESTID: "MOVIE_TITLE_TESTID", 12 | NETWORK_ERROR_TESTID: "NETWORK_ERROR_TESTID" 13 | }; 14 | 15 | // Events 16 | export const events = { 17 | USER_NAVIGATED_TO_APP: "USER_NAVIGATED_TO_APP", 18 | QUERY_CHANGED: "QUERY_CHANGED", 19 | SEARCH_RESULTS_RECEIVED: "SEARCH_RESULTS_RECEIVED", 20 | SEARCH_ERROR_RECEIVED: "SEARCH_ERROR_RECEIVED", 21 | SEARCH_REQUESTED: "SEARCH_REQUESTED", 22 | QUERY_RESETTED: "QUERY_RESETTED", 23 | MOVIE_SELECTED: "MOVIE_SELECTED", 24 | SEARCH_RESULTS_MOVIE_RECEIVED: "SEARCH_RESULTS_MOVIE_RECEIVED", 25 | SEARCH_ERROR_MOVIE_RECEIVED: "SEARCH_ERROR_MOVIE_RECEIVED", 26 | MOVIE_DETAILS_DESELECTED: "MOVIE_DETAILS_DESELECTED" 27 | }; 28 | 29 | // Screens 30 | export const screens = { 31 | LOADING_SCREEN: "LOADING_SCREEN", 32 | SEARCH_RESULTS_SCREEN: "SEARCH_RESULTS_SCREEN", 33 | SEARCH_ERROR_SCREEN: "SEARCH_ERROR_SCREEN", 34 | SEARCH_RESULTS_AND_LOADING_SCREEN: "SEARCH_RESULTS_AND_LOADING_SCREEN", 35 | SEARCH_RESULTS_WITH_MOVIE_DETAILS_AND_LOADING_SCREEN: 36 | "SEARCH_RESULTS_WITH_MOVIE_DETAILS_AND_LOADING_SCREEN", 37 | SEARCH_RESULTS_WITH_MOVIE_DETAILS: "SEARCH_RESULTS_WITH_MOVIE_DETAILS", 38 | SEARCH_RESULTS_WITH_MOVIE_DETAILS_ERROR: 39 | "SEARCH_RESULTS_WITH_MOVIE_DETAILS_ERROR" 40 | }; 41 | 42 | export const DISCOVERY_REQUEST = "/movie/popular?language=en-US&page=1"; 43 | 44 | export const INITIAL_REQUEST = `https://api.themoviedb.org/3/movie/popular?api_key=bf6b860ab05ac2d94054ba9ca96cf1fa&language=en-US&page=1`; 45 | export const PROMPT = "Search for a Title:"; 46 | export const POPULAR_NOW = "Popular Now"; 47 | export const LOADING = "Loading..."; 48 | export const SEARCH_RESULTS_FOR = query => `Search Results for "${query}":`; 49 | export const IMAGE_TMDB_PREFIX = "http://image.tmdb.org/t/p/w300"; 50 | export const NETWORK_ERROR = "Network error"; 51 | 52 | // States 53 | export const START = "start"; 54 | export const MOVIE_QUERYING = "Movie querying"; 55 | export const MOVIE_SELECTION = "Movie selection"; 56 | export const MOVIE_SELECTION_ERROR = "Movie selection error"; 57 | export const MOVIE_DETAIL_SELECTION = "Movie detail selection"; 58 | export const MOVIE_DETAIL_QUERYING = "Movie detail querying"; 59 | export const MOVIE_DETAIL_SELECTION_ERROR = "Movie detail selection error"; 60 | 61 | // Commands 62 | export const COMMAND_MOVIE_SEARCH = "COMMAND_MOVIE_SEARCH"; 63 | export const COMMAND_MOVIE_DETAILS_SEARCH = "COMMAND_MOVIE_DETAILS_SEARCH"; 64 | -------------------------------------------------------------------------------- /tests/fixtures/test-ids.js: -------------------------------------------------------------------------------- 1 | export const SEARCH = "SEARCH"; 2 | export const SEARCH_INPUT = "SEARCH_INPUT"; 3 | export const SEARCH_ERROR = "SEARCH_ERROR"; 4 | export const CANCEL_SEARCH= "CANCEL_SEARCH"; 5 | export const PHOTO= "PHOTO"; 6 | export const PHOTO_DETAIL= "PHOTO_DETAIL"; 7 | 8 | -------------------------------------------------------------------------------- /tests/helpers.js: -------------------------------------------------------------------------------- 1 | import { mapOverObj, mapOverTree } from "fp-rosetree"; 2 | import { applyPatch } from "json-patch-es6"; 3 | import { CONTRACT_MODEL_UPDATE_FN_RETURN_VALUE, INIT_EVENT, NO_OUTPUT, NO_STATE_UPDATE } from "state-transducer"; 4 | import React from "react"; 5 | import prettyFormat from "pretty-format"; 6 | import fetchJsonp from "fetch-jsonp"; 7 | import produce, { nothing } from "immer"; 8 | import h from "react-hyperscript"; 9 | import { GalleryApp } from "./fixtures/components"; 10 | import HTML from "html-parse-stringify"; 11 | import { assoc, forEachObjIndexed, keys, mergeAll, mergeLeft, omit, trim } from "ramda"; 12 | import { COMMAND_RENDER } from "../src"; 13 | 14 | const { parse, stringify } = HTML; 15 | 16 | export const noop = () => {}; 17 | 18 | export const ERR_COMMAND_HANDLERS = command => (`Cannot find valid executor for command ${command}`); 19 | export const NO_ACTIONS = () => ({ outputs: NO_OUTPUT, updates: NO_STATE_UPDATE }); 20 | export const NO_INTENT = null; 21 | export const COMMAND_SEARCH = "command_search"; 22 | 23 | function isFunction(obj) { 24 | return typeof obj === "function"; 25 | } 26 | 27 | function isPOJO(obj) { 28 | const proto = Object.prototype; 29 | const gpo = Object.getPrototypeOf; 30 | 31 | if (obj === null || typeof obj !== "object") { 32 | return false; 33 | } 34 | return gpo(obj) === proto; 35 | } 36 | 37 | export function formatResult(result) { 38 | if (!isPOJO(result)) { 39 | return result; 40 | } 41 | else { 42 | return mapOverObj({ 43 | key: x => x, 44 | leafValue: prop => isFunction(prop) 45 | ? (prop.name || prop.displayName || "anonymous") 46 | : Array.isArray(prop) 47 | ? prop.map(formatResult) 48 | : prop 49 | }, 50 | result); 51 | } 52 | } 53 | 54 | export function formatMap(mapObj) { 55 | return Array.from(mapObj.keys()).map(key => ([key, formatFunction(mapObj.get(key))])); 56 | } 57 | 58 | export function formatFunction(fn) { 59 | return fn.name || fn.displayName || "anonymous"; 60 | } 61 | 62 | export function isArrayOf(predicate) {return obj => Array.isArray(obj) && obj.every(predicate);} 63 | 64 | export function isArrayUpdateOperations(obj) { 65 | return isEmptyArray(obj) || isArrayOf(isUpdateOperation)(obj); 66 | } 67 | 68 | export function isEmptyArray(obj) {return Array.isArray(obj) && obj.length === 0;} 69 | 70 | export function assertContract(contractFn, contractArgs, errorMessage) { 71 | const boolOrError = contractFn.apply(null, contractArgs); 72 | const isPredicateSatisfied = isBoolean(boolOrError) && boolOrError; 73 | 74 | if (!isPredicateSatisfied) { 75 | throw `assertContract: fails contract ${contractFn.name}\n${errorMessage}\n ${boolOrError}`; 76 | } 77 | return true; 78 | } 79 | 80 | export function isBoolean(obj) {return typeof(obj) === "boolean";} 81 | 82 | export function isUpdateOperation(obj) { 83 | return (typeof(obj) === "object" && Object.keys(obj).length === 0) || 84 | ( 85 | ["add", "replace", "move", "test", "remove", "copy"].some(op => obj.op === op) && 86 | typeof(obj.path) === "string" 87 | ); 88 | } 89 | 90 | 91 | /** 92 | * 93 | * @param {ExtendedState} extendedState 94 | * @param {Operation[]} extendedStateUpdateOperations 95 | * @returns {ExtendedState} 96 | */ 97 | export function applyJSONpatch(extendedState, extendedStateUpdateOperations) { 98 | assertContract(isArrayUpdateOperations, [extendedStateUpdateOperations], 99 | `applyUpdateOperations : ${CONTRACT_MODEL_UPDATE_FN_RETURN_VALUE}`); 100 | 101 | // NOTE : we don't validate operations, to avoid throwing errors when for instance the value property for an 102 | // `add` JSON operation is `undefined` ; and of course we don't mutate the document in place 103 | return applyPatch(extendedState, extendedStateUpdateOperations || [], false, false).newDocument; 104 | } 105 | 106 | export function identity(x) {return x;} 107 | 108 | /** 109 | * 110 | * @param Component 111 | * @param {Object} [props={}] 112 | * @returns {RenderCommand} 113 | */ 114 | export function renderCommandFactory(Component, props = {}) { 115 | return { 116 | command: COMMAND_RENDER, 117 | params: trigger => React.createElement(Component, props, null) 118 | }; 119 | } 120 | 121 | export function renderAction(params) { 122 | return { outputs: { command: COMMAND_RENDER, params }, updates: NO_STATE_UPDATE }; 123 | } 124 | 125 | export function renderActionImmer(params) { 126 | return { outputs: { command: COMMAND_RENDER, params }, updates: nothing }; 127 | } 128 | 129 | export function getEventName(eventStruct) { 130 | return eventStruct[0]; 131 | } 132 | 133 | export function getEventData(eventStruct) { 134 | return eventStruct[1]; 135 | } 136 | 137 | export function runSearchQuery(query) { 138 | const encodedQuery = encodeURIComponent(query); 139 | 140 | return fetchJsonp( 141 | `https://api.flickr.com/services/feeds/photos_public.gne?lang=en-us&format=json&tags=${encodedQuery}`, 142 | { jsonpCallback: "jsoncallback" } 143 | ) 144 | .then(res => res.json()); 145 | } 146 | 147 | export function renderGalleryApp(galleryState) { 148 | return function _renderGalleryApp(extendedState, _, fsmSettings) { 149 | const { query, items, photo } = extendedState; 150 | 151 | return renderAction(trigger => h(GalleryApp, { query, items, photo, trigger, gallery: galleryState }, [])); 152 | }; 153 | } 154 | 155 | export function renderGalleryAppImmer(galleryState) { 156 | return function _renderGalleryApp(extendedState, _, fsmSettings) { 157 | const { query, items, photo } = extendedState; 158 | 159 | return renderActionImmer(trigger => h(GalleryApp, { query, items, photo, trigger, gallery: galleryState }, [])); 160 | }; 161 | } 162 | 163 | export function destructureEvent(eventStruct) { 164 | return { 165 | rawEventName: eventStruct[0], 166 | rawEventData: eventStruct[1], 167 | ref: eventStruct[2] 168 | }; 169 | } 170 | 171 | export const NO_IMMER_UPDATES = nothing; 172 | export const immerReducer = function(extendedState, updates) { 173 | if (updates === NO_IMMER_UPDATES) return extendedState; 174 | const updateFn = updates; 175 | return produce(extendedState, updateFn); 176 | }; 177 | 178 | export const mergeOutputs = function(accOutputs, outputs) { 179 | return (accOutputs || []).concat(outputs || []); 180 | }; 181 | 182 | /** 183 | * 184 | * @param input 185 | * @param [generatorState] 186 | * @returns {function(*, *): {hasGeneratedInput: boolean, input: *, generatorState: *}} 187 | */ 188 | export function constGen(input, generatorState) { 189 | return function constGen(extS, genS) { 190 | return { hasGeneratedInput: true, input, generatorState }; 191 | }; 192 | } 193 | 194 | const { DOMElement, DOMCollection } = prettyFormat.plugins; 195 | 196 | export function prettyDOM(htmlElement, maxLength, options) { 197 | if (htmlElement.documentElement) { 198 | htmlElement = htmlElement.documentElement; 199 | } 200 | 201 | const debugContent = prettyFormat(htmlElement, { 202 | plugins: [DOMElement, DOMCollection], 203 | printFunctionName: false, 204 | // highlight: true, 205 | ...options 206 | }); 207 | return maxLength !== undefined && htmlElement.outerHTML.length > maxLength 208 | ? `${debugContent.slice(0, maxLength)}...` 209 | : debugContent; 210 | } 211 | 212 | /** 213 | * We remove data-testid attributes from an html string, and impose the same order for attributes for easier 214 | * comparison between two functionally equivalent HTML strings 215 | * force trim and `;` character at the end of style attributes 216 | * NTH : mix with better version here : https://github.com/TimothyRHuertas/normalizer 217 | * NTH : also normalize UTF-16... 218 | * @param str 219 | */ 220 | export function normalizeHTML(str) { 221 | const strTree = { type: "root", children: parse(str) }; 222 | const lenses = { 223 | getLabel: tree => { 224 | const label = omit(["children"], tree); 225 | // Sort the `attrs`'s keys 226 | const { type, name, voidElement, content, attrs } = label; 227 | let arrAttr = []; 228 | forEachObjIndexed((value, key) => arrAttr.push({ [key]: value }), attrs); 229 | arrAttr.sort((a, b) => keys(a)[0] > keys(b)[0] ? 1 : -1); 230 | return assoc("attrs", mergeAll(arrAttr), label); 231 | }, 232 | getChildren: tree => tree.children || [], 233 | constructTree: (label, children) => { 234 | return mergeLeft({ children }, label); 235 | } 236 | }; 237 | const mapFn = label => { 238 | const { type, name, voidElement, content, attrs } = label; 239 | if (type !== "component") { 240 | let style = attrs && attrs.style && trim(attrs.style); 241 | if (attrs && "style" in attrs) { 242 | if (style[style.length - 1] !== ";") { 243 | style = style + ";"; 244 | } 245 | } 246 | return { 247 | type, name, voidElement, content, 248 | attrs: attrs && style 249 | ? assoc("style", style, omit(["data-testid"], attrs)) 250 | : attrs && omit(["data-testid"], attrs) 251 | }; 252 | } 253 | }; 254 | 255 | const result = mapOverTree(lenses, mapFn, strTree); 256 | return stringify(result.children); 257 | } 258 | 259 | -------------------------------------------------------------------------------- /tests/image_gallery_component.specs.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | cleanup, fireEvent, getAllByTestId, getByLabelText, getByTestId, queryByTestId, render, wait, waitForElement, within 4 | } from "react-testing-library"; 5 | import { createStateMachine, decorateWithEntryActions, INIT_EVENT } from "state-transducer"; 6 | import { COMMAND_RENDER, Machine } from "../src"; 7 | import { applyJSONpatch, noop, normalizeHTML } from "./helpers"; 8 | import prettyFormat from "pretty-format"; 9 | import { imageGallery } from "./fixtures/machines"; 10 | import { CANCEL_SEARCH, PHOTO, PHOTO_DETAIL, SEARCH, SEARCH_ERROR, SEARCH_INPUT } from "./fixtures/test-ids"; 11 | import { COMMAND_SEARCH } from "../src/properties"; 12 | import sinon from "sinon"; 13 | import { testCases } from "./assets/test-generation"; 14 | import { testMachineComponent } from "../src/Machine"; 15 | 16 | // NOTE : this is coupled to index.html 17 | const container = document.getElementById("app"); 18 | 19 | QUnit.module("Testing image gallery component", { 20 | // Restore the default sandbox cf. https://sinonjs.org/releases/v7.1.1/general-setup/ 21 | beforeEach: () => { 22 | // document.getElementById('app').innerHTML = ''; // done by cleanup 23 | }, 24 | afterEach: () => { 25 | // Remove react tree (otherwise further rendering will diff against wrong tree) 26 | // For some reasons, the recommended way to do this (`cleanup`) fails on some specific tests 27 | // cleanup(); 28 | render(null, { container: document.getElementById("app") }); 29 | 30 | // Restore sinon state - avoid memory leaks 31 | sinon.restore(); 32 | }, 33 | after: () => { 34 | // We call cleanup here, because we haven\t called it before because of abovementioned bug 35 | // That way we still free resources and avoid possible memory leaks 36 | cleanup(); 37 | } 38 | }); 39 | 40 | // Test config 41 | const testAPI = { 42 | sinonAPI: sinon, 43 | test: QUnit.test.bind(QUnit), 44 | rtl: { render, fireEvent, waitForElement, getByTestId, queryByTestId, wait, within, getByLabelText }, 45 | debug:{console} 46 | }; 47 | const when = { 48 | [INIT_EVENT]: (testHarness, testCase, component, anchor) => { 49 | const { assert, rtl } = testHarness; 50 | const { render, fireEvent, waitForElement, getByTestId, queryByTestId, wait, within, getByLabelText } = rtl; 51 | 52 | render(component, { container: anchor }); 53 | return waitForElement(() => true, {timeout: 1000}); 54 | }, 55 | SEARCH: (testHarness, testCase, component, anchor) => { 56 | const { assert, rtl } = testHarness; 57 | const { eventData, expectedOutput, mockedEffectHandlers } = testCase; 58 | const { render, fireEvent, waitForElement, getByTestId, queryByTestId, wait, within, getByLabelText } = rtl; 59 | const query = eventData; 60 | 61 | fireEvent.change(getByTestId(container, SEARCH_INPUT), { target: { value: query } }); 62 | fireEvent.submit(getByTestId(container, SEARCH)); 63 | }, 64 | SEARCH_SUCCESS: (testHarness, testCase, component, anchor) => { 65 | const { assert, rtl } = testHarness; 66 | const { render, fireEvent, waitForElement, getByTestId, queryByTestId, wait, within, getByLabelText } = rtl; 67 | 68 | // NOTE: System events are sent by mocked effect handlers (here the API call). On receiving the search response, the 69 | // rendering happens asynchronously, so we need to wait a little to get the updated DOM. It is also possible 70 | // that the DOM is not updated (because the new DOM is exactly as the old DOM...). So we can't really use 71 | // predictably a set amount of time to wait. The best robust way to know when to run this assertion is to 72 | // observe the appearance of new elements, which is what we do here. Still... 73 | // we have to be careful about time dependencies. This will wait for new DOM elements appearing which satisfy 74 | // the condition. STARTING FROM the moment of the wait call. So the wait call must happen before the screen is 75 | // updated, otherwise it waits forever (i.e. the duration of the timeout). This in turns means the related 76 | // input simulation must not wait too long before passing the relay to the assertion section... 77 | return waitForElement(() => getByTestId(container, PHOTO), {timeout: 1000}) 78 | // !! very important for the edge case when the search success is the last to execute. 79 | // Because of react async rendering, the DOM is not updated yet, that or some other reason anyways 80 | // Maybe the problem is when the SECOND search success arrives, there already are elements with testid photo, so 81 | // the wait does not happen, so to actually wait I need to explicitly wait... maybe 82 | .then(() => wait(() => true)); 83 | }, 84 | SEARCH_FAILURE: (testHarness, testCase, component, anchor) => { 85 | return waitForElement(() => getByTestId(container, SEARCH_ERROR), {timeout: 1000}); 86 | }, 87 | SELECT_PHOTO: (testHarness, testCase, component, anchor) => { 88 | const { assert, rtl } = testHarness; 89 | const { eventData, expectedOutput, mockedEffectHandlers } = testCase; 90 | const { render, fireEvent, waitForElement, getByTestId, queryByTestId, wait, within, getByLabelText } = rtl; 91 | const item = eventData; 92 | // find the img to click 93 | const photos = getAllByTestId(container, PHOTO); 94 | const photoToClick = photos.find(photoEl => photoEl.src === item.media.m); 95 | 96 | fireEvent.click(photoToClick); 97 | // Wait a tick defensively. Not strictly necessary as, by implementation of test harness, expectations are delayed 98 | return waitForElement(() => getByTestId(container, PHOTO_DETAIL)); 99 | }, 100 | EXIT_PHOTO: (testHarness, testCase, component, anchor) => { 101 | const { assert, rtl } = testHarness; 102 | const { eventData, expectedOutput, mockedEffectHandlers } = testCase; 103 | const { render, fireEvent, waitForElement, getByTestId, queryByTestId, wait, within, getByLabelText } = rtl; 104 | const photoToClick = getByTestId(container, PHOTO_DETAIL); 105 | fireEvent.click(photoToClick); 106 | 107 | // Wait a tick defensively. Not strictly necessary as, by implementation of test harness, expectations are delayed 108 | return waitForElement(() => true, {timeout: 1000}); 109 | }, 110 | CANCEL_SEARCH: (testHarness, testCase, component, anchor) => { 111 | const { assert, rtl } = testHarness; 112 | const { eventData, expectedOutput, mockedEffectHandlers } = testCase; 113 | const { render, fireEvent, waitForElement, getByTestId, queryByTestId, wait, within, getByLabelText } = rtl; 114 | fireEvent.click(getByTestId(container, CANCEL_SEARCH)); 115 | 116 | return wait(() => !queryByTestId(container, CANCEL_SEARCH)); 117 | } 118 | }; 119 | const then = { 120 | [COMMAND_RENDER]: (testHarness, testCase, component, anchor, output) => { 121 | const { command, params } = output; 122 | const { assert, rtl } = testHarness; 123 | const { eventName, eventData, mockedEffectHandlers } = testCase; 124 | 125 | const actualOutput = normalizeHTML(anchor.innerHTML); 126 | const expectedOutput = normalizeHTML(params); 127 | assert.deepEqual(actualOutput, expectedOutput, `Correct render when : ${prettyFormat({ [eventName]: eventData })}`); 128 | }, 129 | [COMMAND_SEARCH]: (testHarness, testCase, component, anchor, output) => { 130 | const { assert, rtl } = testHarness; 131 | const { eventName, eventData, mockedEffectHandlers } = testCase; 132 | const query = eventData; 133 | 134 | assert.ok(mockedEffectHandlers.runSearchQuery.calledWithExactly(query), `Search query '${query}' made when : ${prettyFormat({ [eventName]: eventData })}`); 135 | } 136 | }; 137 | const mocks = { 138 | runSearchQuery: function getMockedSearchQuery(inputSequence) { 139 | const FIRST_SEARCH = "cathether"; 140 | const SECOND_SEARCH = "cat"; 141 | const [s1Failures, s2Failures] = inputSequence.reduce((acc, input) => { 142 | let [s1Failures, s2Failures, lastSearch] = acc; 143 | const eventName = Object.keys(input)[0]; 144 | const eventData = input[eventName]; 145 | 146 | if (eventName === "SEARCH") { 147 | lastSearch = eventData; 148 | } 149 | if (eventName === "SEARCH_FAILURE") { 150 | lastSearch === FIRST_SEARCH ? s1Failures++ : s2Failures++; 151 | } 152 | 153 | return [s1Failures, s2Failures, lastSearch]; 154 | }, [0, 0, null]); 155 | let s1 = s1Failures, s2 = s2Failures; 156 | 157 | // The way the input sequence is constructed, any search who fails would fails first before succeeding. So we 158 | // know implicitly when the failure occurs : at the beginning. I guess we got lucky. That simplifies the mocking. 159 | 160 | return function mockedSearchQuery(query) { 161 | // NOTE : this is coupled to the input event in `test-generation.js`. Those values are later a part of the 162 | // API call response 163 | return new Promise((resolve, reject) => { 164 | if (query === FIRST_SEARCH) { 165 | s1--; 166 | s1 >= 0 167 | ? setTimeout(() => reject(void 0), 2) 168 | : setTimeout(() => resolve({ 169 | items: [ 170 | { 171 | link: "https://www.flickr.com/photos/155010203@N06/31741086078/", 172 | media: { m: "https://farm2.staticflickr.com/1928/31741086078_8757b4913d_m.jpg" } 173 | }, 174 | { 175 | link: "https://www.flickr.com/photos/159915559@N02/30547921577/", 176 | media: { m: "https://farm2.staticflickr.com/1978/30547921577_f8cbee76f1_m.jpg" } 177 | }, 178 | { 179 | link: "https://www.flickr.com/photos/155010203@N06/44160499005/", 180 | media: { m: "https://farm2.staticflickr.com/1939/44160499005_7c34c4326d_m.jpg" } 181 | }, 182 | { 183 | link: "https://www.flickr.com/photos/139230693@N02/28991566557/", 184 | media: { m: "https://farm2.staticflickr.com/1833/42224900930_360debd33e_m.jpg" } 185 | } 186 | ] 187 | }), 2); 188 | } 189 | else if (query === SECOND_SEARCH) { 190 | s2--; 191 | s2 >= 0 192 | ? setTimeout(() => reject(void 0), 2) 193 | : setTimeout(() => resolve({ 194 | items: [ 195 | { 196 | link: "https://www.flickr.com/photos/155010203@N06/31741086079/", 197 | media: { m: "https://farm5.staticflickr.com/4818/45983626382_b3b758282f_m.jpg" } 198 | }, 199 | { 200 | link: "https://www.flickr.com/photos/159915559@N02/30547921579/", 201 | media: { m: "https://farm5.staticflickr.com/4842/31094302557_25a9fcbe3d_m.jpg" } 202 | }, 203 | { 204 | link: "https://www.flickr.com/photos/155010203@N06/44160499009/", 205 | media: { m: "https://farm5.staticflickr.com/4818/31094358517_55544cfcc6_m.jpg" } 206 | }, 207 | { 208 | link: "https://www.flickr.com/photos/139230693@N02/28991566559/", 209 | media: { m: "https://farm5.staticflickr.com/4808/45121437725_3d5c8249d7_m.jpg" } 210 | } 211 | ] 212 | }), 2); 213 | } 214 | else { 215 | reject(new Error(`no mock defined for the query ${query}`)); 216 | } 217 | }); 218 | }; 219 | 220 | } 221 | }; 222 | 223 | function mockedMachineFactory(machine, mockedEffectHandlers) { 224 | const fsmSpecsWithEntryActions = decorateWithEntryActions(machine, machine.entryActions, null); 225 | const fsm = createStateMachine(fsmSpecsWithEntryActions, { updateState: applyJSONpatch, debug : {console} }); 226 | 227 | return React.createElement(Machine, { 228 | fsm: fsm, 229 | renderWith : machine.renderWith, 230 | options : machine.options, 231 | eventHandler: machine.eventHandler, 232 | preprocessor: machine.preprocessor, 233 | effectHandlers: mockedEffectHandlers, 234 | commandHandlers: machine.commandHandlers 235 | }, null); 236 | } 237 | 238 | const testScenario = { testCases: testCases, mocks, when, then, container, mockedMachineFactory }; 239 | 240 | testMachineComponent(testAPI, testScenario, imageGallery); 241 | -------------------------------------------------------------------------------- /tests/image_gallery_machine.specs.js: -------------------------------------------------------------------------------- 1 | import ReactDOMServer from "react-dom/server"; 2 | import { merge as mergeR, range, omit } from "ramda"; 3 | import { computeTimesCircledOn, decorateWithEntryActions, INIT_EVENT, INIT_STATE, NO_OUTPUT } from "kingly"; 4 | import { generateTestSequences } from "state-transducer-testing"; 5 | import { assertContract, COMMAND_SEARCH, constGen, formatResult, isArrayUpdateOperations } from "./helpers"; 6 | import { applyPatch } from "json-patch-es6/lib/duplex"; 7 | import { CONTRACT_MODEL_UPDATE_FN_RETURN_VALUE } from "../src/properties"; 8 | import { COMMAND_RENDER } from "../src/Machine"; 9 | import { imageGallery } from "./fixtures/machines"; 10 | // import { filter, flatMap, map, shareReplay, switchMap } from "rxjs/operators"; 11 | import { merge as merge$, of, Subject } from "rxjs"; 12 | import { searchFixtures } from "./fixtures/fake"; 13 | 14 | // TODO : I must not only keep the props but also the name of the react component displayed!!!! 15 | // NTH : should also take care of case : fragment, not react component? no name? 16 | export function formatOutputSequence(results) { 17 | const fakeTrigger = eventName => function fakeEventHandler() {}; 18 | 19 | return results.map(result => { 20 | const { inputSequence, outputSequence, controlStateSequence } = result; 21 | return { 22 | inputSequence, 23 | controlStateSequence, 24 | outputSequence: outputSequence.map(outputs => { 25 | return outputs.map(output => { 26 | if (output === null) return output; 27 | const { command, params } = output; 28 | if (command !== "render") return output; 29 | 30 | return { 31 | command, 32 | params 33 | }; 34 | }); 35 | }) 36 | }; 37 | }); 38 | } 39 | 40 | /** 41 | * 42 | * @param {FSM_Model} model 43 | * @param {Operation[]} modelUpdateOperations 44 | * @returns {FSM_Model} 45 | */ 46 | function applyJSONpatch(model, modelUpdateOperations) { 47 | assertContract(isArrayUpdateOperations, [modelUpdateOperations], 48 | `applyUpdateOperations : ${CONTRACT_MODEL_UPDATE_FN_RETURN_VALUE}`); 49 | 50 | // NOTE : we don't validate operations, to avoid throwing errors when for instance the value property for an 51 | // `add` JSON operation is `undefined` ; and of course we don't mutate the document in place 52 | return applyPatch(model, modelUpdateOperations, false, false).newDocument; 53 | } 54 | 55 | const default_settings = { 56 | updateState: applyJSONpatch, 57 | subject_factory: () => { 58 | const subject = new Subject(); 59 | // NOTE : this is intended for Rxjs v4-5!! but should work for `most` also 60 | subject.emit = subject.next || subject.onNext; 61 | return subject; 62 | }, 63 | merge: function merge(arrayObs) {return merge$(...arrayObs);}, 64 | of: of 65 | }; 66 | 67 | QUnit.module("Testing image gallery machine", {}); 68 | 69 | QUnit.test("image search gallery", function exec_test(assert) { 70 | const searchQueries = Object.keys(searchFixtures); 71 | // TODO : move the test generation specs to a stackblitz 72 | const fsmDef = decorateWithEntryActions(imageGallery, imageGallery.entryActions, null); 73 | const genFsmDef = { 74 | transitions: [ 75 | { 76 | from: INIT_STATE, event: INIT_EVENT, to: "init", 77 | gen: constGen(void 0, { pending: [], done: [], current: null }) 78 | }, 79 | { from: "init", event: "START", to: "start", gen: constGen(void 0, { pending: [], done: [], current: null }) }, 80 | { 81 | from: "start", event: "SEARCH", to: "loading", 82 | gen: constGen(searchQueries[0], { pending: [searchQueries[0]], done: [] }) 83 | }, 84 | { 85 | from: "loading", event: "SEARCH_SUCCESS", to: "gallery", gen: (extS, genS) => { 86 | // Assign success to a random query, if any 87 | const { pending, done } = genS; 88 | const hasPendingQueries = pending.length !== 0; 89 | const alea = Math.random(); 90 | const indexSuccessfulQuery = Math.round(alea * (pending.length - 1)); 91 | const input = hasPendingQueries 92 | ? searchFixtures[pending[indexSuccessfulQuery]] 93 | : null; 94 | const generatorState = hasPendingQueries 95 | // Remove the successful query from the list of pending queries 96 | ? { 97 | pending: pending.filter((_, index) => index !== indexSuccessfulQuery), 98 | done: done.concat(pending[indexSuccessfulQuery]), 99 | current: pending[indexSuccessfulQuery] 100 | } 101 | : genS; 102 | 103 | return { hasGeneratedInput: hasPendingQueries, input, generatorState }; 104 | } 105 | }, 106 | { 107 | from: "loading", event: "SEARCH_FAILURE", to: "error", gen: (extS, genS) => { 108 | const { pending, done } = genS; 109 | const hasPendingQueries = pending.length !== 0; // should always be true here by construction 110 | const alea = Math.random(); 111 | const indexErroneousQuery = Math.round(alea * (pending.length - 1)); 112 | 113 | return { 114 | hasGeneratedInput: hasPendingQueries, input: void 0, 115 | generatorState: { 116 | pending: pending.filter((_, index) => index !== indexErroneousQuery), 117 | done: done 118 | } 119 | }; 120 | } 121 | }, 122 | { 123 | from: "loading", event: "CANCEL_SEARCH", to: "gallery", 124 | gen: (extS, genS) => { 125 | // Cancel always relates to the latest search. However that search must remain in the list of pending 126 | // queries as the corresponding API call is in fact not cancelled 127 | // We do not repeat the cancelled query and consider it done, and not pending 128 | const { pending, done } = genS; 129 | const hasPendingQueries = pending.length !== 0; // should always be true here by construction 130 | return { 131 | hasGeneratedInput: hasPendingQueries, input: void 0, 132 | generatorState: { pending: pending.slice(0, -1), done: done.concat(pending[pending.length - 1]) } 133 | }; 134 | } 135 | }, 136 | { 137 | from: "error", event: "SEARCH", to: "loading", gen: (extS, genS) => { 138 | // Next query is among the queries, not done, and not pending. 139 | const { pending, done } = genS; 140 | const possibleQueries = searchQueries.filter(query => !done.includes(query) && !pending.includes(query)); 141 | 142 | return { 143 | hasGeneratedInput: possibleQueries.length > 0, input: possibleQueries[0], 144 | generatorState: { pending: pending.concat(possibleQueries[0]), done: genS.done } 145 | }; 146 | } 147 | }, 148 | { 149 | from: "gallery", event: "SEARCH", to: "loading", gen: (extS, genS) => { 150 | // Next query is the next one in the query search array. 151 | const { pending, done } = genS; 152 | const possibleQueries = searchQueries.filter(query => !done.includes(query) && !pending.includes(query)); 153 | 154 | return { 155 | hasGeneratedInput: possibleQueries.length > 0, input: possibleQueries[0], 156 | generatorState: { pending: pending.concat(possibleQueries[0]), done: genS.done } 157 | }; 158 | } 159 | }, 160 | { 161 | from: "gallery", event: "SELECT_PHOTO", to: "photo", gen: (extS, genS) => { 162 | // we have four pictures for each query in this test setup. So we just pick one randomly 163 | // the query for the selected photo is the latest done query 164 | const { pending, done, current } = genS; 165 | const indexPhoto = Math.round(Math.random() * 3); 166 | const query = done[done.length - 1]; 167 | 168 | return { hasGeneratedInput: current, input: searchFixtures[query][indexPhoto] }; 169 | } 170 | }, 171 | { from: "photo", event: "EXIT_PHOTO", to: "gallery", gen: constGen(void 0) } 172 | ] 173 | }; 174 | const generators = genFsmDef.transitions; 175 | const ALL_n_TRANSITIONS_WITH_REPEATED_TARGET = ({ maxNumberOfTraversals, targetVertex }) => ({ 176 | isTraversableEdge: (edge, graph, pathTraversalState, graphTraversalState) => { 177 | return computeTimesCircledOn(pathTraversalState.path, edge) < (maxNumberOfTraversals || 1); 178 | }, 179 | isGoalReached: (edge, graph, pathTraversalState, graphTraversalState) => { 180 | const { getEdgeTarget, getEdgeOrigin } = graph; 181 | const lastPathVertex = getEdgeTarget(edge); 182 | // Edge case : accounting for initial vertex 183 | const vertexOrigin = getEdgeOrigin(edge); 184 | 185 | const isGoalReached = vertexOrigin 186 | ? lastPathVertex === targetVertex && !(computeTimesCircledOn(pathTraversalState.path, edge) < (maxNumberOfTraversals || 1)) 187 | : false; 188 | return isGoalReached; 189 | } 190 | }); 191 | const strategy = ALL_n_TRANSITIONS_WITH_REPEATED_TARGET({ maxNumberOfTraversals: 2, targetVertex: "gallery" }); 192 | const settings = mergeR({ updateState: applyJSONpatch }, { strategy }); 193 | const results = generateTestSequences(fsmDef, generators, settings); 194 | debugger 195 | console.log(`results`, formatOutputSequence(results)); 196 | 197 | const inputSequences = results.map(result => result.inputSequence); 198 | const outputsSequences = results.map(x => x.outputSequence); 199 | const getInputKey = function getInputKey(input) {return Object.keys(input)[0];}; 200 | const formattedInputSequences = inputSequences.map(inputSequence => inputSequence.map(getInputKey)); 201 | const formattedOutputsSequences = outputsSequences 202 | .map(outputsSequence => { 203 | return outputsSequence.map(outputs => { 204 | if (outputs === NO_OUTPUT) return outputs; 205 | 206 | return outputs 207 | .map(output => { 208 | if (output === NO_OUTPUT) return output; 209 | 210 | const { command, params } = output; 211 | if (command === COMMAND_RENDER) { 212 | return { 213 | command: command, 214 | params: omit(['trigger', 'next'], params) 215 | }; 216 | } 217 | else { 218 | return output; 219 | } 220 | }) 221 | .map(formatResult); 222 | }); 223 | }); 224 | const expectedOutputSequences = inputSequences 225 | .map(inputSequence => { 226 | return inputSequence.reduce((acc, input) => { 227 | const assign = Object.assign.bind(Object); 228 | const defaultProps = { query: "", items: [], photo: undefined, gallery: ""}; 229 | const { outputSeq, state } = acc; 230 | const { pendingQuery, currentItems, currentPhoto } = state; 231 | const event = Object.keys(input)[0]; 232 | const eventData = input[event]; 233 | 234 | function searchCommand(query) { 235 | return { "command": COMMAND_SEARCH, "params": {query} }; 236 | } 237 | 238 | switch (event) { 239 | case INIT_EVENT: 240 | return acc; 241 | case "START": 242 | return { 243 | outputSeq: outputSeq.concat([ 244 | [null, { 245 | command: COMMAND_RENDER, 246 | params: assign({}, defaultProps, { gallery: "start" }) 247 | }] 248 | ]), 249 | state: { pendingQuery: "", currentItems, currentPhoto } 250 | }; 251 | case "SEARCH" : 252 | return { 253 | outputSeq: outputSeq.concat([ 254 | [null, searchCommand(eventData), { 255 | command: COMMAND_RENDER, 256 | params: assign({}, defaultProps, { 257 | gallery: "loading", 258 | items: currentItems, 259 | query: eventData, 260 | photo: currentPhoto 261 | }) 262 | }] 263 | ]), 264 | state: { pendingQuery: eventData, currentItems, currentPhoto } 265 | }; 266 | case "SEARCH_SUCCESS" : 267 | const items = searchFixtures[pendingQuery]; 268 | if (items) { 269 | return { 270 | outputSeq: outputSeq.concat([ 271 | [null, { 272 | command: COMMAND_RENDER, 273 | params: assign({}, defaultProps, { gallery: "gallery", items, photo: currentPhoto }) 274 | }] 275 | ]), 276 | state: { pendingQuery: "", currentItems: items, currentPhoto } 277 | }; 278 | } 279 | else { 280 | return { 281 | outputSeq: outputSeq.concat([null]), 282 | state: state 283 | }; 284 | } 285 | case "SEARCH_FAILURE" : 286 | return { 287 | outputSeq: outputSeq.concat([ 288 | [null, { 289 | command: COMMAND_RENDER, 290 | params: assign({}, defaultProps, { gallery: "error", items: currentItems, photo: currentPhoto }) 291 | }] 292 | ]), 293 | state: { pendingQuery: "", currentItems, currentPhoto } 294 | }; 295 | case "CANCEL_SEARCH" : 296 | return { 297 | outputSeq: outputSeq.concat([ 298 | [null, { 299 | command: COMMAND_RENDER, 300 | params: assign({}, defaultProps, { gallery: "gallery", items: currentItems, photo: currentPhoto }) 301 | }] 302 | ]), 303 | state: { pendingQuery: "", currentItems, currentPhoto } 304 | }; 305 | case "SELECT_PHOTO": 306 | return { 307 | outputSeq: outputSeq.concat([ 308 | [null, { 309 | command: COMMAND_RENDER, 310 | params: assign({}, defaultProps, { gallery: "photo", items: currentItems, photo: eventData }) 311 | }] 312 | ]), 313 | state: { pendingQuery: "", currentItems, currentPhoto: eventData } 314 | }; 315 | case "EXIT_PHOTO" : 316 | return { 317 | outputSeq: outputSeq.concat([ 318 | [null, { 319 | command: COMMAND_RENDER, 320 | params: assign({}, defaultProps, { gallery: "gallery", items: currentItems, photo: currentPhoto }) 321 | }] 322 | ]), 323 | state: { pendingQuery: "", currentItems, currentPhoto } 324 | }; 325 | default : 326 | throw `unknow event??`; 327 | } 328 | 329 | }, { outputSeq: [], state: { pendingQuery: "", currentItems: [], currentPhoto: undefined } }); 330 | }) 331 | .map(x => x.outputSeq); 332 | 333 | // NOTE: I am testing the application here, with the assumption that the test generation is already tested 334 | // So no need to test the input sequence (neither the control state sequence actually 335 | // What we have to test is that the (actual) ouptutSequence correspond to what we would compute otherwise 336 | range(0, inputSequences.length - 1).forEach(index => { 337 | assert.deepEqual( 338 | formattedOutputsSequences[index], 339 | expectedOutputSequences[index], 340 | formattedInputSequences[index].join(" -> ") 341 | ); 342 | }); 343 | }); 344 | 345 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 |
17 |
18 | 19 |

QUnit Test Suite

20 |

21 |
22 |

23 |
    test markup, hidden.
24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | import './image_gallery_machine.specs' 2 | import './image_gallery_component.specs' 3 | QUnit.dump.maxDepth = 20; 4 | QUnit.onUnhandledRejection = (e) => {console.warn(`QUnit > onUnhandledRejection`, e)} 5 | 6 | // to get string version without loosing undefined through JSON conversion 7 | // JSON.stringify(hash, (k, v) => (v === undefined) ? '__undefined' : v) 8 | // .replace('"__undefined"', 'undefined') 9 | -------------------------------------------------------------------------------- /tests/webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 2 | const path = require("path"); 3 | const webpack = require("webpack"); 4 | 5 | module.exports = { 6 | entry: "./tests/index.js", 7 | devServer: { 8 | contentBase: "./tests", 9 | hot: true 10 | }, 11 | 12 | mode: "development", 13 | 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.js$/, 18 | exclude: /node_modules/, 19 | use: { 20 | loader: "babel-loader", 21 | options: { 22 | plugins: ["react-hot-loader/babel"] 23 | } 24 | } 25 | } 26 | ] 27 | }, 28 | 29 | output: { 30 | path: path.resolve(__dirname, "./tests"), 31 | filename: "test-bundle.js" 32 | }, 33 | 34 | plugins: [ 35 | new HtmlWebpackPlugin({ 36 | filename: "tests/index.html", 37 | template: "tests/index.html" 38 | }), 39 | new webpack.NamedModulesPlugin(), 40 | new webpack.HotModuleReplacementPlugin() 41 | ] 42 | }; 43 | -------------------------------------------------------------------------------- /types/fsm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} FSM_Def 3 | * @property {FSM_States} states Object whose every key is a control state admitted by the 4 | * specified state machine. The value associated to that key is unused in the present version of the library. The 5 | * hierarchy of the states correspond to property nesting in the `states` object 6 | * @property {Array} events A list of event monikers the machine is configured to react to 7 | * @property {Array} transitions An array of transitions the machine is allowed to take 8 | * @property {*} initialExtendedState The initial value for the machine's extended state 9 | * @property {{updateState :: Function(ExtendedState, ExtendedStateUpdate) : ExtendedState}} updateState function 10 | * which update the extended state of the state machine 11 | */ 12 | /** 13 | * @typedef {Object.} FSM_States 14 | */ 15 | /** 16 | * @typedef {InconditionalTransition | ConditionalTransition} Transition 17 | */ 18 | /** 19 | * @typedef {{from: ControlState, to: ControlState|HistoryState, event: EventLabel, action: ActionFactory}} InconditionalTransition 20 | * Inconditional_Transition encodes transition with no guards attached. Every time the specified event occurs, and 21 | * the machine is in the specified state, it will transition to the target control state, and invoke the action 22 | * returned by the action factory 23 | */ 24 | /** 25 | * @typedef {{from: ControlState, event: EventLabel, guards: Array}} ConditionalTransition Transition for the 26 | * specified state is contingent to some guards being passed. Those guards are defined as an array. 27 | */ 28 | /** 29 | * @typedef {{predicate: FSM_Predicate, to: ControlState|HistoryState, action: ActionFactory}} Condition On satisfying the 30 | * specified predicate, the received event data will trigger the transition to the specified target control state 31 | * and invoke the action created by the specified action factory, leading to an update of the internal state of the 32 | * extended state machine and possibly an output to the state machine client. 33 | */ 34 | /** 35 | * @typedef {function(ExtendedState, EventData, FSM_Settings) : Actions} ActionFactory 36 | */ 37 | /** 38 | * @typedef {{updates: ExtendedStateUpdate, outputs: Array}} Actions The actions 39 | * to be performed by the state machine in response to a transition. `updates` represents the state update for 40 | * the variables of the extended state machine. `output` represents the output of the state machine passed to the 41 | * API caller. 42 | */ 43 | /** @typedef {function (ExtendedState, EventData) : Boolean} FSM_Predicate */ 44 | /** @typedef {{debug}} FSM_Settings 45 | * Miscellaneous settings including how to update the machine's state and debug 46 | * configuration 47 | * */ 48 | /** @typedef {{merge: MergeObsFn, from: FromObsFn, filter: FilterObsFn, map: MapObsFn, share:ShareObsFn, ...}} FSM$_Settings */ 49 | /** 50 | * @typedef {function (Array) : Observable} MergeObsFn Similar to Rxjs v4's `Rx.Observable.merge`. Takes 51 | * an array of observables and return an observable which passes on all outputs emitted by the observables in the array. 52 | */ 53 | /** 54 | * @typedef {function (value) : Observable} FromObsFn Similar to Rxjs v4's `Rx.Observable.from`. Takes 55 | * a value and lift it into an observable which completes immediately after emitting that value. 56 | */ 57 | /** 58 | * @typedef {function (value) : Observable} FilterObsFn Similar to Rxjs v4's `Rx.Observable.filter`. Takes 59 | * a value and lift it into an observable which completes immediately after emitting that value. 60 | */ 61 | /** 62 | * @typedef {function (value) : Observable} MapObsFn Similar to Rxjs v4's `Rx.Observable.map`. Takes 63 | * a value and lift it into an observable which completes immediately after emitting that value. 64 | */ 65 | /** 66 | * @typedef {function (value) : Observable} ShareObsFn Similar to Rxjs v4's `Rx.Observable.share`. Takes 67 | * a value and lift it into an observable which completes immediately after emitting that value. 68 | */ 69 | /** 70 | * @typedef {Object.} LabelledEvent extended state for a given state machine 71 | */ 72 | /** 73 | * @typedef {Object} FsmTraceData 74 | * @property {ControlState} controlState 75 | * @property {{EventLabel, EventData}} eventLabel 76 | * @property {ControlState} targetControlState 77 | * @property {FSM_Predicate} predicate 78 | * @property {ExtendedStateUpdate} updates 79 | * @property {ExtendedState} extendedState 80 | * @property {ActionFactory} actionFactory 81 | * @property {Number} guardIndex 82 | * @property {Number} transitionIndex 83 | */ 84 | /** 85 | * @typedef {function(historyType: HistoryType, controlState: ControlState): HistoryState} HistoryStateFactory 86 | */ 87 | /** 88 | * @typedef {{type:{}, [HistoryType]: ControlState}} HistoryState 89 | */ 90 | /** 91 | * @typedef {Object.} History history object containing deeep and shallow history states 92 | * for all relevant control states 93 | */ 94 | /** 95 | * @typedef {Object.} HistoryDict Maps a compound control state to its history state 96 | */ 97 | /** 98 | * @typedef {DEEP | SHALLOW} HistoryType 99 | */ 100 | /** @typedef {String} ControlState Name of the control state */ 101 | /** @typedef {String} EventLabel */ 102 | /** 103 | * @typedef {*} EventData 104 | */ 105 | /** 106 | * @typedef {*} ExtendedState extended state for a given state machine 107 | */ 108 | /** 109 | * @typedef {*} ExtendedStateUpdate 110 | */ 111 | /** @typedef {* | NO_OUTPUT} MachineOutput well it is preferrable that that be an object instead of a primitive */ 112 | 113 | 114 | // Contract types 115 | /** 116 | * @typedef {Object} ContractsDef 117 | * @property {String} description name for the series of contracts 118 | * @property {function(FSM_Def):Object} computed a function of the machine definition which returns an object to be 119 | * injected to the contracts predicates 120 | * @property {Array} contracts array of contract definitions 121 | */ 122 | /** 123 | * @typedef {Object} ContractDef 124 | * @property {String} name name for the contract 125 | * @property {Boolean} shouldThrow whether the contract should thrown an exception or alternatively return one 126 | * @property {function(FSM_Def, computed):ContractCheck} predicate array of contract definitions 127 | */ 128 | /** 129 | * @typedef {Object} ContractCheck 130 | * @property {Boolean} isFulfilled whether the contract is fulfilled 131 | * @property {{message:String, info:*}} blame information about the cause for the contract failure. The 132 | * `message` property is destined to the developer (for instnce can be printed in the console). Info aims 133 | * at providing additional data helping to track the error cause 134 | * @property {function(FSM_Def, computed):ContractCheck} predicate array of contract definitions 135 | */ 136 | -------------------------------------------------------------------------------- /types/react-fsm-integration.js: -------------------------------------------------------------------------------- 1 | // Commands 2 | /** 3 | * @typedef {NO_OUTPUT} NoCommand 4 | */ 5 | /** 6 | * @typedef {String} CommandName 7 | */ 8 | /** 9 | * @typedef {RenderCommand | SystemCommand} Command 10 | */ 11 | /** 12 | * @typedef {{command : COMMAND_RENDER, params : * }} RenderCommand 13 | */ 14 | /** 15 | * @typedef {{command : CommandName, params : * }} SystemCommand 16 | */ 17 | 18 | // Mediator 19 | /** 20 | * @typedef {Object} MachineProps 21 | * @property {EventPreprocessor} [preprocessor = x=>x] 22 | * @property {FSM_Def} fsm machine definition (typically events, states and transitions) 23 | * @property {Object.} commandHandlers 24 | * @property {EventHandler} eventHandler Interface for event processing. 25 | * @property {Options} options Interface for event processing. 26 | */ 27 | /** 28 | * @typedef {function (RawEventSource) : MachineEventSource} EventPreprocessor 29 | */ 30 | /** 31 | * @typedef {Subject} EventHandler subject which implements the observer (`next`, `error`, 32 | * `complete`) and observable (`subscribe`) interface. 33 | */ 34 | /** 35 | * @typedef {Observable} MachineEventSource 36 | */ 37 | /** 38 | * @typedef {Subject} RawEventSource 39 | */ 40 | /** 41 | * @typedef {function(Emitter, Params, EffectHandlers): *} CommandHandler A command handler receives parameters to 42 | * perform its command. EffectHandlers are injected for the command handler to delegate effect execution. An 43 | * `Emitter` is also available for sending events to the state machine's `RawEventSource`. An emitter correspond to 44 | * the `next` property of the `Observer` interface 45 | */ 46 | /** 47 | * @typedef {Object.} EffectHandlers 48 | */ 49 | /** 50 | * @typedef {function} EffectHandler 51 | */ 52 | /** 53 | * @typedef {String} EffectName 54 | */ 55 | 56 | // For testing 57 | /** 58 | * @typedef {Object.} EntryActions 59 | */ 60 | /** 61 | * @typedef {function (FSM_Def, MockedEffectHandlers) : FSM} MockedMachineFactory creates the instance of a machine with 62 | * the given specifications, replacing its effect handlers by the given mocked effect handlers 63 | */ 64 | /** 65 | * @typedef {EffectHandlers} MockedEffectHandlers 66 | */ 67 | /** 68 | * @typedef {EffectHandler} MockedEffectHandler 69 | */ 70 | /** 71 | * @typedef {Object} TestScenario 72 | * @property {Array} testCases 73 | * @property {Mocks} mocks 74 | * @property {When} when 75 | * @property {Then} then 76 | * @property {Node} container 77 | * @property {MockedMachineFactory} mockedMachineFactory 78 | */ 79 | /** 80 | * @typedef {{inputSequence: InputSequence, outputSequence:OutputSequence, controlStateSequence:ControlStateSequence}} TestCase 81 | */ 82 | /** 83 | * @typedef {Array} InputSequence 84 | */ 85 | /** 86 | * @typedef {Array>} OutputSequence 87 | */ 88 | /** 89 | * @typedef {Array} ControlStateSequence 90 | */ 91 | /** 92 | * @typedef {Object.} Mocks creates the instance of a machine with 93 | * the given specifications, replacing its effect handlers by the given mocked effect handlers 94 | */ 95 | /** 96 | * @typedef {Object.} When for 97 | * each input of a machine under test, create or simulate the corresponding sequence of user interface events. May 98 | * return a promise allowing to wait for some conditions to be fulfilled before proceeding with the test (typically 99 | * waiting for the events to have a detectable effect) 100 | */ 101 | /** 102 | * @typedef {Node} Anchor Dom element where the React `` is rendered to 103 | */ 104 | /** 105 | * @typedef {{assert, rtl}} TestHarness `assert` is the assertion method for the test framenwork. `rtl` is the 106 | * injected dependency corresponding to the `react-testing-library` import 107 | */ 108 | /** 109 | * @typedef {Object} TestCaseEnv 110 | * @property {LabelledEvent} eventName 111 | * @property {*} eventData 112 | * @property {Array} expectedOutput 113 | * @property {InputSequence} inputSequence 114 | * @property {OutputSequence} expectedOutputSequence 115 | * @property {MockedEffectHandlers} mockedEffectHandlers 116 | * @property {When} when 117 | * @property {Then} then 118 | * @property {Mocks} mocks 119 | */ 120 | /** 121 | * @typedef {Object.) : Promise | *>} Then 122 | * Runs an assertion, possibly returning a promise indicating the end of the assertion. The assertion logic is derived 123 | * primarily from the expected output passed as parameter 124 | */ 125 | --------------------------------------------------------------------------------