Skip to content

Instantly share code, notes, and snippets.

@sukima
Last active July 20, 2022 19:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save sukima/b08983580c367ef12f369a71533780b3 to your computer and use it in GitHub Desktop.
Save sukima/b08983580c367ef12f369a71533780b3 to your computer and use it in GitHub Desktop.
Statecharts iterative example
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import mockFetch from '../utils/mock-fetch';
export default class extends Component {
@tracked state = 'idle';
@tracked isLoading = false;
@tracked hasSuccess = false;
@tracked error = null;
@action execute() {
if (this.isLoading) { return; }
this.isLoading = true;
this.updateStyle();
clearTimeout(this.successTimer);
mockFetch().then(() => {
if (this.isDestroying || this.isDestroyed) { return; }
this.isLoading = false;
this.hasSuccess = true;
this.error = null;
this.updateStyle();
this.successTimer = setTimeout(() => {
if (this.isDestroying || this.isDestroyed) { return; }
this.hasSuccess = false;
this.updateStyle();
}, 3000);
}).catch(() => {
if (this.isDestroying || this.isDestroyed) { return; }
this.isLoading = false;
this.error = true;
this.updateStyle();
});
}
updateStyle() {
if (this.isLoading) {
this.state = 'fetching';
} else if (this.error) {
this.state = 'error';
} else if (this.hasSuccess) {
this.state = 'success';
} else {
this.state = null;
}
}
willDestroy() {
super.willDestroy(...arguments);
clearTimeout(this.successTimer);
}
}
import Component from '@glimmer/component';
import { action } from '@ember/object';
export default class extends Component {
@action highlightBlock(element) {
hljs.highlightBlock(element);
}
}
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import mockFetch from '../utils/mock-fetch';
function reduce(state, event, send) {
function doFetch() {
mockFetch()
.then(() => send('DONE'))
.catch(() => send('ERROR'));
}
switch (state) {
case 'idle':
switch (event) {
case 'CLICK':
doFetch();
return 'fetching';
default: return state;
}
case 'fetching':
switch (event) {
case 'DONE':
setTimeout(() => send('AFTER_DELAY'), 3000);
return 'success';
case 'ERROR': return 'error';
default: return state;
}
case 'success':
switch (event) {
case 'AFTER_DELAY': return 'idle';
case 'CLICK':
doFetch();
return 'fetching';
default: return state;
}
case 'error':
switch (event) {
case 'CLICK':
doFetch();
return 'fetching';
default: return state;
}
}
throw new Error(`Unknown state ${state}`);
}
export default class extends Component {
@tracked state = 'idle';
get label() {
switch (this.state) {
case 'fetching': return 'Loading…';
case 'success': return 'Success ✔︎';
case 'error': return 'There was an error ✘';
default: return 'Click';
}
}
@action send(event) {
if (this.isDestroying || this.isDestroyed) { return; }
this.state = reduce(this.state, event, this.send);
this.updateLabel();
}
}
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import mockFetch from '../utils/mock-fetch';
const { Machine, interpret } = XState;
const LABELS = {
idle: 'Click',
fetching: 'Loading…',
error: 'There was an error ✘',
success: 'Success ✔︎'
};
const buttonMachine = Machine({
id: 'button-machine',
initial: 'idle',
states: {
idle: { on: { CLICK: 'fetching' } },
fetching: {
invoke: {
src: 'doFetch',
onDone: 'success',
onError: 'error'
}
},
error: { on: { CLICK: 'fetching' } },
success: {
after: { 3000: 'idle' },
on: { CLICK: 'fetching' }
}
}
});
export default class extends Component {
@tracked state;
machine = interpret(buttonMachine.withConfig({
services: { doFetch: mockFetch }
}))
.onTransition(state => this.state = state.value)
.start();
get label() {
return LABELS[this.state];
}
willDestroy() {
super.willDestroy(...arguments);
this.machine.stop();
}
@action send() {
this.machine.send(...arguments);
}
}
import EmberRouter from '@ember/routing/router';
import config from './config/environment';
const Router = EmberRouter.extend({
location: 'none',
rootURL: config.rootURL
});
Router.map(function() {
this.route('diagram');
this.route('booleans');
this.route('reducer');
this.route('xstate');
});
export default Router;
import Route from '@ember/routing/route';
export default class IndexRoute extends Route {
beforeModel() {
this.replaceWith('diagram');
}
}
body {
font-family: sans-serif;
background-color: white;
color: black;
}
.site-nav > ol {
display: flex;
justify-content: space-between;
min-width: 400px;
max-width: 600px;
}
.site-nav a {
padding: 10px;
background-color: white;
color: black;
text-decoration: none;
border-radius: 4px;
transition: background-color linear 0.1s;
}
.site-nav a:hover,
.site-nav a.active {
background-color: #e7e7e7;
color: black;
}
.site-nav a.active {
text-decoration: underline;
}
.content {
display: flex;
min-width: 400px;
max-width: 600px;
justify-content: space-between;
}
pre {
margin: 0;
padding: 0;
}
pre code {
border-radius: 8px;
max-width: 450px;
overflow: auto;
}
button {
position: relative;
background-color: #e7e7e7;
border: 2px solid #e7e7e7;
color: black;
padding: 15px 32px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
border-radius: 8px;
transition-duration: 0.4s;
}
button[data-state="fetching"] {
background-color: #008CBA;
border-color: #008CBA;
color: white;
}
button[data-state="success"] {
background-color: #4CAF50;
border-color: #4CAF50;
color: white;
}
button[data-state="error"] {
background-color: #f44336;
border-color: #f44336;
color: white;
}
button[data-state="loading"]:hover,
button[data-state="success"]:hover,
button[data-state="error"]:hover,
button:hover {
background-color: white;
color: black;
}
button[data-state]:after {
content: 'state: ' attr(data-state);
position: absolute;
top: 100%;
left: 0;
margin-top: 0.5rem;
background-color: black;
font-family: monospace;
color: white;
padding: 0.25rem;
border-radius: inherit;
white-space: nowrap;
}
<SiteNav />
<main>
{{outlet}}
</main>
<h1>Spaghetti code with just boolean flags</h1>
<div class="content">
<div><BooleansExample /></div>
<CodeBlock @lang="javascript">
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import mockFetch from '../utils/mock-fetch';
export default class extends Component {
@tracked state = 'idle';
@tracked isLoading = false;
@tracked hasSuccess = false;
@tracked error = null;
@action execute() {
if (this.isLoading) { return; }
this.isLoading = true;
this.updateStyle();
clearTimeout(this.successTimer);
mockFetch().then(() => {
if (this.isDestroying || this.isDestroyed) { return; }
this.isLoading = false;
this.hasSuccess = true;
this.error = null;
this.updateStyle();
this.successTimer = setTimeout(() => {
if (this.isDestroying || this.isDestroyed) { return; }
this.hasSuccess = false;
this.updateStyle();
}, 3000);
}).catch(() => {
if (this.isDestroying || this.isDestroyed) { return; }
this.isLoading = false;
this.error = true;
this.updateStyle();
});
}
updateStyle() {
if (this.isLoading) {
this.state = 'fetching';
} else if (this.error) {
this.state = 'error';
} else if (this.hasSuccess) {
this.state = 'success';
} else {
this.state = null;
}
}
willDestroy() {
super.willDestroy(...arguments);
clearTimeout(this.successTimer);
}
}
</CodeBlock>
</div>
<button
...attributes
data-state={{this.state}}
{{on "click" this.execute}}
>
{{#if this.isLoading}}
Loading&hellip;
{{else if this.error}}
There was an error ✘
{{else if this.hasSuccess}}
Success ✔︎
{{else}}
Click
{{/if}}
</button>
<pre><code class={{@lang}} {{did-insert this.highlightBlock}}>{{yield}}
</code></pre>
<button
...attributes
data-state={{this.state}}
{{on "click" (fn this.send "CLICK")}}
>
{{this.label}}
</button>
<nav class="site-nav" ...attributes>
<ol>
<li><LinkTo @route="diagram">Diagram</LinkTo></li>
<li><LinkTo @route="booleans">Booleans</LinkTo></li>
<li><LinkTo @route="reducer">Reducer</LinkTo></li>
<li><LinkTo @route="xstate">XState</LinkTo></li>
</ol>
</nav>
<button
...attributes
data-state={{this.state}}
{{on "click" (fn this.send "CLICK")}}
>
{{this.label}}
</button>
<h1>Statechart diagram</h1>
<div class="content">
<img
src="https://www.plantuml.com/plantuml/svg/SoWkIImgAStDuUAArefLqBLJo4p9IUK24GgwG5vQQaboHcPUkeALWhEFfxCt5rm095jScfoSMbmCb7JnztD5bKijAiel0igu1WNv1t71LEf2RG1AwEidffMa5kdhAnWNSq5KOxKHMIk699T3QbuAqFa0"
alt="UML state diagram of an interactive button"
>
<ol>
<li>Button in idle shows "Click" label</li>
<li>When clicked show a "Loading" label</li>
<li>When loading complete show a "Success" label</li>
<li>If error loading show an "Error" label</li>
<li>Allow an error to retry</li>
<li>After 3 seconds return to idle with "Click" label</li>
</ol>
</div>
<h1>Switch/case implementation of a state machine</h1>
<div class="content">
<div><ReducerExample /></div>
<CodeBlock @lang="javascript">
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import mockFetch from '../utils/mock-fetch';
function reduce(state, event, send) {
function doFetch() {
mockFetch()
.then(() => send('DONE'))
.catch(() => send('ERROR'));
}
switch (state) {
case 'idle':
switch (event) {
case 'CLICK':
doFetch();
return 'fetching';
default: return state;
}
case 'fetching':
switch (event) {
case 'DONE':
setTimeout(() => send('AFTER_DELAY'), 3000);
return 'success';
case 'ERROR': return 'error';
default: return state;
}
case 'success':
switch (event) {
case 'AFTER_DELAY': return 'idle';
case 'CLICK':
doFetch();
return 'fetching';
default: return state;
}
case 'error':
switch (event) {
case 'CLICK':
doFetch();
return 'fetching';
default: return state;
}
}
throw new Error(`Unknown state ${state}`);
}
export default class extends Component {
@tracked state = 'idle';
get label() {
switch (this.state) {
case 'fetching': return 'Loading…';
case 'success': return 'Success ✔︎';
case 'error': return 'There was an error ✘';
default: return 'Click';
}
}
@action send(event) {
if (this.isDestroying || this.isDestroyed) { return; }
this.state = reduce(this.state, event, this.send);
this.updateLabel();
}
}
</CodeBlock>
</div>
<h1>Using XState</h1>
<div class="content">
<div><XstateExample /></div>
<CodeBlock @lang="javascript">
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import mockFetch from '../utils/mock-fetch';
const { Machine, interpret } = XState;
const LABELS = {
idle: 'Click',
fetching: 'Loading…',
error: 'There was an error ✘',
success: 'Success ✔︎'
};
const buttonMachine = Machine({
id: 'button-machine',
initial: 'idle',
states: {
idle: { on: { CLICK: 'fetching' } },
fetching: {
invoke: {
src: 'doFetch',
onDone: 'success',
onError: 'error'
}
},
error: { on: { CLICK: 'fetching' } },
success: {
after: { 3000: 'idle' },
on: { CLICK: 'fetching' }
}
}
});
export default class extends Component {
@tracked state;
machine = interpret(buttonMachine.withConfig({
services: { doFetch: mockFetch }
}))
.onTransition(state => this.state = state.value)
.start();
get label() {
return LABELS[this.state];
}
willDestroy() {
super.willDestroy(...arguments);
this.machine.stop();
}
@action send() {
this.machine.send(...arguments);
}
}
</CodeBlock>
</div>
{
"version": "0.17.1",
"EmberENV": {
"FEATURES": {},
"_TEMPLATE_ONLY_GLIMMER_COMPONENTS": false,
"_APPLICATION_TEMPLATE_WRAPPER": true,
"_JQUERY_INTEGRATION": true
},
"options": {
"use_pods": false,
"enable-testing": false
},
"dependencies": {
"jquery": "https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.js",
"xstate": "https://unpkg.com/xstate@4/dist/xstate.js",
"highlight": "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.0.3/highlight.min.js",
"highlight_css": "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.0.3/styles/default.min.css",
"ember": "3.18.1",
"ember-template-compiler": "3.18.1",
"ember-testing": "3.18.1"
},
"addons": {
"@glimmer/component": "1.0.0",
"@ember/render-modifiers": "1.0.2"
}
}
export default function mockFetch() {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.5) {
resolve({ data: 'ok' });
} else {
reject(new Error('fake fetch error'));
}
}, 1000);
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment