diff --git a/ui-v2/app/components/state-chart/README.mdx b/ui-v2/app/components/state-chart/README.mdx
new file mode 100644
index 000000000000..b11c56a4b85c
--- /dev/null
+++ b/ui-v2/app/components/state-chart/README.mdx
@@ -0,0 +1,57 @@
+## StateChart
+
+```handlebars
+
+
+```
+
+`` 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:
+
+- ``: Used for rendering matching certain states ([also see State Component](../state/README.mdx))
+- ``: Used to wire together ember actions to xstate actions.
+- ``: 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
+
+
+
+
+ Currently Idle
+
+
+ Currently Loading
+
+
+ Idle and loading
+
+
+
+```
+
+### See
+
+- [Component Source Code](./index.js)
+- [Template Source Code](./index.hbs)
+
+---
diff --git a/ui-v2/app/components/state-chart/action/index.hbs b/ui-v2/app/components/state-chart/action/index.hbs
new file mode 100644
index 000000000000..fb5c4b157d1c
--- /dev/null
+++ b/ui-v2/app/components/state-chart/action/index.hbs
@@ -0,0 +1 @@
+{{yield}}
\ No newline at end of file
diff --git a/ui-v2/app/components/state-chart/action/index.js b/ui-v2/app/components/state-chart/action/index.js
new file mode 100644
index 000000000000..2e22f6f0483e
--- /dev/null
+++ b/ui-v2/app/components/state-chart/action/index.js
@@ -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);
+ },
+});
diff --git a/ui-v2/app/components/state-chart/guard/index.hbs b/ui-v2/app/components/state-chart/guard/index.hbs
new file mode 100644
index 000000000000..fb5c4b157d1c
--- /dev/null
+++ b/ui-v2/app/components/state-chart/guard/index.hbs
@@ -0,0 +1 @@
+{{yield}}
\ No newline at end of file
diff --git a/ui-v2/app/components/state-chart/guard/index.js b/ui-v2/app/components/state-chart/guard/index.js
new file mode 100644
index 000000000000..1eb060f5bfcc
--- /dev/null
+++ b/ui-v2/app/components/state-chart/guard/index.js
@@ -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);
+ },
+});
diff --git a/ui-v2/app/components/state-chart/index.hbs b/ui-v2/app/components/state-chart/index.hbs
new file mode 100644
index 000000000000..2095cac61808
--- /dev/null
+++ b/ui-v2/app/components/state-chart/index.hbs
@@ -0,0 +1,7 @@
+{{yield
+ (component 'state' state=state)
+ (component 'state-chart/guard' chart=this)
+ (component 'state-chart/action' chart=this)
+ (action 'dispatch')
+ state
+}}
\ No newline at end of file
diff --git a/ui-v2/app/components/state-chart/index.js b/ui-v2/app/components/state-chart/index.js
new file mode 100644
index 000000000000..710202e27f8f
--- /dev/null
+++ b/ui-v2/app/components/state-chart/index.js
@@ -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);
+ },
+ },
+});
diff --git a/ui-v2/app/services/state.js b/ui-v2/app/services/state.js
index deeb3dacbfcd..598ae37f0e36 100644
--- a/ui-v2/app/services/state.js
+++ b/ui-v2/app/services/state.js
@@ -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'));
+ },
});
diff --git a/ui-v2/package.json b/ui-v2/package.json
index f0f216a1387d..b5312e18a850 100644
--- a/ui-v2/package.json
+++ b/ui-v2/package.json
@@ -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",
diff --git a/ui-v2/yarn.lock b/ui-v2/yarn.lock
index efc00c641434..3249937cbbe0 100644
--- a/ui-v2/yarn.lock
+++ b/ui-v2/yarn.lock
@@ -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"