@lz129/node-red-contrib-xstate-machine
Version:
Xstate-based state machine implementation using state-machine-cat visualization for node red.
1,017 lines (891 loc) • 43.6 kB
HTML
<style>
#red-ui-sidebar-smxstate-content, #red-ui-sidebar-smxstate-graph {
width: 100%;
height: 100%;
}
#red-ui-sidebar-smxstate-content {
overflow: hidden;
}
#red-ui-sidebar-smxstate-context {
padding: 8px 10px;
}
div.red-ui-sidebar-smxstate-settings {
font-size: x-small;
display: inline-block;
}
div.red-ui-sidebar-smxstate-settings label,
div.red-ui-sidebar-smxstate-settings select,
div.red-ui-sidebar-smxstate-settings input {
font-size: x-small ;
}
div.red-ui-sidebar-smxstate-settings label {
display: inline;
margin-right: 6px;
}
div.red-ui-sidebar-smxstate-settings select {
width: auto;
height: auto;
line-height: inherit;
margin: 0;
padding: 4px;
}
div.red-ui-sidebar-smxstate-settings input {
width: auto;
height: auto ;
line-height: inherit ;
margin: 0 ;
padding: 0 0 0 4px ;
}
</style>
<script type="text/x-red" data-template-name="smxstate">
<div class="form-row">
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row" style="margin-bottom: 0px;">
<label for="node-input-xstateDefinition" style="width: 200px"><i class="fa fa-wrench"></i> XState State-machine</label>
<input type="hidden" id="node-input-xstateDefinition" autofocus="autofocus">
<input type="hidden" id="node-input-noerr">
</div>
<div class="form-row node-text-editor-row" style="position:relative">
<div style="position: absolute; right:0; bottom:calc(100% + 3px);"><button id="node-smxstate-expand-js" class="red-ui-button red-ui-button-small"><i class="fa fa-expand"></i></button></div>
<div style="height: 250px; min-height:150px;" class="node-text-editor" id="node-input-xstateDefinition-editor" ></div>
</div>
</script>
<script type="text/javascript">
// This code runs within the browser
if( !RED ) {
var RED = {}
}
let smxstateUtilExports = (function() {
function getCurrentlySelectedNodeId() {
let selector = $('#red-ui-sidebar-smxstate-display-selected');
let value = selector.val();
if( !value ) return null;
return {
id: value,
rootId: selector.children('option:selected').attr('data-reveal-id'),
aliasId: selector.children('option:selected').attr('data-alias-id'),
label: selector.children('option:selected').text()
};
}
var updateContextStack = [];
var updateContextBusy = false;
function updateContextFcn(data) {
// Limit to 10 updates per second
if( data && data.id && data.id === getCurrentlySelectedNodeId().id ) { updateContextStack.push(data.context); }
if( !updateContextBusy && updateContextStack.length > 0 ) {
updateContextBusy = true;
// Do the actual animation and data update
let context = updateContextStack.shift();
let contextElement = RED.utils.createObjectElement( context, {
key: /*true*/null,
typeHint: "Object",
hideKey: false
} );
$('#red-ui-sidebar-smxstate-context-data').html(contextElement)
setTimeout(() => {
updateContextBusy = false;
updateContextFcn();
}, 100);
if( updateContextStack.length > 5 ) updateContextStack = updateContextStack.splice(-5);
}
}
var animationStack = [];
var animationBusy = false;
function animateFcn(data) {
// Limit to 10 updates per second
if( data && data.state && (data.state.changed === true || data.state.changed === undefined) ) { animationStack.push(data); }
if( !animationBusy && animationStack.length > 0 ) {
animationBusy = true;
// Do the actual animation and data update
let data = animationStack.shift();
// Recurse into state
function getStatepaths(state, parentState) {
if( typeof state === "string" ) return [(parentState ? parentState + "." + state : state)];
if( !state ) return parentState;
let substates = Object.keys(state);
let statePaths = [];
for( let substate of substates ) {
let substatePath = parentState ? parentState + "." + substate : substate;
if( state[substate] ) statePaths.push(substatePath);
statePaths = statePaths.concat(getStatepaths(state[substate], substatePath));
}
//console.log(statePaths)
return statePaths;
}
let activeStates = getStatepaths(data.state.state);
// Reset all other states
let elements = $(
'#red-ui-sidebar-smxstate-content svg g.graph g:not([class="edge"])'
);
elements.children('*[stroke][stroke!="transparent"][stroke!="none"]').attr('stroke','#000000');
// Style active states
for( let activeState of activeStates ) {
elements
.has('title:contains(' + data.machineId + '.' + activeState + '/)')
.has('title:not(:contains(/initial))')
.children('*[stroke][stroke!="transparent"][stroke!="none"]')
.attr('stroke','#FF0000');
}
//updateContextFcn(data.state.context);
setTimeout(() => {
animationBusy = false;
animateFcn();
}, 100);
if( animationStack.length > 5 ) animationStack = animationStack.splice(-5);
}
}
function setupZoom(container, svgElement) {
// Zoom & Pan functions
//const svgelement = document.getElementById("svgImage");
//const container = document.getElementById("svgContainer");
var currentViewBoxCfg;
try {
currentViewBoxCfg = svgElement.getAttribute("viewBox");
currentViewBoxCfg = currentViewBoxCfg.split(/[\n\r\s]+/gi);
if( Array.isArray( currentViewBoxCfg ) && currentViewBoxCfg.length == 4 ) {
currentViewBoxCfg = currentViewBoxCfg.map( e => parseFloat(e) );
if( currentViewBoxCfg.some( e => !Number.isFinite(e)) )
throw("Invalid viewbox");
} else {
throw("Invalid viewbox");
}
}
catch( err ) {
currentViewBoxCfg = null;
}
var viewBox;
if( !currentViewBoxCfg ) {
viewBox = { x: 0, y: 0, w: svgElement.clientWidth, h: svgElement.clientHeight }; //getAttribute("width"), h: svgElement.getAttribute("height") };
svgElement.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
} else {
viewBox = { x: currentViewBoxCfg[0], y: currentViewBoxCfg[1], w: currentViewBoxCfg[2], h: currentViewBoxCfg[3] };
}
var isPanning = false;
var startPoint = { x: 0, y: 0 };
var endPoint = { x: 0, y: 0 };;
var scale = 1;
container.onmousewheel = function (e) {
e.preventDefault();
const svgSize = { w: svgElement.clientWidth, h: svgElement.clientHeight };
var w = viewBox.w;
var h = viewBox.h;
var mx = e.offsetX;//mouse x
var my = e.offsetY;
var dw = w * -Math.sign(e.deltaY) * 0.05;
var dh = h * -Math.sign(e.deltaY) * 0.05;
var dx = dw * mx / svgSize.w;
var dy = dh * my / svgSize.h;
viewBox = { x: viewBox.x + dx, y: viewBox.y + dy, w: viewBox.w - dw, h: viewBox.h - dh };
scale = svgSize.w / viewBox.w;
//zoomValue.innerText = `${Math.round(scale * 100) / 100}`;
svgElement.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
}
container.onmousedown = function (e) {
isPanning = true;
startPoint = { x: e.x, y: e.y };
}
container.onmousemove = function (e) {
if (isPanning) {
endPoint = { x: e.x, y: e.y };
var dx = (startPoint.x - endPoint.x) / scale;
var dy = (startPoint.y - endPoint.y) / scale;
var movedViewBox = { x: viewBox.x + dx, y: viewBox.y + dy, w: viewBox.w, h: viewBox.h };
svgElement.setAttribute('viewBox', `${movedViewBox.x} ${movedViewBox.y} ${movedViewBox.w} ${movedViewBox.h}`);
}
}
container.onmouseup = function (e) {
if (isPanning) {
endPoint = { x: e.x, y: e.y };
var dx = (startPoint.x - endPoint.x) / scale;
var dy = (startPoint.y - endPoint.y) / scale;
viewBox = { x: viewBox.x + dx, y: viewBox.y + dy, w: viewBox.w, h: viewBox.h };
svgElement.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
isPanning = false;
}
}
container.onmouseleave = function (e) {
isPanning = false;
}
}
function displayFcn(forceRedraw = false) {
idObj = getCurrentlySelectedNodeId();
// Clear graphics and get a new one
$('#red-ui-sidebar-smxstate-graph').empty();
// Show spinner
$('#red-ui-sidebar-smxstate-graph').before(
$('<div id="red-ui-sidebar-smxstate-spinner">').append(
'<i class="fa fa-circle-o-notch fa-spin fa-5x fa-fw"></i> <span>Loading...</span>'
).css( {
textAlign: "center",
margin: "10px"
})
);
$.ajax({
url: "smxstate/"+idObj.id+"/getgraph",
type:"POST",
data: { forceRedraw: forceRedraw },
success: function(resp) {
$('#red-ui-sidebar-smxstate-spinner').remove();
RED.notify(`Successfully rendered state-graph for ${idObj.label}`,{type:"success",id:"smxstate"});
$('#red-ui-sidebar-smxstate-graph').replaceWith($(resp).attr("id", "red-ui-sidebar-smxstate-graph"));
setupZoom(
$('#red-ui-sidebar-smxstate-content')[0],
$('#red-ui-sidebar-smxstate-graph')[0]
);
},
error: function(jqXHR,textStatus,errorThrown) {
$('#red-ui-sidebar-smxstate-spinner').remove();
if (jqXHR.status == 404) {
RED.notify(RED._("node-red:common.notification.error",{message:"resource not found"}),"error");
} else if (jqXHR.status == 500) {
RED.notify("Rendering of the state machine failed.","error");
} else if (jqXHR.status == 0) {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.no-response")}),"error");
} else {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.unexpected",{status:jqXHR.status,message:textStatus})}),"error");
}
}
});
}
function resetFcn() {
// Get selected machine and reset state and context to initial ones
let idObj = getCurrentlySelectedNodeId();
if( !idObj ) return;
$.ajax({
url: "smxstate/"+idObj.id+"/reset",
type:"POST",
success: function(resp) {
RED.notify("State machine " + idObj.id + " was reset.", { type:"success", id:"smxstate" });
},
error: function(jqXHR,textStatus,errorThrown) {
if (jqXHR.status == 404) {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.not-deployed")}),"error");
} else if (jqXHR.status == 500) {
RED.notify("Error during reset. See logs for more info.","error");
} else if (jqXHR.status == 0) {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.no-response")}),"error");
} else {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.unexpected",{status:jqXHR.status,message:textStatus})}),"error");
}
}
});
}
function addStatemachineToSidebar(id, label, rootId, aliasId) {
$('#red-ui-sidebar-smxstate-display-selected').append(
$('<option>')
.attr("value", id)
.attr("data-reveal-id", rootId)
.attr("data-alias-id", aliasId ? aliasId : id)
.text(label)
);
}
function deleteStatemachineFromSidebar(id) {
$('#red-ui-sidebar-smxstate-display-selected').children('[value="' + id + '"]').remove();
}
function initFcn() {
// Build DOM
let content = $('<div>').css({display: "flex", flexDirection: "column", height: "100%"});
let toolbar = $('<div class="red-ui-sidebar-header" style="text-align: left;">')
.append(
$('<form>')
.css({ margin: 0, whiteSpace: "normal" })
.append($('<label>')
.attr("for", "red-ui-sidebar-smxstate-display-selected")
.text("State machine to view:")
)
.append($('<select id="red-ui-sidebar-smxstate-display-selected">')
.change( () => { RED.smxstate.display(); })
.css("width", "100%")
.append($('<option disabled selected value=>').text("-- select machine instance --"))
)
.append('<br>', $('<span class="button-group">')
.append($('<a href="#" id="red-ui-sidebar-smxstate-revealRoot" class="red-ui-sidebar-header-button">')
.append(
'<i class="fa fa-search-minus"></i>'
)
.click(() => { RED.smxstate.revealRoot(); })
),
$('<span class="button-group">')
.append($('<a href="#" id="red-ui-sidebar-smxstate-reveal" class="red-ui-sidebar-header-button">')
.append(
'<i class="fa fa-search-plus"></i>'
)
.click(() => { RED.smxstate.reveal(); })
),
$('<span class="button-group">')
.append($('<a href="#" id="red-ui-sidebar-smxstate-reset" class="red-ui-sidebar-header-button">')
.append(
'<i class="fa fa-undo"></i>',
' <span>reset</span>'
)
.click(() => { RED.smxstate.reset(); })
),
$('<span class="button-group">')
.append($('<a href="#" id="red-ui-sidebar-smxstate-refresh" class="red-ui-sidebar-header-button">')
.append(
'<i class="fa fa-refresh"></i>',
' <span>refresh graph</span>'
)
.click(() => { RED.smxstate.display(true); })
),
$('<div class="red-ui-sidebar-smxstate-settings">')
.css({marginRight: "8px"})
.append(
'<label for="red-ui-sidebar-smxstate-settings-renderer">Renderer:</label>'
)
.append(
$('<select id="red-ui-sidebar-smxstate-settings-renderer">')
.change( (ev) => {
RED.smxstate.settings.set('renderer', ev.target.value);
})
),
$('<div class="red-ui-sidebar-smxstate-settings">')
.css({marginRight: "8px"})
.append(
'<label for="red-ui-sidebar-smxstate-settings-renderTimeoutMs">Render timeout in ms:</label>'
)
.append(
$('<input type="text" id="red-ui-sidebar-smxstate-settings-renderTimeoutMs">')
.css("width", "40px")
.change( (ev) => {
try {
let number = Number.parseInt(ev.target.value);
if( Number.isNaN(number) || number <= 0 ) throw("Render timeout must be a strictly positive integer.")
RED.smxstate.settings.set('renderTimeoutMs', number);
} catch(err) {
RED.notify(err,"error");
// Reset
RED.smxstate.settings.get('renderTimeoutMs', (resp) => {
if( resp ) $(ev.target).val(resp);
})
}
})
)
)
);
RED.popover.tooltip(toolbar.find('#red-ui-sidebar-smxstate-reset'),"Reset to initial state");
RED.popover.tooltip(toolbar.find('#red-ui-sidebar-smxstate-revealRoot'),"Reveal instance in flow");
RED.popover.tooltip(toolbar.find('#red-ui-sidebar-smxstate-reveal'),"Reveal prototype in flow");
let smxcontext = $('<div id="red-ui-sidebar-smxstate-context">')
.append(
$('<div id="red-ui-sidebar-smxstate-context-header">').text("Context data:")
).append('<span id="red-ui-sidebar-smxstate-context-data" class="red-ui-debug-msg-payload">');
let smxdisplayhelp = $('<div>').css({ fontSize: "x-small", padding: "8px" }).append('<span><b>Pan:</b> Click+drag / <b>Zoom:</b> Mousewheel</span>');
let smxdisplay = $('<div id="red-ui-sidebar-smxstate-content">').append('<svg id="red-ui-sidebar-smxstate-graph">');
toolbar.appendTo(content);
smxcontext.appendTo(content);
smxdisplayhelp.appendTo(content);
smxdisplay.appendTo(content);
// Populate list
var that = this;
setTimeout( () => {
that.refresh();
}, 1000);
return {
content: content,
footer: toolbar
};
}
function refreshFcn() {
let nodes = RED.nodes.filterNodes({type: "smxstate"});
// The RED.nodes.filterNodes function returns all nodes (including
// deactivated ones) except of instances within subflows. Actually
// only a prototype-node within each subflow prototype is returned
// (no instances!).
//
// The property
// node.d
// is true for deactivated nodes and the function
// RED.workspaces.contains( node.z )
// returns false for prototype nodes within subflows
//
// Because the node-red interface is lacking needed functionality we
// instead request the data from the server every time.
// Clear list
$('#red-ui-sidebar-smxstate-display-selected option:not([disabled])').remove();
$('#red-ui-sidebar-smxstate-settings-renderer option').remove();
// Get node ids from server
$.ajax({
url: "smxstate/getnodes",
type:"GET",
success: function(resp) {
if( !Array.isArray(resp) ) resp = [resp];
for( let e of resp ) {
addStatemachineToSidebar(e.id, e.path.labels.join('/'), e.rootId, e.alias);
}
},
error: function(jqXHR,textStatus,errorThrown) {
if (jqXHR.status == 404) {
RED.notify(RED._("node-red:common.notification.error",{message:"resource not found"}),"error");
} else if (jqXHR.status == 500) {
RED.notify("Retrieval of available state machines failed.","error");
} else if (jqXHR.status == 0) {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.no-response")}),"error");
} else {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.unexpected",{status:jqXHR.status,message:textStatus})}),"error");
}
}
});
// Get available renderers from server
RED.smxstate.settings.get("availableRenderers", (resp) => {
let selectElement = $('#red-ui-sidebar-smxstate-settings-renderer');
if( resp ) {
if( !Array.isArray(resp) ) resp = [resp];
for( let e of resp ) {
selectElement.append(
'<option value="' + e + '">' + e + '</option>'
);
}
}
// Set current settings values
RED.smxstate.settings.get("renderer", (resp) => {
if( resp ) {
selectElement.val(resp);
}
});
});
RED.smxstate.settings.get("renderTimeoutMs", (resp) => {
if( resp ) {
$('#red-ui-sidebar-smxstate-settings-renderTimeoutMs').val(resp);
}
});
}
function revealFcn() {
let idObj = getCurrentlySelectedNodeId();
if(!idObj) return;
// Reveal the prototype node within a subflow if it's in a subflow
RED.view.reveal(idObj.aliasId);
}
function revealRootFcn() {
let idObj = getCurrentlySelectedNodeId();
if(!idObj) return;
// Reveal the root instance of the node
RED.view.reveal(idObj.rootId);
}
function updateSettingsFcn(settings) {
if( settings.hasOwnProperty("renderer") ) {
$("#red-ui-sidebar-smxstate-settings-renderer").val(settings.renderer); // Don't post event
}
if( settings.hasOwnProperty("availableRenderers") ) {
let o;
$("#red-ui-sidebar-smxstate-settings-renderer").children('option').attr('disabled','disabled');
for( o of settings.availableRenderers ) {
$("#red-ui-sidebar-smxstate-settings-renderer").children('option[value="'+o+'"]')
.removeAttr('disabled');
}
}
if( settings.hasOwnProperty("renderTimeoutMs") ) {
$("#red-ui-sidebar-smxstate-settings-renderTimeoutMs").val(settings.renderTimeoutMs); // Don't post event
}
}
return {
init: initFcn,
display: displayFcn,
reset: resetFcn,
refresh: refreshFcn,
addStatemachineToSidebar: addStatemachineToSidebar,
deleteStatemachineFromSidebar: deleteStatemachineFromSidebar,
animate: animateFcn,
updateContext: updateContextFcn,
revealRoot: revealRootFcn,
reveal: revealFcn,
updateSettings: updateSettingsFcn
};
})();
if( RED.smxstate ) Object.assign(RED.smxstate, smxstateUtilExports);
else RED.smxstate = smxstateUtilExports;
delete smxstateUtilExports;
</script>
<script type="text/javascript">
// This code runs within the browser
if( !RED ) {
var RED = {}
}
if( !RED.smxstate ) {
RED.smxstate = {};
}
RED.smxstate.settings = (function() {
function setFcn(prop,val,success) {
return $.ajax({
url: "smxstate/settings",
type:"POST",
data: { property: prop, value: val },
success: success,
error: function(jqXHR,textStatus,errorThrown) {
if (jqXHR.status == 404) {
RED.notify(RED._("node-red:common.notification.error",{message:"resource not found"}),"error");
} else if (jqXHR.status == 500) {
RED.notify("smxstate: Unable to set property " + prop + ".","error");
} else if (jqXHR.status == 0) {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.no-response")}),"error");
} else {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.unexpected",{status:jqXHR.status,message:textStatus})}),"error");
}
}
});
}
function getFcn(prop, success) {
return $.ajax({
url: "smxstate/settings",
type:"GET",
data: { property: prop },
success: (resp) => {
if( resp && typeof resp === "object" && resp.hasOwnProperty(prop) ) {
resp = resp[prop];
} else resp = null;
if(success && typeof success === "function")
success(resp);
},
error: function(jqXHR,textStatus,errorThrown) {
if (jqXHR.status == 404) {
RED.notify(RED._("node-red:common.notification.error",{message:"resource not found"}),"error");
} else if (jqXHR.status == 500) {
RED.notify("smxstate: Retrieval of property " + prop + " failed.","error");
} else if (jqXHR.status == 0) {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.no-response")}),"error");
} else {
RED.notify(RED._("node-red:common.notification.error",{message:RED._("node-red:common.notification.errors.unexpected",{status:jqXHR.status,message:textStatus})}),"error");
}
}
});
}
return {
set: setFcn,
get: getFcn
}
})();
</script>
<script type="text/javascript">
// Get the default state machine via file inclusion
var defaultStateMachineCode = "// Available variables/objects/functions:\n// xstate\n// - .Machine\n// - .interpret\n// - .assign\n// - .send\n// - .sendParent\n// - .spawn\n// - .raise\n// - .actions\n//\n// Common\n// - setInterval, setTimeout, clearInterval, clearTimeout\n// - node.send, node.warn, node.log, node.error\n// - context.get, context.set\n// - flow.get, flow.set\n// - env.get\n// - util\n\nconst { assign } = xstate;\n\n// First define names guards, actions, ...\n\n/**\n * Guards\n */\nconst maxValueReached = (context, event) => {\n return context.counter >= 10;\n};\n\n/**\n * Actions\n */\nconst incrementCounter = assign({\n counter: (context, event) => context.counter + 1\n});\n\nconst resetCounter = assign({\n counter: (context, event) => {\n // Can send log messages via\n // - node.log\n // - node.warn\n // - node.error\n //node.warn(\"RESET\");\n\n // Can send messages to the second outport\n // Specify an array to send multiple messages\n // at once\n // - node.send(msg)\n node.send({ payload: \"resetCounter\" });\n \n return 0;\n }\n});\n\n/**\n * Activities\n */\nconst doStuff = () => {\n // See https://xstate.js.org/docs/guides/activities.html\n const interval = setInterval(() => {\n node.send({ payload: 'BEEP' });\n }, 1000);\n return () => clearInterval(interval);\n};\n\n/***************************\n * Main machine definition * \n ***************************/\nreturn {\n machine: {\n context: {\n counter: 0\n },\n initial: 'run',\n states: {\n run: {\n initial: 'count',\n states: {\n count: {\n on: {\n '': { target: 'reset', cond: 'maxValueReached' }\n },\n after: {\n 1000: { target: 'count', actions: 'incrementCounter' }\n }\n },\n reset: {\n exit: 'resetCounter',\n after: {\n 5000: { target: 'count' }\n },\n activities: 'doStuff'\n }\n },\n on: {\n PAUSE: 'pause'\n }\n },\n pause: {\n on: {\n RESUME: 'run'\n }\n }\n }\n },\n // Configuration containing guards, actions, activities, ...\n // see above\n config: {\n guards: { maxValueReached },\n actions: { incrementCounter, resetCounter },\n activities: { doStuff }\n },\n // Define listeners (can be an array of functions)\n // Functions get called on every state/context update\n listeners: (data) => {\n //node.warn(data.state + \":\" + data.context.counter);\n }\n};";
RED.nodes.registerType('smxstate',{
category: 'function',
color: '#C7E9C0',
defaults: {
name: { value: "" },
xstateDefinition: { value: defaultStateMachineCode },
noerr: { value:0, required:true, validate:(v) => !v }
},
inputs:1,
outputs:2,
icon: "font-awesome/fa-dot-circle-o",
label: function() {
return this.name || "smxstate";
},
inputLabels: "trigger",
outputLabels: ["stateChanged", "msgOutput" ],
oneditprepare: function() {
var that = this;
this.editor = RED.editor.createEditor({
extraLibs: [
{var: "xstate", module: "xstate"},
{var: "util", module: "util"}
], // for monaco
id: 'node-input-xstateDefinition-editor',
mode: 'ace/mode/nrjavascript',
value: $("#node-input-xstateDefinition").val(),
globals: {
msg:true,
context:true,
RED: true,
util: true,
flow: true,
global: true,
console: true,
Buffer: true,
setTimeout: true,
clearTimeout: true,
setInterval: true,
clearInterval: true
}
});
this.editor.focus();
RED.popover.tooltip($("#node-smxstate-expand-js"), RED._("node-red:common.label.expand"));
$("#node-smxstate-expand-js").on("click", function(e) {
e.preventDefault();
var value = that.editor.getValue();
RED.editor.editJavaScript({
value: value,
width: "Infinity",
cursor: that.editor.getCursorPosition(),
mode: "ace/mode/nrjavascript",
complete: function(v,cursor) {
that.editor.setValue(v, -1);
that.editor.gotoLine(cursor.row+1,cursor.column,false);
setTimeout(function() {
that.editor.focus();
},300);
}
})
})
},
oneditsave: function() {
var annot = this.editor.getSession().getAnnotations();
this.noerr = 0;
$("#node-input-noerr").val(0);
for (var k=0; k < annot.length; k++) {
//console.log(annot[k].type,":",annot[k].text, "on line", annot[k].row);
if (annot[k].type === "error") {
$("#node-input-noerr").val(annot.length);
this.noerr = annot.length;
}
}
$("#node-input-xstateDefinition").val(this.editor.getValue());
// TODO trigger update on panel to redraw state machine
this.editor.destroy();
delete this.editor;
},
oneditcancel: function() {
this.editor.destroy();
delete this.editor;
},
oneditresize: function(size) {
var rows = $("#dialog-form>div:not(.node-text-editor-row)");
var height = $("#dialog-form").height();
for (var i=0; i<rows.length; i++) {
height -= $(rows[i]).outerHeight(true);
}
var editorRow = $("#dialog-form>div.node-text-editor-row");
height -= (parseInt(editorRow.css("marginTop"))+parseInt(editorRow.css("marginBottom")));
$(".node-text-editor").css("height",height+"px");
this.editor.resize();
},
onpaletteadd: function() {
var that = this;
let uiComponents = RED.smxstate.init();
var uiObserver = new MutationObserver(function(mutations) {
// This gets fired when the sidebar is shown or hidden
let visible = $(mutations[0].target).is(':visible');
if( visible ) {
RED.comms.unsubscribe('smxstate_transition', that.handleStateMachineTransition);
RED.comms.subscribe('smxstate_transition', that.handleStateMachineTransition);
// The runtime now sends state transition information of the last selected state-machine id
} else {
RED.comms.unsubscribe('smxstate_transition', that.handleStateMachineTransition);
}
});
RED.sidebar.addTab({
id: 'smxstate',
label: 'state-machines',
name: 'State-machines',
content: uiComponents.content,
toolbar: $('<div><span class="button-group"><a id="red-ui-sidebar-smxstate-open" class="red-ui-footer-button" href="#"><i class="fa fa-desktop"></i></a></span></div>'),
enableOnEdit: true,
pinned: false,
iconClass: 'fa fa-dot-circle-o',
action: 'contrib:show-smxstate-tab'
});
RED.actions.add('contrib:show-smxstate-tab', function() {
RED.sidebar.show('smxstate');
});
RED.events.on("nodes:add", function(node) {
if( node.hasOwnProperty("type") && node.type == "smxstate" ) {
// DO NOTHING!
}
});
// Select the parent container for our sidebar
let sidebarContainer = document.querySelector('#red-ui-sidebar-smxstate-content').parentNode.parentNode;
// Setup the observer
uiObserver.observe(sidebarContainer, {
attributes:true
});
// TODO: Extra view window
RED.popover.tooltip($("#red-ui-sidebar-smxstate-open"),RED._('node-red:debug.sidebar.openWindow'));
$("#red-ui-sidebar-smxstate-open").on("click", function(e) {
e.preventDefault();
alert("NOT YET IMPLEMENTED");
return;
subWindow = window.open(document.location.toString().replace(/[?#].*$/,"")+"debug/view/view.html"+document.location.search,"nodeREDDebugView","menubar=no,location=no,toolbar=no,chrome,height=500,width=600");
subWindow.onload = function() {
subWindow.postMessage({event:"workspaceChange",activeWorkspace:RED.workspaces.active()},"*");
};
});
// Subscribe to comms
this.handleStateMachineTransition = function(t,o) {
if( !(typeof o === "object") || !("type" in o) || typeof o.type !== "string" ) return;
switch( o.type ) {
case 'transition':
// Animate graph
// TODO: Probably add a checkbox in the panel to enable/disable animation
RED.smxstate.animate(o);
break;
case 'context':
RED.smxstate.updateContext(o);
break;
default:
return;
}
};
this.handleStateMachineDisplay = function(t,o) {
if( !(typeof o === "object") || !("type" in o) ) return;
switch( o.type.toLowerCase() ) {
case 'add':
// Add to sidebar
if( !Array.isArray(o.data) ) o.data = [o.data];
for( let el of o.data )
RED.smxstate.addStatemachineToSidebar(el.id, el.path.labels.join('/'), el.rootId, el.alias);
break;
case 'delete':
// Remove from sidebar
if( !Array.isArray(o.data) ) o.data = [o.data];
for( let el of o.data )
RED.smxstate.deleteStatemachineFromSidebar(el.id);
break;
}
}
RED.comms.subscribe('smxstate', this.handleStateMachineDisplay);
},
onpaletteremove: function() {
RED.comms.unsubscribe('smxstate', this.handleStateMachineDisplay);
RED.comms.unsubscribe('smxstate_transition', this.handleStateMachineTransition);
RED.sidebar.removeTab('smxstate');
RED.actions.remove("contrib:show-smxstate-tab");
delete RED.smxstate;
}
});
</script>
<script type="text/x-red" data-help-name="smxstate">
<p>Provides a runtime environment for state machines using <a href="https://xstate.js.org/docs/" target="_blank">XSTATE</a>.</p>
<style>
#red-ui-smxstate-help-container pre {
overflow-x: auto;
white-space: pre;
}
</style>
<div id="red-ui-smxstate-help-container">
<h3>Properties</h3>
<dl class="message-properties">
<dt>name<span class="property-type">string</span></dt>
<dd>The name of the node as displayed in the editor</dd>
<dt>xstateDefinition<span class="property-type">string/javascript</span></dt>
<dd>
This contains the xstate-compatible code to setup the state-machine. The code has to end with
a statement that returns an object of the form
<pre>{
<<a href="https://xstate.js.org/docs/guides/machines.html#configuration" target="_blank">xstate machine definition</a>>
}</pre>
or
<pre>{
machine: <<a href="https://xstate.js.org/docs/guides/machines.html#configuration" target="_blank">xstate machine definition</a>>,
config: <<a href="https://xstate.js.org/docs/guides/machines.html#options" target="_blank">xstate machine options</a>>
}</pre>
Anywhere in the code you may use the same functions as in the function node such as e.g. <code>node.send()</code>
or <code>setTimeout()</code>. Additionally you can use all the exports of the <em>xstate</em> library via the
<code>xstate</code> object. For examle to import the <code>assign</code>, <code>raise</code> and <code>log</code> functions type:
<pre>const { actions, assign } = xstate;
const { raise, log } = actions;</pre>
</dd>
</dl>
<h3>Inputs</h3>
<dl class="message-properties">
<dt>topic <span class="property-type">string</span></dt>
<dd>
- <code>"reset"</code> to reset machine to initial state<br/>
- <code><name of event></code> to trigger a transition<br/>
</dd>
<dt>payload <span class="property-type">object</span> </dt>
<dd>
The data which comes with the event. It can then be used via the
<code>.value</code> property of the event object within
action/activity/guard/service callbacks.
</dd>
</dl>
<h3>Outputs</h3>
<p>The two outports output messages of the following specifications:</p>
<ol class="node-ports">
<li>On occuring event/transition/change of context
<dl class="message-properties">
<dt>topic <span class="property-type">string</span></dt>
<dd>Equals to <code>"state"</code> if an event or transition occured. If the data changed it equals to <code>"context"</code>.</dd>
<dl class="message-properties">
<dt>payload <span class="property-type">object</span></dt>
<dd>
Contains an object that represents the current state of the machine if the topic is <code>"state"</code>.
See details below for more information. In case of a changed context this contains an object with the new
context value.
</dd>
</dl>
</li>
<li>Message sent internally from the machine
<dl class="message-properties">
<dd>Analogous to the function-node all messages sent via <code>node.send([msg1, msg2, ...]);</code> from within
the machine are output through this outport.
</dd>
</dl>
</li>
</ol>
<h3>Details</h3>
<p>
See the default node for an example implementation. Also please refer to the excellent
<a href="https://xstate.js.org/docs/guides/machines.html" target="_blank">xstate documentation</a>
for futher details about how to model your use-case as a xstate machine.
</p>
<p>
The <code>payload</code> objects for messages with a topic of <code>"state"</code> output from the
first outport have the following properties:</p>
<p>
<dl class="message-properties">
<dt>state <span class="property-type">string or object</span></dt>
<dd>
the path of the currently active states as object. If only a top-level
state is active then this is a string with the name of the active state.
If multiple states are active this is an object where each key is a parent
state and each leaf is a string property value, e.g.
<pre>{
parentstate1: "childstate1",
parentstate2: {
childparentstate1: "childstate211",
childstate21: {}
}
}</pre>
Here the active state <code>parentstate2.childstate21</code> does not
contain any childstate, so the property value is an empty object <code>{}</code>.
</dd>
<dt>changed <span class="property-type">boolean</span></dt>
<dd>boolean flag that is true if the state or context was changed</dd>
<dt>done <span class="property-type">boolean</span></dt>
<dd>
boolean flag that is true if the machine contains a final state which has been reached.
You can use it to e.g. trigger a <code>"reset"</code> event.
</dd>
<dt>activities <span class="property-type">object</span></dt>
<dd>object containing all activities with a boolean flag indicating if they are running</dd>
<dt>actions <span class="property-type">object</span></dt>
<dd>object containing all actions which are currently active</dd>
<dt>event <span class="property-type">object</span></dt>
<dd>
the event object (including the <code>.value</code> property containing event data)
that triggered this message e.g.
<pre>{
type: "TRIGGER", // The event name
value: 5 // The event data (may be an object itself)
}</pre>
</dd>
<dt>context <span class="property-type">object</span></dt>
<dd>an object containing the current value data context of the machine</dd>
</dl>
</p>
<h3>The sidebar</h3>
<p>
Open the <a href="#" onclick="RED.sidebar.show('smxstate')">sidebar</a> in the node-red editor UI to get a visual graph
representation of your machine and its current state and context data. The graph
is drawn using <a href="https://www.npmjs.com/package/state-machine-cat">state-machine-cat</a>.
</p>
<p>
On the sidebar you will find a dropdown box containin all running state-machine
instances. Upon selection of an instance the graph below gets redrawn and current
context is shown. Also the current state is highlighted in red within the graph.
</p>
<p>
The sidebar offers buttons to control various things of the viewed machine:
<ul>
<li><i class="fa fa-search-minus"></i> <span>Reveal the node containing the state machine instance</span></li>
<li><i class="fa fa-search-plus"></i> <span>Reveal the prototype node within a subflow if it is defined within one</span></li>
<li><i class="fa fa-undo"></i> <span>Reset the machine to its initial state and context</span></li>
<li><i class="fa fa-refresh"></i> <span>Reload the state-machine graph</span></li>
</ul>
</p>
</div>
</script>