From ac3b257ff42ca231462beebf4d6d147460b4f451 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Fri, 1 May 2020 09:49:56 +0100 Subject: [PATCH] ui: Adds `` component for wiring together Ember+XState (#7742) --- ui-v2/app/components/state-chart/README.mdx | 57 ++++++++++++++ .../components/state-chart/action/index.hbs | 1 + .../components/state-chart/action/index.js | 13 ++++ .../components/state-chart/guard/index.hbs | 1 + .../app/components/state-chart/guard/index.js | 20 +++++ ui-v2/app/components/state-chart/index.hbs | 7 ++ ui-v2/app/components/state-chart/index.js | 74 +++++++++++++++++++ ui-v2/app/services/state.js | 64 +++++++++++++++- ui-v2/package.json | 1 + ui-v2/yarn.lock | 5 ++ 10 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 ui-v2/app/components/state-chart/README.mdx create mode 100644 ui-v2/app/components/state-chart/action/index.hbs create mode 100644 ui-v2/app/components/state-chart/action/index.js create mode 100644 ui-v2/app/components/state-chart/guard/index.hbs create mode 100644 ui-v2/app/components/state-chart/guard/index.js create mode 100644 ui-v2/app/components/state-chart/index.hbs create mode 100644 ui-v2/app/components/state-chart/index.js 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 000000000..b11c56a4b --- /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 000000000..fb5c4b157 --- /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 000000000..2e22f6f04 --- /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 000000000..fb5c4b157 --- /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 000000000..1eb060f5b --- /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 000000000..2095cac61 --- /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 000000000..710202e27 --- /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 deeb3dacb..598ae37f0 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 f0f216a13..b5312e18a 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 efc00c641..3249937cb 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"