ui: Adds `<StateChart />` component for wiring together Ember+XState (#7742)

This commit is contained in:
John Cowen 2020-05-01 09:49:56 +01:00 committed by John Cowen
parent 02a2d0a0be
commit ac3b257ff4
10 changed files with 242 additions and 1 deletions

View File

@ -0,0 +1,57 @@
## StateChart
```handlebars
<StateChart
@chart={{xstateStateChartObject}}
as |State Guard Action dispatch state|>
</StateChart>
```
`<StateChart />` is a renderless component that eases rendering of different states
from within templates using XState State Machine and Statechart objects.
### Arguments
| Argument/Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `chart` | `object` | | An xstate statechart/state machine object |
| `initial` | `String` | The initial value of the state chart itself | The initial state of the machine/chart (defaults to whatever is defined on the object itself) |
The component currently yields 3 conextual components:
- `<State />`: Used for rendering matching certain states ([also see State Component](../state/README.mdx))
- `<Action @name="" @exec={{action ""}} />`: Used to wire together ember actions to xstate actions.
- `<Guard @name="" @cond={{action ""}} />`: Used to wire together ember actions or props to xstate guards.
and 2 further objects:
- `dispatch`: An action to dispatch an xstate event
- `state`: The state object itself for usage in the `state-matches` helper
### Example
```handlebars
<StateChart
@chart={{xstateStateChartObject}}
as |State Guard Action dispatch state|>
<Guard @name="nameOfGuard" @cond={{action "testGuardCondition"}} />
<Action @name="nameOfAction" @exec={{action "executeAction"}} />
<State @matches="idle">
Currently Idle
</State>
<State @matches="loading">
Currently Loading
</State>
<State @matches={{array 'loading' 'idle'}}>
Idle and loading
<button disabled={{state-matches state "loading"}} onclick={{action dispatch "START"}}>Load</button>
</State>
</StateChart>
```
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -0,0 +1,13 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
didInsertElement: function() {
this._super(...arguments);
this.chart.addAction(this.name, (context, event) => this.exec(context, event));
},
willDestroy: function() {
this._super(...arguments);
this.chart.removeAction(this.type);
},
});

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -0,0 +1,20 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
didInsertElement: function() {
this._super(...arguments);
const component = this;
this.chart.addGuard(this.name, function() {
if (typeof component.cond === 'function') {
return component.cond(...arguments);
} else {
return component.cond;
}
});
},
willDestroy: function() {
this._super(...arguments);
this.chart.removeGuard(this.name);
},
});

View File

@ -0,0 +1,7 @@
{{yield
(component 'state' state=state)
(component 'state-chart/guard' chart=this)
(component 'state-chart/action' chart=this)
(action 'dispatch')
state
}}

View File

@ -0,0 +1,74 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { set } from '@ember/object';
export default Component.extend({
chart: service('state'),
tagName: '',
ontransition: function(e) {},
init: function() {
this._super(...arguments);
this._actions = {};
this._guards = {};
},
didReceiveAttrs: function() {
if (typeof this.machine !== 'undefined') {
this.machine.stop();
}
if (typeof this.initial !== 'undefined') {
this.src.initial = this.initial;
}
this.machine = this.chart.interpret(this.src, {
onTransition: state => {
const e = new CustomEvent('transition', { detail: state });
this.ontransition(e);
if (!e.defaultPrevented) {
state.actions.forEach(item => {
const action = this._actions[item.type];
if (typeof action === 'function') {
this._actions[item.type](item.type, state.context, state.event);
}
});
}
set(this, 'state', state);
},
onGuard: (name, ...rest) => {
return this._guards[name](...rest);
},
});
},
didInsertElement: function() {
this._super(...arguments);
// xstate has initialState xstate/fsm has state
set(this, 'state', this.machine.initialState || this.machine.state);
// set(this, 'state', this.machine.initialState);
this.machine.start();
},
willDestroy: function() {
this._super(...arguments);
this.machine.stop();
},
addAction: function(name, value) {
this._actions[name] = value;
},
removeAction: function(name) {
delete this._actions[name];
},
addGuard: function(name, value) {
this._guards[name] = value;
},
removeGuard: function(name) {
delete this._guards[name];
},
dispatch: function(eventName, payload) {
this.machine.send(eventName, payload);
},
actions: {
dispatch: function(eventName, e) {
if (e && e.preventDefault) {
e.preventDefault();
}
this.dispatch(eventName);
},
},
});

View File

@ -1,6 +1,53 @@
import Service from '@ember/service';
import Service, { inject as service } from '@ember/service';
import { set } from '@ember/object';
import flat from 'flat';
import { createMachine, interpret } from '@xstate/fsm';
export default Service.extend({
logger: service('logger'),
// @xstate/fsm
log: function(chart, state) {
this.logger.execute(`${chart.id} > ${state.value}`);
},
addGuards: function(chart, options) {
this.guards(chart).forEach(function([path, name]) {
// xstate/fsm has no guard lookup
set(chart, path, function() {
return !!options.onGuard(...[name, ...arguments]);
});
});
return [chart, options];
},
machine: function(chart, options = {}) {
return createMachine(...this.addGuards(chart, options));
},
prepareChart: function(chart) {
// xstate/fsm has no guard lookup so we clone the chart here
// for when we replace the string based guards with functions
// further down
chart = JSON.parse(JSON.stringify(chart));
// xstate/fsm doesn't seem to interpret toplevel/global events
// artificially add them here instead
if (typeof chart.on !== 'undefined') {
Object.values(chart.states).forEach(function(state) {
if (typeof state.on === 'undefined') {
state.on = chart.on;
} else {
Object.keys(chart.on).forEach(function(key) {
if (typeof state.on[key] === 'undefined') {
state.on[key] = chart.on[key];
}
});
}
});
}
return chart;
},
// abstract
matches: function(state, matches) {
if (typeof state === 'undefined') {
return false;
}
const values = Array.isArray(matches) ? matches : [matches];
return values.some(item => {
return state.matches(item);
@ -11,4 +58,19 @@ export default Service.extend({
matches: cb,
};
},
interpret: function(chart, options) {
chart = this.prepareChart(chart);
const service = interpret(this.machine(chart, options));
// returns subscription
service.subscribe(state => {
if (state.changed) {
this.log(chart, state);
options.onTransition(state);
}
});
return service;
},
guards: function(chart) {
return Object.entries(flat(chart)).filter(([key]) => key.endsWith('.cond'));
},
});

View File

@ -58,6 +58,7 @@
"@glimmer/tracking": "^1.0.0",
"@hashicorp/consul-api-double": "^2.6.2",
"@hashicorp/ember-cli-api-double": "^3.0.2",
"@xstate/fsm": "^1.4.0",
"babel-eslint": "^10.0.3",
"base64-js": "^1.3.0",
"broccoli-asset-rev": "^3.0.0",

View File

@ -1542,6 +1542,11 @@
"@webassemblyjs/wast-parser" "1.7.11"
"@xtuc/long" "4.2.1"
"@xstate/fsm@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@xstate/fsm/-/fsm-1.4.0.tgz#6fd082336fde4d026e9e448576189ee5265fa51a"
integrity sha512-uTHDeu2xI5E1IFwf37JFQM31RrH7mY7877RqPBS4ZqSNUwoLDuct8AhBWaXGnVizBAYyimVwgCyGa9z/NiRhXA==
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"