ui: Adds `<StateChart />` component for wiring together Ember+XState (#7742)
This commit is contained in:
parent
02a2d0a0be
commit
ac3b257ff4
|
@ -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)
|
||||
|
||||
---
|
|
@ -0,0 +1 @@
|
|||
{{yield}}
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
{{yield}}
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
{{yield
|
||||
(component 'state' state=state)
|
||||
(component 'state-chart/guard' chart=this)
|
||||
(component 'state-chart/action' chart=this)
|
||||
(action 'dispatch')
|
||||
state
|
||||
}}
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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'));
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue