├── .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 |
5 |
6 | ## This is the statecharts generated from the actually implemented version
7 |
8 | 
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 | 
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 | 
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 | 
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 | 
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 |
156 |
{JSON.stringify(state.value)}
157 |
158 | )
159 | }
160 |
--------------------------------------------------------------------------------
/_old/word-paralell-state/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 log = assign((ctx, e) => {
14 | console.log( '[Log]', ctx, e )
15 | })
16 |
--------------------------------------------------------------------------------
/_old/word-paralell-state/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/word-paralell-state/src/helpers.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 |
3 | import { sprintf } from 'sprintf-js'
4 |
5 | export const dump = (item, depth) => {
6 | const MAX_DEPTH = 100
7 | depth = depth || 0
8 | let isString = typeof item === 'string'
9 | let isDeep = depth > MAX_DEPTH
10 |
11 | if (isString || isDeep) {
12 | console.log(item)
13 | return
14 | }
15 |
16 | for (var key in item) {
17 | console.group(key)
18 | dump(item[key], depth + 1)
19 | console.groupEnd()
20 | }
21 | }
22 |
23 | // 取得目前 state 的字串,例如 'main.start'
24 | export const current = state => state.toStrings().pop()
25 |
26 | export const doMath = ctx => {
27 | const { operand1, operand2, operator } = ctx
28 |
29 | let result = 0
30 |
31 | switch (operator) {
32 | case '+':
33 | result = operand1 + operand2
34 | break
35 | case '-':
36 | result = operand1 - operand2
37 | break
38 | case '*':
39 | result = operand1 * operand2
40 | break
41 | case '/':
42 | result = operand1 / operand2
43 | break
44 | }
45 |
46 | // 解決 float point 問題
47 | result = +sprintf('%.6f', result)
48 | // console.log( '\t計算結果:', result )
49 | return result
50 | }
51 |
52 | export const isNumber = val => {
53 | if (isNaN(val)) {
54 | // console.warn(`Number required, but got '${val}', did you pass in an operator?`)
55 |
56 | return false
57 | }
58 |
59 | return true
60 | }
61 |
62 | export const isOperator = val => {
63 | if (!['+', '-', '*', '/'].includes(val)) {
64 | // console.warn(`Operator required, but got '${val}'`)
65 | return false
66 | }
67 | return true
68 | }
69 |
70 | export const timer = time => {
71 | return new Promise((resolve, reject) => {
72 | setTimeout(resolve, time)
73 | })
74 | }
75 |
--------------------------------------------------------------------------------
/_old/word-paralell-state/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 |
18 |
19 |
20 | /*
21 | const svc = interpret(machine, {execute: true})
22 | .onTransition( state => {
23 | // console.log( '[state] ', state.changed, '|', state.value, state.context, )
24 |
25 | if(state.changed == false){
26 | console.log( '有人送了沒人在聽的事件,可能是 ui 端寫錯事件名稱了', )
27 | // 有人送了沒人在聽的事件,可能是 ui 端寫錯事件名稱了
28 | // debugger //
29 | }
30 | })
31 | .onEvent( e => {
32 | // console.log( '\n[event] ', e )
33 | })
34 | svc.start()
35 |
36 | debugger //
37 | // const b = svc.send('GO')
38 | */
39 |
--------------------------------------------------------------------------------
/_old/word-paralell-state/src/machine.js:
--------------------------------------------------------------------------------
1 | import { Machine, assign } from 'xstate'
2 | import * as actions from './actions'
3 |
4 | export default Machine(
5 | {
6 | id: 'word',
7 | context: {
8 | test: 'test',
9 | },
10 | type: 'parallel',
11 | states: {
12 |
13 | // 這模擬主程式 screen 切換狀態
14 | main: {
15 | initial: 'editor',
16 | states: {
17 | loading: {},
18 | editor: {
19 | initial: 'aa',
20 | states: {
21 | aa: {
22 | initial: 'bb',
23 | states: {
24 | bb: {
25 | on: {
26 | BBB1: {
27 | actions: assign((c, e) => console.log('BBB > bb 跑了')),
28 | },
29 | },
30 | },
31 | },
32 | on: {
33 | BBB: {
34 | actions: assign((c, e) => console.log('BBB > aa 跑了')),
35 | },
36 | },
37 | },
38 | },
39 | on: {
40 | BBB3: {
41 | actions: assign((c, e) => console.log('BBB > editor 跑了')),
42 | },
43 | },
44 | },
45 | },
46 | on: {
47 | BBB4: {
48 | actions: assign((c, e) => console.log('BBB > main 跑了')),
49 | },
50 | },
51 | },
52 |
53 | // 全域狀態控制
54 | globals: {
55 | initial: 'selection',
56 | type: 'parallel',
57 | states: {
58 |
59 | selection: {
60 | initial: 'notSelected',
61 | states: {
62 | selected: {
63 | type: 'parallel',
64 | states: {
65 | bold: {
66 | initial: 'off',
67 | states: {
68 | on: {
69 | on: {
70 | 'toggleBold': {
71 | target: 'off',
72 | actions: 'log',
73 | },
74 | },
75 | },
76 | off: {
77 | on: {
78 | 'toggleBold': {
79 | target: 'on',
80 | actions: 'log',
81 | },
82 | BBB: {
83 | actions: assign((c, e) => console.log('BBB > globals.bold.off 跑了')),
84 | },
85 | },
86 | },
87 | },
88 | },
89 | italic: {
90 | initial: 'off',
91 | states: {
92 | on: {
93 | on: {
94 | 'toggleItalic': {
95 | target: 'off',
96 | actions: 'log',
97 | },
98 | },
99 | },
100 | off: {
101 | on: {
102 | 'toggleItalic': {
103 | target: 'on',
104 | actions: 'log',
105 | },
106 | },
107 | },
108 | },
109 | },
110 | underline: {
111 | initial: 'off',
112 | states: {
113 | on: {
114 | on: {
115 | 'toggleUnderline': {
116 | target: 'off',
117 | actions: 'log',
118 | },
119 | },
120 | },
121 | off: {
122 | on: {
123 | 'toggleUnderline': {
124 | target: 'on',
125 | actions: 'log',
126 | },
127 | },
128 | },
129 | },
130 | },
131 | },
132 | on: {
133 | 'textUnselected': 'notSelected',
134 | }
135 | },
136 | notSelected: {
137 | on: {
138 | 'textSelected': 'selected'
139 | }
140 | }
141 | }
142 | },
143 |
144 | clipboard: {
145 | initial: 'notFilled',
146 | states: {
147 | filled: {
148 | // 要用到,但不需放東西
149 | },
150 | notFilled: {
151 | on: {
152 | 'setClipboardContent': {
153 | target: 'filled',
154 | actions: 'log',
155 | },
156 | },
157 | },
158 | },
159 | },
160 |
161 | },
162 | },
163 | },
164 |
165 | // 頂層全域事件
166 | on: {
167 | BBB: {
168 | actions: assign((c, e) => console.log('BBB > TOP 跑了')),
169 | },
170 | 'set.bold.on': {
171 | target: 'bold.on',
172 | actions: 'log',
173 | },
174 | RESET: '#word', // TODO: this should be 'word' or [{ internal: false }]
175 | },
176 | },
177 | { actions },
178 | )
179 |
--------------------------------------------------------------------------------
/_old/word-paralell-state/src/services.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import { assign } from './xstateImmer'
3 |
4 | // +TBD: 示範用
5 | export const myService = (ctx, e) => (cb, onEvent) => {
6 | const a = setInterval(() => {
7 | console.log( '\n\nmyService 廣播', )
8 | cb('FOO')
9 | }, 1000)
10 |
11 | onEvent( (e) => {
12 | console.log( '\n\n收到 parent: ', e)
13 | })
14 |
15 | return () => clearInterval(a)
16 | }
17 |
--------------------------------------------------------------------------------
/_old/word-paralell-state/src/styles.css:
--------------------------------------------------------------------------------
1 |
2 | .intLink {
3 | cursor: pointer;
4 | text-decoration: underline;
5 | color: #0000ff;
6 | }
7 |
8 | body * {
9 | margin: 0;
10 | padding: 0;
11 | box-sizing: border-box;
12 | }
13 |
14 | .container {
15 | width: 300px;
16 | height: 200px;
17 | margin: 0 auto;
18 | border: 2px solid gray;
19 | border-radius: 4px;
20 | box-sizing: border-box;
21 | font-size: 1.2em;
22 | }
23 |
24 | .readout {
25 | font-size: 32px;
26 | color: #333;
27 | text-align: right;
28 | padding: 5px 13px;
29 | width: 100%;
30 | border: none;
31 | border-bottom: 1px solid gray;
32 | box-sizing: border-box;
33 | }
34 |
35 | .button-grid {
36 | display: grid;
37 | padding: 20px;
38 | grid-template-columns: repeat(4, 1fr);
39 | grid-gap: 15px;
40 | }
41 |
42 | .btn {
43 | padding: 4px;
44 | font-size: 1em;
45 | color: #d29b9b;
46 | cursor: pointer;
47 | border-radius: 6px;
48 | border: 1px solid grey;
49 | /*opacity: 0.8;*/
50 | }
51 |
52 | .btn:disabled {
53 | opacity: 0.2;
54 | }
55 |
56 | .btn-down {
57 | background: rgba(0, 0, 0, 0.5);
58 | color: white;
59 | }
60 |
61 | .calc-button:hover {
62 | opacity: 1;
63 | }
64 |
65 | .calc-button:active {
66 | background: #999;
67 | box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.6);
68 | }
69 |
70 | .two-span {
71 | grid-column: span 2;
72 | background-color: #3572db;
73 | }
74 |
--------------------------------------------------------------------------------
/_old/word-paralell-state/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 |
--------------------------------------------------------------------------------
/crud-v1-services/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Goals
3 |
4 | - This is a typical CRUD applicaiton with master/detail/modal screens.
5 |
6 | - Demonstrating how to model CRUD application with `xstate` using `parallel` states for screen transition and manage states using xstsat's `extended context` to replace redux.
7 |
8 | ## What to see in this example
9 |
10 | - switch between master|detail|modal screen by fsm states
11 |
12 | - optimistic update for creating and deleting items
13 |
14 | - how to control show/hide of modal window, along with preparing data for it to display
15 |
16 | - how to integrate any 3rd parti ui libraries, using `notifications` as an example here
17 |
18 | - storing { state, send } in react context using `useContext` so that child components could easily fetch and use it, no need to pass it around as `props`
19 |
20 | - the relationship between fsm state and ui may not be mapped 1:1, pay attention to how `loading` and `error` are using the same component to represent different states
21 |
22 | - making `machine` file clean and serializable by moving all `actions` and `guards` into it's own file
23 |
24 | - bonus: multipe requests could be cancelled, for use case like `search as you type` where multipe requests might be sent in a short time
25 |
26 | ## Statechart
27 |
28 | 
29 | All charts generated using [StatesKit](https://stateskit.com)
30 |
--------------------------------------------------------------------------------
/crud-v1-services/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 | "@testing-library/jest-dom": "^4.1.0",
10 | "@welldone-software/why-did-you-render": "^3.3.5",
11 | "@xstate/react": "^0.7.1",
12 | "classnames": "^2.2.6",
13 | "enumify": "^1.0.4",
14 | "react": "16.9.0",
15 | "react-dom": "16.9.0",
16 | "react-scripts": "3.1.2",
17 | "react-spring": "^8.0.27",
18 | "react-testing-library": "^8.0.1",
19 | "styled-components": "^4.4.0",
20 | "toasted-notes": "^3.0.0",
21 | "todomvc-app-css": "^2.2.0",
22 | "todomvc-common": "^1.0.5",
23 | "uuid-v4": "0.1.0",
24 | "xstate": "@next"
25 | },
26 | "devDependencies": {
27 | "typescript": "3.6.3"
28 | },
29 | "scripts": {
30 | "start": "react-scripts start",
31 | "build": "react-scripts build",
32 | "test": "react-scripts test --env=jsdom",
33 | "eject": "react-scripts eject"
34 | },
35 | "browserslist": [
36 | ">0.2%",
37 | "not dead",
38 | "not ie <= 11",
39 | "not op_mini all"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/crud-v1-services/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
18 | React App
19 |
20 |
21 |
22 |
25 |
26 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/crud-v1-services/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, fireEvent, prettyDOM } from 'react-testing-library'
5 | import { Wrap } from '../components/App'
6 | import { timer } from '../utils/helpers'
7 |
8 | let dom, container
9 |
10 | beforeEach(() => {
11 | dom = render()
12 | container = dom.container
13 | })
14 |
15 | afterEach(cleanup)
16 |
17 | describe('fetch data and display', () => {
18 |
19 | it('has rows of data', () => {
20 | const rows = document.querySelector('#rows')
21 | expect(rows.children.length).toEqual(3)
22 | expect(rows.children.length).not.toEqual(0)
23 | })
24 |
25 | it('add button click', () => {
26 |
27 | const btn = document.querySelector('#btnAdd')
28 | fireEvent.click( btn )
29 |
30 | const form = document.querySelector('form')
31 | const input = document.querySelector('form input')
32 | const btnSubmit = document.querySelector('#btnSubmit')
33 | const btnCancel = document.querySelector('#btnCancel')
34 |
35 | expect(form).not.toBeNull()
36 | expect(input).toHaveAttribute('value', '')
37 | expect(btnSubmit).not.toBeNull()
38 | expect(btnCancel).not.toBeNull()
39 | })
40 |
41 | it('add new item', async () => {
42 |
43 | const btn = document.querySelector('#btnAdd')
44 | fireEvent.click( btn )
45 |
46 | const input = document.querySelector('form input')
47 | const btnSubmit = document.querySelector('#btnSubmit')
48 | const value = 'foobar'
49 |
50 | fireEvent.change(input, { target: {value: 'foobar'}})
51 | expect(input).toHaveAttribute('value', value)
52 |
53 | fireEvent.click( btnSubmit )
54 |
55 | const newElem = dom.getByText(/foobar/i).textContent
56 | expect( newElem ).not.toBeUndefined()
57 |
58 | const a = await dom.findByText(/label_foobar/i)
59 | expect(a).toBeTruthy()
60 | console.log( 'aaa:', a.textContent )
61 | })
62 |
63 | it('select item to have bg color and edit button enabled ', () => {
64 |
65 | // div.span
66 | const row1 = document.querySelector('#rows').firstChild.firstChild
67 | fireEvent.click( row1 )
68 |
69 | const row2 = document.querySelector('[style*="background-color"]')
70 | expect(row2).not.toBeNull()
71 | expect(row2).toHaveAttribute('style', 'background-color: pink;')
72 |
73 | const btn = document.querySelector('#btnEdit')
74 | expect(btn).not.toHaveAttribute('disabled')
75 |
76 | })
77 |
78 | it('edit item - display', () => {
79 |
80 | // div.span
81 | const row1 = document.querySelector('#rows').firstChild.firstChild
82 | fireEvent.click( row1 )
83 |
84 | const btn = document.querySelector('#btnEdit')
85 | fireEvent.click( btn )
86 |
87 | const value = row1.textContent.split('-')[1].trim()
88 | const t = dom.getByDisplayValue( new RegExp(value, 'i') )
89 | expect(t).not.toBeNull()
90 | })
91 |
92 | it('edit item - submit', () => {
93 |
94 | // div.span
95 | const row1 = document.querySelector('#rows').firstChild.firstChild
96 | fireEvent.click( row1 )
97 | console.log( 'row1:', row1.textContent )
98 | const btn = document.querySelector('#btnEdit')
99 | fireEvent.click( btn )
100 |
101 | const input = document.querySelector('form input')
102 | const val = 'foobar'
103 |
104 | fireEvent.change(input, { target: {value: 'foobar'}})
105 | expect(input).toHaveAttribute('value', val)
106 |
107 | fireEvent.click( document.querySelector('#btnSubmit') )
108 |
109 | const result = document.querySelectorAll('h2')[1]
110 | console.log( 'result: ', result.textContent )
111 | expect(result.textContent).toEqual(`Content: ${val}`)
112 |
113 | // console.log( prettyDOM(container) )
114 | })
115 |
116 | it('edit item - cancel', () => {
117 |
118 | const row1 = document.querySelector('#rows').firstChild.firstChild
119 | fireEvent.click( row1 )
120 | // console.log( 'row1:', row1.outerHTML )
121 |
122 | fireEvent.click( document.querySelector('#btnEdit') )
123 |
124 | fireEvent.click( document.querySelector('#btnCancel') )
125 |
126 | const oldItem = document.querySelector('[style*="background-color"')
127 | expect(oldItem.outerHTML).toBe(row1.outerHTML)
128 |
129 | // console.log( prettyDOM(container) )
130 | })
131 |
132 | })
133 |
134 |
--------------------------------------------------------------------------------
/crud-v1-services/src/components/styles.css:
--------------------------------------------------------------------------------
1 | .App {
2 | font-family: sans-serif;
3 | text-align: center;
4 | }
5 |
--------------------------------------------------------------------------------
/crud-v1-services/src/fsm/actions.js:
--------------------------------------------------------------------------------
1 | import { send, assign } from 'xstate'
2 |
3 | export const reloadItems = send(
4 | { type: 'ServiceLoadItems' }, // the event to be sent
5 | { to: 'ItemService' }, // the target servcie to receive that event
6 | )
7 |
8 | export const listDataSuccess = assign((ctx, evt) => {
9 | ctx.items = evt.data
10 | ctx.notify('Data fetched 1')
11 | ctx.notify('Data fetched 2')
12 | ctx.notify('Data fetched 3')
13 | })
14 |
15 | export const listDataError = assign((ctx, e) => {
16 | console.log('\n[listDataError]', e.data)
17 | //
18 | ctx.modalData = {
19 | type: 'MODAL_ERROR',
20 | title: 'ListData Fetching Failed',
21 | content: `Failed for reason: ${e.data}`,
22 | data: e.data,
23 | }
24 | })
25 |
26 | export const selectItem = assign((ctx, e) => {
27 | ctx.selectedItemId = e.item.id
28 | ctx.exitNewItemTo = e.exitTo
29 | })
30 |
31 | export const setExitTo = assign((ctx, e) => {
32 | ctx.exitNewItemTo = e.exitTo
33 | })
34 |
35 | export const confirmItemDelete = send(
36 | // notify ItemService to delete item and dispatch once the job is completed
37 | (ctx, e) => {
38 | return {
39 | type: 'ServiceItemDeleteConfirm',
40 | data: e.data,
41 | }
42 | },
43 | // this is 2nd arguement, not part of the Event{}
44 | { to: 'ItemService' },
45 | )
46 |
47 | // optimistic update
48 | export const preDeleteItem = assign((ctx, e) => {
49 | const selectedItemId = e.data.id
50 | const newItems = ctx.items.filter(it => it.id !== selectedItemId)
51 | ctx.items = newItems
52 | ctx.selectedItemId = null
53 | ctx.modalData = null
54 | })
55 |
56 | //
57 | export const cancelItemDelete = assign((ctx, e) => {
58 | ctx.modalData = null
59 | })
60 |
61 | export const modalDeleteItemFail = assign((ctx, e) => {
62 | const { info, payload } = e.error
63 | const restoreItem = payload
64 | // console.log( '[MODAL_DELETE_ITEM_FAIL]', info )
65 | ctx.items.push(restoreItem)
66 | ctx.notify(info)
67 | })
68 |
69 | export const modalDeleteItemSuccess = assign((ctx, e) => {
70 | console.log('[MODAL_DELETE_ITEM_RESULT]', e)
71 | const { result } = e
72 | ctx.notify(result.info)
73 | })
74 |
75 | export const modalErrorDataClose = assign((ctx, e) => {
76 | ctx.modalData = null
77 | ctx.notify('Loading Error dismissed')
78 | })
79 |
80 | export const modalErrorDataRetry = assign((ctx, e) => {
81 | ctx.modalData = null
82 | ctx.notify('Loading Error dismissed')
83 | })
84 |
85 | export const createNewItem = assign((ctx, e) => {
86 | // config which screen to exit to from creating new item screen
87 | ctx.exitNewItemTo = e.exitTo
88 | })
89 |
90 | // optimistic update, insert the item with local id
91 | export const preSubmitNewItem = assign((ctx, e) => {
92 | const newItem = e.payload
93 | ctx.items.push(newItem)
94 | ctx.selectedItemId = newItem.id
95 | })
96 |
97 | // then invoke service to persist new item via external api call
98 | export const submitNewItem = send(
99 | (ctx, e) => ({
100 | type: 'ServiceCreateItems',
101 | payload: e.payload,
102 | forceFail: e.forceFail,
103 | }),
104 | { to: 'ItemService' },
105 | )
106 |
107 | // after data was persisted to server, replace local item id with the official one sent back from the server
108 | export const newItemSuccess = assign((ctx, e) => {
109 | const {
110 | result: { info, serverItem, localItem },
111 | } = e
112 | // console.log( '[NEW_ITEM_SUCCESS]', serverItem )
113 |
114 | ctx.items = ctx.items.map(it => (it.id === localItem.id ? serverItem : it))
115 |
116 | ctx.notify(info)
117 |
118 | ctx.selectedItemId = serverItem.id
119 |
120 | ctx.exitNewItemTo = null
121 |
122 | })
123 |
124 | export const newItemFail = assign((ctx, e) => {
125 | const { info, localItem } = e.error
126 | // console.log('NEW_ITEM_FAIL', info)
127 | ctx.items = ctx.items.filter(it => it.id !== localItem.id)
128 | ctx.notify(info)
129 | ctx.selectedItemId = null
130 | ctx.exitNewItemTo = null
131 | })
132 |
133 | export const editSubmit = assign((ctx, e) => {
134 | const edited = e.payload
135 | ctx.items = ctx.items.map(it => (it.id === edited.id ? edited : it))
136 | })
137 |
138 | export const clearNotification = assign((ctx, e) => {
139 | ctx.notifications = ctx.notifications.filter(it => !e.popped.includes(it))
140 | })
141 |
142 | export const testAction = send(
143 | (ctx, e) => ({
144 | type: 'test',
145 | signal: e.signal,
146 | }),
147 | { to: 'CancelService' },
148 | )
149 |
150 | export const testResultAction = assign((ctx, e) => {
151 | console.log('[subMachine result]', e)
152 | })
153 |
154 | export const testMe = assign((ctx, e) => {
155 | console.log('[subMachine]', e)
156 | })
157 |
158 | export const itemDelete = assign((ctx, e) => {
159 | ctx.modalData = e.modalData
160 | })
161 |
--------------------------------------------------------------------------------
/crud-v1-services/src/fsm/fsm.js:
--------------------------------------------------------------------------------
1 | export const fsm = {
2 | id: 'MyApp',
3 | initial: 'main',
4 |
5 | context: {
6 | items: [],
7 | selectedItemId: null,
8 | modalData: null,
9 | },
10 |
11 | // main | global
12 | type: 'parallel',
13 |
14 | // top level
15 | states: {
16 | main: {
17 | initial: 'loading',
18 |
19 | invoke: [
20 | {
21 | id: 'ItemService',
22 | src: 'itemService',
23 | },
24 | ],
25 |
26 | states: {
27 |
28 | //
29 | loading: {
30 | // when entrying 'entry' state, run 'reloadItems' action
31 | // which will send an event to 'ItemService' to fetch data via API
32 | entry: 'reloadItems',
33 | },
34 |
35 | //
36 | 'loadFailed': {
37 | on: {
38 | modalDataErrorClose: {
39 | target: 'master',
40 | actions: 'modalErrorDataClose',
41 | },
42 | modalDataErrorRetry: {
43 | target: 'loading',
44 | actions: 'modalErrorDataRetry',
45 | },
46 | }
47 | },
48 |
49 | //
50 | master: {
51 | on: {
52 | itemDetails: {
53 | target: 'details',
54 | actions: 'selectItem',
55 | },
56 | itemEdit: {
57 | target: 'edit',
58 | actions: 'setExitTo',
59 | },
60 | },
61 | },
62 |
63 | //
64 | details: {
65 | on: {
66 | itemEdit: {
67 | target: 'edit',
68 | actions: 'setExitTo',
69 | },
70 | itemBack: {
71 | target: 'master',
72 | },
73 | },
74 | },
75 |
76 | //
77 | new: {
78 | on: {
79 | // cancel an edit might lead back to master or detail screen, hence using a guard state to tell
80 | newItemCancel: {
81 | target: 'unknown',
82 | },
83 | newItemSubmit: {
84 | target: 'master',
85 | actions: ['preSubmitNewItem', 'submitNewItem'],
86 | },
87 | },
88 | },
89 |
90 | //
91 | edit: {
92 | on: {
93 | editCancel: {
94 | target: 'unknown',
95 | },
96 | editSubmit: [
97 | {
98 | target: 'master',
99 | cond: 'unknownMaster',
100 | actions: 'editSubmit',
101 | },
102 | {
103 | target: 'details',
104 | cond: 'unknownDetails',
105 | actions: 'editSubmit',
106 | },
107 | ],
108 | },
109 | },
110 |
111 | // for transient state, which will be transferred to next state immediately
112 | unknown: {
113 | on: {
114 | '': [
115 | {
116 | target: 'master',
117 | cond: 'unknownMaster',
118 | },
119 | {
120 | target: 'details',
121 | cond: 'unknownDetails',
122 | },
123 | ],
124 | },
125 | },
126 | },
127 |
128 | // main - top level events
129 | on: {
130 | itemReload: {
131 | target: '.loading',
132 | },
133 |
134 | // shared by both 'loading' and 'master' states, hence moved up one level here
135 | itemLoadSuccess: {
136 | target: '.master',
137 | actions: 'listDataSuccess',
138 | },
139 | itemLoadFail: {
140 | target: '.loadFailed',
141 | actions: 'listDataError',
142 | },
143 |
144 | itemNew: {
145 | target: '.new',
146 | actions: 'createNewItem',
147 | },
148 |
149 | newItemSuccess: {
150 | actions: 'newItemSuccess',
151 | },
152 |
153 | newItemFail: {
154 | actions: 'newItemFail',
155 | },
156 |
157 | modalDeleteItemConfirm: {
158 | target: '.master',
159 | actions: ['confirmItemDelete', 'preDeleteItem'],
160 | },
161 |
162 | modalDeleteItemSuccess: {
163 | actions: 'modalDeleteItemSuccess',
164 | },
165 |
166 | modalDeleteItemFail: {
167 | actions: 'modalDeleteItemFail',
168 | },
169 |
170 | clearNotification: {
171 | actions: 'clearNotification',
172 | },
173 | },
174 | },
175 |
176 | global: {
177 | type: 'parallel',
178 | states: {
179 | // p1 - selection
180 | selection: {
181 | initial: 'unSelected',
182 | states: {
183 | selected: {},
184 | unSelected: {},
185 | },
186 | on: {
187 | itemSelect: {
188 | target: '.selected',
189 | actions: 'selectItem',
190 | },
191 | modalDeleteItemConfirm: '.unSelected',
192 | },
193 | },
194 |
195 | // p2 - modal
196 | modal: {
197 | initial: 'hide',
198 | states: {
199 | show: {},
200 | hide: {},
201 | },
202 | on: {
203 | itemDelete: {
204 | target: '.show',
205 | actions: 'itemDelete',
206 | },
207 | modalDeleteItemConfirm: '.hide',
208 | modalDeleteItemCancel: {
209 | target: '.hide',
210 | actions: ['cancelItemDelete'],
211 | },
212 | },
213 | },
214 | },
215 | },
216 | },
217 | }
218 |
219 |
--------------------------------------------------------------------------------
/crud-v1-services/src/fsm/guards.js:
--------------------------------------------------------------------------------
1 | export const unknownMaster = ctx => ctx.exitNewItemTo === 'master'
2 | export const unknownDetails = ctx => ctx.exitNewItemTo === 'details'
3 |
--------------------------------------------------------------------------------
/crud-v1-services/src/fsm/machine.js:
--------------------------------------------------------------------------------
1 | import { Machine } from 'xstate'
2 | import * as actions from './actions'
3 | import * as services from './services'
4 | import * as guards from './guards'
5 | import { fsm } from './fsm'
6 |
7 | export const machine = Machine(
8 | fsm,
9 | {
10 | actions,
11 | services,
12 | guards,
13 | },
14 | )
15 |
--------------------------------------------------------------------------------
/crud-v1-services/src/fsm/services.js:
--------------------------------------------------------------------------------
1 | import { randomId, random } from '../utils/helpers'
2 |
3 | // A Callback service
4 | // cb() let's up dispatch event to the parent
5 | // onReceive() allows us to receive events from the parent while the service is running
6 | export const itemService = (ctx, e) => (cb, onReceive) => {
7 | //
8 | onReceive(evt => {
9 | switch (evt.type) {
10 |
11 | //
12 | case 'ServiceLoadItems':
13 |
14 | const fakeItem = () => {
15 | const id = randomId()
16 | const d = {
17 | id,
18 | label: `Label_${id}`,
19 | }
20 | return d
21 | }
22 |
23 | // instead of fetching data via API, we fake them here
24 | const arr = [fakeItem(), fakeItem(), fakeItem()]
25 |
26 | console.log( '\nfetched: ', arr )
27 |
28 | // eslint-disable-next-line
29 | const t = random(300, 2000)
30 |
31 | setTimeout(() => {
32 |
33 | // for test only
34 | // randomly trigger happy and sorrow path to test both scenarios
35 | // if((t % 2) == 0 ){
36 | if(true){
37 | // if(false){
38 | // if fetching succeeded
39 | cb({
40 | type: 'itemLoadSuccess',
41 | data: arr,
42 | })
43 | } else {
44 | // if fetching failed, we trigger the sorrow path
45 | cb({
46 | type: 'itemLoadFail',
47 | data: 'network error',
48 | })
49 | }
50 | }, random(100, 1000))
51 |
52 | break
53 |
54 | case 'ServiceItemDeleteConfirm':
55 | const item = evt.data
56 |
57 | new Promise((resolve, reject) => {
58 | setTimeout(() => {
59 | resolve({
60 | info: `item: ${item.id} deleted succesfully`,
61 | })
62 |
63 | // reject({
64 | // info: `item: ${item.id} removal failed`,
65 | // payload: item,
66 | // })
67 | }, 1200)
68 | })
69 | .then(result => {
70 | // console.log( '\tconfirmHandler completed: ', result )
71 | cb({
72 | type: 'modalDeleteItemSuccess',
73 | result,
74 | })
75 | })
76 | .catch(error => {
77 | cb({
78 | type: 'modalDeleteItemFail',
79 | error,
80 | })
81 | })
82 |
83 | break
84 |
85 | // create new item
86 | case 'ServiceCreateItems':
87 | const localItem = evt.payload
88 |
89 | // async side effect
90 | return new Promise((resolve, reject) => {
91 |
92 | // simulate id generated from server, to replace the temp local id
93 | const serverId = 'server_' + localItem.id.split('tmp_')[1]
94 | setTimeout(() => {
95 | resolve({
96 | info: `item: ${localItem.id} - ${localItem.label} created succesfully`,
97 | serverItem: { ...localItem, id: serverId },
98 | localItem,
99 | })
100 | // reject({
101 | // info: `Create item: ${localItem.id} failed`,
102 | // localItem,
103 | // })
104 | }, 1000)
105 | })
106 | .then(result => {
107 | cb({
108 | type: 'newItemSuccess',
109 | result,
110 | })
111 | })
112 | .catch(error => {
113 | cb({
114 | type: 'newItemFail',
115 | error,
116 | })
117 | })
118 |
119 | default:
120 | console.log('unhandled method call=', evt.type)
121 | }
122 | })
123 | }
124 |
--------------------------------------------------------------------------------
/crud-v1-services/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { Wrap } from './components/app'
4 |
5 | const rootElement = document.getElementById('root')
6 | ReactDOM.render(, rootElement)
7 |
--------------------------------------------------------------------------------
/crud-v1-services/src/utils/helpers.js:
--------------------------------------------------------------------------------
1 |
2 | export const uuid = () => {
3 | return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
4 | // eslint-disable-next-line
5 | (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
6 | )
7 | }
8 |
9 | export const randomId = () => Math.floor(Math.random()*999)
10 |
11 | export const noop = () => {}
12 |
13 | // dump state tree in string format
14 | export const dumpState = (item, depth = 1) => {
15 | // if (depth == 1) console.log('\n')
16 |
17 | const MAX_DEPTH = 100
18 | depth = depth || 0
19 | let isString = typeof item === 'string'
20 | let isDeep = depth > MAX_DEPTH
21 |
22 | if (isString || isDeep) {
23 | console.log(item)
24 | return
25 | }
26 |
27 | for (var key in item) {
28 | console.group(key)
29 | dumpState(item[key], depth + 1)
30 | console.groupEnd()
31 | }
32 | }
33 |
34 | // for fsm test
35 | export const dump = svc => {
36 | if(svc.state){
37 | console.log(
38 | '\n--------------------------------------\n[state]',
39 | svc.state.value,
40 | '\n [ctx]',
41 | svc.state.context,
42 | '\n--------------------------------------',
43 | )
44 | }else{
45 | console.log( 'empty: ', svc )
46 | }
47 | }
48 |
49 |
50 | export const current = state => state.toStrings().pop()
51 |
52 | export const timer = time => {
53 | return new Promise((resolve, reject) => {
54 | setTimeout(resolve, time)
55 | })
56 | }
57 |
58 | export const randomFloat = (min=0, max=999) => {
59 | return Math.random() * (max - min) + min;
60 | }
61 |
62 | export const random = (min=0, max=999) => {
63 | min = Math.ceil(min);
64 | max = Math.floor(max);
65 | return Math.floor(Math.random() * (max - min + 1)) + min;
66 | }
67 |
--------------------------------------------------------------------------------
/crud-v1-services/src/utils/useMyHooks.js:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from 'react'
2 | import {
3 | interpret,
4 | } from 'xstate'
5 |
6 | // machine is raw state machine, will run it with the interpreter
7 | export const useMachineEx = (machine, { debug=false, name='', interpreterOptions={}}) => {
8 | // eslint-disable-next-line
9 | const [_, force] = useState(0)
10 | const machineRef = useRef(null)
11 | const serviceRef = useRef() // started Interpreter
12 |
13 | if(machineRef.current !== machine){
14 |
15 | machineRef.current = machine
16 |
17 | serviceRef.current = interpret(machineRef.current, interpreterOptions)
18 | .onTransition( state => {
19 |
20 | if(state.event.type === 'xstate.init') {
21 | // debugger //
22 | return
23 | }
24 | //
25 | if( state.changed === false && debug === true ){
26 | console.error(
27 | `\n\n💣💣💣 [UNHANDLED EVENT][useMachine]💣💣💣\nEvent=`,
28 | state.event,
29 |
30 | '\nState=',
31 | state.value, state,
32 |
33 | '\nContext=',
34 | state.context,
35 | '\n\n' )
36 |
37 | return
38 | }
39 |
40 | if( debug === true ){
41 | console.group(`%c[useMachine ${name}]`, 'color: darkblue')
42 | dumpState(state.value)
43 | console.log( 'ctx=', state.context )
44 | console.log( 'evt=', state.event )
45 | console.log( '\n', )
46 | console.groupEnd()
47 | }
48 |
49 | // re-render if the state changed
50 | force(x => x+1)
51 | })
52 |
53 | // start immediately, as it's in the constructor
54 | serviceRef.current.start()
55 | }
56 |
57 | // didMount
58 | useEffect(() => {
59 | return () => {
60 | console.log( 'useMachine unload')
61 | serviceRef.current.stop()
62 | }
63 | }, [])
64 |
65 | return [serviceRef.current.state, serviceRef.current.send, serviceRef.current]
66 | }
67 |
68 | // service is Interpreter, already started
69 | export const useServiceEx = (service, { debug=false, name=''}) => {
70 | const lastRef = useRef(null)
71 |
72 | // eslint-disable-next-line
73 | const [_, force] = useState(0)
74 |
75 | if(lastRef.current !== service){
76 |
77 | lastRef.current = service
78 |
79 | service.onTransition( state => {
80 |
81 | // unhandled events
82 | if( state.changed === false && debug === true ){
83 | console.error(
84 | `\n\n💣💣💣 [UNHANDLED EVENT][useService]💣💣💣\nEvent=`,
85 | state.event,
86 |
87 | '\nState=',
88 | state.value, state,
89 |
90 | '\nContext=',
91 | state.context,
92 | '\n\n' )
93 |
94 | return
95 | }
96 |
97 | if( debug === true ){
98 | console.group(`%c[useService ${name}]`, 'color: green')
99 | console.log(state.value)
100 | console.log( `ctx=`, state.context )
101 | console.log( 'evt=', state.event )
102 | console.log( '\n', )
103 | console.groupEnd()
104 | }
105 |
106 | force(x => x+1)
107 | })
108 | }
109 |
110 | return [service.state, service.send, service]
111 |
112 | }
113 |
114 | // +TBD
115 | export const useActorEx = p => {}
116 |
117 | // helper
118 | export const dumpState = (item, depth = 1) => {
119 | // if (depth == 1) console.log('\n')
120 |
121 | const MAX_DEPTH = 100
122 | depth = depth || 0
123 | let isString = typeof item === 'string'
124 | let isDeep = depth > MAX_DEPTH
125 |
126 | if (isString || isDeep) {
127 | console.log(item)
128 | return
129 | }
130 |
131 | for (var key in item) {
132 | console.group(key)
133 | dumpState(item[key], depth + 1)
134 | console.groupEnd()
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Goals
3 |
4 | This is an alternative version of how to implement `optimistic update` with `parallel` states to give you a feel of it's true power.
5 |
6 | ## What to see in this example
7 |
8 | - Refactored implementation of `optimistic update` with more detailed states
9 |
10 | - Pay attentions to how the parallel state `optimisticPending` was designed and the events it's repsonsible for
11 |
12 | ```
13 | optimisticPending: {
14 | on: {
15 | // optimistic result - delete item
16 | OPTIMISTIC_DELETE_ITEM_SUCCESS: [
17 | {
18 | target: ['#Root.main.master', '#Root.global.selection.unSelected'],
19 | actions: 'deleteOptimisticItemSuccess',
20 | },
21 | ],
22 | OPTIMISTIC_DELETE_ITEM_FAIL: [
23 | {
24 | target: ['#Root.main.master', '#Root.global.selection.selected'],
25 | actions: 'restoreOptimisticDeleteItem',
26 | },
27 | ],
28 |
29 | // optimistic result - create item
30 | OPTIMISTIC_CREATE_ITEM_SUCCESS: [
31 | {
32 | target: ['#Root.main.master'],
33 | actions: 'createOptimisticItemSuccess',
34 | },
35 | ],
36 | OPTIMISTIC_CREATE_ITEM_FAIL: [
37 | {
38 | target: ['#Root.main.master'],
39 | actions: 'restoreOptimisticNewItem',
40 | },
41 | ],
42 |
43 | // optimistic result - edit item
44 | OPTIMISTIC_EDIT_ITEM_SUCCESS: [
45 | {
46 | target: ['#Root.main.master'],
47 | actions: 'editOptimisticItemSuccess',
48 | },
49 | ],
50 | OPTIMISTIC_EDIT_ITEM_FAIL: [
51 | {
52 | target: ['#Root.main.master'],
53 | actions: 'restoreOptimisticEditItem',
54 | },
55 | ],
56 | }
57 | }
58 | ```
59 |
60 | - Notice `toaster.notify()` was treated as a side effect and can be directly invoked inside `actions`, instead of delegating it via the ui.
61 |
62 |
63 | ## Statechart
64 |
65 | 
66 | All charts generated using [StatesKit](https://stateskit.com)
67 |
68 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/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 | "@testing-library/jest-dom": "^4.1.0",
10 | "@welldone-software/why-did-you-render": "^3.3.5",
11 | "@xstate/react": "^0.7.1",
12 | "classnames": "^2.2.6",
13 | "enumify": "^1.0.4",
14 | "react": "16.9.0",
15 | "react-dom": "16.9.0",
16 | "react-scripts": "3.1.2",
17 | "react-spring": "^8.0.27",
18 | "react-testing-library": "^8.0.1",
19 | "styled-components": "^4.4.0",
20 | "toasted-notes": "^3.0.0",
21 | "todomvc-app-css": "^2.2.0",
22 | "todomvc-common": "^1.0.5",
23 | "uuid-v4": "0.1.0",
24 | "xstate": "@next"
25 | },
26 | "devDependencies": {
27 | "typescript": "3.6.3"
28 | },
29 | "scripts": {
30 | "start": "react-scripts start",
31 | "build": "react-scripts build",
32 | "test": "react-scripts test --env=jsdom",
33 | "eject": "react-scripts eject"
34 | },
35 | "browserslist": [
36 | ">0.2%",
37 | "not dead",
38 | "not ie <= 11",
39 | "not op_mini all"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
18 | React App
19 |
20 |
21 |
22 |
25 |
26 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/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, fireEvent, prettyDOM } from 'react-testing-library'
5 | import { Wrap } from '../components/App'
6 | import { timer } from '../utils/helpers'
7 |
8 | let dom, container
9 |
10 | beforeEach(() => {
11 | dom = render()
12 | container = dom.container
13 | })
14 |
15 | afterEach(cleanup)
16 |
17 | describe('fetch data and display', () => {
18 |
19 | it('has rows of data', () => {
20 | const rows = document.querySelector('#rows')
21 | expect(rows.children.length).toEqual(3)
22 | expect(rows.children.length).not.toEqual(0)
23 | })
24 |
25 | it('add button click', () => {
26 |
27 | const btn = document.querySelector('#btnAdd')
28 | fireEvent.click( btn )
29 |
30 | const form = document.querySelector('form')
31 | const input = document.querySelector('form input')
32 | const btnSubmit = document.querySelector('#btnSubmit')
33 | const btnCancel = document.querySelector('#btnCancel')
34 |
35 | expect(form).not.toBeNull()
36 | expect(input).toHaveAttribute('value', '')
37 | expect(btnSubmit).not.toBeNull()
38 | expect(btnCancel).not.toBeNull()
39 | })
40 |
41 | it('add new item', async () => {
42 |
43 | const btn = document.querySelector('#btnAdd')
44 | fireEvent.click( btn )
45 |
46 | const input = document.querySelector('form input')
47 | const btnSubmit = document.querySelector('#btnSubmit')
48 | const value = 'foobar'
49 |
50 | fireEvent.change(input, { target: {value: 'foobar'}})
51 | expect(input).toHaveAttribute('value', value)
52 |
53 | fireEvent.click( btnSubmit )
54 |
55 | const newElem = dom.getByText(/foobar/i).textContent
56 | expect( newElem ).not.toBeUndefined()
57 |
58 | const a = await dom.findByText(/label_foobar/i)
59 | expect(a).toBeTruthy()
60 | console.log( 'aaa:', a.textContent )
61 | })
62 |
63 | it('select item to have bg color and edit button enabled ', () => {
64 |
65 | // div.span
66 | const row1 = document.querySelector('#rows').firstChild.firstChild
67 | fireEvent.click( row1 )
68 |
69 | const row2 = document.querySelector('[style*="background-color"]')
70 | expect(row2).not.toBeNull()
71 | expect(row2).toHaveAttribute('style', 'background-color: pink;')
72 |
73 | const btn = document.querySelector('#btnEdit')
74 | expect(btn).not.toHaveAttribute('disabled')
75 |
76 | })
77 |
78 | it('edit item - display', () => {
79 |
80 | // div.span
81 | const row1 = document.querySelector('#rows').firstChild.firstChild
82 | fireEvent.click( row1 )
83 |
84 | const btn = document.querySelector('#btnEdit')
85 | fireEvent.click( btn )
86 |
87 | const value = row1.textContent.split('-')[1].trim()
88 | const t = dom.getByDisplayValue( new RegExp(value, 'i') )
89 | expect(t).not.toBeNull()
90 | })
91 |
92 | it('edit item - submit', () => {
93 |
94 | // div.span
95 | const row1 = document.querySelector('#rows').firstChild.firstChild
96 | fireEvent.click( row1 )
97 | console.log( 'row1:', row1.textContent )
98 | const btn = document.querySelector('#btnEdit')
99 | fireEvent.click( btn )
100 |
101 | const input = document.querySelector('form input')
102 | const val = 'foobar'
103 |
104 | fireEvent.change(input, { target: {value: 'foobar'}})
105 | expect(input).toHaveAttribute('value', val)
106 |
107 | fireEvent.click( document.querySelector('#btnSubmit') )
108 |
109 | const result = document.querySelectorAll('h2')[1]
110 | console.log( 'result: ', result.textContent )
111 | expect(result.textContent).toEqual(`Content: ${val}`)
112 |
113 | // console.log( prettyDOM(container) )
114 | })
115 |
116 | it('edit item - cancel', () => {
117 |
118 | const row1 = document.querySelector('#rows').firstChild.firstChild
119 | fireEvent.click( row1 )
120 | // console.log( 'row1:', row1.outerHTML )
121 |
122 | fireEvent.click( document.querySelector('#btnEdit') )
123 |
124 | fireEvent.click( document.querySelector('#btnCancel') )
125 |
126 | const oldItem = document.querySelector('[style*="background-color"')
127 | expect(oldItem.outerHTML).toBe(row1.outerHTML)
128 |
129 | // console.log( prettyDOM(container) )
130 | })
131 |
132 | })
133 |
134 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/src/components/styles.css:
--------------------------------------------------------------------------------
1 | .App {
2 | font-family: sans-serif;
3 | text-align: center;
4 | }
5 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/src/fsm/actions.js:
--------------------------------------------------------------------------------
1 | import { send, assign } from 'xstate'
2 | import { getItemById } from '../utils/helpers'
3 | import toaster from 'toasted-notes'
4 | import 'toasted-notes/src/styles.css'
5 |
6 | // helper: toaster invocation as a side effect
7 | const notify = msg => toaster.notify(msg, {
8 | position: 'bottom-right',
9 | })
10 |
11 | /* read item
12 | -------------------------------------------------- */
13 |
14 | export const reloadItems = send(
15 | { type: 'SERVICE.LOAD.ITEMS' }, // the event to be sent
16 | { to: 'ItemService' }, // the target servcie to receive that event
17 | )
18 |
19 | export const listDataSuccess = assign((ctx, evt) => {
20 | ctx.items = evt.data
21 | })
22 |
23 | export const listDataError = assign((ctx, e) => {
24 | ctx.modalData = {
25 | title: 'Fetching list data failed.',
26 | content: `Details: ${e.data}`,
27 | data: e.data,
28 | }
29 | })
30 |
31 | /* delete item
32 | -------------------------------------------------- */
33 |
34 | export const deleteItem = assign((ctx, e) => {
35 | const selectedItem = getItemById(ctx.items, ctx.selectedItemId)
36 | ctx.modalData = {
37 | title: 'Item Removal Confirmation',
38 | content: `Are you sure to delete ${selectedItem.label}?`,
39 | data: selectedItem,
40 | }
41 | ctx.opFrom = e.from
42 | })
43 |
44 | // optimistic update
45 | export const localDeleteItem = assign((ctx, e) => {
46 | const selectedItemId = e.data.id
47 | const newItems = ctx.items.filter(it => it.id !== selectedItemId)
48 | ctx.items = newItems
49 | ctx.selectedItemId = null
50 | ctx.modalData = null
51 | })
52 |
53 | export const remoteDeleteItem = send(
54 | // notify ItemService to delete item and dispatch once the job is completed
55 | { type: 'SERVICE.DELETE.ITEM' },
56 | // designating who to receive this event
57 | { to: 'ItemService' },
58 | )
59 |
60 |
61 | //
62 | export const cancelItemDelete = assign((ctx, e) => {
63 | ctx.modalData = null
64 | })
65 |
66 | // ok
67 | export const restoreOptimisticDeleteItem = assign((ctx, e) => {
68 | const { info, payload } = e.error
69 | const restoreItem = payload
70 | ctx.items.push(restoreItem)
71 | notify(info)
72 | })
73 |
74 | // ok
75 | export const deleteOptimisticItemSuccess = assign((ctx, e) => {
76 | const { result } = e
77 | notify(result.info)
78 | })
79 |
80 |
81 | /* create item
82 | -------------------------------------------------- */
83 |
84 | // ok
85 | export const createItem = assign((ctx, e) => {
86 | const { from } = e
87 | // config which screen to exit to from creating new item screen
88 | ctx.opFrom = from
89 | })
90 |
91 | // ok, optimistic update, insert the item with local id
92 | export const localCreateNewItem = assign((ctx, e) => {
93 | const newItem = e.payload
94 | ctx.items.push(newItem)
95 | ctx.selectedItemId = newItem.id
96 | })
97 |
98 | // ok, then invoke service to persist new item via external api call
99 | export const remoteCreateNewItem = send(
100 | (ctx, e) => ({
101 | type: 'SERVICE.CREATE.ITEM',
102 | payload: e.payload,
103 | }),
104 | { to: 'ItemService' },
105 | )
106 |
107 | // ok, update item with server returned from server when optimistic adding succeeded
108 | export const createOptimisticItemSuccess = assign((ctx, e) => {
109 | const { info, serverItem, localItem } = e.result
110 | notify(info)
111 | ctx.items = ctx.items.map(it => (it.id === localItem.id ? serverItem : it))
112 | ctx.selectedItemId = serverItem.id
113 | ctx.opFrom = null
114 | })
115 |
116 | // ok, resotre item when optimistic adding new item failed
117 | export const restoreOptimisticNewItem = assign((ctx, e) => {
118 | const { info, localItem } = e.error
119 | ctx.items = ctx.items.filter(it => it.id !== localItem.id)
120 | notify(info)
121 | ctx.selectedItemId = null
122 | ctx.opFrom = null
123 | })
124 |
125 |
126 | /* edit item
127 | -------------------------------------------------- */
128 |
129 | // ok
130 | export const editItem = assign((ctx, e) => {
131 | const { from } = e
132 | // config which screen to exit to from creating new item screen
133 | ctx.opFrom = from
134 | })
135 |
136 | // ok, optimistic update, insert the item with local id
137 | export const localEditItem = assign((ctx, e) => {
138 | const edited = e.payload
139 | ctx.items = ctx.items.map(it => (it.id === edited.id ? edited : it))
140 | ctx.selectedItemId = edited.id
141 | ctx.opFrom = null
142 | })
143 |
144 | // ok: then invoke service to persist new item via external api call
145 | export const remoteEditItem = send(
146 | (ctx, e) => ({
147 | type: 'SERVICE.EDIT.ITEM',
148 | editedItem: e.payload,
149 | oldItem: e.oldItem,
150 | }),
151 | { to: 'ItemService' },
152 | )
153 |
154 | // ok: update item with server returned from server when optimistic adding succeeded
155 | export const editOptimisticItemSuccess = assign((ctx, e) => {
156 | const { info, editedItem } = e.result
157 | notify(info)
158 | // replace local item with the one from server, maybe some of it's content had changed
159 | ctx.items = ctx.items.map(it => (it.id === editedItem.id ? editedItem : it))
160 | ctx.selectedItemId = editedItem.id
161 | ctx.opFrom = null
162 | })
163 |
164 | // ok: resotre item when optimistic adding new item failed
165 | export const restoreOptimisticEditItem = assign((ctx, e) => {
166 | const { info, oldItem } = e.error
167 | ctx.items = ctx.items.map(it => it.id === oldItem.id ? oldItem : it)
168 | notify(info)
169 | ctx.selectedItemId = oldItem.id
170 | ctx.opFrom = null
171 | })
172 |
173 |
174 | /* misc
175 | -------------------------------------------------- */
176 |
177 | // ok
178 | export const selectItem = assign((ctx, e) => {
179 | ctx.selectedItemId = e.item.id
180 | })
181 |
182 | // ok
183 | export const modalReset = assign((ctx, e) => {
184 | ctx.modalData = null
185 | })
186 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/src/fsm/guards.js:
--------------------------------------------------------------------------------
1 | export const backToMaster = ctx => ctx.opFrom === 'master'
2 | export const backToDetails = ctx => ctx.opFrom === 'details'
3 | export const cancelToMaster = (_, e) => e.from === 'master'
4 | export const cancelToDetails = (_, e) => e.from === 'details'
5 |
6 | export const catchAll = (ctx, e) => {
7 | debugger // should never be here
8 | }
9 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/src/fsm/machine.js:
--------------------------------------------------------------------------------
1 | import { Machine } from 'xstate'
2 | import * as actions from './actions'
3 | import * as services from './services'
4 | import * as guards from './guards'
5 | import { fsm } from './fsm'
6 |
7 | export const machine = Machine(
8 | fsm,
9 | {
10 | actions,
11 | services,
12 | guards,
13 | },
14 | )
15 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/src/fsm/services.js:
--------------------------------------------------------------------------------
1 | import { randomId, random, getItemById } from '../utils/helpers'
2 |
3 | // A Callback service
4 | // cb() let's up dispatch event to the parent
5 | // onReceive() allows us to receive events from the parent while the service is running
6 | export const itemService = (ctx, e) => (cb, onReceive) => {
7 |
8 | onReceive(evt => {
9 | switch (evt.type) {
10 |
11 | //
12 | case 'SERVICE.LOAD.ITEMS':
13 | const fakeItem = () => {
14 | const id = randomId()
15 | const d = {
16 | id,
17 | label: `Label_${id}`,
18 | }
19 | return d
20 | }
21 |
22 | // instead of fetching data via API, we fake them here
23 | const arr = [fakeItem(), fakeItem(), fakeItem()]
24 |
25 | console.log( '\nfetched: ', arr )
26 |
27 | // eslint-disable-next-line
28 | const t = random(300, 2000)
29 |
30 | setTimeout(() => {
31 |
32 | // for test only
33 | // randomly trigger happy and sorrow path to test both scenarios
34 | // if((t % 2) == 0 ){
35 | if(true){
36 | // if(false){
37 | // if fetching succeeded
38 | cb({
39 | type: 'LOAD_ITEM_SUCCESS',
40 | data: arr,
41 | })
42 | } else {
43 | // if fetching failed, trigger the sorrow path
44 | cb({
45 | type: 'LOAD_ITEM_FAIL',
46 | data: 'network error',
47 | })
48 | }
49 | }, random(100, 1000))
50 |
51 | break
52 |
53 | case 'SERVICE.DELETE.ITEM':
54 | const { selectedItemId } = ctx
55 |
56 | // eslint-disable-next-line
57 | const item = getItemById(ctx.items, selectedItemId)
58 |
59 | new Promise((resolve, reject) => {
60 | setTimeout(() => {
61 |
62 | // happy path
63 | resolve({
64 | info: `${selectedItemId} deleted succesfully from the server`,
65 | })
66 |
67 | // sorrow path
68 | // reject({
69 | // info: `Delete ${selectedItemId} from server failed, data restored.`,
70 | // payload: item,
71 | // })
72 | }, 1200)
73 | })
74 | .then(result => {
75 | // console.log( '\tconfirmHandler completed: ', result )
76 | cb({
77 | type: 'OPTIMISTIC_DELETE_ITEM_SUCCESS',
78 | result,
79 | })
80 | })
81 | .catch(error => {
82 | cb({
83 | type: 'OPTIMISTIC_DELETE_ITEM_FAIL',
84 | error,
85 | })
86 | })
87 |
88 | break
89 |
90 | // create new item
91 | case 'SERVICE.CREATE.ITEM':
92 | const localItem = evt.payload
93 |
94 | // async side effect
95 | return new Promise((resolve, reject) => {
96 |
97 | // simulate id generated from server, to replace the temporary local id
98 | const serverId = 'server_' + localItem.id.split('tmp_')[1]
99 | setTimeout(() => {
100 |
101 | // happy path
102 | resolve({
103 | info: `${localItem.id} - ${localItem.label} created succesfully on the server`,
104 | serverItem: { ...localItem, id: serverId },
105 | localItem,
106 | })
107 |
108 | // sorrow path
109 | // reject({
110 | // info: `Create item: ${localItem.id} on server failed, data restored`,
111 | // localItem,
112 | // })
113 | }, 1000)
114 | })
115 | .then(result => {
116 | cb({
117 | type: 'OPTIMISTIC_CREATE_ITEM_SUCCESS',
118 | result,
119 | })
120 | })
121 | .catch(error => {
122 | cb({
123 | type: 'OPTIMISTIC_CREATE_ITEM_FAIL',
124 | error,
125 | })
126 | })
127 |
128 | // edit item
129 | case 'SERVICE.EDIT.ITEM':
130 |
131 | // eslint-disable-next-line
132 | const { editedItem, oldItem } = evt
133 |
134 | // async side effect
135 | return new Promise((resolve, reject) => {
136 |
137 | setTimeout(() => {
138 | // happy path
139 | // simulating itm returned from server has added props
140 | editedItem.modifiedDate = new Date()
141 | resolve({
142 | info: `${editedItem.id} - ${editedItem.label} edited succesfully on the server`,
143 | editedItem,
144 | })
145 |
146 | // sorrow path
147 | // reject({
148 | // info: `Edit item: ${oldItem.id} on server failed, data restored`,
149 | // oldItem,
150 | // })
151 | }, 1000)
152 | })
153 | .then(result => {
154 | cb({
155 | type: 'OPTIMISTIC_EDIT_ITEM_SUCCESS',
156 | result,
157 | })
158 | })
159 | .catch(error => {
160 | cb({
161 | type: 'OPTIMISTIC_EDIT_ITEM_FAIL',
162 | error,
163 | })
164 | })
165 |
166 | default:
167 | console.log('unhandled method call=', evt.type)
168 | }
169 | })
170 | }
171 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { Wrap } from './components/app'
4 |
5 | const rootElement = document.getElementById('root')
6 | ReactDOM.render(, rootElement)
7 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/src/utils/helpers.js:
--------------------------------------------------------------------------------
1 | export const uuid = () => {
2 | return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
3 | // eslint-disable-next-line
4 | (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
5 | )
6 | }
7 |
8 | export const randomId = () => Math.floor(Math.random()*999)
9 |
10 | export const noop = () => {}
11 |
12 | // dump state tree in string format
13 | export const dumpState = (item, depth = 1) => {
14 | // if (depth == 1) console.log('\n')
15 |
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 | dumpState(item[key], depth + 1)
29 | console.groupEnd()
30 | }
31 | }
32 |
33 | // for fsm test
34 | export const dump = svc => {
35 | if(svc.state){
36 | console.log(
37 | '\n--------------------------------------\n[state]',
38 | svc.state.value,
39 | '\n [ctx]',
40 | svc.state.context,
41 | '\n--------------------------------------',
42 | )
43 | }else{
44 | console.log( '是空的: ', svc )
45 | }
46 | }
47 |
48 |
49 | export const current = state => state.toStrings().pop()
50 |
51 | export const timer = time => {
52 | return new Promise((resolve, reject) => {
53 | setTimeout(resolve, time)
54 | })
55 | }
56 |
57 | export const randomFloat = (min=0, max=999) => {
58 | return Math.random() * (max - min) + min;
59 | }
60 |
61 | export const random = (min=0, max=999) => {
62 | min = Math.ceil(min);
63 | max = Math.floor(max);
64 | return Math.floor(Math.random() * (max - min + 1)) + min;
65 | }
66 |
67 | export const getItemById = (items, id) => items.find(it => it.id === id)
68 |
--------------------------------------------------------------------------------
/crud-v2-optimistic-update/src/utils/useMyHooks.js:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from 'react'
2 | import {
3 | interpret,
4 | } from 'xstate'
5 |
6 | // machine is raw state machine, will run it with the interpreter
7 | export const useMachineEx = (machine, { debug=false, name='', interpreterOptions={}}) => {
8 | // eslint-disable-next-line
9 | const [_, force] = useState(0)
10 | const machineRef = useRef(null)
11 | const serviceRef = useRef() // started Interpreter
12 |
13 | if(machineRef.current !== machine){
14 |
15 | machineRef.current = machine
16 |
17 | serviceRef.current = interpret(machineRef.current, interpreterOptions)
18 | .onTransition( state => {
19 |
20 | if(state.event.type === 'xstate.init') {
21 | // debugger //
22 | return
23 | }
24 | //
25 | if( state.changed === false && debug === true ){
26 | console.error(
27 | `\n\n💣💣💣 [UNHANDLED EVENT][useMachine]💣💣💣\nEvent=`,
28 | state.event,
29 |
30 | '\nState=',
31 | state.value, state,
32 |
33 | '\nContext=',
34 | state.context,
35 | '\n\n' )
36 |
37 | return
38 | }
39 |
40 | if( debug === true ){
41 | console.group(`%c[useMachine ${name}]`, 'color: darkblue')
42 | dumpState(state.value)
43 | console.log( 'ctx=', state.context )
44 | console.log( 'evt=', state.event )
45 | console.log( '\n', )
46 | console.groupEnd()
47 | }
48 |
49 | // re-render if the state changed
50 | force(x => x+1)
51 | })
52 |
53 | // start immediately, as it's in the constructor
54 | serviceRef.current.start()
55 | }
56 |
57 | // didMount
58 | useEffect(() => {
59 | return () => {
60 | console.log( 'useMachine unload')
61 | serviceRef.current.stop()
62 | }
63 | }, [])
64 |
65 | return [serviceRef.current.state, serviceRef.current.send, serviceRef.current]
66 | }
67 |
68 | // service is Interpreter, already started
69 | export const useServiceEx = (service, { debug=false, name=''}) => {
70 | const lastRef = useRef(null)
71 |
72 | // eslint-disable-next-line
73 | const [_, force] = useState(0)
74 |
75 | if(lastRef.current !== service){
76 |
77 | lastRef.current = service
78 |
79 | service.onTransition( state => {
80 |
81 | // unhandled events
82 | if( state.changed === false && debug === true ){
83 | console.error(
84 | `\n\n💣💣💣 [UNHANDLED EVENT][useService]💣💣💣\nEvent=`,
85 | state.event,
86 |
87 | '\nState=',
88 | state.value, state,
89 |
90 | '\nContext=',
91 | state.context,
92 | '\n\n' )
93 |
94 | return
95 | }
96 |
97 | if( debug === true ){
98 | console.group(`%c[useService ${name}]`, 'color: green')
99 | console.log(state.value)
100 | console.log( `ctx=`, state.context )
101 | console.log( 'evt=', state.event )
102 | console.log( '\n', )
103 | console.groupEnd()
104 | }
105 |
106 | force(x => x+1)
107 | })
108 | }
109 |
110 | return [service.state, service.send, service]
111 |
112 | }
113 |
114 | // +TBD
115 | export const useActorEx = p => {}
116 |
117 | // helper
118 | export const dumpState = (item, depth = 1) => {
119 | // if (depth == 1) console.log('\n')
120 |
121 | const MAX_DEPTH = 100
122 | depth = depth || 0
123 | let isString = typeof item === 'string'
124 | let isDeep = depth > MAX_DEPTH
125 |
126 | if (isString || isDeep) {
127 | console.log(item)
128 | return
129 | }
130 |
131 | for (var key in item) {
132 | console.group(key)
133 | dumpState(item[key], depth + 1)
134 | console.groupEnd()
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/crud-v3-promises/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Goals
3 |
4 | This example demos using `Promise` instead of `Callback` for handling async operations (API invocation, database operation and any side-effects)
5 |
6 | ## What to see in this example
7 |
8 | - See `services.js` for details, pay attentions to `loadItems` and `deleteItems`
9 |
10 | ```js
11 | export const loadItems = (ctx, e) => {
12 |
13 | const t = random(300, 1000)
14 |
15 | return new Promise((resolve, reject) => {
16 | setTimeout(() => {
17 |
18 | const fakeItem = () => {
19 | const id = randomId()
20 | const d = {
21 | id,
22 | label: `Label_${id}`,
23 | }
24 | return d
25 | }
26 |
27 | // instead of fetching data via API, we fake them here
28 | const arr = [fakeItem(), fakeItem(), fakeItem()]
29 |
30 | console.log( '\nfetched: ', arr )
31 |
32 | // for test only
33 | // randomly trigger happy and sorrow path to test both scenarios
34 | // if((t % 2) == 0 ){
35 | if(true){
36 | // if(false){
37 | resolve(arr)
38 | } else {
39 | reject('network error')
40 | }
41 | }, t)
42 | })
43 | }
44 | ```
45 |
46 | - Most of the time you will want to use `Callback` for that it can communicate with it's parent by sending multiple events, but for simpler tasks `Promise` might work as well, just bear in one you only get one chance to communicate with the parent (when the Promise was resolved or rejected)
47 |
48 | ## Statechart
49 |
50 | 
51 | All charts generated using [StatesKit](https://stateskit.com)
52 |
--------------------------------------------------------------------------------
/crud-v3-promises/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 | "@testing-library/jest-dom": "^4.1.0",
10 | "@welldone-software/why-did-you-render": "^3.3.5",
11 | "@xstate/react": "^0.7.1",
12 | "classnames": "^2.2.6",
13 | "enumify": "^1.0.4",
14 | "react": "16.9.0",
15 | "react-dom": "16.9.0",
16 | "react-scripts": "3.1.2",
17 | "react-spring": "^8.0.27",
18 | "react-testing-library": "^8.0.1",
19 | "styled-components": "^4.4.0",
20 | "toasted-notes": "^3.0.0",
21 | "todomvc-app-css": "^2.2.0",
22 | "todomvc-common": "^1.0.5",
23 | "uuid-v4": "0.1.0",
24 | "xstate": "@next"
25 | },
26 | "devDependencies": {
27 | "typescript": "3.6.3"
28 | },
29 | "scripts": {
30 | "start": "react-scripts start",
31 | "build": "react-scripts build",
32 | "test": "react-scripts test --env=jsdom",
33 | "eject": "react-scripts eject"
34 | },
35 | "browserslist": [
36 | ">0.2%",
37 | "not dead",
38 | "not ie <= 11",
39 | "not op_mini all"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/crud-v3-promises/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
18 | React App
19 |
20 |
21 |
22 |
25 |
26 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/crud-v3-promises/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, fireEvent, prettyDOM } from 'react-testing-library'
5 | import { Wrap } from '../components/App'
6 | import { timer } from '../utils/helpers'
7 |
8 | let dom, container
9 |
10 | beforeEach(() => {
11 | dom = render()
12 | container = dom.container
13 | })
14 |
15 | afterEach(cleanup)
16 |
17 | describe('fetch data and display', () => {
18 |
19 | it('has rows of data', () => {
20 | const rows = document.querySelector('#rows')
21 | expect(rows.children.length).toEqual(3)
22 | expect(rows.children.length).not.toEqual(0)
23 | })
24 |
25 | it('add button click', () => {
26 |
27 | const btn = document.querySelector('#btnAdd')
28 | fireEvent.click( btn )
29 |
30 | const form = document.querySelector('form')
31 | const input = document.querySelector('form input')
32 | const btnSubmit = document.querySelector('#btnSubmit')
33 | const btnCancel = document.querySelector('#btnCancel')
34 |
35 | expect(form).not.toBeNull()
36 | expect(input).toHaveAttribute('value', '')
37 | expect(btnSubmit).not.toBeNull()
38 | expect(btnCancel).not.toBeNull()
39 | })
40 |
41 | it('add new item', async () => {
42 |
43 | const btn = document.querySelector('#btnAdd')
44 | fireEvent.click( btn )
45 |
46 | const input = document.querySelector('form input')
47 | const btnSubmit = document.querySelector('#btnSubmit')
48 | const value = 'foobar'
49 |
50 | fireEvent.change(input, { target: {value: 'foobar'}})
51 | expect(input).toHaveAttribute('value', value)
52 |
53 | fireEvent.click( btnSubmit )
54 |
55 | const newElem = dom.getByText(/foobar/i).textContent
56 | expect( newElem ).not.toBeUndefined()
57 |
58 | const a = await dom.findByText(/label_foobar/i)
59 | expect(a).toBeTruthy()
60 | console.log( 'aaa:', a.textContent )
61 | })
62 |
63 | it('select item to have bg color and edit button enabled ', () => {
64 |
65 | // div.span
66 | const row1 = document.querySelector('#rows').firstChild.firstChild
67 | fireEvent.click( row1 )
68 |
69 | const row2 = document.querySelector('[style*="background-color"]')
70 | expect(row2).not.toBeNull()
71 | expect(row2).toHaveAttribute('style', 'background-color: pink;')
72 |
73 | const btn = document.querySelector('#btnEdit')
74 | expect(btn).not.toHaveAttribute('disabled')
75 |
76 | })
77 |
78 | it('edit item - display', () => {
79 |
80 | // div.span
81 | const row1 = document.querySelector('#rows').firstChild.firstChild
82 | fireEvent.click( row1 )
83 |
84 | const btn = document.querySelector('#btnEdit')
85 | fireEvent.click( btn )
86 |
87 | const value = row1.textContent.split('-')[1].trim()
88 | const t = dom.getByDisplayValue( new RegExp(value, 'i') )
89 | expect(t).not.toBeNull()
90 | })
91 |
92 | it('edit item - submit', () => {
93 |
94 | // div.span
95 | const row1 = document.querySelector('#rows').firstChild.firstChild
96 | fireEvent.click( row1 )
97 | console.log( 'row1:', row1.textContent )
98 | const btn = document.querySelector('#btnEdit')
99 | fireEvent.click( btn )
100 |
101 | const input = document.querySelector('form input')
102 | const val = 'foobar'
103 |
104 | fireEvent.change(input, { target: {value: 'foobar'}})
105 | expect(input).toHaveAttribute('value', val)
106 |
107 | fireEvent.click( document.querySelector('#btnSubmit') )
108 |
109 | const result = document.querySelectorAll('h2')[1]
110 | console.log( 'result: ', result.textContent )
111 | expect(result.textContent).toEqual(`Content: ${val}`)
112 |
113 | // console.log( prettyDOM(container) )
114 | })
115 |
116 | it('edit item - cancel', () => {
117 |
118 | const row1 = document.querySelector('#rows').firstChild.firstChild
119 | fireEvent.click( row1 )
120 | // console.log( 'row1:', row1.outerHTML )
121 |
122 | fireEvent.click( document.querySelector('#btnEdit') )
123 |
124 | fireEvent.click( document.querySelector('#btnCancel') )
125 |
126 | const oldItem = document.querySelector('[style*="background-color"')
127 | expect(oldItem.outerHTML).toBe(row1.outerHTML)
128 |
129 | // console.log( prettyDOM(container) )
130 | })
131 |
132 | })
133 |
134 |
--------------------------------------------------------------------------------
/crud-v3-promises/src/components/styles.css:
--------------------------------------------------------------------------------
1 | .App {
2 | font-family: sans-serif;
3 | text-align: center;
4 | }
5 |
--------------------------------------------------------------------------------
/crud-v3-promises/src/fsm/actions.js:
--------------------------------------------------------------------------------
1 | import { send, assign } from 'xstate'
2 | import { getItemById } from '../utils/helpers'
3 | import toaster from 'toasted-notes'
4 | import 'toasted-notes/src/styles.css'
5 |
6 | // helper: toaster invocation as a side effect
7 | const notify = msg => toaster.notify(msg, {
8 | position: 'bottom-right',
9 | })
10 |
11 | /* read item
12 | -------------------------------------------------- */
13 |
14 | export const reloadItems = send(
15 | { type: 'SERVICE.LOAD.ITEMS' }, // the event to be sent
16 | { to: 'ItemService' }, // the target servcie to receive that event
17 | )
18 |
19 | export const listDataSuccess = assign((ctx, evt) => {
20 | ctx.items = evt.data
21 | })
22 |
23 | export const listDataError = assign((ctx, e) => {
24 | ctx.modalData = {
25 | title: 'Fetching list data failed.',
26 | content: `Details: ${e.data}`,
27 | data: e.data,
28 | }
29 | })
30 |
31 | /* delete item
32 | -------------------------------------------------- */
33 |
34 | export const deleteItem = assign((ctx, e) => {
35 | const selectedItem = getItemById(ctx.items, ctx.selectedItemId)
36 | ctx.modalData = {
37 | title: 'Item Removal Confirmation',
38 | content: `Are you sure to delete ${selectedItem.label}?`,
39 | data: selectedItem,
40 | }
41 | ctx.opFrom = e.from
42 | })
43 |
44 | //
45 | export const cancelItemDelete = assign((ctx, e) => {
46 | ctx.modalData = null
47 | })
48 |
49 | // ok
50 | export const restoreOptimisticDeleteItem = assign((ctx, e) => {
51 | const { info, payload } = e.data
52 | const restoreItem = payload
53 | ctx.items.push(restoreItem)
54 | notify(info)
55 | })
56 |
57 | // ok
58 | export const deleteOptimisticItemSuccess = assign((ctx, e) => {
59 | const { info } = e.data
60 | notify(info)
61 | })
62 |
63 |
64 | /* create item
65 | -------------------------------------------------- */
66 |
67 | // ok
68 | export const createItem = assign((ctx, e) => {
69 | const { from } = e
70 | // config which screen to exit to from creating new item screen
71 | ctx.opFrom = from
72 | })
73 |
74 | // ok, optimistic update, insert the item with local id
75 | export const localCreateNewItem = assign((ctx, e) => {
76 | const newItem = e.payload
77 | ctx.items.push(newItem)
78 | ctx.selectedItemId = newItem.id
79 | })
80 |
81 | // ok, then invoke service to persist new item via external api call
82 | export const remoteCreateNewItem = send(
83 | (ctx, e) => ({
84 | type: 'SERVICE.CREATE.ITEM',
85 | payload: e.payload,
86 | }),
87 | { to: 'ItemService' },
88 | )
89 |
90 | // ok, update item with server returned from server when optimistic adding succeeded
91 | export const createOptimisticItemSuccess = assign((ctx, e) => {
92 | const { info, serverItem, localItem } = e.result
93 | notify(info)
94 | ctx.items = ctx.items.map(it => (it.id === localItem.id ? serverItem : it))
95 | ctx.selectedItemId = serverItem.id
96 | ctx.opFrom = null
97 | })
98 |
99 | // ok, resotre item when optimistic adding new item failed
100 | export const restoreOptimisticNewItem = assign((ctx, e) => {
101 | const { info, localItem } = e.error
102 | ctx.items = ctx.items.filter(it => it.id !== localItem.id)
103 | notify(info)
104 | ctx.selectedItemId = null
105 | ctx.opFrom = null
106 | })
107 |
108 |
109 | /* edit item
110 | -------------------------------------------------- */
111 |
112 | // ok
113 | export const editItem = assign((ctx, e) => {
114 | const { from } = e
115 | // config which screen to exit to from creating new item screen
116 | ctx.opFrom = from
117 | })
118 |
119 | // ok, optimistic update, insert the item with local id
120 | export const localEditItem = assign((ctx, e) => {
121 | const edited = e.payload
122 | ctx.items = ctx.items.map(it => (it.id === edited.id ? edited : it))
123 | ctx.selectedItemId = edited.id
124 | ctx.opFrom = null
125 | })
126 |
127 | // ok: then invoke service to persist new item via external api call
128 | export const remoteEditItem = send(
129 | (ctx, e) => ({
130 | type: 'SERVICE.EDIT.ITEM',
131 | editedItem: e.payload,
132 | oldItem: e.oldItem,
133 | }),
134 | { to: 'ItemService' },
135 | )
136 |
137 | // ok: update item with server returned from server when optimistic adding succeeded
138 | export const editOptimisticItemSuccess = assign((ctx, e) => {
139 | const { info, editedItem } = e.result
140 | notify(info)
141 | // replace local item with the one from server, maybe some of it's content had changed
142 | ctx.items = ctx.items.map(it => (it.id === editedItem.id ? editedItem : it))
143 | ctx.selectedItemId = editedItem.id
144 | ctx.opFrom = null
145 | })
146 |
147 | // ok: resotre item when optimistic adding new item failed
148 | export const restoreOptimisticEditItem = assign((ctx, e) => {
149 | const { info, oldItem } = e.error
150 | ctx.items = ctx.items.map(it => it.id === oldItem.id ? oldItem : it)
151 | notify(info)
152 | ctx.selectedItemId = oldItem.id
153 | ctx.opFrom = null
154 | })
155 |
156 |
157 | /* misc
158 | -------------------------------------------------- */
159 |
160 | // ok
161 | export const selectItem = assign((ctx, e) => {
162 | ctx.selectedItemId = e.item.id
163 | })
164 |
165 | // ok
166 | export const modalReset = assign((ctx, e) => {
167 | ctx.modalData = null
168 | })
169 |
--------------------------------------------------------------------------------
/crud-v3-promises/src/fsm/guards.js:
--------------------------------------------------------------------------------
1 | export const backToMaster = ctx => ctx.opFrom === 'master'
2 | export const backToDetails = ctx => ctx.opFrom === 'details'
3 | export const cancelToMaster = (_, e) => e.from === 'master'
4 | export const cancelToDetails = (_, e) => e.from === 'details'
5 |
6 | export const catchAll = (ctx, e) => {
7 | debugger // should never be here
8 | }
9 |
--------------------------------------------------------------------------------
/crud-v3-promises/src/fsm/machine.js:
--------------------------------------------------------------------------------
1 | import { Machine } from 'xstate'
2 | import * as actions from './actions'
3 | import * as services from './services'
4 | import * as guards from './guards'
5 | import { fsm } from './fsm'
6 |
7 | export const machine = Machine(
8 | fsm,
9 | {
10 | actions,
11 | services,
12 | guards,
13 | },
14 | )
15 |
--------------------------------------------------------------------------------
/crud-v3-promises/src/fsm/services.js:
--------------------------------------------------------------------------------
1 | import { randomId, random, getItemById } from '../utils/helpers'
2 |
3 | // A Callback service
4 | // cb() let's up dispatch event to the parent
5 | // onReceive() allows us to receive events from the parent while the service is running
6 | export const itemService = (ctx, e) => (cb, onReceive) => {
7 |
8 | onReceive(evt => {
9 | switch (evt.type) {
10 |
11 | // create new item
12 | case 'SERVICE.CREATE.ITEM':
13 | const localItem = evt.payload
14 |
15 | // async side effect
16 | return new Promise((resolve, reject) => {
17 |
18 | // simulate id generated from server, to replace the temporary local id
19 | const serverId = 'server_' + localItem.id.split('tmp_')[1]
20 | setTimeout(() => {
21 |
22 | // happy path
23 | resolve({
24 | info: `${localItem.id} - ${localItem.label} created succesfully on the server`,
25 | serverItem: { ...localItem, id: serverId },
26 | localItem,
27 | })
28 |
29 | // sorrow path
30 | // reject({
31 | // info: `Create item: ${localItem.id} on server failed, data restored`,
32 | // localItem,
33 | // })
34 | }, 1000)
35 | })
36 | .then(result => {
37 | cb({
38 | type: 'OPTIMISTIC_CREATE_ITEM_SUCCESS',
39 | result,
40 | })
41 | })
42 | .catch(error => {
43 | cb({
44 | type: 'OPTIMISTIC_CREATE_ITEM_FAIL',
45 | error,
46 | })
47 | })
48 |
49 | // edit item
50 | case 'SERVICE.EDIT.ITEM':
51 |
52 | // eslint-disable-next-line
53 | const { editedItem, oldItem } = evt
54 |
55 | // async side effect
56 | return new Promise((resolve, reject) => {
57 |
58 | setTimeout(() => {
59 | // happy path
60 | // simulating itm returned from server has added props
61 | editedItem.modifiedDate = new Date()
62 | resolve({
63 | info: `${editedItem.id} - ${editedItem.label} edited succesfully on the server`,
64 | editedItem,
65 | })
66 |
67 | // sorrow path
68 | // reject({
69 | // info: `Edit item: ${oldItem.id} on server failed, data restored`,
70 | // oldItem,
71 | // })
72 | }, 1000)
73 | })
74 | .then(result => {
75 | cb({
76 | type: 'OPTIMISTIC_EDIT_ITEM_SUCCESS',
77 | result,
78 | })
79 | })
80 | .catch(error => {
81 | cb({
82 | type: 'OPTIMISTIC_EDIT_ITEM_FAIL',
83 | error,
84 | })
85 | })
86 |
87 | default:
88 | console.log('unhandled method call=', evt.type)
89 | }
90 | })
91 | }
92 |
93 | export const loadItems = (ctx, e) => {
94 |
95 | const t = random(300, 1000)
96 |
97 | return new Promise((resolve, reject) => {
98 | setTimeout(() => {
99 |
100 | const fakeItem = () => {
101 | const id = randomId()
102 | const d = {
103 | id,
104 | label: `Label_${id}`,
105 | }
106 | return d
107 | }
108 |
109 | // instead of fetching data via API, we fake them here
110 | const arr = [fakeItem(), fakeItem(), fakeItem()]
111 |
112 | console.log( '\nfetched: ', arr )
113 |
114 | // for test only
115 | // randomly trigger happy and sorrow path to test both scenarios
116 | // if((t % 2) == 0 ){
117 | if(true){
118 | // if(false){
119 | resolve(arr)
120 | } else {
121 | reject('network error')
122 | }
123 | }, t)
124 | })
125 | }
126 |
127 | //
128 | export const deleteItem = (ctx, e) => {
129 | const { selectedItemId } = ctx
130 |
131 | // eslint-disable-next-line
132 | const item = getItemById(ctx.items, selectedItemId)
133 |
134 | // delete local item immediately
135 | ctx.items = ctx.items.filter(it => it.id !== selectedItemId)
136 | ctx.selectedItemId = null
137 |
138 | return new Promise((resolve, reject) => {
139 | setTimeout(() => {
140 |
141 | // happy path
142 | resolve({
143 | info: `${selectedItemId} deleted succesfully from the server`,
144 | })
145 |
146 | // sorrow path
147 | // reject({
148 | // info: `Delete ${selectedItemId} from server failed, data restored.`,
149 | // payload: item,
150 | // })
151 | }, 1200)
152 | })
153 | }
154 |
--------------------------------------------------------------------------------
/crud-v3-promises/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { Wrap } from './components/app'
4 |
5 | const rootElement = document.getElementById('root')
6 | ReactDOM.render(, rootElement)
7 |
--------------------------------------------------------------------------------
/crud-v3-promises/src/utils/helpers.js:
--------------------------------------------------------------------------------
1 |
2 | export const uuid = () => {
3 | return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
4 | // eslint-disable-next-line
5 | (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
6 | )
7 | }
8 |
9 | export const randomId = () => Math.floor(Math.random()*999)
10 |
11 | export const noop = () => {}
12 |
13 | // dump state tree in string format
14 | export const dumpState = (item, depth = 1) => {
15 | // if (depth == 1) console.log('\n')
16 |
17 | const MAX_DEPTH = 100
18 | depth = depth || 0
19 | let isString = typeof item === 'string'
20 | let isDeep = depth > MAX_DEPTH
21 |
22 | if (isString || isDeep) {
23 | console.log(item)
24 | return
25 | }
26 |
27 | for (var key in item) {
28 | console.group(key)
29 | dumpState(item[key], depth + 1)
30 | console.groupEnd()
31 | }
32 | }
33 |
34 | // for fsm test
35 | export const dump = svc => {
36 | if(svc.state){
37 | console.log(
38 | '\n--------------------------------------\n[state]',
39 | svc.state.value,
40 | '\n [ctx]',
41 | svc.state.context,
42 | '\n--------------------------------------',
43 | )
44 | }else{
45 | console.log( 'Empty: ', svc )
46 | }
47 | }
48 |
49 |
50 | export const current = state => state.toStrings().pop()
51 |
52 | export const timer = time => {
53 | return new Promise((resolve, reject) => {
54 | setTimeout(resolve, time)
55 | })
56 | }
57 |
58 | export const randomFloat = (min=0, max=999) => {
59 | return Math.random() * (max - min) + min;
60 | }
61 |
62 | export const random = (min=0, max=999) => {
63 | min = Math.ceil(min);
64 | max = Math.floor(max);
65 | return Math.floor(Math.random() * (max - min + 1)) + min;
66 | }
67 |
68 | export const getItemById = (items, id) => items.find(it => it.id === id)
69 |
--------------------------------------------------------------------------------
/crud-v3-promises/src/utils/useMyHooks.js:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from 'react'
2 | import {
3 | interpret,
4 | } from 'xstate'
5 |
6 | // machine is raw state machine, will run it with the interpreter
7 | export const useMachineEx = (machine, { debug=false, name='', interpreterOptions={}}) => {
8 | // eslint-disable-next-line
9 | const [_, force] = useState(0)
10 | const machineRef = useRef(null)
11 | const serviceRef = useRef() // started Interpreter
12 |
13 | if(machineRef.current !== machine){
14 |
15 | machineRef.current = machine
16 |
17 | serviceRef.current = interpret(machineRef.current, interpreterOptions)
18 | .onTransition( state => {
19 |
20 | if(state.event.type === 'xstate.init') {
21 | // debugger //
22 | return
23 | }
24 | //
25 | if( state.changed === false && debug === true ){
26 | console.error(
27 | `\n\n💣💣💣 [UNHANDLED EVENT][useMachine]💣💣💣\nEvent=`,
28 | state.event,
29 |
30 | '\nState=',
31 | state.value, state,
32 |
33 | '\nContext=',
34 | state.context,
35 | '\n\n' )
36 |
37 | return
38 | }
39 |
40 | if( debug === true ){
41 | console.group(`%c[useMachine ${name}]`, 'color: darkblue')
42 | dumpState(state.value)
43 | console.log( 'ctx=', state.context )
44 | console.log( 'evt=', state.event )
45 | console.log( '\n', )
46 | console.groupEnd()
47 | }
48 |
49 | // re-render if the state changed
50 | force(x => x+1)
51 | })
52 |
53 | // start immediately, as it's in the constructor
54 | serviceRef.current.start()
55 | }
56 |
57 | // didMount
58 | useEffect(() => {
59 | return () => {
60 | console.log( 'useMachine unload')
61 | serviceRef.current.stop()
62 | }
63 | }, [])
64 |
65 | return [serviceRef.current.state, serviceRef.current.send, serviceRef.current]
66 | }
67 |
68 | // service is Interpreter, already started
69 | export const useServiceEx = (service, { debug=false, name=''}) => {
70 | const lastRef = useRef(null)
71 |
72 | // eslint-disable-next-line
73 | const [_, force] = useState(0)
74 |
75 | if(lastRef.current !== service){
76 |
77 | lastRef.current = service
78 |
79 | service.onTransition( state => {
80 |
81 | // unhandled events
82 | if( state.changed === false && debug === true ){
83 | console.error(
84 | `\n\n💣💣💣 [UNHANDLED EVENT][useService]💣💣💣\nEvent=`,
85 | state.event,
86 |
87 | '\nState=',
88 | state.value, state,
89 |
90 | '\nContext=',
91 | state.context,
92 | '\n\n' )
93 |
94 | return
95 | }
96 |
97 | if( debug === true ){
98 | console.group(`%c[useService ${name}]`, 'color: green')
99 | console.log(state.value)
100 | console.log( `ctx=`, state.context )
101 | console.log( 'evt=', state.event )
102 | console.log( '\n', )
103 | console.groupEnd()
104 | }
105 |
106 | force(x => x+1)
107 | })
108 | }
109 |
110 | return [service.state, service.send, service]
111 |
112 | }
113 |
114 | // +TBD
115 | export const useActorEx = p => {}
116 |
117 | // helper
118 | export const dumpState = (item, depth = 1) => {
119 | // if (depth == 1) console.log('\n')
120 |
121 | const MAX_DEPTH = 100
122 | depth = depth || 0
123 | let isString = typeof item === 'string'
124 | let isDeep = depth > MAX_DEPTH
125 |
126 | if (isString || isDeep) {
127 | console.log(item)
128 | return
129 | }
130 |
131 | for (var key in item) {
132 | console.group(key)
133 | dumpState(item[key], depth + 1)
134 | console.groupEnd()
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/crud-v4-actors/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/crud-v4-actors/README.md:
--------------------------------------------------------------------------------
1 | ## Goals
2 |
3 | This demo showcased how to model multi-thread application with `xstate` which requires frequent parent <-> child communcation, and how to separate concerns between parent and child machines using the `Actor` model.
4 |
5 | ## What to see in this example?
6 |
7 | - Using `spawn` to create `actor` for child screen
8 |
9 | - How child component hooks up with `actor`(`todoMachine`) by passing the ref around via `props`
10 |
11 | - Communcation between `todoMachine` and `todosMachine` using `sned` and `sendParent`
12 |
13 | - Provided alternative implementation of `useMachine` and `useService` which eliminates unnecessary redraws as much as possible, it it in `useMyHooks.js`
14 |
15 | - Replaced `focusInput` and `service.execute` from 'todo.jsx' with a more easy to understand approach to handle content changes
16 |
17 | - Fixed a bug when hitting `esc` key will trigger both `cancel` and `blur` events with the latter not being handled
18 |
19 | ## Statechart
20 | 
21 | 
22 | All charts generated using [StatesKit](https://stateskit.com)
23 |
--------------------------------------------------------------------------------
/crud-v4-actors/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 | "@testing-library/jest-dom": "^4.1.0",
10 | "@welldone-software/why-did-you-render": "^3.3.5",
11 | "@xstate/react": "^0.7.1",
12 | "classnames": "^2.2.6",
13 | "enumify": "^1.0.4",
14 | "react": "16.9.0",
15 | "react-dom": "16.9.0",
16 | "react-scripts": "3.1.2",
17 | "react-spring": "^8.0.27",
18 | "react-testing-library": "^8.0.1",
19 | "styled-components": "^4.4.0",
20 | "toasted-notes": "^3.0.0",
21 | "todomvc-app-css": "^2.2.0",
22 | "todomvc-common": "^1.0.5",
23 | "uuid-v4": "0.1.0",
24 | "xstate": "@next"
25 | },
26 | "devDependencies": {
27 | "typescript": "3.6.3"
28 | },
29 | "scripts": {
30 | "start": "react-scripts start",
31 | "build": "react-scripts build",
32 | "test": "react-scripts test --env=jsdom",
33 | "eject": "react-scripts eject"
34 | },
35 | "browserslist": [
36 | ">0.2%",
37 | "not dead",
38 | "not ie <= 11",
39 | "not op_mini all"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/crud-v4-actors/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
16 | React App
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Template • TodoMVC
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
42 |
43 |
44 |
45 |
46 |
47 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/crud-v4-actors/src/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [package.json]
11 | indent_style = space
12 | indent_size = 2
13 |
14 | [*.md]
15 | trim_trailing_whitespace = false
16 |
--------------------------------------------------------------------------------
/crud-v4-actors/src/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 |
--------------------------------------------------------------------------------
/crud-v4-actors/src/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/crud-v4-actors/src/Todo.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react'
2 | import { useServiceEx } from './useMyHooks'
3 | import cn from 'classnames'
4 |
5 | export const Todo = ({ todoRef }) => {
6 |
7 | const [state, send] = useServiceEx(todoRef, { debug: true, name: 'Todo_Child'})
8 | const inputRef = useRef(null)
9 |
10 | const { id, title, completed } = state.context
11 | const isEditing = state.matches('editing')
12 |
13 | useEffect(() => {
14 | // only select the text when first entering `editing` state
15 | if(isEditing && state.history.value !== state.value) {
16 | inputRef.current.select()
17 | }
18 | }, [state, isEditing])
19 |
20 | console.log( '\n\tChild render')
21 |
22 | return (
23 |
31 |
32 |
33 | todoRef.send('TOGGLE_COMPLETE')}
37 | value={completed}
38 | checked={completed}
39 | />
40 | {' '}
45 |
47 |
48 | {
53 | // when hitting ESC, 'CANCEL' event will be triggered first and switch to `reading.pending`
54 | // so when later 'BLUR' event happens there will be no one there to handle it
55 | // hence we check it's the correct state before dispatching the event
56 | state.matches('editing') && send('BLUR')
57 | }}
58 |
59 | onChange={e => send('CHANGE', { value: e.target.value })}
60 |
61 | onKeyPress={e => {
62 | if (e.key === 'Enter') {
63 | send('COMMIT')
64 | }
65 | }}
66 |
67 | onKeyDown={e => {
68 | if (e.key === 'Escape') send('CANCEL')
69 | }}
70 |
71 | ref={inputRef}
72 | />
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/crud-v4-actors/src/Todos.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import cn from "classnames";
3 | import "todomvc-app-css/index.css";
4 | import { useHashChange } from "./useHashChange";
5 | import { Todo } from "./Todo";
6 | import { todosMachine } from "./todosMachine";
7 | import { useMachineEx } from './useMyHooks'
8 |
9 | function filterTodos(state, todos) {
10 | if (state.matches("active")) {
11 | return todos.filter(todo => !todo.completed);
12 | }
13 |
14 | if (state.matches("completed")) {
15 | return todos.filter(todo => todo.completed);
16 | }
17 |
18 | return todos;
19 | }
20 |
21 | const persistedTodosMachine = todosMachine.withConfig(
22 | {
23 | actions: {
24 | persist: ctx => {
25 | localStorage.setItem("todos-xstate", JSON.stringify(ctx.todos));
26 | }
27 | }
28 | },
29 |
30 | {
31 | todo: "Learn state machines",
32 |
33 | // IIFE
34 | todos: (() => {
35 | try {
36 | return JSON.parse(localStorage.getItem("todos-xstate")) || [];
37 | } catch (e) {
38 | return [];
39 | }
40 | })()
41 | }
42 | );
43 |
44 | export function Todos() {
45 |
46 | const [state, send ] = useMachineEx(persistedTodosMachine, {debug: true, name: 'Parent'});
47 |
48 | console.log( '\nParent render' )
49 |
50 | useHashChange(() => {
51 | send(`SHOW.${window.location.hash.slice(2) || "all"}`);
52 | });
53 |
54 | const { todos, todo } = state.context;
55 |
56 | const numActiveTodos = todos.filter(todo => !todo.completed).length;
57 | const allCompleted = todos.length > 0 && numActiveTodos === 0;
58 | const mark = !allCompleted ? "completed" : "active";
59 | const markEvent = `MARK.${mark}`;
60 | const filteredTodos = filterTodos(state, todos);
61 |
62 |
63 | return (
64 |
65 |
80 |
101 |
102 | {!!todos.length && (
103 |
149 | )}
150 |
151 | );
152 | }
153 |
--------------------------------------------------------------------------------
/crud-v4-actors/src/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import React from "react";
3 | import ReactDOM from "react-dom";
4 | import "todomvc-app-css/index.css";
5 |
6 | import { Todos } from "./Todos";
7 |
8 | ReactDOM.render(, document.querySelector("#app"));
9 |
--------------------------------------------------------------------------------
/crud-v4-actors/src/todoMachine.js:
--------------------------------------------------------------------------------
1 | import { Machine, actions, sendParent } from "xstate";
2 | const { assign } = actions;
3 |
4 | export const todoMachine = Machine({
5 | id: "todo",
6 | initial: "reading",
7 |
8 | context: {
9 | id: undefined,
10 | title: "",
11 | prevTitle: ""
12 | },
13 |
14 | on: {
15 | DELETE: "deleted"
16 | },
17 |
18 | //
19 | states: {
20 | //
21 | reading: {
22 | initial: "unknown",
23 | states: {
24 |
25 | unknown: {
26 | on: {
27 | "": [
28 | { target: "completed", cond: ctx => ctx.completed },
29 | { target: "pending" }
30 | ]
31 | }
32 | },
33 |
34 | pending: {
35 | on: {
36 | TOGGLE_COMPLETE: {
37 | target: "completed",
38 | actions: [
39 | assign({ completed: true }),
40 | sendParent(ctx => ({ type: "TODO.COMMIT", todo: ctx }))
41 | ]
42 | },
43 |
44 | SET_COMPLETED: {
45 | target: "completed",
46 | actions: [
47 | assign({ completed: true }),
48 | sendParent(ctx => ({ type: "TODO.COMMIT", todo: ctx }))
49 | ]
50 | }
51 | }
52 | },
53 |
54 | //
55 | completed: {
56 | on: {
57 | TOGGLE_COMPLETE: {
58 | target: "pending",
59 | actions: [
60 | assign({ completed: false }),
61 | sendParent(ctx => ({ type: "TODO.COMMIT", todo: ctx }))
62 | ]
63 | },
64 |
65 | SET_ACTIVE: {
66 | target: "pending",
67 | actions: [
68 | assign({ completed: false }),
69 | sendParent(ctx => ({ type: "TODO.COMMIT", todo: ctx }))
70 | ]
71 | }
72 | }
73 | },
74 |
75 | hist: {
76 | type: "history"
77 | }
78 | },
79 | on: {
80 | EDIT: {
81 | target: "editing",
82 | }
83 | }
84 | },
85 |
86 | //
87 | editing: {
88 | onEntry: assign({ prevTitle: ctx => ctx.title }),
89 | on: {
90 |
91 | CHANGE: {
92 | actions: assign({
93 | title: (ctx, e) => e.value
94 | })
95 | },
96 |
97 | COMMIT: [
98 | {
99 | target: "reading.hist",
100 | actions: sendParent(ctx => ({ type: "TODO.COMMIT", todo: ctx })),
101 | cond: ctx => ctx.title.trim().length > 0
102 | },
103 | { target: "deleted" }
104 | ],
105 |
106 | BLUR: {
107 | target: "reading",
108 | actions: sendParent(ctx => ({ type: "TODO.COMMIT", todo: ctx }))
109 | },
110 |
111 | CANCEL: {
112 | target: "reading",
113 | actions: assign({ title: ctx => ctx.prevTitle })
114 | }
115 | }
116 | },
117 |
118 | //
119 | deleted: {
120 | onEntry: sendParent(ctx => ({ type: "TODO.DELETE", id: ctx.id }))
121 | }
122 | }
123 | });
124 |
--------------------------------------------------------------------------------
/crud-v4-actors/src/todosMachine.js:
--------------------------------------------------------------------------------
1 | import { Machine, assign, spawn } from "xstate";
2 | import uuid from "uuid-v4";
3 | import { todoMachine } from "./todoMachine";
4 |
5 | const createTodo = title => {
6 | return {
7 | id: uuid(),
8 | title: title,
9 | completed: false
10 | };
11 | };
12 |
13 | export const todosMachine = Machine({
14 | id: "todos",
15 | context: {
16 | todo: "", // new todo
17 | todos: []
18 | },
19 | initial: "initializing",
20 |
21 | states: {
22 |
23 | //
24 | initializing: {
25 | entry: assign({
26 | todos: (ctx, e) => {
27 | return ctx.todos.map(todo => ({
28 | ...todo,
29 | ref: spawn(todoMachine.withContext(todo))
30 | }));
31 | }
32 | }),
33 | on: {
34 | "": "all"
35 | }
36 | },
37 | all: {},
38 | active: {},
39 | completed: {}
40 | },
41 |
42 | on: {
43 | "NEWTODO.CHANGE": {
44 | actions: assign({
45 | todo: (ctx, e) => e.value
46 | })
47 | },
48 | "NEWTODO.COMMIT": {
49 | actions: [
50 | assign({
51 | todo: "", // clear todo
52 | todos: (ctx, e) => {
53 | const newTodo = createTodo(e.value.trim());
54 | return ctx.todos.concat({
55 | ...newTodo,
56 | ref: spawn(todoMachine.withContext(newTodo))
57 | });
58 | }
59 | }),
60 | "persist"
61 | ],
62 | cond: (ctx, e) => e.value.trim().length
63 | },
64 |
65 | "TODO.COMMIT": {
66 | actions: [
67 | assign({
68 | todos: (ctx, e) =>
69 | ctx.todos.map(todo => {
70 | return todo.id === e.todo.id
71 | ? { ...todo, ...e.todo, ref: todo.ref }
72 | : todo;
73 | })
74 | }),
75 | "persist"
76 | ]
77 | },
78 |
79 | "SHOW.all": ".all",
80 | "SHOW.active": ".active",
81 | "SHOW.completed": ".completed",
82 |
83 | "MARK.completed": {
84 | actions: ctx => {
85 | ctx.todos.forEach(todo => todo.ref.send("SET_COMPLETED"));
86 | }
87 | },
88 |
89 | "MARK.active": {
90 | actions: ctx => {
91 | ctx.todos.forEach(todo => todo.ref.send("SET_ACTIVE"));
92 | }
93 | },
94 |
95 | "TODO.DELETE": {
96 | actions: [
97 | assign({
98 | todos: (ctx, e) => ctx.todos.filter(todo => todo.id !== e.id)
99 | }),
100 | "persist"
101 | ]
102 | },
103 |
104 | CLEAR_COMPLETED: {
105 | actions: assign({
106 | todos: ctx => ctx.todos.filter(todo => !todo.completed)
107 | })
108 | }
109 | }
110 | });
111 |
--------------------------------------------------------------------------------
/crud-v4-actors/src/useHashChange.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 |
3 | export function useHashChange(onHashChange) {
4 | useEffect(() => {
5 | window.addEventListener("hashchange", onHashChange);
6 |
7 | return () => window.removeEventListener("hashchange", onHashChange);
8 | }, [onHashChange]);
9 | }
10 |
--------------------------------------------------------------------------------
/crud-v4-actors/src/useMyHooks.js:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from 'react'
2 | import {
3 | interpret,
4 | } from 'xstate'
5 |
6 | // machine is raw state machine, will run it with the interpreter
7 | export const useMachineEx = (machine, { debug=false, name='', interpreterOptions={}}) => {
8 | // eslint-disable-next-line
9 | const [_, force] = useState(0)
10 | const machineRef = useRef(null)
11 | const serviceRef = useRef() // started Interpreter
12 |
13 | if(machineRef.current !== machine){
14 |
15 | machineRef.current = machine
16 |
17 | serviceRef.current = interpret(machineRef.current, interpreterOptions)
18 | .onTransition( state => {
19 |
20 | if(state.event.type === 'xstate.init') {
21 | // debugger //
22 | return
23 | }
24 | //
25 | if( state.changed === false && debug === true ){
26 | console.error(
27 | `\n\n💣💣💣 [UNHANDLED EVENT][useMachine]💣💣💣\nEvent=`,
28 | state.event,
29 |
30 | '\nState=',
31 | state.value, state,
32 |
33 | '\nContext=',
34 | state.context,
35 | '\n\n' )
36 |
37 | return
38 | }
39 |
40 | if( debug === true ){
41 | console.group(`%c[useMachine ${name}]`, 'color: darkblue')
42 | dumpState(state.value)
43 | console.log( 'ctx=', state.context )
44 | console.log( 'evt=', state.event )
45 | console.log( '\n', )
46 | console.groupEnd()
47 | }
48 |
49 | // re-render if the state changed
50 | force(x => x+1)
51 | })
52 |
53 | // start immediately, as it's in the constructor
54 | serviceRef.current.start()
55 | }
56 |
57 | // didMount
58 | useEffect(() => {
59 | return () => {
60 | console.log( 'useMachine unload')
61 | serviceRef.current.stop()
62 | }
63 | }, [])
64 |
65 | return [serviceRef.current.state, serviceRef.current.send, serviceRef.current]
66 | }
67 |
68 | // service is Interpreter, already started
69 | export const useServiceEx = (service, { debug=false, name=''}) => {
70 | const lastRef = useRef(null)
71 |
72 | // eslint-disable-next-line
73 | const [_, force] = useState(0)
74 |
75 | if(lastRef.current !== service){
76 |
77 | lastRef.current = service
78 |
79 | service.onTransition( state => {
80 |
81 | // unhandled events
82 | if( state.changed === false && debug === true ){
83 | console.error(
84 | `\n\n💣💣💣 [UNHANDLED EVENT][useService]💣💣💣\nEvent=`,
85 | state.event,
86 |
87 | '\nState=',
88 | state.value, state,
89 |
90 | '\nContext=',
91 | state.context,
92 | '\n\n' )
93 |
94 | return
95 | }
96 |
97 | if( debug === true ){
98 | console.group(`%c[useService ${name}]`, 'color: green')
99 | console.log(state.value)
100 | console.log( `ctx=`, state.context )
101 | console.log( 'evt=', state.event )
102 | console.log( '\n', )
103 | console.groupEnd()
104 | }
105 |
106 | force(x => x+1)
107 | })
108 | }
109 |
110 | return [service.state, service.send, service]
111 |
112 | }
113 |
114 | // +TBD
115 | export const useActorEx = p => {}
116 |
117 | // helper
118 | export const dumpState = (item, depth = 1) => {
119 | // if (depth == 1) console.log('\n')
120 |
121 | const MAX_DEPTH = 100
122 | depth = depth || 0
123 | let isString = typeof item === 'string'
124 | let isDeep = depth > MAX_DEPTH
125 |
126 | if (isString || isDeep) {
127 | console.log(item)
128 | return
129 | }
130 |
131 | for (var key in item) {
132 | console.group(key)
133 | dumpState(item[key], depth + 1)
134 | console.groupEnd()
135 | }
136 | }
137 |
--------------------------------------------------------------------------------