UNPKG

@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
<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 !important; } 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 !important; line-height: inherit !important; margin: 0 !important; padding: 0 0 0 4px !important; } </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>', '&nbsp;<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>', '&nbsp;<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>{ &lt;<a href="https://xstate.js.org/docs/guides/machines.html#configuration" target="_blank">xstate machine definition</a>&gt; }</pre> or <pre>{ machine: &lt;<a href="https://xstate.js.org/docs/guides/machines.html#configuration" target="_blank">xstate machine definition</a>&gt;, config: &lt;<a href="https://xstate.js.org/docs/guides/machines.html#options" target="_blank">xstate machine options</a>&gt; }</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>&lt;name of event&gt;</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>