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({
|
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) {
|
matches: function(state, matches) {
|
||||||
|
if (typeof state === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const values = Array.isArray(matches) ? matches : [matches];
|
const values = Array.isArray(matches) ? matches : [matches];
|
||||||
return values.some(item => {
|
return values.some(item => {
|
||||||
return state.matches(item);
|
return state.matches(item);
|
||||||
|
@ -11,4 +58,19 @@ export default Service.extend({
|
||||||
matches: cb,
|
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",
|
"@glimmer/tracking": "^1.0.0",
|
||||||
"@hashicorp/consul-api-double": "^2.6.2",
|
"@hashicorp/consul-api-double": "^2.6.2",
|
||||||
"@hashicorp/ember-cli-api-double": "^3.0.2",
|
"@hashicorp/ember-cli-api-double": "^3.0.2",
|
||||||
|
"@xstate/fsm": "^1.4.0",
|
||||||
"babel-eslint": "^10.0.3",
|
"babel-eslint": "^10.0.3",
|
||||||
"base64-js": "^1.3.0",
|
"base64-js": "^1.3.0",
|
||||||
"broccoli-asset-rev": "^3.0.0",
|
"broccoli-asset-rev": "^3.0.0",
|
||||||
|
|
|
@ -1542,6 +1542,11 @@
|
||||||
"@webassemblyjs/wast-parser" "1.7.11"
|
"@webassemblyjs/wast-parser" "1.7.11"
|
||||||
"@xtuc/long" "4.2.1"
|
"@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":
|
"@xtuc/ieee754@^1.2.0":
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
|
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
|
||||||
|
|
Loading…
Reference in New Issue