├── .gitignore ├── README.md ├── _old ├── calc │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ ├── src │ │ ├── App.js │ │ ├── actions.js │ │ ├── fsm.test.js │ │ ├── guards.js │ │ ├── helpers.js │ │ ├── index.js │ │ ├── machine.js │ │ ├── styles.css │ │ ├── ui.test.js │ │ └── xstateImmer.js │ └── yarn.lock ├── fsm-service-1-callback │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── app.js │ │ ├── compressionService.js │ │ ├── fsm.test.js │ │ ├── helpers.js │ │ ├── index.js │ │ ├── machine.js │ │ ├── styles.css │ │ ├── ui.test.js │ │ ├── useMachine.js │ │ └── xstateImmer.js ├── fsm-service-2-submachine │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── app.js │ │ ├── compressionService.js │ │ ├── fsm.test.js │ │ ├── helpers.js │ │ ├── index.js │ │ ├── machine.js │ │ ├── row.js │ │ ├── styles.css │ │ ├── ui.test.js │ │ ├── useMachine.js │ │ ├── workerMachine.js │ │ └── xstateImmer.js ├── fsm-service-3-actor │ ├── README.md │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── __tests__ │ │ ├── fsm.test.js │ │ └── ui.test.js │ │ ├── components │ │ ├── app.js │ │ ├── row.js │ │ └── styles.css │ │ ├── fsm │ │ ├── compressionService.js │ │ ├── guards.js │ │ ├── mainActions.js │ │ ├── mainMachine.js │ │ ├── services.js │ │ ├── workerActions.js │ │ └── workerMachine.js │ │ ├── index.js │ │ ├── utils │ │ └── helpers.js │ │ └── xstate-custom │ │ ├── useMyMachine.js │ │ └── xstateImmer.js └── word-paralell-state │ ├── README.md │ ├── package.json │ ├── public │ └── index.html │ ├── src │ ├── App.js │ ├── actions.js │ ├── fsm.test.js │ ├── guards.js │ ├── helpers.js │ ├── index.js │ ├── machine.js │ ├── services.js │ ├── styles.css │ ├── ui.test.js │ └── xstateImmer.js │ └── yarn.lock ├── crud-v1-services ├── README.md ├── package.json ├── public │ └── index.html ├── src │ ├── __tests__ │ │ ├── fsm.test.js │ │ └── ui.test.js │ ├── components │ │ ├── app.js │ │ └── styles.css │ ├── fsm │ │ ├── actions.js │ │ ├── fsm.js │ │ ├── guards.js │ │ ├── machine.js │ │ └── services.js │ ├── index.js │ └── utils │ │ ├── helpers.js │ │ └── useMyHooks.js └── yarn.lock ├── crud-v2-optimistic-update ├── README.md ├── package.json ├── public │ └── index.html ├── src │ ├── __tests__ │ │ ├── fsm.test.js │ │ └── ui.test.js │ ├── components │ │ ├── app.js │ │ └── styles.css │ ├── fsm │ │ ├── actions.js │ │ ├── fsm.js │ │ ├── guards.js │ │ ├── machine.js │ │ └── services.js │ ├── index.js │ └── utils │ │ ├── helpers.js │ │ └── useMyHooks.js └── yarn.lock ├── crud-v3-promises ├── README.md ├── package.json ├── public │ └── index.html ├── src │ ├── __tests__ │ │ ├── fsm.test.js │ │ └── ui.test.js │ ├── components │ │ ├── app.js │ │ └── styles.css │ ├── fsm │ │ ├── actions.js │ │ ├── fsm.js │ │ ├── guards.js │ │ ├── machine.js │ │ └── services.js │ ├── index.js │ └── utils │ │ ├── helpers.js │ │ └── useMyHooks.js └── yarn.lock └── crud-v4-actors ├── .gitignore ├── README.md ├── package.json ├── public └── index.html ├── src ├── .editorconfig ├── .gitattributes ├── .gitignore ├── Todo.jsx ├── Todos.jsx ├── index.js ├── todoMachine.js ├── todosMachine.js ├── useHashChange.js └── useMyHooks.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | # /demo 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | .env 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # Local Netlify folder 28 | .netlify 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Here are four examples showing different usages of xstate. 3 | 4 | Each one was built upon the previous one, hence it's recommended to start with `crud-v1-services` and move forward from there. All examples also available on codesandbox, feel free to play and fork those examples, and report any issues you found here, PRs welcome too. 5 | 6 | All these examples are built with [StatesKit](https://stateskit.com) - a visual statechart editor where you can drag and drop to creat states, events and stuff, no need to code at all, the goal is to help dumping the ideas from your brain to states real quick, don't forget to give it shot! 😎 7 | 8 | Also [here's a detailed write up](https://gist.github.com/coodoo/0a7658a6c6580cb11101a9c22904d425) on statecharts introducing it's core benefits. 9 | 10 | ## Core features of each example 11 | 12 | ### `crud-v1-services` 13 | 14 | - A typical CRUD app showing how to `model` application states with `statechart` and implement the basic functionalities in `xstate`, pay attention to how `invoked` Services are used to serve different API calls. 15 | 16 | - There are four kind of services in `xstate`, which are `Promise, Callback, Observable and Machine`, for this example we are focused on `Callback` because it's the most commonly used services in a real world application. 17 | 18 | - Read about different kind of [Services here](https://xstate.js.org/docs/guides/communication.html#invoking-services) 19 | 20 | - Play on [[codesandbox]](https://codesandbox.io/s/crud-v1-services-fy1du) 21 | 22 | ### `crud-v2-optimistic-update` 23 | 24 | - `v2` is built upon `v1`, but with more delicate handling of `optimistic update` processing and used different child state to model the app, observe how `parallel` states were used to handle different steps of each operation also pay attention to both `happy` and `sorrow` paths. 25 | 26 | - Play on [[codesandbox]](https://codesandbox.io/s/crud-v2-optimistic-update-3bc58) 27 | 28 | ### `crud-v3-promises` 29 | 30 | - `v3` is a slightly differnt version based on `v2` using a different `invoked` Service called `Promise`, pay attention to `services.js` and see how `loadItems` and `deleteItem` worked. 31 | 32 | - Key different between `Callback` and `Promise` service is you get to dispatch events back to the parent just once with `Promise`, whereas in `Callback` you could use `cb` and `onReceive` functions to dispatch events multiple times, both has it's own place in an application, hence this example. 33 | 34 | - Play on [[codesandbox]](https://codesandbox.io/s/crud-v3-promises-h9d5t) 35 | 36 | ### `crud-v4-actors` 37 | 38 | - `v4` is based on [David's](https://github.com/davidkpiano)[TodoMVC](https://codesandbox.io/s/xstate-todomvc-33wr94qv1) example but with a couple of improvements. 39 | 40 | - This is by far the most complicated example, it showcased how to use the latest `Actor` model for communication between child components and their parent. 41 | 42 | - Pay attention to how `TodosMachine` spawned child `TodoMachine`s and pass it's ref to each child component as a local single truth that handles the component state., more details in the folder's `Readme.md` 43 | 44 | - See detailed docs on [actor here](https://xstate.js.org/docs/guides/actors.html), this is something you don't want to miss 😎 45 | 46 | - In short, `Service` and `Actor` are basically the same thing but used differently, rule of thumb: 47 | 48 | - Statically invoke services (you have to write all services in machine statemenet in advance) 49 | - Dynamically spawn actors (you can spawn new actors from any events whenever needed) 50 | 51 | - Play on [[codesandbox]](https://codesandbox.io/s/crud-v4-actors-oxx7y) 52 | 53 | ## Notes 54 | 55 | - Generic naming convention for `states` and `events` are: 56 | 57 | - `camelCaseForState` 58 | 59 | - `UPPER.CASE.FOR.EVENT` 60 | 61 | - By using `dots` for event it is possible in the future to implement wildcase event matching, for example `UPPER.*` to match all events starting with `UPPER` 62 | 63 | - Basic guiding rule for all these example are hoping to make `ui` a `dumb layer` 64 | - meaning ui only does two things 65 | - draw the user interface 66 | - accept user inputs (keyboard/mouse events) 67 | - then delegate those events to `xstate` to handle, where all business logics are encapsulated 68 | 69 | ## Todo 70 | 71 | - Rewrite tests 72 | 73 | - Enable `whyDidYouRender` to eliminate unnecessary renders. 74 | -------------------------------------------------------------------------------- /_old/calc/README.md: -------------------------------------------------------------------------------- 1 | 2 | This is a simple calculator implemented as depicted in the book [`Constructing the User Interface with Statecharts`](https://dl.acm.org/citation.cfm?id=520870) 3 | 4 | Screen Shot 2019-05-14 at 10 28 15 AM 5 | 6 | ## This is the statecharts generated from the actually implemented version 7 | 8 | ![](https://user-images.githubusercontent.com/325936/57836017-67df7700-77f2-11e9-838f-61b0675d096f.png) 9 | -------------------------------------------------------------------------------- /_old/calc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "a1", 3 | "license": "Apache-2.0", 4 | "version": "1.0.0", 5 | "description": "", 6 | "keywords": [], 7 | "main": "src/index.js", 8 | "dependencies": { 9 | "@xstate/react": "^0.2.0", 10 | "jest-dom": "^3.1.3", 11 | "react": "16.8.3", 12 | "react-dom": "16.8.3", 13 | "react-scripts": "2.1.8", 14 | "react-testing-library": "^7.0.0", 15 | "sprintf-js": "^1.1.2", 16 | "xstate": "^4.5.0" 17 | }, 18 | "devDependencies": { 19 | "typescript": "3.3.3" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test --env=jsdom", 25 | "eject": "react-scripts eject" 26 | }, 27 | "browserslist": [ 28 | ">0.2%", 29 | "not dead", 30 | "not ie <= 11", 31 | "not op_mini all" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /_old/calc/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /_old/calc/src/App.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React, { useRef, useEffect } from "react" 3 | import ReactDOM from "react-dom" 4 | import { useMachine } from "@xstate/react" 5 | import { interpret } from 'xstate' 6 | import machine from './machine' 7 | import "./styles.css" 8 | import { 9 | isNumber, 10 | isOperator, 11 | current, 12 | } from './helpers' 13 | 14 | export const App = () => { 15 | 16 | // fsm hook - 這是唯一用到 useState 的地方 17 | const [state, send ] = useMachine( machine, {log: true} ) 18 | 19 | // 畫面顯示用的變數 20 | const input = useRef([]) 21 | const resetInput = useRef(false) 22 | 23 | // 是否要改顯示 fsm 返還的計算結果 24 | const useAns = useRef(false) 25 | 26 | // console.log( '\n[現在狀態] = ', state.value, '\n[context] = ', state.context ) 27 | 28 | // console.log( '顯示答案嗎?', useAns.current ) 29 | 30 | // divideByZero 時要顯示錯誤訊息 31 | if( current(state) === 'alert' ){ 32 | console.log( '強迫顯示 fsm 狀態', ) 33 | useAns.current = true 34 | } 35 | 36 | // fsm 有返還計算後的答案,就要改顯示它 37 | if( useAns.current ){ 38 | // 用 fsm.context.display 內容取代本地 input 變數 39 | input.current = [state.context.display] 40 | // 重置這個變數 41 | useAns.current = false 42 | } 43 | 44 | const handleNumber = item => { 45 | 46 | // 如在 'operator' 狀態就要清空 input 內容值來存 operand2 47 | if( current(state) === 'operator' ){ 48 | 49 | input.current = [item] 50 | resetInput.current = false 51 | 52 | } else { 53 | 54 | // 只準有一個 dot, 要判斷是 1.1 或 1.1.2 不可出現兩個 dot 55 | if(item === '.' && input.current.indexOf('.') != -1 ) return 56 | 57 | // 1+2=3 接著輸入數字時,要先清空 3 這個舊值 58 | if( resetInput.current === true ){ 59 | input.current = [] 60 | resetInput.current = false 61 | } 62 | 63 | // 如果當前 input 值為 0,將其移除並用 [] 取代 64 | if( +input.current[0] === 0) { 65 | input.current = [] 66 | } 67 | 68 | input.current.push(item) 69 | } 70 | 71 | send({ type: 'NUMBER', value: +input.current.join('') }) 72 | } 73 | 74 | const handleOperator = item => { 75 | 76 | useAns.current = true 77 | 78 | // send() 一定要放在設定完區域變數後面才能執行 79 | if( item === '-'){ 80 | send({ type: 'OPERATOR_MINUS', value: item }) 81 | } else { 82 | send({ type: 'OPERATOR_OTHERS', value: item }) 83 | } 84 | } 85 | 86 | const handleEqual = item => { 87 | useAns.current = true 88 | resetInput.current = true 89 | send({ type: 'EQUAL', value: null }) 90 | } 91 | 92 | const handleC = item => { 93 | useAns.current = true 94 | send({ type: 'C', value: null }) 95 | } 96 | 97 | const handleAC = item => { 98 | useAns.current = true 99 | send({ type: 'AC', value: null }) 100 | } 101 | 102 | const handleInput = item => { 103 | // console.log( 'button 拿到: ', item ) 104 | 105 | if ( isNumber(+item) || item === '.' ) { 106 | handleNumber(item) 107 | // } else if ( item === '.' ) { 108 | // handleDot(item) 109 | } else if (isOperator(item)) { 110 | handleOperator(item) 111 | } else if (item === '=' || item === 'enter') { 112 | handleEqual(item) 113 | } else if (item === 'C') { 114 | // C 是清楚最後一次的輸入值 115 | handleC(item) 116 | } else if (item === 'AC' || item === 'escape') { 117 | // AC = all clear 重設一切 118 | handleAC(item) 119 | } else if (item === "%") { 120 | // not implemented 121 | } else { 122 | console.log( '未知鍵: ', item ) 123 | } 124 | } 125 | 126 | // 處理 mouse 127 | const handleClick = item => { 128 | // console.log( 'click 按了: ', item ) 129 | handleInput(item) 130 | } 131 | 132 | // 處理 keyboard 133 | const handleKeyDown = evt => { 134 | const { key } = evt 135 | // console.log( '鍵盤: ', key.toLowerCase() ) 136 | handleInput( key.toLowerCase() ) 137 | } 138 | 139 | const calcButtons = () => { 140 | // 141 | const buttons = [ 142 | "C", 143 | "AC", 144 | "/", 145 | "7", 146 | "8", 147 | "9", 148 | "*", 149 | "4", 150 | "5", 151 | "6", 152 | "-", 153 | "1", 154 | "2", 155 | "3", 156 | "+", 157 | "0", 158 | ".", 159 | "=", 160 | "%" 161 | ]; 162 | 163 | return buttons.map((itm, idx) => { 164 | let classNames = "calc-button"; 165 | 166 | if (itm === "C") { 167 | classNames += " two-span"; 168 | } 169 | 170 | return ( 171 | 179 | ); 180 | }); 181 | } 182 | 183 | const display = () => { 184 | let val = input.current.join('') 185 | // console.log( '顯示:', val, ' > ', input.current ) 186 | return val.length == 0 ? '0' : val 187 | } 188 | 189 | useEffect(() => { 190 | // 讓 focus 自動放在 container 身上,可接收鍵盤事件 191 | window.addEventListener('keydown', handleKeyDown ) 192 | return () => window.removeEventListener('keydown', handleKeyDown ) 193 | }); 194 | 195 | return ( 196 |
200 | 205 |
206 |
{calcButtons()}
207 |
208 | ) 209 | } 210 | -------------------------------------------------------------------------------- /_old/calc/src/actions.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { assign } from './xstateImmer' 3 | 4 | import { 5 | divideByZero, 6 | doMath, 7 | isNumber, 8 | isOperator, 9 | } from './helpers' 10 | 11 | export const noop = () => {} 12 | 13 | export const calculate = assign((ctx, e) => { 14 | ctx.operand1 = doMath(ctx) 15 | ctx.operand2 = null 16 | ctx.operator = e.value 17 | ctx.display = ctx.operand1 18 | return ctx 19 | }) 20 | 21 | export const setNegative = assign((ctx, e) => { 22 | ctx.operand1 = 0 23 | ctx.display = ctx.operand1 24 | return ctx 25 | }) 26 | 27 | // export const setStart = assign((ctx, e) => { 28 | // ctx.operand1 = 0 29 | // ctx.display = ctx.operand1 30 | // return ctx 31 | // }) 32 | 33 | export const setOperator = assign((ctx, e) => { 34 | isOperator(e.value) 35 | ctx.operator = e.value 36 | return ctx 37 | }) 38 | 39 | export const setOperand1 = assign((ctx, e) => { 40 | isNumber( e.value ) 41 | ctx.operand1 = e.value 42 | ctx.display = ctx.operand1 43 | return ctx 44 | }) 45 | 46 | export const setOperand2 = assign((ctx, e) => { 47 | isNumber( e.value ) 48 | ctx.operand2 = e.value 49 | ctx.display = ctx.operand2 50 | return ctx 51 | }) 52 | 53 | export const setOperand1Negative = assign((ctx, e) => { 54 | isNumber( e.value ) 55 | ctx.operand1 = e.value * -1 56 | ctx.display = ctx.operand1 57 | return ctx 58 | }) 59 | 60 | export const resetOperand1 = assign((ctx, e) => { 61 | ctx.operand1 = 0 62 | ctx.display = ctx.operand1 63 | return ctx 64 | }) 65 | 66 | export const resetOperand2 = assign((ctx, e) => { 67 | ctx.operand2 = 0 68 | ctx.display = ctx.operand2 69 | return ctx 70 | }) 71 | 72 | export const setError = assign((ctx, e) => { 73 | ctx.display = 'ERROR' 74 | return ctx 75 | }) 76 | 77 | // 這是清除所有資料 78 | export const setAC = assign((ctx, e) => { 79 | ctx.operand1 = 0 80 | ctx.operand2 = 0 81 | ctx.operator = null 82 | ctx.display = 0 83 | return ctx 84 | }) 85 | 86 | // 這是清除上一筆輸入 87 | // export const setC = assign((ctx, e) => { 88 | // ctx.operand1 = 0 89 | // ctx.operand2 = null 90 | // ctx.operator = null 91 | // ctx.display = 0 92 | // return ctx 93 | // }) 94 | 95 | -------------------------------------------------------------------------------- /_old/calc/src/guards.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | export const divideByZero = (ctx, e) => { 4 | const zero = ctx.operator === '/' && e.value === 0 5 | // console.log( 'divideByZero:', zero) 6 | // console.log( e ) 7 | // console.log( ctx ) 8 | return zero 9 | } 10 | -------------------------------------------------------------------------------- /_old/calc/src/helpers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { sprintf } from 'sprintf-js' 4 | 5 | // 取得目前 state 的字串,例如 'main.start' 6 | export const current = state => state.toStrings().pop() 7 | 8 | export const doMath = ctx => { 9 | const { operand1, operand2, operator } = ctx 10 | 11 | let result = 0 12 | 13 | switch( operator ) { 14 | case '+': 15 | result = operand1 + operand2 16 | break 17 | case '-': 18 | result = operand1 - operand2 19 | break 20 | case '*': 21 | result = operand1 * operand2 22 | break 23 | case '/': 24 | result = operand1 / operand2 25 | break 26 | } 27 | 28 | // 解決 float point 問題 29 | result = +sprintf('%.6f', result) 30 | // console.log( '\t計算結果:', result ) 31 | return result 32 | } 33 | 34 | export const isNumber = val => { 35 | 36 | if(isNaN(val)){ 37 | // console.warn(`Number required, but got '${val}', did you pass in an operator?`) 38 | 39 | return false 40 | } 41 | 42 | return true 43 | } 44 | 45 | export const isOperator = val => { 46 | if( !['+', '-', '*', '/'].includes(val) ){ 47 | // console.warn(`Operator required, but got '${val}'`) 48 | return false 49 | } 50 | return true 51 | } 52 | 53 | export const timer = time => { 54 | return new Promise((resolve, reject) => { 55 | setTimeout(resolve, time) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /_old/calc/src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React, { useRef, useEffect } from "react" 3 | import ReactDOM from "react-dom" 4 | import { useMachine } from "@xstate/react" 5 | import { interpret } from 'xstate' 6 | import machine from './machine' 7 | import "./styles.css" 8 | import { 9 | isNumber, 10 | isOperator, 11 | current, 12 | } from './helpers' 13 | import { App } from './App' 14 | 15 | const rootElement = document.getElementById("root"); 16 | ReactDOM.render(, rootElement); 17 | -------------------------------------------------------------------------------- /_old/calc/src/machine.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { Machine, interpret, send } from 'xstate' 3 | import { updater, assign } from './xstateImmer' 4 | import * as actions from './actions' 5 | import * as guards from './guards' 6 | 7 | export default Machine({ 8 | context: { 9 | operand1: null, 10 | operand2: null, 11 | operator: null, 12 | display: 0, // 這是計算後的結果,供 ui 顯示 13 | }, 14 | 15 | initial: 'main', 16 | 17 | id: 'calc', 18 | 19 | states: { 20 | 21 | // group 22 | main: { 23 | 24 | initial: 'start', 25 | 26 | states: { 27 | 28 | // 29 | start: { 30 | on: { 31 | 'OPERATOR_MINUS': { 32 | target: '#calc.negative1', 33 | actions: actions.setNegative, 34 | }, 35 | 'OPERATOR_OTHERS': { 36 | target: '#calc.operator', 37 | actions: actions.setOperator, 38 | }, 39 | } 40 | }, 41 | 42 | // 43 | result: { 44 | on: { 45 | 'OPERATOR_OTHERS': { 46 | target: '#calc.operator', 47 | actions: actions.setOperator, 48 | }, 49 | 'OPERATOR_MINUS': { 50 | target: '#calc.operator', 51 | actions: actions.setOperator, 52 | }, 53 | // placeholder, to not show warnings 54 | 'C': { 55 | actions: actions.noop, 56 | }, 57 | } 58 | }, 59 | }, 60 | 61 | // 62 | on: { 63 | 'NUMBER': { 64 | target: 'operand1', 65 | actions: actions.setOperand1, 66 | }, 67 | } 68 | }, 69 | 70 | negative1: { 71 | on: { 72 | 'C': { 73 | target: 'main.start', 74 | actions: actions.resetOperand1, 75 | }, 76 | 'NUMBER': { 77 | target: 'operand1', 78 | actions: actions.setOperand1Negative, 79 | }, 80 | } 81 | }, 82 | 83 | // 84 | operand1: { 85 | on: { 86 | 'NUMBER': { 87 | target: 'operand1', 88 | actions: actions.setOperand1, 89 | }, 90 | 'C': { 91 | target: 'main.start', 92 | actions: actions.resetOperand1, 93 | }, 94 | 'OPERATOR_OTHERS': { 95 | target: 'operator', 96 | actions: actions.setOperator, 97 | }, 98 | 'OPERATOR_MINUS': { 99 | target: 'operator', 100 | actions: actions.setOperator, 101 | }, 102 | } 103 | }, 104 | 105 | // 106 | operator: { 107 | on: { 108 | 'NUMBER': [ 109 | { 110 | target: 'alert', 111 | cond: guards.divideByZero, 112 | actions: actions.setError, 113 | }, 114 | { 115 | target: 'operand2', 116 | actions: actions.setOperand2, 117 | }, 118 | ], 119 | 120 | 'OPERATOR_OTHERS': { 121 | target: 'operator', 122 | actions: actions.setOperator, 123 | }, 124 | 'OPERATOR_MINUS': { 125 | target: 'operator', 126 | actions: actions.setOperator, 127 | }, 128 | } 129 | }, 130 | 131 | operand2: { 132 | 133 | on: { 134 | 135 | // 136 | 'NUMBER': { 137 | target: 'operand2', 138 | actions: actions.setOperand2, 139 | }, 140 | 141 | // 142 | 'C': { 143 | target: 'operator', 144 | actions: actions.resetOperand2, 145 | }, 146 | 147 | // 148 | 'OPERATOR_OTHERS': [ 149 | { 150 | target: 'alert', 151 | cond: guards.divideByZero, 152 | }, 153 | { 154 | target: 'operator', 155 | actions: actions.calculate, 156 | }, 157 | ], 158 | // 159 | 'OPERATOR_MINUS': [ 160 | { 161 | target: 'alert', 162 | cond: guards.divideByZero, 163 | }, 164 | { 165 | target: 'operator', 166 | actions: actions.calculate, 167 | }, 168 | ], 169 | // 170 | 'EQUAL': [ 171 | { 172 | target: 'alert', 173 | cond: guards.divideByZero, 174 | }, 175 | { 176 | target: 'main.result', 177 | actions: actions.calculate, 178 | }, 179 | ], 180 | } 181 | }, 182 | 183 | alert: { 184 | on: { 185 | 'C': { 186 | target: 'operand2', 187 | actions: actions.resetOperand2, 188 | }, 189 | }, 190 | }, 191 | }, 192 | 193 | on: { 194 | // 這是真正的 AC,其它地方都是 C 195 | 'AC': { 196 | target: 'main.start', 197 | actions: actions.setAC, 198 | }, 199 | }, 200 | }, 201 | { 202 | actions, 203 | updater, 204 | }) 205 | 206 | -------------------------------------------------------------------------------- /_old/calc/src/styles.css: -------------------------------------------------------------------------------- 1 | body * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | .container { 8 | max-width: 300px; 9 | margin: 0 auto; 10 | border: 2px solid gray; 11 | border-radius: 4px; 12 | box-sizing: border-box; 13 | } 14 | 15 | .readout { 16 | font-size: 32px; 17 | color: #333; 18 | text-align: right; 19 | padding: 5px 13px; 20 | width: 100%; 21 | border: none; 22 | border-bottom: 1px solid gray; 23 | box-sizing: border-box; 24 | } 25 | 26 | .button-grid { 27 | display: grid; 28 | padding: 20px; 29 | grid-template-columns: repeat(4, 1fr); 30 | grid-gap: 15px; 31 | } 32 | 33 | .calc-button { 34 | padding: 10px; 35 | font-size: 22px; 36 | color: #eee; 37 | background: rgba(0, 0, 0, 0.5); 38 | cursor: pointer; 39 | border-radius: 2px; 40 | border: 0; 41 | outline: none; 42 | opacity: 0.8; 43 | transition: opacity 0.2s ease-in-out; 44 | } 45 | 46 | .calc-button:hover { 47 | opacity: 1; 48 | } 49 | 50 | .calc-button:active { 51 | background: #999; 52 | box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.6); 53 | } 54 | 55 | .two-span { 56 | grid-column: span 2; 57 | background-color: #3572db; 58 | } 59 | -------------------------------------------------------------------------------- /_old/calc/src/ui.test.js: -------------------------------------------------------------------------------- 1 | import 'jest-dom/extend-expect' 2 | import 'react-testing-library/cleanup-after-each' 3 | import React from 'react' 4 | import { render, fireEvent } from 'react-testing-library' 5 | import { App } from './App' 6 | 7 | let container, elemDot, elem0, elem1, elem2, elem3, elem4, elemAdd, elemMultiply, elemDivide, elemEqual, elemAC, readout 8 | 9 | beforeEach(() => { 10 | const dom = render() 11 | container = dom.container 12 | elemDot = container.querySelector('#key_\\.') 13 | elem0 = container.querySelector('#key_0') 14 | elem1 = container.querySelector('#key_1') 15 | elem2 = container.querySelector('#key_2') 16 | elem3 = container.querySelector('#key_3') 17 | elem4 = container.querySelector('#key_4') 18 | elemAdd = container.querySelector('#key_\\+') 19 | elemMultiply = container.querySelector('#key_\\*') 20 | elemDivide = container.querySelector('#key_\\/') 21 | elemEqual = container.querySelector('#key_\\=') 22 | elemAC = container.querySelector('#key_AC') 23 | readout = container.querySelector('.readout') 24 | // const t = container.querySelector('#foo') 25 | }) 26 | 27 | describe('click test', () => { 28 | 29 | test('1+1=2', () => { 30 | 31 | fireEvent.click( elem1 ) 32 | fireEvent.click( elemAdd ) 33 | fireEvent.click( elem1 ) 34 | fireEvent.click( elemEqual ) 35 | 36 | expect(readout).toHaveAttribute( 'value', '2') 37 | }) 38 | 39 | test('3/2=1.5', () => { 40 | 41 | fireEvent.click( elem3 ) 42 | fireEvent.click( elemAC ) 43 | expect(readout).toHaveAttribute( 'value', '0') 44 | 45 | fireEvent.click( elem3 ) 46 | fireEvent.click( elemDivide ) 47 | fireEvent.click( elem2 ) 48 | fireEvent.click( elemEqual ) 49 | 50 | expect(readout).toHaveAttribute( 'value', '1.5') 51 | // expect(readout).toHaveAttribute( 'value', '2') 52 | }) 53 | 54 | test('3/0 要顯示 divideByZero ERROR', () => { 55 | 56 | fireEvent.click( elem3 ) 57 | fireEvent.click( elemDivide ) 58 | fireEvent.click( elem0 ) 59 | fireEvent.click( elemEqual ) 60 | 61 | expect(readout).toHaveAttribute( 'value', 'ERROR') 62 | }) 63 | 64 | test('0.1+0.2=0.3', () => { 65 | 66 | fireEvent.click( elemDot ) 67 | fireEvent.click( elem1 ) 68 | fireEvent.click( elemAdd ) 69 | fireEvent.click( elemDot ) 70 | fireEvent.click( elem2 ) 71 | fireEvent.click( elemEqual ) 72 | 73 | expect(readout).toHaveAttribute( 'value', '0.3') 74 | 75 | }) 76 | 77 | test('2 * .2 = 0.4', () => { 78 | 79 | fireEvent.click( elem2 ) 80 | fireEvent.click( elemMultiply ) 81 | fireEvent.click( elemDot ) 82 | fireEvent.click( elem2 ) 83 | fireEvent.click( elemEqual ) 84 | 85 | expect(readout).toHaveAttribute( 'value', '0.4') 86 | 87 | }) 88 | 89 | test('2 * . . 2 = 0.4 測輸入多個 dot 能成功擋掉', () => { 90 | 91 | fireEvent.click( elem2 ) 92 | fireEvent.click( elemMultiply ) 93 | fireEvent.click( elemDot ) 94 | fireEvent.click( elemDot ) 95 | fireEvent.click( elem2 ) 96 | fireEvent.click( elemEqual ) 97 | 98 | expect(readout).toHaveAttribute( 'value', '0.4') 99 | 100 | }) 101 | 102 | test('1+2=3, 再輸入 .1 此時顯示 1,應是 .1', () => { 103 | 104 | fireEvent.click( elem1 ) 105 | fireEvent.click( elemAdd ) 106 | fireEvent.click( elem2 ) 107 | fireEvent.click( elemEqual ) 108 | expect(readout).toHaveAttribute( 'value', '3') 109 | 110 | fireEvent.click( elemDot ) 111 | fireEvent.click( elem1 ) 112 | fireEvent.click( elemEqual ) 113 | expect(readout).toHaveAttribute( 'value', '.1') 114 | 115 | }) 116 | 117 | test('1+2=3, 再輸入 *.1,該顯示 0.1', () => { 118 | 119 | fireEvent.click( elem1 ) 120 | fireEvent.click( elemAdd ) 121 | fireEvent.click( elem2 ) 122 | fireEvent.click( elemEqual ) 123 | expect(readout).toHaveAttribute( 'value', '3') 124 | 125 | fireEvent.click( elemMultiply ) 126 | fireEvent.click( elemDot ) 127 | fireEvent.click( elem1 ) 128 | expect(readout).toHaveAttribute( 'value', '.1') 129 | 130 | fireEvent.click( elemEqual ) 131 | expect(readout).toHaveAttribute( 'value', '0.3') 132 | 133 | }) 134 | 135 | test('.2 + .3 輸入第二個 . 後會得到 3,應顯示 0.3', () => { 136 | 137 | fireEvent.click( elemDot ) 138 | fireEvent.click( elem2 ) 139 | fireEvent.click( elemAdd ) 140 | fireEvent.click( elemDot ) 141 | fireEvent.click( elem3 ) 142 | expect(readout).toHaveAttribute( 'value', '.3') 143 | 144 | fireEvent.click( elemEqual ) 145 | expect(readout).toHaveAttribute( 'value', '0.5') 146 | 147 | }) 148 | 149 | test('[bug] 鍵入 1. 顯示 10. 應是 1.', () => { 150 | 151 | // console.log( 'test 有貨:', elemMultiply ) 152 | 153 | fireEvent.click( elem1 ) 154 | fireEvent.click( elemDot ) 155 | 156 | expect(readout).toHaveAttribute( 'value', '1.') 157 | 158 | }) 159 | 160 | }) 161 | 162 | describe('keyboard', () => { 163 | /* 164 | 165 | https://testing-library.com/docs/dom-testing-library/api-events#fireevent 166 | 167 | fireEvent.keyDown(domNode, { key: 'Enter', code: 13 }) 168 | 169 | // note: you should set the charCode or it will be fallback to 0 170 | // will Fire an KeyboardEvent with charCode = 0 171 | fireEvent.keyDown(domNode, { key: 'Enter', code: 13 }) 172 | 173 | // will Fire an KeyboardEvent with charCode = 65 174 | fireEvent.keyDown(domNode, { key: 'A', code: 65, charCode: 65 }) 175 | 176 | */ 177 | 178 | test('1+2=3', () => { 179 | fireEvent.keyDown( container, { key: '1'} ) 180 | fireEvent.keyDown( container, { key: '+'} ) 181 | fireEvent.keyDown( container, { key: '2'} ) 182 | expect(readout).toHaveAttribute( 'value', '2') 183 | 184 | fireEvent.keyDown( container, { key: '='} ) 185 | expect(readout).toHaveAttribute( 'value', '3') 186 | 187 | }) 188 | 189 | test('3/0 = ERROR', () => { 190 | fireEvent.keyDown( container, { key: '3'} ) 191 | fireEvent.keyDown( container, { key: '/'} ) 192 | fireEvent.keyDown( container, { key: '0'} ) 193 | expect(readout).toHaveAttribute( 'value', 'ERROR') 194 | 195 | fireEvent.keyDown( container, { key: '='} ) 196 | expect(readout).toHaveAttribute( 'value', 'ERROR') 197 | 198 | }) 199 | 200 | test('1111+2222=3333', () => { 201 | fireEvent.keyDown( container, { key: '1'} ) 202 | fireEvent.keyDown( container, { key: '1'} ) 203 | fireEvent.keyDown( container, { key: '1'} ) 204 | fireEvent.keyDown( container, { key: '1'} ) 205 | fireEvent.keyDown( container, { key: '+'} ) 206 | fireEvent.keyDown( container, { key: '2'} ) 207 | fireEvent.keyDown( container, { key: '2'} ) 208 | fireEvent.keyDown( container, { key: '2'} ) 209 | fireEvent.keyDown( container, { key: '2'} ) 210 | fireEvent.keyDown( container, { key: '='} ) 211 | expect(readout).toHaveAttribute( 'value', '3333') 212 | 213 | }) 214 | }) 215 | -------------------------------------------------------------------------------- /_old/calc/src/xstateImmer.js: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { actionTypes } from 'xstate/lib/actions'; 3 | 4 | export function assign( assignment ) { 5 | return { 6 | type: actionTypes.assign, 7 | assignment 8 | } 9 | } 10 | 11 | export function updater( context, event, assignActions ) { 12 | const updatedContext = context 13 | 14 | ? assignActions.reduce((acc, assignAction) => { 15 | const { assignment } = assignAction 16 | const update = produce(acc, interim => assignment(interim, event)) 17 | return update 18 | }, context) 19 | 20 | : context 21 | 22 | return updatedContext 23 | } 24 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/README.md: -------------------------------------------------------------------------------- 1 | 2 | This is a sample app showing how to share one service in the background to servce multiple concurrent jobs 3 | 4 | # Goal 5 | 6 | - Using one service function as multi-thread workers 7 | 8 | # Key Features 9 | 10 | - Starting a service in the background to serve multiple compression jobs 11 | 12 | - Service is implemented as pure function hence could be easily scale out (having multiple instances running at the same time) 13 | 14 | - Showing each of the job could have it status updated without causing the whole app to re-render 15 | 16 | - Each job could be cancelled/paused/resumed without affecting other jobs 17 | 18 | ## Statechart 19 | 20 | ![service-1](https://user-images.githubusercontent.com/325936/57836015-6746e080-77f2-11e9-8210-3bca93b74849.png) 21 | 22 | 23 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "a1", 3 | "license": "Apache-2.0", 4 | "version": "1.0.0", 5 | "description": "", 6 | "keywords": [], 7 | "main": "src/index.js", 8 | "dependencies": { 9 | "@welldone-software/why-did-you-render": "^3.0.7", 10 | "@xstate/react": "^0.2.0", 11 | "enumify": "^1.0.4", 12 | "jest-dom": "^3.1.3", 13 | "react": "16.8.3", 14 | "react-dom": "16.8.3", 15 | "react-scripts": "2.1.8", 16 | "react-testing-library": "^7.0.0", 17 | "toasted-notes": "^2.1.5", 18 | "xstate": "^4.5.0" 19 | }, 20 | "devDependencies": { 21 | "typescript": "3.3.3" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test --env=jsdom", 27 | "eject": "react-scripts eject" 28 | }, 29 | "browserslist": [ 30 | ">0.2%", 31 | "not dead", 32 | "not ie <= 11", 33 | "not op_mini all" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/src/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React, { useEffect, useState, useMemo, useRef, useContext } from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { useMachine } from '@xstate/react' 5 | import CompressMachine from './machine' 6 | import { dump } from './helpers' 7 | 8 | import { StateChart } from '@statecharts/xstate-viz' 9 | 10 | import './styles.css' 11 | 12 | import whyDidYouRender from '@welldone-software/why-did-you-render' 13 | whyDidYouRender(React) 14 | 15 | const MyContext = React.createContext() 16 | 17 | let uid = 0 18 | 19 | const App = () => { 20 | const { state, send } = useContext(MyContext) 21 | 22 | const { songs } = state.context 23 | 24 | // console.log('\n[State] = ', state.value, '\n[context] = ', state.context) 25 | 26 | // console.log( '\n changed:', state ) 27 | // dump(state.value) 28 | // console.log('\n[context] = ', state.context) 29 | 30 | if (state.matches('main.running')) { 31 | // dump(state.value) 32 | // console.log('\ncontext:', state.context.songs) 33 | } 34 | 35 | let cnt = 0 36 | 37 | const rows = songs.map( (it,idx) => { 38 | if (it.progress === 100) cnt++ 39 | const rowDone = it.progress === 100 40 | const jobStyle = { 41 | backgroundColor: rowDone ? 'green' : 'yellow', 42 | color: rowDone ? 'white' : 'black', 43 | } 44 | return ( 45 |
48 | {it.id + ':' + it.progress} 49 | 50 | 51 | 52 |
53 | ) 54 | }) 55 | 56 | const allDone = cnt == rows.length 57 | // console.log( 'cnt:', cnt, allDone) 58 | 59 | const handleAddFile = () => { 60 | send({ 61 | type: 'addFile', 62 | data: { 63 | id: ++uid, 64 | name: `Song_${uid}.flac`, 65 | progress: 0, 66 | }, 67 | }) 68 | } 69 | 70 | const handleCancel = id => { 71 | send({ 72 | type: 'cancelFile', 73 | id 74 | }) 75 | } 76 | 77 | const handlePause = id => { 78 | send({ 79 | type: 'pauseFile', 80 | id 81 | }) 82 | } 83 | const handleResume = (id, progress) => { 84 | send({ 85 | type: 'resumeFile', 86 | id, progress 87 | }) 88 | } 89 | 90 | useEffect(() => { 91 | handleAddFile() 92 | // handleAddFile() 93 | // handleAddFile() 94 | // handleAddFile() 95 | // handleAddFile() 96 | }, []) 97 | 98 | const jobStyle = { 99 | border: allDone ? '10px solid red' : null, 100 | width: 150, 101 | } 102 | 103 | // return 104 | 105 | return ( 106 |
107 | 108 |
109 | { rows } 110 |
111 |
112 | ) 113 | } 114 | 115 | export const Wrap = () => { 116 | const [state, send] = useMachine(CompressMachine, { log: true }) 117 | 118 | return ( 119 | 120 | 121 | 122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/src/compressionService.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { uuid, randomFloat, random } from './helpers' 3 | 4 | export const compressionService = (ctx, e) => (cb, onReceive) => { 5 | 6 | let workers = {} 7 | 8 | onReceive(evt => { 9 | 10 | switch( evt.type ){ 11 | 12 | case 'pauseSong': 13 | case 'cancelSong': 14 | 15 | const id = evt.data 16 | clearTimeout(workers[id]) 17 | workers[id] = null 18 | // console.log( '取消壓縮:', evt, workers, workers ) 19 | break 20 | 21 | case 'resumeSong': 22 | case 'compressSong': 23 | 24 | console.log( '請求啟動:', evt, workers ) 25 | 26 | const { data } = evt 27 | 28 | if( evt.type === 'resumeSong' && workers[data.id] !== null ){ 29 | console.log( '\n\t已在跑,不可 resume', workers ) 30 | return 31 | } 32 | 33 | // resume 就是將上次 progress 值還原即可 34 | let progress = evt.type === 'compressSong' ? 0 : data.progress 35 | 36 | const run = () => { 37 | 38 | progress = Math.min(100, progress + random(5, 15) ) 39 | 40 | // console.log(`[${data.id}] ${progress} >>`, workers) 41 | 42 | if(progress < 100){ 43 | 44 | cb({ 45 | type: 'workerProgress', 46 | job: { id: data.id, progress} 47 | }) 48 | 49 | // 排下次進度 50 | workers[data.id] = setTimeout( run, random(200, 1000) ) 51 | }else{ 52 | cb({ 53 | type: 'workerDone', 54 | job: { id: data.id, progress} 55 | }) 56 | } 57 | 58 | } 59 | 60 | run() 61 | break 62 | 63 | default: 64 | console.log( '沒人接的 method call=', evt.type ) 65 | } 66 | }) 67 | 68 | } 69 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/src/fsm.test.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coodoo/xstate-examples/5dcd1c810fa0d0a10594e3bef80dc5019cbfd873/_old/fsm-service-1-callback/src/fsm.test.js -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/src/helpers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { sprintf } from 'sprintf-js' 4 | 5 | // +TBD: 將來改用 uuid 6 | export const uuid = () => { 7 | return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => 8 | (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) 9 | ) 10 | } 11 | 12 | export const noop = () => {} 13 | 14 | // dump state tree in string format 15 | export const dump = (item, depth=100) => { 16 | const MAX_DEPTH = 100 17 | depth = depth || 0 18 | let isString = typeof item === 'string' 19 | let isDeep = depth > MAX_DEPTH 20 | 21 | if (isString || isDeep) { 22 | console.log(item) 23 | return 24 | } 25 | 26 | for (var key in item) { 27 | console.group(key) 28 | dump(item[key], depth + 1) 29 | console.groupEnd() 30 | } 31 | } 32 | 33 | export const current = state => state.toStrings().pop() 34 | 35 | export const timer = time => { 36 | return new Promise((resolve, reject) => { 37 | setTimeout(resolve, time) 38 | }) 39 | } 40 | 41 | export const randomFloat = (min=0, max=999) => { 42 | return Math.random() * (max - min) + min; 43 | } 44 | 45 | export const random = (min=0, max=999) => { 46 | min = Math.ceil(min); 47 | max = Math.floor(max); 48 | return Math.floor(Math.random() * (max - min + 1)) + min; 49 | } 50 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { Wrap } from './app' 5 | 6 | const rootElement = document.getElementById('root') 7 | ReactDOM.render(, rootElement) 8 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/src/machine.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { Machine, send } from 'xstate' 3 | import { updater, assign } from './xstateImmer' 4 | import { Enum } from 'enumify' 5 | import { compressionService } from './compressionService' 6 | 7 | export class Types extends Enum {} 8 | Types.initEnum(['itemSelect']) 9 | 10 | export default Machine( 11 | { 12 | id: 'Compressor', 13 | type: 'parallel', 14 | context: { 15 | songs: [], 16 | }, 17 | states: { 18 | main: { 19 | initial: 'idle', 20 | invoke: { 21 | id: 'CompressionService', 22 | src: compressionService, 23 | }, 24 | states: { 25 | idle: {}, 26 | running: {}, 27 | completed: {}, 28 | }, 29 | on: { 30 | // 31 | addFile: { 32 | target: '.running', 33 | actions: [ 34 | assign((ctx, e) => { 35 | ctx.songs.push(e.data) 36 | return ctx 37 | }), 38 | send( 39 | (ctx, e) => ({ 40 | type: 'compressSong', 41 | data: e.data, 42 | }), 43 | { to: 'CompressionService' }, 44 | ), 45 | ], 46 | }, 47 | 48 | // 49 | workerProgress: { 50 | target: '', 51 | actions: [ 52 | assign((ctx, e) => { 53 | const { id, progress } = e.job 54 | ctx.songs = ctx.songs.map(it => { 55 | if (it.id === id) it.progress = progress 56 | return it 57 | }) 58 | return ctx 59 | }), 60 | ], 61 | }, 62 | 63 | // 64 | workerDone: { 65 | // target: '.completed', 66 | actions: [ 67 | assign((ctx, e) => { 68 | // console.log( '\t完工:', ctx.songs, e ) 69 | const { id, progress } = e.job 70 | ctx.songs = ctx.songs.map(it => { 71 | if (it.id === id) it.progress = progress 72 | return it 73 | }) 74 | return ctx 75 | }), 76 | ], 77 | }, 78 | 79 | cancelFile: { 80 | // target: '.completed', 81 | actions: [ 82 | assign((ctx, e) => { 83 | ctx.songs = ctx.songs.filter(it => it.id !== e.id) 84 | return ctx 85 | }), 86 | send( 87 | (ctx, e) => ({ 88 | type: 'cancelSong', 89 | data: e.id, 90 | }), 91 | { to: 'CompressionService' }, 92 | ), 93 | ], 94 | }, 95 | 96 | pauseFile: { 97 | // target: '.completed', 98 | actions: [ 99 | assign((ctx, e) => { 100 | // ctx.songs = ctx.songs.filter( it => it.id !== e.id ) 101 | // return ctx 102 | }), 103 | send( 104 | (ctx, e) => ({ 105 | type: 'pauseSong', 106 | data: e.id, 107 | }), 108 | { to: 'CompressionService' }, 109 | ), 110 | ], 111 | }, 112 | 113 | resumeFile: { 114 | // target: '.completed', 115 | actions: [ 116 | assign((ctx, e) => { 117 | // console.log( '\t接續:', e.id ) 118 | // ctx.songs = ctx.songs.filter( it => it.id !== e.id ) 119 | // return ctx 120 | }), 121 | send( 122 | (ctx, e) => ({ 123 | type: 'resumeSong', 124 | data: { id: e.id, progress: e.progress }, 125 | }), 126 | { to: 'CompressionService' }, 127 | ), 128 | ], 129 | }, 130 | }, 131 | }, 132 | global: { 133 | initial: 'modal', 134 | states: { 135 | modal: {}, 136 | }, 137 | }, 138 | }, 139 | }, 140 | 141 | { 142 | updater, 143 | }, 144 | ) 145 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/src/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | div { 5 | box-sizing: border-box; 6 | } 7 | *, 8 | *:before, 9 | *:after { 10 | box-sizing: inherit; 11 | } 12 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/src/ui.test.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coodoo/xstate-examples/5dcd1c810fa0d0a10594e3bef80dc5019cbfd873/_old/fsm-service-1-callback/src/ui.test.js -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/src/useMachine.js: -------------------------------------------------------------------------------- 1 | import { useState, useMemo, useEffect } from "react"; 2 | import { interpret } from "xstate"; 3 | 4 | export function useMachine(machine, options = {}) { 5 | 6 | const [current, setCurrent] = useState(machine.initialState); 7 | 8 | const service = useMemo( 9 | () => 10 | 11 | // 啟動 fsm 的必要過程 12 | interpret(machine, { execute: false }) 13 | // 這支主要目地是為了打印當前狀態,順便操作 internal state 指令 setCurrent 14 | .onTransition(state => { 15 | options.log && console.log("CONTEXT:", state.context); 16 | setCurrent(state); 17 | }) 18 | .onEvent(e => options.log && console.log("EVENT:", e)), 19 | [] 20 | ); 21 | 22 | useEffect( 23 | () => { 24 | service.execute(current); 25 | }, 26 | [current] 27 | ); 28 | 29 | useEffect(() => { 30 | service.start(); 31 | 32 | return () => service.stop(); 33 | }, []); 34 | 35 | return [current, service.send]; 36 | } 37 | -------------------------------------------------------------------------------- /_old/fsm-service-1-callback/src/xstateImmer.js: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { actionTypes } from 'xstate/lib/actions'; 3 | 4 | export function assign( assignment ) { 5 | return { 6 | type: actionTypes.assign, 7 | assignment 8 | } 9 | } 10 | 11 | export function updater( context, event, assignActions ) { 12 | const updatedContext = context 13 | 14 | ? assignActions.reduce((acc, assignAction) => { 15 | const { assignment } = assignAction 16 | const update = produce(acc, interim => assignment(interim, event)) 17 | return update 18 | }, context) 19 | 20 | : context 21 | 22 | return updatedContext 23 | } 24 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/README.md: -------------------------------------------------------------------------------- 1 | 2 | This example show cases another way of having multiple sub-fsm running, each tied to a child component, and hooked with update function. 3 | 4 | # Goal 5 | 6 | - showing another way of implementing concurrency with multiple sub-machines and how they interact with the ui (via child components) 7 | 8 | - at the same time trying to avoid unnecessary re-render as much as possible 9 | 10 | # Core features 11 | 12 | - Notice this is not using `invoke` to start service, instead it let each child component to start it's own `fsm` 13 | 14 | - Pay attention to how main fsm communicate with sub-fsm via the `onUpdate` function inside the child component 15 | 16 | - Generally speaking this is a bad approach once the new API `spawn(actor)` is out, that's a much better way of handling concurrency and inter-fsm communication along with lowest impact on the ui front 17 | 18 | ## Statechart 19 | 20 | ![service-2](https://user-images.githubusercontent.com/325936/57836014-6746e080-77f2-11e9-9650-a7184fa40dd4.png) 21 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "a1", 3 | "license": "Apache-2.0", 4 | "version": "1.0.0", 5 | "description": "", 6 | "keywords": [], 7 | "main": "src/index.js", 8 | "dependencies": { 9 | "@welldone-software/why-did-you-render": "^3.0.7", 10 | "@xstate/react": "^0.2.0", 11 | "enumify": "^1.0.4", 12 | "jest-dom": "^3.1.3", 13 | "react": "16.8.3", 14 | "react-dom": "16.8.3", 15 | "react-scripts": "2.1.8", 16 | "react-testing-library": "^7.0.0", 17 | "toasted-notes": "^2.1.5", 18 | "xstate": "^4.5.0" 19 | }, 20 | "devDependencies": { 21 | "typescript": "3.3.3" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test --env=jsdom", 27 | "eject": "react-scripts eject" 28 | }, 29 | "browserslist": [ 30 | ">0.2%", 31 | "not dead", 32 | "not ie <= 11", 33 | "not op_mini all" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React, { useEffect, useState, useRef, memo } from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { useMachine } from '@xstate/react' 5 | import CompressMachine from './machine' 6 | import WorkerMachine from './workerMachine' 7 | import { dump } from './helpers' 8 | import Row from './row' 9 | 10 | import './styles.css' 11 | 12 | import { StateChart } from '@statecharts/xstate-viz' 13 | 14 | import whyDidYouRender from '@welldone-software/why-did-you-render' 15 | whyDidYouRender(React) 16 | 17 | const MyContext = React.createContext() 18 | 19 | let uid = 0 20 | 21 | const App = () => { 22 | 23 | const [state, send] = useMachine(CompressMachine) 24 | 25 | const { songs } = state.context 26 | 27 | const onUpdate = song => { 28 | send({ 29 | type: 'update', 30 | data: song, 31 | }) 32 | } 33 | 34 | const onCancel = song => { 35 | send({ 36 | type: 'cancel', 37 | data: song, 38 | }) 39 | } 40 | 41 | const rows = songs.map((it, idx) => { 42 | return ( 43 | ) 49 | }) 50 | 51 | const allDone = songs.every(it => it.progress == 100) 52 | 53 | const handleAddFile = () => { 54 | send({ 55 | type: 'addFile', 56 | data: { 57 | id: ++uid, 58 | name: `Song_${uid}.flac`, 59 | progress: 0, 60 | }, 61 | }) 62 | } 63 | 64 | useEffect(() => { 65 | handleAddFile() 66 | handleAddFile() 67 | handleAddFile() 68 | handleAddFile() 69 | handleAddFile() 70 | }, []) 71 | 72 | const jobStyle = { 73 | border: allDone ? '10px solid red' : null, 74 | width: 150, 75 | } 76 | 77 | // return 78 | 79 | return ( 80 |
81 | 82 |
{rows}
83 |
84 | ) 85 | } 86 | 87 | App.whyDidYouRender = true 88 | export default memo(App) 89 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/compressionService.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { uuid, randomFloat, random } from './helpers' 3 | 4 | export const compressionService = (ctx, e) => (cb, onReceive) => { 5 | let workers = {} 6 | 7 | onReceive(evt => { 8 | switch (evt.type) { 9 | case 'pauseSong': 10 | case 'cancelSong': 11 | const id = evt.data 12 | clearTimeout(workers[id]) 13 | workers[id] = null 14 | console.log('取消壓縮:', evt, workers, workers) 15 | break 16 | 17 | case 'resumeSong': 18 | case 'compressSong': 19 | console.log('請求啟動:', evt, workers) 20 | 21 | const { data } = evt 22 | 23 | // resume 就是將上次 progress 值還原即可 24 | let progress = evt.type === 'compressSong' ? 0 : data.progress 25 | 26 | const run = () => { 27 | progress = Math.min(100, progress + random(5, 15)) 28 | 29 | console.log(`[${data.id}] ${progress} >> intervalId:`, workers) 30 | 31 | if (progress < 100) { 32 | cb({ 33 | type: 'workerProgress', 34 | job: { id: data.id, progress }, 35 | }) 36 | 37 | // 排下次進度 38 | workers[data.id] = setTimeout(run, random(200, 400)) 39 | } else { 40 | cb({ 41 | type: 'workerDone', 42 | job: { id: data.id, progress }, 43 | }) 44 | } 45 | } 46 | 47 | run() 48 | break 49 | 50 | default: 51 | console.log('沒人接的 method call=', evt.type) 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/fsm.test.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coodoo/xstate-examples/5dcd1c810fa0d0a10594e3bef80dc5019cbfd873/_old/fsm-service-2-submachine/src/fsm.test.js -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/helpers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { sprintf } from 'sprintf-js' 4 | 5 | // +TBD: 將來改用 uuid 6 | export const uuid = () => { 7 | return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => 8 | (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) 9 | ) 10 | } 11 | 12 | export const noop = () => {} 13 | 14 | // dump state tree in string format 15 | export const dump = (item, depth=100) => { 16 | const MAX_DEPTH = 100 17 | depth = depth || 0 18 | let isString = typeof item === 'string' 19 | let isDeep = depth > MAX_DEPTH 20 | 21 | if (isString || isDeep) { 22 | console.log(item) 23 | return 24 | } 25 | 26 | for (var key in item) { 27 | console.group(key) 28 | dump(item[key], depth + 1) 29 | console.groupEnd() 30 | } 31 | } 32 | 33 | export const current = state => state.toStrings().pop() 34 | 35 | export const timer = time => { 36 | return new Promise((resolve, reject) => { 37 | setTimeout(resolve, time) 38 | }) 39 | } 40 | 41 | export const randomFloat = (min=0, max=999) => { 42 | return Math.random() * (max - min) + min; 43 | } 44 | 45 | export const random = (min=0, max=999) => { 46 | min = Math.ceil(min); 47 | max = Math.floor(max); 48 | return Math.floor(Math.random() * (max - min + 1)) + min; 49 | } 50 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import App from './app' 5 | 6 | const rootElement = document.getElementById('root') 7 | ReactDOM.render(, rootElement) 8 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/machine.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { Machine, send } from 'xstate' 3 | import { updater, assign } from './xstateImmer' 4 | import { Enum } from 'enumify' 5 | 6 | export class Types extends Enum {} 7 | Types.initEnum(['itemSelect']) 8 | 9 | export default Machine( 10 | { 11 | id: 'Compressor', 12 | type: 'parallel', 13 | context: { 14 | songs: [], 15 | }, 16 | states: { 17 | main: { 18 | initial: 'main', 19 | states: { 20 | main: {}, 21 | running: {}, 22 | completed: {}, 23 | }, 24 | on: { 25 | // 26 | addFile: { 27 | target: '', 28 | actions: [ 29 | assign((ctx, e) => { 30 | ctx.songs.push(e.data) 31 | return ctx 32 | }), 33 | ], 34 | }, 35 | 36 | update: { 37 | actions: assign((ctx, e) => { 38 | ctx.songs = ctx.songs.map(it => { 39 | if (it.id === e.data.id) it.progress = e.data.progress 40 | return it 41 | }) 42 | return ctx 43 | }), 44 | }, 45 | 46 | cancel: { 47 | actions: assign((ctx, e) => { 48 | ctx.songs = ctx.songs.filter(it => it.id !== e.data.id) 49 | // console.log( '\n\n刪完:', ctx.songs ) 50 | return ctx 51 | }), 52 | }, 53 | }, 54 | }, 55 | global: { 56 | initial: 'modal', 57 | states: { 58 | modal: {}, 59 | }, 60 | }, 61 | }, 62 | }, 63 | 64 | { 65 | updater, 66 | }, 67 | ) 68 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/row.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React, { useEffect, useState, useRef, useContext, memo } from 'react' 3 | import { useMachine } from '@xstate/react' 4 | import WorkerMachine from './workerMachine' 5 | 6 | const Row = props => { 7 | 8 | const { item, onUpdate, onCancel } = props 9 | 10 | const [state, send, service] = useMachine( 11 | 12 | // notice how react functions are hooked into fsm as an action 13 | WorkerMachine.withConfig( 14 | { 15 | actions: { 16 | notifyProgress: (ctx, e) => { 17 | onUpdate(ctx) 18 | }, 19 | notifyCancel: (ctx, e) => { 20 | onCancel(ctx) 21 | }, 22 | }, 23 | }, 24 | item, 25 | ), 26 | ) 27 | 28 | // console.log('\n[Child State] = ', state.value, '\n[context] = ', state.context) 29 | 30 | const rowDone = state.context.progress === 100 31 | const jobStyle = { 32 | backgroundColor: rowDone ? 'green' : 'yellow', 33 | color: rowDone ? 'white' : 'black', 34 | } 35 | 36 | const handleCancel = id => { 37 | send({ 38 | type: 'cancelFile', 39 | id, 40 | }) 41 | } 42 | 43 | const handlePause = id => { 44 | send({ 45 | type: 'pauseFile', 46 | id, 47 | }) 48 | } 49 | const handleResume = (id, progress) => { 50 | send({ 51 | type: 'resumeFile', 52 | id, 53 | progress, 54 | }) 55 | } 56 | 57 | useEffect(() => { 58 | send({ 59 | type: 'addFile', 60 | data: item, 61 | }) 62 | }, [item]) 63 | 64 | // 65 | return ( 66 |
67 | {item.id + ':' + state.context.progress} 68 | 69 | 70 | 71 |
72 | ) 73 | } 74 | 75 | Row.whyDidYouRender = true 76 | 77 | export default memo(Row) 78 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | div { 5 | box-sizing: border-box; 6 | } 7 | *, 8 | *:before, 9 | *:after { 10 | box-sizing: inherit; 11 | } 12 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/ui.test.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coodoo/xstate-examples/5dcd1c810fa0d0a10594e3bef80dc5019cbfd873/_old/fsm-service-2-submachine/src/ui.test.js -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/useMachine.js: -------------------------------------------------------------------------------- 1 | import { useState, useMemo, useEffect } from "react"; 2 | import { interpret } from "xstate"; 3 | 4 | export function useMachine(machine, options = {}) { 5 | 6 | const [current, setCurrent] = useState(machine.initialState); 7 | 8 | const service = useMemo( 9 | () => 10 | 11 | // 啟動 fsm 的必要過程 12 | interpret(machine, { execute: false }) 13 | // 這支主要目地是為了打印當前狀態,順便操作 internal state 指令 setCurrent 14 | .onTransition(state => { 15 | options.log && console.log("CONTEXT:", state.context); 16 | setCurrent(state); 17 | }) 18 | .onEvent(e => options.log && console.log("EVENT:", e)), 19 | [] 20 | ); 21 | 22 | useEffect( 23 | () => { 24 | service.execute(current); 25 | }, 26 | [current] 27 | ); 28 | 29 | useEffect(() => { 30 | service.start(); 31 | 32 | return () => service.stop(); 33 | }, []); 34 | 35 | return [current, service.send]; 36 | } 37 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/workerMachine.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { Machine, send, sendParent } from 'xstate' 3 | import { updater, assign } from './xstateImmer' 4 | import { compressionService } from './compressionService' 5 | 6 | export default Machine( 7 | { 8 | id: 'WorkerMachine', 9 | initial: 'idle', 10 | context: { 11 | dumb: 'foo', 12 | }, 13 | 14 | invoke: { 15 | id: 'CompressionService', 16 | src: compressionService, 17 | }, 18 | 19 | states: { 20 | idle: { 21 | on: { 22 | addFile: { 23 | target: 'progress', 24 | actions: [ 25 | assign((ctx, e) => { 26 | // console.log('單支 machine > addFile: ', e, ctx) 27 | }), 28 | send( 29 | (ctx, e) => ({ 30 | type: 'compressSong', 31 | data: e.data, 32 | }), 33 | { to: 'CompressionService' }, 34 | ), 35 | ], 36 | }, 37 | }, 38 | }, 39 | 40 | progress: { 41 | on: { 42 | // 進度更新 43 | workerProgress: { 44 | target: '', 45 | actions: [ 46 | assign((ctx, e) => { 47 | const { id, progress } = e.job 48 | // console.log( `收到 ${id}: ${progress}` ) 49 | ctx.progress = progress 50 | return ctx 51 | }), 52 | 'notifyProgress', 53 | ], 54 | }, 55 | 56 | // 進度完成 57 | workerDone: { 58 | target: 'completed', 59 | actions: [ 60 | assign((ctx, e) => { 61 | // console.log( '\t完工:', ctx, e ) 62 | const { id, progress } = e.job 63 | ctx.progress = progress 64 | return ctx 65 | }), 66 | 'notifyProgress', 67 | ], 68 | }, 69 | 70 | // 工作暫停 71 | pauseFile: { 72 | target: 'paused', 73 | actions: [ 74 | assign((ctx, e) => { 75 | console.log('\t暫停:', e.id) 76 | }), 77 | send( 78 | (ctx, e) => ({ 79 | type: 'pauseSong', 80 | data: e.id, 81 | }), 82 | { to: 'CompressionService' }, 83 | ), 84 | ], 85 | }, 86 | }, 87 | }, 88 | 89 | // 90 | paused: { 91 | on: { 92 | resumeFile: { 93 | target: 'progress', 94 | actions: [ 95 | assign((ctx, e) => { 96 | console.log('\t繼續:', e.id, ctx.progress) 97 | }), 98 | send( 99 | (ctx, e) => ({ 100 | type: 'resumeSong', 101 | data: { id: e.id, progress: ctx.progress }, 102 | }), 103 | { to: 'CompressionService' }, 104 | ), 105 | ], 106 | }, 107 | }, 108 | }, 109 | 110 | completed: { 111 | type: 'final', 112 | }, 113 | 114 | cancelled: { 115 | type: 'final', 116 | }, 117 | }, 118 | 119 | // onEntry: assign((ctx, e) => console.log( '\n\n啟動時 ctx: ', ctx )), 120 | 121 | on: { 122 | // 取消工作 123 | cancelFile: { 124 | target: 'cancelled', 125 | actions: [ 126 | assign((ctx, e) => { 127 | console.log('\t取消:', e.id) 128 | }), 129 | send( 130 | (ctx, e) => ({ 131 | type: 'cancelSong', 132 | data: e.id, 133 | }), 134 | { to: 'CompressionService' }, 135 | ), 136 | 'notifyCancel', 137 | ], 138 | }, 139 | }, 140 | }, 141 | 142 | { 143 | // actions, 144 | // services, 145 | // guards, 146 | updater, 147 | }, 148 | ) 149 | -------------------------------------------------------------------------------- /_old/fsm-service-2-submachine/src/xstateImmer.js: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { actionTypes } from 'xstate/lib/actions'; 3 | 4 | export function assign( assignment ) { 5 | return { 6 | type: actionTypes.assign, 7 | assignment 8 | } 9 | } 10 | 11 | export function updater( context, event, assignActions ) { 12 | const updatedContext = context 13 | 14 | ? assignActions.reduce((acc, assignAction) => { 15 | const { assignment } = assignAction 16 | const update = produce(acc, interim => assignment(interim, event)) 17 | return update 18 | }, context) 19 | 20 | : context 21 | 22 | return updatedContext 23 | } 24 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/README.md: -------------------------------------------------------------------------------- 1 | This is a simple song conversion app which allows user to add multiple files and convert them from one format to antoher, concurrently. 2 | 3 | ## Goal 4 | 5 | Demonstrating how to model multi-thread application with `xstate` which requires frequent parent <-> child communcation, and how to separate concerns between parent and child machines in an `actor` manner. 6 | 7 | ## Key Features 8 | 9 | - Modeling multi-thread application with `xstate` 10 | 11 | - Create one thread for each song for the compression job, modeled as an `actor`(`workMachine`) 12 | 13 | - Utilizing `spawn(actor)` command to create multiple threads 14 | 15 | - Each actor creates it's own `service` as a side effect to process file conversion (say invoking `ffmpeg` for example) 16 | 17 | - Communcation between `mainMachine` and `workMachine` using `sned` and `sendParent` 18 | 19 | - See how child component hooks up with `actor`(`workMachine`) by passing in the instance via `props` 20 | 21 | ## Sub Features 22 | 23 | - See how `fsm.test.js` made sure all key `fsm` scenarios are tested 24 | 25 | - See how `ui.test.js` uses `react-testing-library` to test the ui parts as a black box and covered all key use cases 26 | 27 | - Also note tests and implementations were done side by side, not before nor after, which means when you are implementing the feature, you are also writing the test on the side (to verify the feature you just implemented actually works), gone are the day of manually testing during development, then add unit tests right before openning the PR just for the sake of it. 28 | 29 | As a side note, this is not `TDD` either, for that it requires one to write test up-front, instead, I'm proposing to write test along with the implementation, hence making writing tests part of the implementation, and vice versa. 30 | 31 | ## Statechart 32 | 33 | ![service-3](https://user-images.githubusercontent.com/325936/57836013-6746e080-77f2-11e9-9cf6-39b6f380595f.png) 34 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "a1", 3 | "license": "Apache-2.0", 4 | "version": "1.0.0", 5 | "description": "", 6 | "keywords": [], 7 | "main": "src/index.js", 8 | "dependencies": { 9 | "@welldone-software/why-did-you-render": "^3.0.7", 10 | "@xstate/react": "^0.2.0", 11 | "enumify": "^1.0.4", 12 | "jest-dom": "^3.1.3", 13 | "react": "16.8.3", 14 | "react-dom": "16.8.3", 15 | "react-scripts": "2.1.8", 16 | "react-testing-library": "^7.0.0", 17 | "toasted-notes": "^2.1.5", 18 | "xstate": "https://github.com/davidkpiano/xstate" 19 | }, 20 | "devDependencies": { 21 | "typescript": "3.3.3" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test --env=jsdom", 27 | "eject": "react-scripts eject" 28 | }, 29 | "browserslist": [ 30 | ">0.2%", 31 | "not dead", 32 | "not ie <= 11", 33 | "not op_mini all" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/__tests__/ui.test.js: -------------------------------------------------------------------------------- 1 | import 'jest-dom/extend-expect' 2 | import 'react-testing-library/cleanup-after-each' 3 | import React from 'react' 4 | import { render, cleanup, act, fireEvent, prettyDOM } from 'react-testing-library' 5 | import { findByText } from 'dom-testing-library' 6 | import App from '../components/App' 7 | import { timer } from '../utils/helpers' 8 | 9 | let dom, container 10 | 11 | const $ = sel => document.querySelector.call(document, sel) 12 | const $$ = sel => document.querySelectorAll.call(document, sel) 13 | 14 | // query non-existent str to dump the DOM, for debugging 15 | const dump = () => console.log( prettyDOM(container) ) 16 | 17 | beforeEach(() => { 18 | dom = render() 19 | container = dom.container 20 | }) 21 | 22 | afterEach(cleanup) 23 | 24 | describe('add songs', () => { 25 | 26 | it('add one song', () => { 27 | 28 | fireEvent.click( dom.getByTestId('btnAdd') ) 29 | 30 | const rows = dom.getByTestId('rows') 31 | expect(rows.children.length).toEqual(1) 32 | 33 | const item = $('#song_1') 34 | expect(item.querySelector('#songId').textContent).toEqual('1') 35 | expect(item.querySelector('#songProgress').textContent).not.toEqual(0) 36 | 37 | }) 38 | 39 | it('add many song', () => { 40 | 41 | const btn = dom.getByTestId('btnAdd') 42 | fireEvent.click( btn ) 43 | fireEvent.click( btn ) 44 | fireEvent.click( btn ) 45 | 46 | // dump() 47 | const rows = dom.getByTestId('rows') 48 | expect(rows.children.length).toEqual(3) 49 | 50 | const item1 = rows.firstChild 51 | expect(item1.querySelector('#songId').textContent).toEqual('2') 52 | expect(item1.querySelector('#songProgress').textContent).not.toEqual(0) 53 | 54 | const item2 = rows.children[1] 55 | expect(item2.querySelector('#songId').textContent).toEqual('3') 56 | expect(item2.querySelector('#songProgress').textContent).not.toEqual(0) 57 | 58 | const item3 = rows.children[2] 59 | expect(item3.querySelector('#songId').textContent).toEqual('4') 60 | expect(item3.querySelector('#songProgress').textContent).not.toEqual(0) 61 | 62 | }) 63 | }) 64 | 65 | describe('delete songs', () => { 66 | 67 | it('delete one song', () => { 68 | 69 | fireEvent.click( dom.getByTestId('btnAdd') ) 70 | 71 | const rows = dom.getByTestId('rows') 72 | expect(rows.children.length).toEqual(1) 73 | 74 | fireEvent.click( dom.getByTestId('btnCancel') ) 75 | expect(rows.children.length).toEqual(0) 76 | }) 77 | 78 | it('delete many song', () => { 79 | 80 | // add 3 songs 81 | const add = dom.getByTestId('btnAdd') 82 | fireEvent.click( add ) 83 | fireEvent.click( add ) 84 | fireEvent.click( add ) 85 | 86 | const rows = dom.getByTestId('rows') 87 | expect(rows.children.length).toEqual(3) 88 | 89 | const btns = $$('[data-testid="btnCancel"]') 90 | expect(btns.length).toEqual(3) 91 | 92 | // click remove btn 3 times 93 | btns.forEach( b => fireEvent.click(b) ) 94 | expect(dom.getByTestId('rows').children.length).toEqual(0) 95 | }) 96 | }) 97 | 98 | describe('pause songs', () => { 99 | it('pause one song', () => { 100 | 101 | fireEvent.click( dom.getByTestId('btnAdd') ) 102 | const rows = dom.getByTestId('rows') 103 | expect(rows.children.length).toEqual(1) 104 | 105 | const btn = dom.getByTestId('btnPause') 106 | fireEvent.click( btn ) 107 | expect(btn).toHaveAttribute('style', 'background-color: black;') 108 | }) 109 | }) 110 | 111 | describe('resume songs', () => { 112 | it('resume one song', () => { 113 | 114 | fireEvent.click( dom.getByTestId('btnAdd') ) 115 | const rows = dom.getByTestId('rows') 116 | expect(rows.children.length).toEqual(1) 117 | 118 | const pause = dom.getByTestId('btnPause') 119 | fireEvent.click( pause ) 120 | 121 | const resume = dom.getByTestId('btnResume') 122 | fireEvent.click(resume) 123 | expect(pause).not.toHaveAttribute('style', 'background-color: black;') 124 | expect(pause).toHaveAttribute('style', 'background-color: white;') 125 | }) 126 | }) 127 | 128 | describe('async job', () => { 129 | 130 | it.only('one done', async () => { 131 | 132 | cleanup() 133 | 134 | act(() => { 135 | dom = render() 136 | const add = dom.getByTestId('btnAdd') 137 | fireEvent.click( add ) 138 | }) 139 | 140 | await dom.findByText(/100/i) 141 | 142 | const song = dom.getByTestId('rows').firstChild 143 | 144 | // show green backgorund when item completed 145 | expect( song ).toHaveAttribute('style', 'background-color: green; color: white;') 146 | 147 | // show red border when all completed 148 | expect(dom.getByTestId('rows')).toHaveAttribute('style', 'width: 150px; border: 10px solid red;') 149 | 150 | // dump() 151 | }) 152 | 153 | it.only('all done', async () => { 154 | 155 | cleanup() 156 | 157 | act(() => { 158 | dom = render() 159 | const add = dom.getByTestId('btnAdd') 160 | fireEvent.click( add ) 161 | fireEvent.click( add ) 162 | fireEvent.click( add ) 163 | }) 164 | 165 | const rows = dom.getByTestId('rows') 166 | 167 | const s1 = rows.children[0] 168 | const s2 = rows.children[1] 169 | const s3 = rows.children[2] 170 | 171 | // wait till 3 rows were completed 172 | // notice it's not using `dom.findByText` from `RTL` 173 | await findByText( s1, /100/i) 174 | await findByText( s2, /100/i) 175 | await findByText( s3, /100/i) 176 | 177 | // show green backgorund when each item was completed 178 | expect( s1 ).toHaveAttribute('style', 'background-color: green; color: white;') 179 | expect( s2 ).toHaveAttribute('style', 'background-color: green; color: white;') 180 | expect( s3 ).toHaveAttribute('style', 'background-color: green; color: white;') 181 | 182 | // show red border when all items was completed 183 | expect(dom.getByTestId('rows')).toHaveAttribute('style', 'width: 150px; border: 10px solid red;') 184 | 185 | // dump() 186 | }) 187 | 188 | }) 189 | 190 | describe('xxx', () => { 191 | 192 | }) 193 | 194 | describe('xxx', () => { 195 | 196 | }) 197 | 198 | describe('xxx', () => { 199 | 200 | }) 201 | 202 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/components/app.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, memo } from 'react' 2 | import { useMachine } from '../xstate-custom/useMyMachine' 3 | import { MainMachine, MainTypes } from '../fsm/mainMachine' 4 | import Row from './row' 5 | 6 | import './styles.css' 7 | 8 | import { StateChart } from '@statecharts/xstate-viz' 9 | 10 | import whyDidYouRender from '@welldone-software/why-did-you-render' 11 | whyDidYouRender(React) 12 | 13 | let uid = 0 14 | 15 | const App = () => { 16 | const [state, send] = useMachine(MainMachine, { debug: false }) 17 | 18 | const { songs, completed } = state.context 19 | 20 | // console.log( 21 | // '\n\n[Parent State] = ', 22 | // state.value, 23 | // '\n[context] = ', 24 | // state.context, 25 | // ) 26 | 27 | const rows = songs.map((it, idx) => { 28 | return 29 | }) 30 | 31 | const allDone = songs.length === completed.length 32 | 33 | const handleAddFile = () => { 34 | send({ 35 | type: MainTypes.addFile, 36 | song: { 37 | id: ++uid, 38 | name: `Song_${uid}.flac`, 39 | progress: 0, 40 | }, 41 | }) 42 | } 43 | 44 | // add some jobs to run 45 | useEffect(() => { 46 | // handleAddFile() 47 | // handleAddFile() 48 | // handleAddFile() 49 | // handleAddFile() 50 | // handleAddFile() 51 | }, []) 52 | 53 | const jobStyle = { 54 | border: allDone ? '10px solid red' : null, 55 | width: 150, 56 | } 57 | 58 | // return 59 | 60 | return ( 61 |
62 | 65 |
68 | {rows} 69 |
70 |
71 | ) 72 | } 73 | 74 | App.whyDidYouRender = true 75 | export default memo(App) 76 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/components/row.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/accessible-emoji */ 2 | import React, { memo } from 'react' 3 | import { WorkerTypes } from '../fsm/workerMachine' 4 | import { useService } from '../xstate-custom/useMyMachine' 5 | 6 | 7 | const Row = props => { 8 | 9 | const { item } = props 10 | 11 | // console.log('\n[child item]', item) 12 | 13 | // hook up ui with actor using `useService` 14 | const [ state, send, service ] = useService( item.actor ) 15 | 16 | // console.log('\n[Child State] = ', state.value, '\n[context] = ', state.context) 17 | 18 | const { id, name, progress } = state.context 19 | 20 | const rowDone = progress === 100 21 | const jobStyle = { 22 | backgroundColor: rowDone ? 'green' : 'yellow', 23 | color: rowDone ? 'white' : 'black', 24 | } 25 | 26 | const pauseStyle = { 27 | backgroundColor: state.value === 'paused' ? 'black' : 'white', 28 | } 29 | 30 | const handleCancel = () => { 31 | send({ 32 | type: WorkerTypes.cancelFile, 33 | }) 34 | } 35 | 36 | const handlePause = () => { 37 | send({ 38 | type: WorkerTypes.pauseFile, 39 | }) 40 | } 41 | const handleResume = () => { 42 | send({ 43 | type: WorkerTypes.resumeFile, 44 | }) 45 | } 46 | 47 | // 48 | return ( 49 |
50 | {id} 51 | : 52 | {progress} 53 | 58 | 59 | 65 | 66 | 71 |
72 | ) 73 | } 74 | 75 | Row.whyDidYouRender = true 76 | 77 | export default memo(Row) 78 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/components/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | div { 5 | box-sizing: border-box; 6 | } 7 | *, 8 | *:before, 9 | *:after { 10 | box-sizing: inherit; 11 | } 12 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/fsm/compressionService.js: -------------------------------------------------------------------------------- 1 | import { random } from '../utils/helpers' 2 | import { WorkerTypes } from './workerMachine' 3 | import { Enum } from 'enumify' 4 | 5 | export class CompServiceTypes extends Enum {} 6 | CompServiceTypes.initEnum([ 7 | 'pauseSong', 8 | 'cancelSong', 9 | 'resumeSong', 10 | 'compressSong', 11 | ]) 12 | 13 | 14 | export const compressionService = (ctx, e) => (cb, onReceive) => { 15 | let workers = {} 16 | 17 | onReceive(evt => { 18 | switch (evt.type) { 19 | case CompServiceTypes.pauseSong: 20 | case CompServiceTypes.cancelSong: 21 | const id = evt.data 22 | clearTimeout(workers[id]) 23 | workers[id] = null 24 | // console.log( '[CompService pause|cancel song]', evt, workers ) 25 | break 26 | 27 | case CompServiceTypes.resumeSong: 28 | case CompServiceTypes.compressSong: 29 | // console.log('[CompService initJob]', evt, workers) 30 | 31 | const { data } = evt 32 | 33 | // resume job by restoring last progress 34 | let progress = evt.type === 'compressSong' ? 0 : data.progress 35 | 36 | const run = () => { 37 | progress = Math.min(100, progress + random(5, 15)) 38 | 39 | // console.log(`\n[Service] ${data.id}:${progress}`) 40 | 41 | if (progress < 100) { 42 | cb({ 43 | type: WorkerTypes.workerProgress, 44 | job: { id: data.id, progress }, 45 | }) 46 | 47 | // random update time 48 | const time = 60 49 | workers[data.id] = setTimeout(run, random(time, time)) 50 | } else { 51 | cb({ 52 | type: WorkerTypes.workerDone, 53 | job: { id: data.id, progress }, 54 | }) 55 | } 56 | } 57 | 58 | run() 59 | break 60 | 61 | default: 62 | console.error('[CompService unhandled event]', evt.type) 63 | } 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/fsm/guards.js: -------------------------------------------------------------------------------- 1 | export const unknownMaster = ctx => ctx.exitNewItemTo === 'master' 2 | export const unknownDetails = ctx => ctx.exitNewItemTo === 'details' 3 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/fsm/mainActions.js: -------------------------------------------------------------------------------- 1 | import { assign } from '../xstate-custom/xstateImmer' 2 | import { send, spawn } from 'xstate' 3 | import { WorkerMachine, WorkerTypes } from './workerMachine' 4 | 5 | export const addJobAssign = assign((ctx, e) => { 6 | const { song } = e 7 | // feeding in inital context for the child actor 8 | // notice it's not `machine.withConfig()` 9 | song.actor = spawn( WorkerMachine.withContext(song) ) 10 | ctx.songs.push(song) 11 | return ctx 12 | }) 13 | 14 | export const addFileSend = send( 15 | { type: WorkerTypes.startJob }, 16 | { to: (ctx, e) => ctx.songs.find(it => it.id === e.song.id).actor }, 17 | ) 18 | 19 | export const updateJob = assign((ctx, e) => { 20 | ctx.completed.push(e.song.id) 21 | return ctx 22 | }) 23 | 24 | // fix: when songs were completed and deleted, they need to be removed from `completed[]` too 25 | export const cancelJob = assign((ctx, e) => { 26 | ctx.songs = ctx.songs.filter(it => it.id !== e.id) 27 | ctx.completed = ctx.completed.filter(it => it !== e.id) 28 | return ctx 29 | }) 30 | 31 | export const onUpdate = assign((ctx, e) => { 32 | // console.log( '[onUpdate]', e) 33 | }) 34 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/fsm/mainMachine.js: -------------------------------------------------------------------------------- 1 | import { Machine } from 'xstate' 2 | import { updater } from '../xstate-custom/xstateImmer' 3 | import * as actions from './mainActions' 4 | // import * as services from './services' 5 | // import * as guards from './guards' 6 | import { Enum } from 'enumify' 7 | 8 | export class MainTypes extends Enum {} 9 | MainTypes.initEnum([ 10 | 'addFile', 11 | 'updateJob', 12 | 'cancelJob', 13 | ]) 14 | 15 | export const MainMachine = Machine( 16 | { 17 | id: 'Compressor', 18 | type: 'parallel', 19 | context: { 20 | songs: [], 21 | completed: [], 22 | }, 23 | states: { 24 | main: { 25 | initial: 'idle', 26 | states: { 27 | idle: {}, 28 | progress: {}, 29 | completed: {}, 30 | unknown: { 31 | on: { 32 | '': [ 33 | { 34 | target: '#Compressor.main.progress', 35 | cond: ctx => ctx.completed.length !== ctx.songs.length 36 | }, 37 | { 38 | target: '#Compressor.main.completed', 39 | cond: ctx => ctx.completed.length === ctx.songs.length 40 | } 41 | ] 42 | } 43 | } 44 | }, 45 | 46 | // global event 47 | on: { 48 | // 49 | [MainTypes.addFile]: { 50 | target: '.progress', 51 | actions: [ 52 | 'addJobAssign', // fire up a child actor 53 | 'addFileSend', // then kick start the actor with an init event 54 | ], 55 | }, 56 | 57 | [MainTypes.updateJob]: { 58 | actions: 'updateJob', 59 | target: '.unknown', 60 | }, 61 | 62 | [MainTypes.cancelJob]: { 63 | actions: 'cancelJob', 64 | }, 65 | }, 66 | 67 | // onUpdate: '', 68 | }, 69 | global: { 70 | initial: 'modal', 71 | states: { 72 | modal: {}, 73 | }, 74 | }, 75 | }, 76 | }, 77 | 78 | { 79 | actions, 80 | // services, 81 | // guards, 82 | 83 | // immer version of updater 84 | updater, 85 | }, 86 | ) 87 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/fsm/services.js: -------------------------------------------------------------------------------- 1 | import { uuid } from '../utils/helpers' 2 | import { Enum } from 'enumify' 3 | import { Types } from '../fsm/machine' 4 | 5 | export class ServiceTypes extends Enum {} 6 | ServiceTypes.initEnum([ 7 | 'loadItems', 8 | 'itemDeleteConfirm', 9 | 'createItems', 10 | ]) 11 | 12 | // Not used, for demo purpose only 13 | export const itemService = (ctx, e) => (cb, onReceive) => { 14 | 15 | onReceive(evt => { 16 | 17 | switch( evt.type ){ 18 | 19 | case ServiceTypes.loadItems: 20 | 21 | const newItem = () => { 22 | const id = uuid() 23 | const d = { 24 | id, 25 | label: `Label_${id}`, 26 | } 27 | return d 28 | } 29 | const arr = [newItem(), newItem(), newItem()] 30 | 31 | cb({ 32 | type: Types.itemLoadSuccess, 33 | data: arr, 34 | }) 35 | 36 | // cb({ 37 | // type: Types.itemLoadFail, 38 | // data: 'network error', 39 | // }) 40 | 41 | break 42 | 43 | case ServiceTypes.itemDeleteConfirm: 44 | 45 | const item = evt.data 46 | 47 | new Promise((resolve, reject) => { 48 | setTimeout(() => { 49 | 50 | resolve({ 51 | info: `item: ${item.id} deleted successfully`, 52 | }) 53 | 54 | // reject({ 55 | // info: `Delete item: ${item.id} failed`, 56 | // payload: item, 57 | // }) 58 | }, 1200) 59 | }) 60 | 61 | .then( result => { 62 | // console.log( '\tconfirmHandler 跑完了,返還: ', result ) 63 | cb({ 64 | type: Types.modalDeleteItemSuccess, 65 | result 66 | }) 67 | }) 68 | 69 | .catch( error => { 70 | cb({ 71 | type: Types.modalDeleteItemFail, 72 | error 73 | }) 74 | }) 75 | 76 | break 77 | 78 | case ServiceTypes.createItems: 79 | 80 | const localItem = evt.payload 81 | 82 | return new Promise((resolve, reject) => { 83 | 84 | const serverId = 'server_' + localItem.id.split('tmp_')[1] 85 | 86 | setTimeout(() => { 87 | resolve({ 88 | info: `item: ${localItem.id} - ${localItem.label} created successfully`, 89 | serverItem: { ...localItem, id: serverId }, 90 | localItem, 91 | }) 92 | // reject({ 93 | // info: `Create item: ${localItem.id} failed`, 94 | // localItem, 95 | // }) 96 | }, 1000) 97 | }) 98 | .then( result => { 99 | console.log( '\tnewItem done: ', result ) 100 | cb({ 101 | type: Types.newItemSuccess, 102 | result, 103 | }) 104 | }) 105 | .catch( error => { 106 | console.log( 'newItem failed', ) 107 | cb({ 108 | type: Types.newItemFail, 109 | error, 110 | }) 111 | }) 112 | 113 | default: 114 | console.log( 'unhandled method call=', evt.type ) 115 | } 116 | }) 117 | 118 | } 119 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/fsm/workerActions.js: -------------------------------------------------------------------------------- 1 | import { assign } from '../xstate-custom/xstateImmer' 2 | import { send, sendParent } from 'xstate' 3 | import { CompServiceTypes } from './compressionService' 4 | import { MainTypes } from './mainMachine' 5 | 6 | export const onEntry = assign((ctx, e) => { 7 | // console.log('[WorkerMachine ctx]', ctx.name) 8 | }) 9 | 10 | export const startJobAssign = assign((ctx, e) => { 11 | // console.log('[SubMachine startJob]', e) 12 | }) 13 | 14 | export const startJobSend = send( 15 | (ctx, e) => ({ 16 | type: CompServiceTypes.compressSong, 17 | data: ctx, 18 | }), 19 | { to: 'CompressionService' }, 20 | ) 21 | 22 | 23 | export const workerProgressAssign = assign((ctx, e) => { 24 | const { progress } = e.job 25 | // console.log( `workerMachine ${id}: ${progress}` ) 26 | ctx.progress = progress 27 | return ctx 28 | }) 29 | 30 | export const workerDoneAssign = assign((ctx, e) => { 31 | // console.log( '\t[workerDone]', ctx, e ) 32 | const { progress } = e.job 33 | ctx.progress = progress 34 | return ctx 35 | }) 36 | 37 | export const workerDoneSend = sendParent((ctx, e) => ({ 38 | type: MainTypes.updateJob, 39 | song: ctx, 40 | })) 41 | 42 | export const pauseFileAssign = assign((ctx, e) => { 43 | // console.log('\t[pauseFile]', e.id) 44 | }) 45 | 46 | export const pauseFileSend = send( 47 | (ctx, e) => ({ 48 | type: CompServiceTypes.pauseSong, 49 | data: ctx.id, 50 | }), 51 | { to: 'CompressionService' }, 52 | ) 53 | 54 | export const resumeFileSend = send( 55 | (ctx, e) => ({ 56 | type: CompServiceTypes.resumeSong, 57 | data: { id: ctx.id, progress: ctx.progress }, 58 | }), 59 | { to: 'CompressionService' }, 60 | ) 61 | 62 | export const cancelFileSend = send( 63 | (ctx, e) => ({ 64 | type: CompServiceTypes.cancelSong, 65 | data: ctx.id, 66 | }), 67 | { to: 'CompressionService' }, 68 | ) 69 | 70 | export const cancelFileSendParent = sendParent((ctx, e) => ({ 71 | type: MainTypes.cancelJob, 72 | id: ctx.id, 73 | })) 74 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/fsm/workerMachine.js: -------------------------------------------------------------------------------- 1 | import { Machine } from 'xstate' 2 | import { updater } from '../xstate-custom/xstateImmer' 3 | import * as actions from '../fsm/workerActions' 4 | import { compressionService } from '../fsm/compressionService' 5 | import { Enum } from 'enumify' 6 | 7 | export class WorkerTypes extends Enum {} 8 | WorkerTypes.initEnum([ 9 | 'startJob', 10 | 'workerProgress', 11 | 'workerDone', 12 | 'pauseFile', 13 | 'resumeFile', 14 | 'cancelFile', 15 | ]) 16 | 17 | /* 18 | Create a standalone actor machine for each song, 19 | each actor starts an internal service as a side effect to do the compression job 20 | */ 21 | export const WorkerMachine = Machine( 22 | { 23 | id: 'WorkerMachine', 24 | initial: 'idle', 25 | 26 | // 27 | context: { 28 | id: null, 29 | name: null, 30 | progress: null, 31 | }, 32 | 33 | invoke: { 34 | id: 'CompressionService', 35 | src: compressionService, 36 | }, 37 | 38 | states: { 39 | 40 | idle: { 41 | onEntry: 'onEntry', 42 | on: { 43 | [WorkerTypes.startJob]: { 44 | target: 'progress', 45 | actions: [ 46 | 'startJobAssign', 47 | 'startJobSend', 48 | ], 49 | }, 50 | }, 51 | }, 52 | 53 | progress: { 54 | on: { 55 | // 56 | [WorkerTypes.workerProgress]: { 57 | actions: [ 58 | 'workerProgressAssign', 59 | // 'workerProgressSend', 60 | ], 61 | }, 62 | 63 | // 64 | [WorkerTypes.workerDone]: { 65 | target: 'completed', 66 | actions: [ 67 | 'workerDoneAssign', 68 | 'workerDoneSend', 69 | ], 70 | }, 71 | 72 | // 73 | [WorkerTypes.pauseFile]: { 74 | target: 'paused', 75 | actions: [ 76 | 'pauseFileAssign', 77 | 'pauseFileSend' 78 | ], 79 | }, 80 | }, 81 | }, 82 | 83 | // 84 | paused: { 85 | on: { 86 | [WorkerTypes.resumeFile]: { 87 | target: 'progress', 88 | actions: [ 89 | 'resumeFileSend', 90 | ], 91 | }, 92 | }, 93 | }, 94 | 95 | completed: { 96 | type: 'final', 97 | }, 98 | 99 | cancelled: { 100 | type: 'final', 101 | }, 102 | }, 103 | 104 | // onEntry: 'onEntry', 105 | 106 | on: { 107 | 108 | // cancel job 109 | [WorkerTypes.cancelFile]: { 110 | target: 'cancelled', 111 | actions: [ 112 | 'cancelFileSend', 113 | 'cancelFileSendParent', 114 | ], 115 | }, 116 | }, 117 | }, 118 | 119 | { 120 | actions, 121 | // services, 122 | // guards, 123 | updater, 124 | }, 125 | ) 126 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './components/app' 4 | 5 | const rootElement = document.getElementById('root') 6 | ReactDOM.render(, rootElement) 7 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/utils/helpers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { sprintf } from 'sprintf-js' 4 | 5 | export const uuid = () => { 6 | return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => 7 | (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) 8 | ) 9 | } 10 | 11 | export const noop = () => {} 12 | 13 | // dump state tree in string format 14 | export const dumpState = (item, depth=100) => { 15 | const MAX_DEPTH = 100 16 | depth = depth || 0 17 | let isString = typeof item === 'string' 18 | let isDeep = depth > MAX_DEPTH 19 | 20 | if (isString || isDeep) { 21 | console.log(item) 22 | return 23 | } 24 | 25 | for (var key in item) { 26 | console.group(key) 27 | dump(item[key], depth + 1) 28 | console.groupEnd() 29 | } 30 | } 31 | 32 | // for fsm test 33 | export const dump = svc => 34 | console.log( 35 | '\n--------------------------------------\n[state]', 36 | svc.state.value, 37 | '\n [ctx]', 38 | svc.state.context, 39 | '\n--------------------------------------', 40 | ) 41 | 42 | 43 | export const current = state => state.toStrings().pop() 44 | 45 | export const timer = time => { 46 | return new Promise((resolve, reject) => { 47 | setTimeout(resolve, time) 48 | }) 49 | } 50 | 51 | export const randomFloat = (min=0, max=999) => { 52 | return Math.random() * (max - min) + min; 53 | } 54 | 55 | export const random = (min=0, max=999) => { 56 | min = Math.ceil(min); 57 | max = Math.floor(max); 58 | return Math.floor(Math.random() * (max - min + 1)) + min; 59 | } 60 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/xstate-custom/useMyMachine.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react' 2 | import { interpret } from 'xstate' 3 | 4 | export function useMachine(machine, options={}) { 5 | 6 | // Keep track of the current machine state 7 | const [current, setCurrent] = useState(machine.initialState) 8 | 9 | // Reference the service 10 | const serviceRef = useRef(null) 11 | 12 | if (serviceRef.current === null) { 13 | 14 | serviceRef.current = interpret(machine, options) 15 | 16 | // 17 | .onTransition( state => { 18 | if (state.changed) { 19 | setCurrent(state) 20 | } 21 | 22 | const { debug = false } = options 23 | 24 | // DEBUG 25 | if( debug === true && state.changed === false ){ 26 | console.error( 27 | `\n\n💣💣💣 [UNHANDLED EVENT]💣💣💣\nEvent=`, 28 | state.event, 29 | 30 | '\nState=', 31 | state.value, state, 32 | 33 | '\nContext=', 34 | state.context, 35 | '\n\n' ) 36 | 37 | // console.log( 'state:', state ) 38 | } 39 | 40 | }) 41 | 42 | // 43 | .onEvent( e => { 44 | const { debug = false } = options 45 | if( debug === true ) console.log( '\t[Event]', e ) 46 | }) 47 | 48 | } 49 | 50 | const service = serviceRef.current 51 | 52 | useEffect(() => { 53 | // Start the service when the component mounts 54 | service.start() 55 | 56 | return () => { 57 | // Stop the service when the component unmounts 58 | service.stop() 59 | } 60 | }, []) 61 | 62 | return [current, service.send, service] 63 | } 64 | 65 | export function useService(service) { 66 | const [current, setCurrent] = useState(service.state) 67 | 68 | useEffect(() => { 69 | const listener = state => { 70 | if (state.changed) { 71 | setCurrent(state) 72 | } 73 | } 74 | 75 | service.onTransition(listener) 76 | 77 | return () => { 78 | service.off(listener) 79 | } 80 | }, []) 81 | 82 | return [current, service.send, service] 83 | } 84 | -------------------------------------------------------------------------------- /_old/fsm-service-3-actor/src/xstate-custom/xstateImmer.js: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { actionTypes } from 'xstate/lib/actions'; 3 | 4 | export function assign( assignment ) { 5 | return { 6 | type: actionTypes.assign, 7 | assignment 8 | } 9 | } 10 | 11 | export function updater( context, event, assignActions ) { 12 | const updatedContext = context 13 | 14 | ? assignActions.reduce((acc, assignAction) => { 15 | const { assignment } = assignAction 16 | const update = produce(acc, interim => assignment(interim, event)) 17 | return update 18 | }, context) 19 | 20 | : context 21 | 22 | return updatedContext 23 | } 24 | -------------------------------------------------------------------------------- /_old/word-paralell-state/README.md: -------------------------------------------------------------------------------- 1 | 2 | A extremely simplified text editor, this is one of the examples used in the book [`Constructing the User Interface with Statecharts`](https://dl.acm.org/citation.cfm?id=520870) 3 | 4 | 5 | ## Goal 6 | 7 | to show case the relationship between `fsm` and `ui` not necessarily 1:1, also shows how to model a program with `parallel`states, 8 | 9 | ## Key Features 10 | 11 | - pay attention to the `global` states, where it houses `selection` and `clipboard` for managing parallel states 12 | 13 | - also notice how `selection` state manages it's own parallel child states (this is `bold`, `italic` and `underline`) 14 | 15 | - this examples show cases the power of modeling a program with a mix of `parallel` and `hierarchy` states, this is a must-have capability when modeling program with statecharts 16 | 17 | ## Statechart 18 | 19 | ![word](https://user-images.githubusercontent.com/325936/57836012-66ae4a00-77f2-11e9-9a5a-e233efacc352.png) 20 | -------------------------------------------------------------------------------- /_old/word-paralell-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "a1", 3 | "license": "Apache-2.0", 4 | "version": "1.0.0", 5 | "description": "", 6 | "keywords": [], 7 | "main": "src/index.js", 8 | "dependencies": { 9 | "@xstate/react": "^0.2.0", 10 | "classnames": "^2.2.6", 11 | "jest-dom": "^3.1.3", 12 | "react": "16.8.3", 13 | "react-dom": "16.8.3", 14 | "react-scripts": "2.1.8", 15 | "react-testing-library": "^7.0.0", 16 | "sprintf-js": "^1.1.2", 17 | "xstate": "^4.5.0" 18 | }, 19 | "devDependencies": { 20 | "typescript": "3.3.3" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test --env=jsdom", 26 | "eject": "react-scripts eject" 27 | }, 28 | "browserslist": [ 29 | ">0.2%", 30 | "not dead", 31 | "not ie <= 11", 32 | "not op_mini all" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /_old/word-paralell-state/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /_old/word-paralell-state/src/App.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React, { useRef, useEffect } from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { useMachine } from '@xstate/react' 5 | import { interpret } from 'xstate' 6 | import machine from './machine' 7 | import './styles.css' 8 | import { isNumber, isOperator, current, dump } from './helpers' 9 | import classNames from 'classnames' 10 | 11 | function insertMetachars(oMsgInput, sStartTag, sEndTag) { 12 | var bDouble = arguments.length > 1, 13 | // oMsgInput = document.myForm.myTxtArea, 14 | nSelStart = oMsgInput.selectionStart, 15 | nSelEnd = oMsgInput.selectionEnd, 16 | sOldText = oMsgInput.value 17 | oMsgInput.value = 18 | sOldText.substring(0, nSelStart) + 19 | (bDouble 20 | ? sStartTag + sOldText.substring(nSelStart, nSelEnd) + sEndTag 21 | : sStartTag) + 22 | sOldText.substring(nSelEnd) 23 | oMsgInput.setSelectionRange( 24 | bDouble || nSelStart === nSelEnd ? nSelStart + sStartTag.length : nSelStart, 25 | (bDouble ? nSelEnd : nSelStart) + sStartTag.length, 26 | ) 27 | oMsgInput.focus() 28 | } 29 | 30 | export const App = () => { 31 | const [state, send] = useMachine(machine, { log: true }) 32 | 33 | console.clear() 34 | dump(state.value, 3) 35 | // console.log('\n[State] = ', state.toStrings(), '\n[context] = ', state.context) 36 | 37 | const ta = useRef() 38 | const sel = useRef(null) 39 | const clip = useRef(null) 40 | 41 | useEffect(() => { 42 | window.addEventListener('keydown', handleKey ) 43 | return () => window.removeEventListener('keydown', handleKey ) 44 | }) 45 | 46 | const handleKey = evt => { 47 | if( evt.metaKey == true && evt.key == 'b'){ 48 | handleBold() 49 | } else if ( evt.metaKey == true && evt.key == 'u'){ 50 | handleUnderline() 51 | } else if ( evt.metaKey == true && evt.key == 'i'){ 52 | handleItalic() 53 | } else if ( evt.metaKey == true && evt.key == 'c'){ 54 | handleCopy() 55 | } else if ( evt.metaKey == true && evt.key == 'p'){ 56 | handlePaste() 57 | } 58 | } 59 | 60 | const handleBold = () => { 61 | send('toggleBold') 62 | insertMetachars(ta.current, '', '') 63 | } 64 | 65 | const handleItalic = () => { 66 | send('toggleItalic') 67 | insertMetachars(ta.current, '', '') 68 | } 69 | 70 | const handleUnderline = () => { 71 | send('toggleUnderline') 72 | insertMetachars(ta.current, '', '') 73 | } 74 | 75 | const handleCopy = () => { 76 | document.execCommand('copy') 77 | const txt = navigator.clipboard 78 | .readText() 79 | .then(res => clip.current = res) 80 | .catch(e => console.log( 'Failed accessing clipboard: ', e )) 81 | ta.current.focus() 82 | send('setClipboardContent') 83 | } 84 | 85 | const handlePaste = () => { 86 | ta.current.focus() 87 | ta.current.setRangeText( clip.current, sel.current.start, sel.current.end) 88 | } 89 | 90 | const handleSelection = () => { 91 | const start = ta.current.selectionStart 92 | const end = ta.current.selectionEnd 93 | // console.log( 'select change:', start, end ) 94 | if (start === end) { 95 | send('textUnselected') 96 | } else { 97 | send('textSelected') 98 | } 99 | sel.current = { 100 | start, 101 | end, 102 | } 103 | } 104 | 105 | const btnDisabled = state.matches('globals.selection.notSelected') 106 | const pasteDisabled = state.matches('globals.clipboard.notFilled') 107 | 108 | return ( 109 |
110 | 117 | 118 | 125 | 126 | 133 | 134 | 141 | 148 | 149 |