/** This file is part of Reactor.
 *  Copyright (c) 2020-2026 Kedron Holdings LLC, All Rights Reserved.
 *  Reactor is not public domain or open source. Distribution or derivative works are expressly prohibited.
 */

export const version = 26120;

import api from '/client/ClientAPI.js';

import Expression from '/client/Expression.js';
import Reaction from '/client/Reaction.js';
import Rulesets from '/client/Rulesets.js';
import Rule from '/client/Rule.js';

import * as Common from './reactor-ui-common.js';
import EmbeddableEditor from './ee.js';
import entitypicker from './entity-picker.js';
import { RuleEditor } from "./rule-editor.js";

import { _T } from './i18n.js';

import "/common/lexp.js";  /* global lexp */

/* Frequently used */
const isEmpty = Common.isEmpty;
const idSelector = Common.idSelector;

const varRefPattern = /\$\{\{/;
const boolFalsePattern = /^(0|false|no|off)$/i;
const boolTruePattern = /^(1|true|yes|on)$/i;

/* Note: int/uint === i4/ui4 */
const inttypes = {
    "ui1": { min: 0, max: 255 }, "i1": { min: -128, max: 127 },
    "ui2": { min: 0, max: 65535 }, "i2": { min: -32768, max: 32767 },
    "ui4": { min: 0, max: 4294967295 }, "i4": { min: -2147483648, max: 2147483647 },
    "ui8": { min: 0, max: Number.MAX_SAFE_INTEGER }, "i8": { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
    "uint": { min: 0, max: 4294967295 }, "int": { min: -2147483648, max: 2147483647 }
};

const autoGrowDefaults = { minWidth: 48 };

function isGroup( action ) {
    return "group" === action.type || "while" === action.type;
}

export default class ReactionEditor extends EmbeddableEditor {

    constructor( reaction, $container, options ) {
        super( reaction, $container, options );

        this.$editor.addClass("re-reaction-editor")
            .attr( 'id', reaction.id );
    }

    toString() {
        return 'ReactionEditor';
    }

    notifySaved() {
        super.notifySaved();
        $( '.tbmodified', this.$editor ).removeClass( 'tbmodified' );
        this.updateActionControls();
    }

    isEmpty() {
        return 0 === ( this.data.actions || [] ).length;
    }

    edit() {
        const starting = ! this.isEditing();
        //console.log("Reaction editor editing",this);

        /* Let the base class do its work first */
        super.edit();

        /** Subscribe to expression editor, if present in container (editing rules).
         *  Update all expression/variable menus when notified by expression editor of a change.
         */
        if ( starting && this.options.contextRule ) {
            try {
                const expr_editor = this.$editor.closest( 'div.re-tab-container' ).data( 'tabobject' ).getEditor( 'expr_editor' );
                const self = this;
                /* Get the related expression editor to notify us of changes. */
                this.subscribe( expr_editor, ( msg ) => {
                    if ( "modified" === msg.type ) {
                        /* Find all variable menus and update them. */
                        const $sels = $( 'select.re-variable', self.editor() );
                        $sels.each( async (ix, obj) => {
                            const $sel = $( obj );
                            const selval = $sel.val() || "";
                            const $mm = await self.makeExprMenu( selval );
                            $sel.empty().append( $mm.children() );
                            Common.menuSelectDefaultInsert( $sel, selval );
                        });
                    }
                });
            } catch ( err ) {
                console.error( err );
            }
        }
    }

    canSave() {
        if ( ! super.canSave() ) {
            console.log("reaction-editor: super can't save");
            return false;
        }

        /* Check all subordinate editors */
        $( '.re-embeddable-editor', this.$editor ).each( (ix, ed) => {
            console.log(ed);
            const $ed = $(ed);
            const editor = $ed.data( 'embeddable-editor' );
            if ( editor && ! editor.canSave() ) {
                console.log("reaction-editor subordinate", $ed, "can't save");
                return false;
            }
        });
    }

    /* Override of redraw() below */

    /* -------------------- IMPLEMENTATION SPECIFICS ---------------------- */

    rename( newname ) {
        let data = this.current();
        data.name = newname;
        $( '.re-actions-header .re-title', this.$editor ).text( newname );
        this.signalModified();
    }

    divwrap( $el ) {
        return $( '<div></div>' ).append( $el );
    }

    /**
     * Maybe for future localization, return an object key/values and a format string?
     */
    static async makeActionDescription( action, rule, level ) {
        let e, steps;
        let str = " ".repeat( ( level || 0 ) * 4 );
        switch ( action.type ) {

            case "group":
                str += _T( 'Group {0}', "" );
                steps = [];
                await Common.asyncForEach( action.actions || [], async ( act ) => {
                    let r = await this.makeActionDescription( act, rule, level + 1 );
                    steps.push( r );
                });
                str += steps.join( '; ' );
                break;

            case "while":
                {
                    let conds = _T( "(conditions)" );
                    try {
                        /** If the first condition is a comment, display just that. Otherwise,
                         *  ask the RuleEditor to format the first condition and show that. If
                         *  there are multiple conditions, add elipsis.
                         */
                        if ( action.constraints?.conditions?.length ) {
                            const cc = action.constraints.conditions;
                            if ( cc[0].type === "comment" ) {
                                conds = `(${cc[0].data.comment})`;
                            } else {
                                conds = await RuleEditor.makeConditionDescription( null, cc[0] );
                                if ( cc.length > 1 ) {
                                    conds = `(${conds} + ...)`;
                                } else {
                                    conds = `(${conds})`;
                                }
                            }
                        }
                    } catch ( err ) {
                        console.log( err );
                    }
                    str += _T( 'While {0}', conds );
                    steps = [];
                    await Common.asyncForEach( action.actions || [], async ( act ) => {
                        let r = await this.makeActionDescription( act, rule, level + 1 );
                        steps.push( r );
                    });
                    str += ': ' + steps.join( '; ' );
                }
                break;

            case "delay":
                if ( "start" === action.data.from ) {
                    str += _T( ['#action-desc-delay-start', 'Delay for {0} seconds from start of reaction'],
                        action.data.delay || 0 );
                } else {
                    str += _T( ['#action-desc-delay-inline', 'Delay for {0} seconds'],
                        action.data.delay || 0 );
                }
                break;

            case "comment":
                str += _T( ['#action-desc-comment', 'Comment: {0}'],
                    String( action.data.comment || "" ) );
                break;

            case "entity":
                {
                    e = api.getEntity( action.data.entity );
                    const ename = e ? `${e.getName()} (${e.getCanonicalID()})` :
                        ( String( action.data.entity ) + "?" );
                    let m = [];
                    Object.keys( action.data.args || {} ).forEach( arg => {
                        m.push( arg + "=" + JSON.stringify( action.data.args[ arg ] ) );
                    });
                    str += _T( ['#action-desc-perform', 'Perform {0} on {1}{2:" with "?#>0}{2}'],
                        String( action.data.action || "?" ), ename, m.join( ", " ) );
                }
                break;

            case "script":
                str += _T( ['#action-desc-script', 'Script: {0}' ], action.data.expr );
                break;

            case "setvar":
                if ( action.data.rule ) {
                    let svr = await Rule.getInstance( action.data.rule );
                    svr = svr ? svr.name : ( action.data.rule + "?" );
                    // ??? DEPRECATED: action.data.reeval
                    str += _T( ['#action-desc-setvar-rule', 'Set Variable {0} in {1} to {2}{3:" and re-evaluate"?t}'],
                        action.data.var, svr, String( action.data.value ), false /* !!action.data.reeval */ );
                } else {
                    str += _T( ['#action-desc-setvar-global', 'Set Variable {0} to {1}{2:" and re-evaluate"?t}'],
                        action.data.var, String( action.data.value ), false /* !!action.data.reeval */ );
                }
                break;

            case "run":
                {
                    e = ( action.data.r || "" ).match( /:[SR]$/ );
                    if ( e ) {
                        let rid = action.data.r.substring( 0, e.index );
                        let r = await Rule.getInstance( rid );
                        str += _T( ['#action-desc-run-rule', 'Run rule {0} ({1}) {2:"Set"?t}{2:"Reset"?f} reaction'],
                            r && r.name ? r.name : rid, rid, ":R" !== e[0] );
                    } else {
                        let re = await Reaction.getInstance( action.data.r || "" );
                        str += _T( ['#action-desc-run-global', 'Run global reaction {0} ({1})'],
                            re.name || action.data.r, action.data.r );
                    }
                    if ( "inline" === action.data.mode ) {
                        str += _T( ['#reaction-desc-run-wait', ' (wait for it to finish)' ] );
                    } else {
                        str += _T( ['#reaction-desc-run-continue', ' (do not wait for it to finish)' ] );
                    }
                }
                break;

            case "stop":
                {
                    if ( null === action.data.r ) {
                        str += _T( ['#action-desc-stop-current','Stop this reaction'] );
                    } else {
                        e = ( action.data.r || "" ).match( /:[SR]$/ );
                        if ( e ) {
                            let rid = action.data.r.substring( 0, e.index );
                            let r = await Rule.getInstance( rid );
                            str += _T( ['#action-desc-stop-rule', 'Stop rule {0} ({1}) {2:"Set"?t}{2:"Reset"?f} reaction'],
                                r && r.name ? r.name : rid, rid, ":R" !== e[0] );
                        } else {
                            let re = await Reaction.getInstance( action.data.r || "" );
                            str += _T( ['#action-desc-stop-global', 'Stop global reaction {0} ({1})'],
                                re.name || action.data.r, action.data.r );
                        }
                    }
                }
                break;

            case "notify":
                {
                    let params = [];
                    Object.keys( action.data || {} ).forEach( arg => {
                        if ( "method" === arg || "message" === arg ) {
                            return;
                        }
                        params.push( arg + "=" + JSON.stringify( action.data[arg] ) );
                    });
                    params.unshift( JSON.stringify( action.data.message ) );
                    str += _T( ['#action-desc-notify', 'Notify via {0}: {1}'],
                        action.data.method, params.join( '; ' ) );
                }
                break;

            case "request":
                // rvar, rule, headers
                str += _T( ['#action-desc-request', 'Request HTTP {0} {1:q}{2:"; wait for completion"?#t}'],
                    action.data.method || "GET", action.data.url || "?", !!action.data.wait );
                if ( action.data.rvar ) {
                    let m = action.data.rvar.match( /^([^:]+):(.*)$/ );
                    if ( m ) {
                        if ( m[1] === rule ) {  /* in current rule */
                            str += _T( "; save response to {0:q} (local)", m[2] );
                        } else {
                            let r = await Rule.getInstance( m[1] );
                            str += _T( "; save response to {0:q} in rule {1} ({2})",
                                m[2], (r && r.name) || m[1], m[1] );
                        }
                    } else {
                        str += _T( "; save response to {0:q} (global)", action.data.rvar );
                    }
                }
                break;

            case "shell":
                str += _T( ['#action-desc-shell', 'Shell command: {0:q}{1:" (ignore exit code)"?t}'],
                    action.data.command, !!action.data.ignore_exit );
                if ( action.data.rvar ) {
                    if ( action.data.rule ) {
                        if ( rule === action.data.rule ) {
                            str += _T( "; capture output to {0:q} (local)", action.data.rvar );
                        } else {
                            let r = await Rule.getInstance( action.data.rule );
                            str += _T( "; capture output to {0:q} in rule {1} ({2})",
                                action.data.rvar, (r && r.name) || action.data.rule, action.data.rule );
                        }
                    } else {
                        str += _T( "; capture output to {0:q} (global)", action.data.rvar );
                    }
                }
                break;

            default:
                str += _T( ['#action-desc-unrecognized', 'Unrecognized action type {0:q}? {1}'],
                    action.type, JSON.stringify( action.data || null ) );
        }
        return str;
    }

    indicateError( $el, msg ) {
        $el.addClass('is-invalid').removeClass('is-valid');
        if ( msg ) {
            // $('<div class="invalid-tooltip"></div>').text(msg).insertAfter( $el );
            $el.attr( 'title', msg );
        }
    }

    fieldCheck( bool, $el, msg ) {
        console.assert( 1 === $el.length, "fieldCheck problem "+String(msg) );
        if ( bool ) {
            this.indicateError( $el, msg );
        }
        return !bool;
    }

    appendVariables() {
        console.log("appendVariables");
    }

    /**
     *  Find an action by ID.
     */
    findAction( id ) {
        const self = this;
        let s = function( actions ) {
            let n = ( actions || [] ).length;
            for ( let i=0; i<n; ++i ) {
                /* IDs for groups are stored with the reaction ID, so watch for that */
                if ( isGroup( actions[ i ] ) && String( self.data.id + '-' + id ) === actions[ i ].id ) {
                    return actions[ i ];
                } else if ( id === actions[ i ].id ) {
                    return actions[ i ];
                }
                if ( isGroup( actions[ i ] ) ) {
                    let t = s( actions[ i ].actions );
                    if ( t ) {
                        return t;
                    }
                }
            }
            return false;
        };
        return s( this.data.actions );
    }

    async tryAction( $row ) {
        const rid = $row.attr( 'id' );
        switch ( $( 'select.re-actiontype', $row ).val() ) {
            case 'entity':
                {
                    const eid = entitypicker.getEntity( $( 'div.entity-selector', $row ) );
                    const action = $( 'select.re-actionmenu', $row ).val() || "";
                    const entity = api.getEntity( eid );
                    if ( entity ) {
                        const params = {};
                        const act = entity.getActionDef( action );
                        let expref = false;
                        if ( act ) {
                            Object.keys( act.arguments || {} ).forEach( arg => {
                                const val = $( '#' + Common.idSelector( rid + '-' + arg ) , $row ).val() || "";
                                if ( ! isEmpty( val ) ) {
                                    let fval = val;
                                    expref = expref || null !== val.match( varRefPattern );
                                    let typ = act.arguments[ arg ].type;
                                    if ( inttypes[ typ ] ) {
                                        fval = Number( val );
                                        fval = Number.isInteger( fval ) ? fval : val;
                                    } else if ( "real" === typ ) {
                                        fval = Number( val );
                                        fval = isNaN( fval ) ? val : fval;
                                    } else if ( "bool" === typ || "boolean" === typ ) {
                                        fval = "null" === val ? null : boolTruePattern.test( val );
                                    } else if ( "object" === typ ) {
                                        /* Store string as given, JSON or perhaps YAML (needs more support) */
                                        fval = val;
                                    }
                                    params[ arg ] = fval;
                                }
                            });
                        }
                        if ( expref ) {
                            Common.showSysModal({
                                title: _T("Insufficient Context"),
                                body: _T("The single-action trial tool cannot be used when the action parameters contain variable/expression substitutions.")
                            });
                            return;
                        }
                        console.log("tryAction() performing", action, params);
                        return entity.perform( action, params );
                    }
                }
                break;

            case 'run':
                {
                    let reaction_id = $( 'select.re-scene', $row ).val() || "";
                    if ( "" !== reaction_id ) {
                        return api.startReaction( reaction_id );
                    }
                    return Promise.reject( "No Reaction selected" );
                }

            default:
                /* nada */
        }
        return Promise.reject( 'Invalid action type' );
    }

    async importReaction( reid, $insertPoint ) {
        let actions;
        const m = reid.match( /^(.*):([SR])$/ ); /* Rule reaction? */
        if ( m ) {
            /* Rule Reaction. */
            try {
                const rule = await Rule.getInstance( m[1] );
                actions = "R" === m[2] ? rule.react_reset.actions : rule.react_set.actions;
            } catch ( err ) {
                Common.showSysModal({
                    title: "Unable to Load",
                    body: "The rule cannot be loaded."
                });
                return Promise.reject( err );
            }
        } else {
            /* Global Reaction */
            try {
                const reaction = await Reaction.getInstance( reid );
                actions = reaction.actions;
            } catch ( err ) {
                console.warn( err );
                Common.showSysModal({
                    title: "Unable to Load",
                    body: "Can't load specified reaction " + reid
                });
                return Promise.reject( err );
            }
        }
        if ( 0 === ( actions || [] ).length ) {
            Common.showSysModal({
                title: "Unable to Load",
                body: "The selected reaction is empty."
            });
            return Promise.reject();
        }
        // tbmodified on inserted rows??? maybe done by loadActions and removed for initial load?
        return this.loadActions( false, $insertPoint, actions );
    }

    async handleActionControlClick( event ) {
        const $el = $( event.currentTarget );
        if ( $el.prop('disabled') ) {
            return;
        }
        const $row = $el.closest( 'div.actionrow' );
        const op = $el.data( 'action' );
        switch ( op ) {
            case 'tryaction':
                this.tryAction( $row );
                break;

            case 'import':
                {
                    let rid = $( 'select.re-scene', $row ).val() || "";
                    if ( !isEmpty( rid ) ) {
                        await this.importReaction( rid, $row ).then( () => {
                            $row.remove();
                            this.updateActionList(); /* signals modified */
                        });
                    }
                }
                break;

            case 'clone':
                {
                    const actid = $row.attr( 'id' );
                    const action = this.findAction( actid );
                    if ( action ) {
                        if ( isGroup( action ) ) {
                            const self = this;
                            const reassign_group_ids = function( node ) {
                                let newid = Common.getUID();
                                node.id = self.data.id + '-' + newid;
                                if ( node.constraints ) {
                                    node.constraints.id = node.id + '-cons';
                                    ( node.constraints.conditions || [] ).forEach( ( cond ) => {
                                        if ( "group" === cond.type ) {
                                            reassign_group_ids( cond );
                                        } else {
                                            cond.id = Common.getUID( 'cond' );
                                        }
                                    });
                                }
                            };
                            reassign_group_ids( action );
                            if ( action.constraints ) {
                                action.constraints.name = ( action.constraints.name || action.constraints.id ) + ' Copy';
                            }
                        } else {
                            delete action.id;
                        }
                        const newrows = await this.loadActions( false, $row, [ action ] );
                        for ( let $r of newrows ) {
                            $r.addClass( 'tbmodified' );
                        }
                        this.updateActionList(); /* signals modified */
                    } else {
                        console.error( "can't clone",actid,"; not found" );
                    }
                }
                break;

            case 'delete':
                $row.remove();
                this.updateActionList(); /* signals modified */
                break;

            case 'drag':
                /* Handled by other */
                break;

            default:
                console.log("Unhandled control button click on", $el);
        }
        return false;
    }

    makeRuleGroupMenu() {
        console.log("makeRuleGroupMenu");
    }

    updateActionControls() {
        $( 'div.controls button.draghandle', this.$editor )
            .prop( 'disabled', $( 'div.actionrow', this.$editor ).length <= 1 );

        /* "Try Reaction" button only when saved/unmodified */
        $( 'button.re-tryreaction', this.$editor ).prop( 'disabled', this.configModified );

        /* Save and revert buttons */
        this.updateSaveControls();
    }

    /**
     * Make a menu of defined expressions with no expression, as these are the only
     * ones that the Set Variable action can modify.
     */
    async makeExprMenu( currExpr ) {
        const $el = $( '<select class="form-select form-select-sm re-variable"></select>' );
        $( '<option></option>' ).val( "" ).text( _T('#opt-choose','--choose--') ).appendTo( $el );

        /* Local (rule) expressions */
        if ( this.options.contextRule ) {
            const tab = this.$editor.closest( '.re-tab-container' ).data( 'tabobject' );
            if ( tab ) {
                const expr_editor = tab.getEditor( "expr_editor" );
                if ( expr_editor ) {
                    const exp = Object.values( expr_editor.data || {} );
                    exp.sort( ( a, b ) => {
                        if ( a.index === b.index ) {
                            return 0;
                        }
                        return a.index < b.index ? -1 : 1;
                    });
                    let $og = false;
                    for ( let expr of exp ) {
                        if ( ! $og ) {
                            $og = $( '<optgroup></optgroup>' ).attr( 'label', _T('Local Rule Variables') )
                                .appendTo( $el );
                        }
                        $( '<option></option>' ).val( this.options.contextRule.id + ':' + expr.name )
                            .text( expr.name )
                            .appendTo( $og );
                    };
                } else {
                    console.warn( "RuleEditor no expr_editor for", this.$editor );
                }
            } else {
                console.warn( "RuleEditor no tab for", this.$editor );
            }
        }

        const exprlist = await Expression.getGlobalExpressions();
        let $og = false;
        for ( let expr of exprlist ) {
            if ( "" === ( expr.expr || "" ) ) {
                if ( ! $og ) {
                    $og = $( '<optgroup></optgroup>' )
                        .attr( 'label', _T("Global Variables") )
                        .appendTo( $el );
                }
                $( '<option></option>' ).val( expr.name ).text( _T('{0} (global)', expr.name) )
                    .appendTo( $og );
            }
        }

/*
        let rss = await Rulesets.getRulesets();
        const self = this;
        await Common.asyncForEach( rss, async (set) => {
            let $rsg = false;
            let rules = await set.getRules();
            rules.forEach( rule => {
                if ( rule.id === ( self.options.contextRule || {} ).id ) {
                    return;
                }
                let $rg = false;
                let exprlist = Object.values( rule.expressions );
                exprlist.forEach( expr => {
                    if ( "" === ( expr.expr || "" ) ) {
                        if ( ! $rsg ) {
                            $( '<option class="data-divider"></option>' ).appendTo( $el );
                            $rsg = $( '<optgroup></optgroup>' ).attr( 'label', set.name.toUpperCase() )
                                .appendTo( $el );
                        }
                        if ( ! $rg ) {
                            $rg = $( '<optgroup></optgroup>' ).attr( 'label', rule.name )
                                .appendTo( $el );
                        }
                        $( '<option></option>' ).val( rule.getID() + ":" + expr.name )
                            .text( expr.name + ' (in ' + rule.name + ')' )
                            .appendTo( $rg );
                    }
                });
            });
        });
*/

        /* Select current (insert if not present) */
        Common.menuSelectDefaultInsert( $el, currExpr || "" );
        return $el;
    }

    /**
     * Simple for now.
     * ??? maybe later sort out rule-owned vs global
     */
    async makeReactionMenu() {
        const allglobal = await Reaction.getGlobalReactions();
        const rlist = allglobal.filter( r => ! r.ruleset ).sort( function( a, b ) {
            return (a.name || a.id).localeCompare( b.name || b.id, undefined, { sensitivity: 'base' } );
        });
        const $mm = $( '<select class="form-select form-select-sm re-scene"></select>' );
        let $og = $( '<optgroup></optgroup>' )
            .attr( 'label', _T('Global Reactions') )
            .appendTo( $mm );
        const self = this;
        rlist.forEach( function( reaction ) {
            let title = reaction.name || reaction.id;
            if ( reaction.rule ) {
                title += " (from rule)";
            }
            $( '<option></option>' ).val( reaction.id ).text( title )
                .prop( 'disabled', reaction.id === self.data.id )
                .appendTo( $og );
        });

        let r = await Rulesets.getRulesets();
        let nrs = r.length;
        for ( let k=0; k<nrs; ++k ) {
            const set = r[k];
            if ( set.hidden ) {
                continue;
            }
            const rules = await set.getRules();
            let $og;
            if ( rules.length ) {
                $og = $( '<optgroup></optgroup>' ).attr( 'label', _T('Rule Set: {0}', set.name) )
                    .appendTo( $mm );
                for ( const rule of rules ) {
                    if ( rule.react_set ) {
                        $( '<option></option>' ).val( rule.react_set.id )
                            .text( _T('{0} - Set', rule.name) )
                            .prop( 'disabled', rule.getID() === ( self.options.contextRule || {} ).id )
                            .appendTo( $og );
                    }
                    if ( rule.react_reset ) {
                        $( '<option></option>' ).val( rule.react_reset.id )
                            .text( _T('{0} - Reset', rule.name) )
                            .prop( 'disabled', rule.getID() === ( self.options.contextRule || {} ).id )
                            .appendTo( $og );
                    }
                }
            }
            const reactions = await set.getReactions();
            if ( reactions.length ) {
                if ( ! $og ) {
                    $og = $( '<optgroup></optgroup>' ).attr( 'label', _T('Rule Set: {0}', set.name) )
                        .appendTo( $mm );
                }
                for ( const reaction of reactions ) {
                    $( '<option></option>' ).val( reaction.id )
                        .text( reaction.name || reaction.id )
                        .appendTo( $og );
                }
            }
        }

        return $mm;
    }

    /* Rebuild actions for section (class re-reaction-editor) */
    buildActionList( $root ) {
        $root = $root || $( 'div.activity-group', this.$editor );
        if ( $('.tberror', $root ).length > 0 ) {
            return false;
        }
        /* Set up scene framework and first group with no delay */
        let scene = [];
        const self = this;
        $root.children( 'div.actionrow' ).each( ( ix, rr ) => {
            const $row = $( rr );
            const pfx = $row.attr( 'id' ) + '-';
            const actionType = $( 'select.re-actiontype', $row ).val();
            let action = { id: $row.attr( 'id' ), type: actionType };
            let pt, t;

            switch ( actionType ) {
                case "group":
                case "while":
                    {
                        /* Be careful to select only our own action-group, not any nested deeper within (group in group). */
                        let al = self.buildActionList( $( 'div.action-group-container > div.action-group', $row ).first() );
                        action.actions = al;
                        action.id = self.data.id + '-' + $row.attr( 'id' ); /* Make it look like a reaction (same structure) */
                        const editor = $( 'div.editor-group div.re-embeddable-editor', $row ).data( 'embeddable-editor' );
                        action.constraints = editor.data;
                        al = $( 'input.re-maxiter', $row ).val() || "";
                        const n = "" !== al ? Common.coalesce( parseInt( al ), 0 ) : 0;
                        if ( n > 0 ) {
                            action.max_iterations = n;
                        } else {
                            delete action.max_iterations;
                        }
                    }
                    break;

                case "comment":
                    action.data = { "comment": $( 'textarea.re-comment', $row ).val() || "" };
                    break;

                case "delay":
                    t = $( 'input#' + idSelector( pfx + 'delay' ), $row ).val() || "0";
                    if ( t.match( varRefPattern ) ) {
                        /* Variable reference is OK as is. */
                    } else {
                        if ( t.indexOf( ':' ) >= 0 ) {
                            pt = t.split( /:/ );
                            t = 0;
                            for ( let i=0; i<pt.length; i++ ) {
                                t = t * 60 + parseInt( pt[i] );
                            }
                        } else {
                            t = parseInt( t );
                        }
                        if ( isNaN( t ) ) {
                            scene = false;
                            return false;
                        }
                    }
                    action.data = { "delay": t };
                    t = $( 'select.re-delaytype', $row ).val() || "";
                    if ( !isEmpty( t ) ) {
                        action.data.from = t;
                    }
                    break;

                case "entity":
                    {
                        const eid = entitypicker.getEntity( $( 'div.entity-selector', $row ) );
                        action.data = { "entity": eid };
                        let e = api.getEntity( action.data.entity );
                        action.data.action = $( 'select.re-actionmenu', $row ).val() || "";
                        let ad = e ? e.getActionDef( action.data.action ) : {};
                        /* Go over displayed fields, store. These have been validated, so loose/no checks here */
                        $( '.argument', $row ).each( ( ix, ar ) => {
                            if ( !action.data.args ) {
                                action.data.args = {};
                            }
                            let val = $( ar ).val() || "";
                            const pname = ($( ar ).attr( 'id' ) || "unnamed").replace( pfx, '' );
                            if ( val.match( varRefPattern ) ) {
                                /* Variable reference is OK as is. */
                                action.data.args[ pname ] = val;
                            } else {
                                const ap = ( ad.arguments || {} )[ pname ];
                                if ( ! isEmpty( val ) ) {
                                    if ( !ap ) {
                                        /* Field, but not in argument defs; just carry/copy */
                                        const st = $( ar ).data( 'sourcetype' ) || "string";
                                        let vv;
                                        switch ( st ) {
                                            case "bool":
                                            case "boolean":
                                                val = "null" === val ? null : boolTruePattern.test( val );
                                                break;
                                            case "int":
                                            case "uint":
                                                /* Lenient: save non-numeric value even when numeric expected. */
                                                vv = parseInt( val );
                                                val = Number.isNaN( vv ) ? val : vv;
                                                break;
                                            case "real":
                                            case "float":
                                            case "number":
                                                /* Lenient: save non-numeric value even when numeric expected. */
                                                val = isNaN( val ) ? val : parseFloat( val );
                                                break;
                                            case "object":
                                                /* Take as given */
                                                break;
                                            default:
                                                /* Nothing for default; it's already a string */
                                                console.error( `Action parameter ${pname} type ${st} not a valid type` );
                                        }
                                        /* Only put in array of non-blank/null */
                                        if ( "" !== val && null !== val ) {
                                            action.data.args[ pname ] = val;
                                        }
                                    } else {
                                        /* Argument/field in definition */
                                        const typ = ap.type || "any";
                                        if ( inttypes[ typ ] ) {
                                            val = parseInt( val );
                                        } else if ( "real" === typ ) {
                                            val = parseFloat( val );
                                        } else if ( "bool" === typ || "boolean" === typ ) {
                                            val = "null" === val ? null : boolTruePattern.test( val );
                                        } else if ( "object" === typ ) {
                                            /* Take as given */
                                        }
                                        action.data.args[ pname ] = val;
                                    }
                                }
                            }
                        });
                        t = $( 'select.re-resp-target', $row );
                        if ( ! t.prop( 'disabled' ) ) {
                            let vn = t.val() || "";
                            if ( ! isEmpty( vn ) ) {
                                let k = vn.split( /:/ );
                                action.data.rvar = k.pop();
                                if ( k.length > 0 ) {
                                    action.data.rule = k.pop();
                                } else {
                                    delete action.data.rule;
                                }
                            } else {
                                delete action.data.rvar;
                                delete action.data.rule;
                            }
                        } else {
                            delete action.data.rvar;
                            delete action.data.rule;
                        }
                    }
                    break;

                case "script":
                    {
                        action.data = { expr: $( 'textarea.re-script-expr', $row ).val() || "" };
                    }
                    break;

                case "setvar":
                    {
                        /* Split menu value if rule:name (rule-based expression). */
                        let name = $( 'select.re-variable', $row ).val() || "";
                        let pt = name.match( /^([^:]+):(.*)$/ );
                        if ( pt ) {
                            action.data = { "var": pt[2], "rule": pt[1] };
                        } else {
                            action.data = { "var": name };
                        }
                        action.data.value = $( 'input#' + idSelector( pfx + "value" ), $row ).val();
                        if ( action.data.rule ) {
                            /* Rule-based variable (globals don't get re-eval, which is for rules) */
                            /* ??? DEPRECATED. Using hidden field temporarily to preserve value
                            action.data.reeval = $( "input.tbreeval", $row ).prop( "checked" );
                            */
                            if ( "" === $( "input.tbreeval", $row ).val() ) {
                                delete action.data.reeval;
                            } else {
                                action.data.reeval = true;
                            }
                        }
                    }
                    break;

                case "run":
                case "stop":
                    action.data = { "r": $( "select.re-scene", $row ).val() || "" };
                    if ( "stop" === actionType && "" === action.data.r ) {
                        action.data.r = null;
                    } else if ( $( "input.tb-run-inline", $row ).prop( "checked" ) ) {
                        action.data.mode = "inline";
                    }
                    break;

                case "notify":
                    {
                        const method = $( 'select.re-method', $row ).val() || "";
                        action.data = { method: method };
                        action.data.message = $( '.re-message', $row ).val() || "";
                        let profile = $( 'select.re-profile', $row ).val() || "";
                        if ( ! isEmpty( profile ) ) {
                            action.data.profile = profile;
                        }
                        $( '.re-notify-extra', $row ).each( ( ix, obj ) => {
                            let $el = $( obj );
                            let fld = $el.data( 'field' );
                            let val = $el.val() || "";
                            if ( ! isEmpty( val ) ) {
                                action.data[ fld ] = val;
                            }
                        });
                    }
                    break;

                case "request":
                    action.data = { "method": $( 'select.re-method', $row ).val() || "GET" };
                    action.data.url = $( 'textarea.re-requrl', $row ).val() || "";
                    if ( action.data.url.startsWith( 'https:' ) ) {
                        action.data.ignore_cert = $( 'input.re-ignorecert', $row ).prop( 'checked' );
                    }
                    t = $( 'textarea.re-reqheads', $row ).val() || "";
                    if ( ! isEmpty( t ) ) {
                        action.data.headers = [];
                        t.split( /[\r\n]+/ ).forEach( function( txt ) {
                            const m = txt.match( /^\s*([^:]+):\s*(.*)$/ );
                            if ( null !== m ) {
                                /** A couple of things here. According to RFC 2616 Section 4.2, header field
                                 *  names are case-insensitive, but we store what we're given in case the user
                                 *  runs into a non-compliant server that enforces case on a header it requires.
                                 *  We also store them as an array of key/value pairs, rather than as a dict/obj,
                                 *  because headers can repeat (e.g. lists that are cumulative). This change is
                                 *  as of 21084, so we'll need to support the old (object) storage for a while
                                 *  on restore/edit and in the Engine execution of the action.
                                 *  RTFM: http://www.w3.org/Protocols/rfc2616/rfc2616.html
                                 */
                                action.data.headers.push( { key: m[1].trim(), value: m[2].trim() } );
                            }
                        });
                    }
                    t = $( 'select.re-reqauth', $row ).val() || "";
                    if ( "" !== t ) {
                        action.data.http_auth = t;
                        action.data.username = $( 'input.re-requser', $row ).val() || "";
                        action.data.password = $( 'input.re-reqpass', $row ).val() || "";
                    }
                    t = $( 'textarea.re-reqdata', $row ).val() || "";
                    if ( "GET" !== action.data.method && !isEmpty( t ) ) {
                        action.data.reqdata = t;
                    }
                    t = $( 'select.re-reqtarget', $row ).val() || "";
                    if ( ! isEmpty( t ) ) {
                        let k = t.indexOf( ':' );
                        if ( k >= 0 ) {
                            action.data.rule = t.substring( 0, k );
                            action.data.rvar = t.substring( k + 1 );
                        } else {
                            action.data.rvar = t;
                            delete action.data.rule;
                        }
                    } else if ( $( 'input.re-reqwait', $row ).prop( 'checked' ) ) {
                        action.data.wait = true;
                    }
                    if ( $( 'input.re-reqquiet', $row ).prop( 'checked' ) ) {
                        action.data.quiet = true;
                    }
                    if ( $( 'input.re-reqadv', $row ).prop( 'checked' ) ) {
                        action.data.advanced = true;
                    }
                    break;

                case "shell":
                    action.data = { command: $( 'input.re-shellcmd', $row ).val() || "#" };
                    t = $( 'select.re-shelltarget', $row ).val() || "";
                    if ( ! isEmpty( t ) ) {
                        let k = t.indexOf( ':' );
                        if ( k >= 0 ) {
                            action.data.rule = t.substring( 0, k );
                            action.data.rvar = t.substring( k + 1 );
                        } else {
                            action.data.rvar = t;
                            delete action.data.rule;
                        }
                    }
                    if ( $( 'input.re-ignoreexit', $row ).prop( 'checked' ) ) {
                        action.data.ignore_exit = true;
                    }
                    break;

                default:
                    {
                        console.log("buildActionList:", actionType, "action unrecognized");
                        const ax = $( 'input#' + idSelector( pfx + 'unrecdata' ), $row ).val() || "";
                        if ( ! isEmpty( ax ) ) {
                            action = JSON.parse( ax );
                            if ( ! action ) {
                                scene = false;
                            }
                        } else {
                            scene = false;
                        }
                        if ( !scene ) {
                            return false;
                        }
                    }
            }

            scene.push( action );
        });
        console.log("buildActionList finished!");
        console.log($root, scene);
        return scene;
    }

    validateActionRow( $row ) {
        const actionType = $('select.re-actiontype', $row).val();

        // Clear error flags from row and dhildren
        $( '.tberror,.tbwarn,.is-invalid,.gv', $row ).removeClass( 'tberror tbwarn is-invalid gv' );  // remove from children...
        $row.removeClass( 'tberror tbwarn is-invalid gv' );  // and row itself

        const pfx = $row.attr( 'id' ) + '-';
        let dev, k, $f;

        switch ( actionType ) {
            case "group":
            case "while":
                const editor = $( 'div.editor-group div.re-embeddable-editor', $row ).data( 'embeddable-editor' );
                $f = $( 'div.action-group-container > div.action-group', $row ).first();
                /* Any error in our interior (conditions) is an error for the group. */
                if ( 0 !== $( '.tberror', $row ).length ) {
                    $row.addClass( 'tberror' );
                }
                /* While action must have constraint conditions */
                if ( "while" === actionType && 0 === (editor?.data?.conditions || []).length ) {
                    $row.addClass( 'tberror' );
                }
                /* Group must have actions. */
                if ( 0 === $( 'div.actionrow', $f ).length ) {
                    $row.addClass( 'tberror' );
                }
                /* Max-iteration count must be positive integer or 0/blank */
                $f = $( 'input.re-maxiter', $row );
                this.fieldCheck( ( $f.val() || "" ).match( /[^0-9]/ ), $f, _T('#validation-int-minimum', 0) );
                break;

            case "comment":
                $f = $( 'textarea', $row );
                this.fieldCheck( isEmpty( $f.val() ), $f, _T('Required value.') );
                break;

            case "delay":
                {
                    $f = $( 'input#' + idSelector( pfx + 'delay' ), $row );
                    const delay = $f.val() || "";
                    if ( delay.match( varRefPattern ) ) {
                        // Variable reference. ??? check it?
                    } else if ( delay.match( /^([0-9][0-9]?)(:[0-9][0-9]?){1,2}$/ ) ) {
                        // MM:SS or HH:MM:SS
                    } else {
                        const n = parseInt( delay );
                        this.fieldCheck( isNaN( n ) || n < 1, $f, _T('Must be integer > 0, or MM:SS or HH:MM:SS') );
                    }
                }
                break;

            case "entity":
                {
                    $row.removeClass( 'tbsubst' );
                    $f = $( 'div.entity-selector', $row );
                    const eid = entitypicker.getEntity( $f );
                    if ( this.fieldCheck( isEmpty( eid ), $f, _T('Entity required') ) ) {
                        let entity = api.getEntity( eid );
                        $f.toggleClass( 'tberror', ! entity );
                        $f = $('select.re-actionmenu', $row);
                        if ( entity && this.fieldCheck( isEmpty( $f.val() ), $f, _T('Selection required.') ) ) {
                            // check parameters, with value/type check when available?
                            let action = entity.getActionDef( $f.val() );
                            if ( action && action.arguments ) {
                                for ( const [pname, p] of Object.entries( action.arguments ) ) {
                                    if ( "undefined" !== typeof p.value ) {
                                        /* ignore fixed value field */
                                        continue;
                                    }
                                    /* Fetch value */
                                    $f = $( '#' + idSelector( pfx + pname ), $row );
                                    if ( 1 !== $f.length ) {
                                        console.log("validateActionRow: field ", pname, "expected 1 found", $f.length );
                                        continue; /* don't validate to avoid user jail */
                                    }
                                    let v = ($f.val() || "").trim();
                                    $f.val( v ); /* replace with trimmed value */
                                    /* Ignore default here, it's assumed to be valid when needed */
                                    /* Blank and optional OK? Move on. */
                                    if ( "" === v ) {
                                        /* Not optional/empty allowed, flag error. */
                                        $f.toggleClass( 'tbwarn', !p.optional );
                                    } else if ( v.match( varRefPattern ) ) {
                                        /** Variable reference, do nothing, can't check. Use class to flag
                                         *  "try" button disabled, since we can't do substitutions only the
                                         *  fly, only from within the engine (i.e. when running full reaction).
                                         */
                                        $row.addClass( 'tbsubst' );
                                    } else {
                                        // check value type, range?
                                        // ??? subtypes? like RGB; validation pattern(s) from data?
                                        const typ = p.type || "any";
                                        if ( inttypes[ typ ] ) {
                                            /* Integer. Watch for RGB spec of form #xxx or #xxxxxx */
                                            v = v.replace( /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i, "0x\\1\\1\\2\\2\\3\\3" );
                                            v = v.replace( /^#[0-9a-f]{6,8}$/, "0x" );
                                            v = parseInt( v );
                                            if ( isNaN(v) || ( v < inttypes[typ].min ) || ( v > inttypes[typ].max ) ||
                                                ( undefined !== p.min && v < p.min ) || ( undefined !== p.max && v > p.max ) ) {
                                                $f.addClass( 'tbwarn' ); // ???explain why?
                                            }
                                        } else if ( "real" === typ ) {
                                            /* Float */
                                            v = parseFloat( v );
                                            if ( isNaN( v ) || ( undefined !== p.min && v < p.min ) || ( undefined !== p.max && v > p.max ) ) {
                                                $f.addClass( 'tbwarn' );
                                            }
                                        } else if ( "bool" === typ || "boolean" === typ ) {
                                            /* Boolean should match true or false pattern */
                                            if ( ! ( boolTruePattern.test( v ) || boolFalsePattern.test( v ) ) ) {
                                                $f.addClass( 'tbwarn' );
                                            }
                                        } else if ( "object" === typ ) {
                                            try {
                                                const tv = v.trim();
                                                if ( tv.startsWith( '{' ) ) {
                                                    JSON.parse( v.replace( /\$\{\{[^}]*\}\}/, "null" ) );
                                                }
                                                // ??? Not checking valid YAML here; eventually should.
                                            } catch ( err ) {  // eslint-disable-line no-unused-vars
                                                $f.addClass( 'tbwarn' );
                                            }
                                        } else if ( "string" !== typ && "any" !== typ ) {
                                            /* Known unsupported/TBD: date/dateTime/dateTime.tz/time/time.tz (ISO8601), bin.base64, bin.hex, uri, uuid, char, fixed.lll.rrr */
                                            console.log("validateActionRow: no validation for type ", typ);
                                        }
                                    }
                                }
                            }
                        }
                    } else {
                        $row.addClass( 'tberror' );
                    }
                }
                break;

            case "script":
                $f = $( 'textarea.re-script-expr', $row );
                k = $f.val() || "";
                $( 'div.re-script-err', $row ).remove();
                if ( this.fieldCheck( isEmpty( k ), $f, _T('Script/expression required.') ) && lexp ) {
                    try {
                        lexp.compile( k );
                    } catch ( err ) {
                        this.fieldCheck( true, $f, err.message );
                        $( '<div class="re-script-err"></div>' )
                            .text( err.message )
                            .appendTo( $( 'div.actiondata', $row ) );
                        console.error( err );
                    }
                }
                break;

            case "setvar":
                $f = $( 'select.re-variable', $row );
                k = $f.val() || "";
                if ( this.fieldCheck( isEmpty( k ), $f, _T('Selection required.') ) ) {
                    /* Disable re-eval checkbox for global, because we always do */
                    $( 'div.form-check', $row ).toggle( k.indexOf( ':' ) >= 0 );
                } else {
                    $( 'div.form-check', $row ).toggle( false );
                }
                break;

            case "run":
            case "stop":
                $f =  $( 'select.re-scene', $row );
                this.fieldCheck( isEmpty( $f.val() ) && "stop" !== actionType , $f, _T('Selection required.') );
                break;

            case "notify":
                {
                    /* Message cannot be empty (ever). */
                    dev = $( '.re-message', $row );
                    let vv = (dev.val() || "").trim();
                    dev.val( vv );
                    this.fieldCheck( isEmpty( vv ), dev, _T('Required value.') );
                    /* Check method */
                    dev = $( 'select.re-method', $row );
                    const method = dev.val() || "";
                    if ( this.fieldCheck( isEmpty( method ), dev, _T('Selection required.') ) ) {
                        api.getNotifiers().then( notifiers => {
                            let ninfo = notifiers[ method ];
                            if ( ninfo ) {
                                if ( false !== ninfo.uidata.usesProfiles ) {
                                    let vv = $( 'select.re-profile', $row ).val() || "";
                                    $( 'select.profile', $row ).toggleClass( 'is-invalid', isEmpty( vv ) );
                                }
                                ( ninfo.uidata.extra || [] ).forEach( fld => {
                                    let dev = $( '.re-extra-' + fld.id, $row );
                                    vv = (dev.val() || "").trim();
                                    let fails = false;
                                    if ( isEmpty( vv ) ) {
                                        fails = !fld.optional;
                                    } else if ( vv.match( varRefPattern ) ) {
                                        /* Always OK */
                                    } else if ( fld.validpattern && !vv.match( fld.validpattern ) ) {
                                        fails = true;
                                    }
                                    dev.toggleClass( 'is-invalid', fails );
                                });
                            }
                        });
                    }
                }
                break;


            case "request":
                {
                    const req_method = $( 'select.re-method', $row ).val() || "GET";
                    $f = $( '.re-requrl', $row );
                    const url = ($f.val() || "").trim();
                    $f.val( url );
                    if ( ! ( url.match( varRefPattern ) || url.match( /^https?:\/\// ) ) ) {
                        this.fieldCheck( true, $f, _T("Must be http:// or https:// or substitution") );
                    }
                    $( '.re-ignorecert', $row ).prop( 'disabled', url.startsWith( 'http://' ) );
                    /* Header format check */
                    $f = $( '.re-reqheads', $row );
                    let pd = $f.val() || "";
                    if ( ! isEmpty( pd ) ) {
                        const heads = pd.trim().split( /[\r\n]+/ );
                        const lh = heads.length;
                        for ( let k=0; k<lh; ++k ) {
                            /* Must be empty or "Header-Name: stuff" */
                            if ( this.fieldCheck( !heads[k].match( /^[A-Z0-9-]+:\s+/i ), $f, _T("Invalid format") ) ) {
                                break;
                            }
                        }
                    }
                    if ( "GET" !== req_method && ! pd.match( /content-type:/i ) ) {
                        $( '.re-reqheads', $row ).val( "Content-Type: application/x-www-form-urlencoded\n" + pd );
                    }
                    $( 'div.re-reqdatafs', $row ).toggle( "GET" !== req_method ); /* We don't validate post data */
                    pd = ! isEmpty( $( '.re-reqtarget', $row ).val() || "" );
                    $( '.re-reqwait', $row ).prop( 'disabled', pd )
                        .prop( 'checked', pd ? pd : undefined );  // don't change if no target
                    $( '.re-reqadv', $row ).prop( 'disabled', !pd )
                        .prop( 'checked', pd ? undefined : false );
                    pd = $( 'select.re-reqauth', $row ).val();
                    $( '.re-reqauthdata', $row ).prop( 'disabled', isEmpty( pd ) );
                    if ( ! isEmpty( pd ) ) {
                        $f = $( 'input.re-requser', $row );
                        this.fieldCheck( isEmpty( $f.val() ), $f, _T("Required value.") );
                        $f = $( 'input.re-reqpass', $row );
                        this.fieldCheck( isEmpty( $f.val() ), $f, _T("Required value.") );
                    }
                }
                break;

            case "shell":
                {
                    $f = $( 'input.re-shellcmd', $row );
                    let cmd = ($f.val() || "").trim();
                    $f.val( cmd );
                    if ( this.fieldCheck( isEmpty( cmd ), $f, _T('Required value.') ) ) {
                        this.fieldCheck( cmd.match( /[\r\n]/ ), $f, _T("Command contains illegal characters (line breaks)") );
                    }
                    /* no need to validate checkbox */
                }
                break;

            default:
                /* Do nothing */
        }

        $row.has('.is-invalid').addClass( 'tberror' );

        /* Disable single-action "try" button if there are errors or if substitions are used in parameters. */
        $( 'button[data-action="tryaction"]', $row )
            .prop( 'disabled',
                $row.hasClass( 'tberror' ) || $row.hasClass( 'tbsubst' ) || $( '.tberror', $row ).length > 0 )
            .prop( 'title',
                $row.hasClass( 'tbsubst' ) ?
                    _T('"Try" button cannot be used when substitutions are used in parameters') :
                    _T('Try this action') );

        /* Validate up */
        let $parentGroup = $row.closest( 'div.action-group-container' );
        if ( 0 !== $parentGroup.length ) {
            this.validateActionRow( $parentGroup.closest( 'div.actionrow' ) );
        }
    }

    updateActionList() {
        this.signalModified();
        if ( 0 === $( '.tberror', this.$editor ).length ) {
            const scene = this.buildActionList();
            if ( scene ) {
                this.data.actions = scene;
                this.data.editor_version = version;
                this.updateActionControls();
                return true;
            }
        }
        this.updateActionControls();
        return false;
    }

    /* N.B.: This must only be called from event handlers */
    changeActionRow( $row ) {
        $row.addClass( "tbmodified" );
        this.validateActionRow( $row );
        this.updateActionList(); /* signals modified */
    }

    handleActionValueChange( ev ) {
        const $row = $( ev.currentTarget ).closest( 'div.actionrow' );
        this.changeActionRow( $row );
    }

    getArgumentField( entity, action, pname, parm, $container ) {
        let $inp, $opt;
        parm.type = parm.type || "any";
        if ( "undefined" !== typeof parm.value ) {
            $inp = $( '<input type="hidden" class="argument">' ).val( String( parm.value ) );
        }
        let values;
        if ( parm.values_from_attr_array && entity && entity.hasAttribute( parm.values_from_attr_array ) ) {
            values = entity.getAttribute(parm.values_from_attr_array);
        } else {
            values = parm.values;
        }
        /* Turn boolean into value list */
        if ( parm.type.match( /^bool(ean)?$/ ) && ! values ) {
            values = [ 'false', 'true' ];
            if ( parm.optional ) {
                values.push( 'null' );
            }
        }
        if ( values ) {
            /* Menu, can be array of strings or objects */
            if ( "string" === typeof values ) {
                /* String to array of values */
                values = values.split( /, */ );
            } else if ( ! Array.isArray( values ) ) {
                /* Convenience format of capability: params as dict rather than array of key/value pairs */
                if ( null !== values && "object" === typeof values ) {
                    let m = [];
                    Object.keys( values ).forEach( key => { m.push( { [key]: values[key] } ); } );
                    values = m;
                } else {
                    /* Ugly, but not crashy */
                    values = [ String( values ) ];
                }
            }
            if ( "undefined" !== typeof window.HTMLDataListElement ) {
                /* Use datalist when supported (allows more flexible entry) */
                const dlid = (['dl', entity ? entity.getCanonicalID() : "any", action, pname]).join( '-' ).replace( /[^a-z0-9_-]/ig, "-" );
                if ( 0 === $( 'datalist#' + idSelector( dlid ) ).length ) {
                    /* Datalist doesn't exist yet, create it */
                    $inp = $('<datalist class="argdata"></datalist>').attr( 'id', dlid );
                    values.forEach( val => {
                        $opt = $( '<option></option>' );
                        if ( null !== val && "object" === typeof( val ) ) {
                            Object.keys( val ).forEach( z => {
                                $opt.val( String(z) );
                                $opt.text( String( val[z] ) + ( val[z] === parm.default ? " *" : "" ) );
                            });
                        } else {
                            $opt.val( String( val ) );
                            $opt.text( String( val ) + ( val === parm.default ? " *" : "" ) );
                        }
                        $inp.append( $opt );
                    });
                    /* Add variables and append to tab (datalists are global to tab) */
                    if ( ! parm.novars ) {
                        this.appendVariables( $inp );
                    }
                    $container.append( $inp );
                }
                /* Now pass on the input field */
                let t = { ...autoGrowDefaults };
                t.maxWidth = Math.floor( $container.innerWidth() - 54 );
                $inp = $( '<input class="argument form-control form-control-sm w-100">' )
                    .attr( 'autocomplete', 'off' )
                    .attr( 'placeholder', _T('Click for predefined values or enter your own') )
                    .attr( 'list', dlid )
                    .inputAutogrow( t );
                if ( undefined !== parm.default ) {
                    $inp.val( String( parm.default ) );
                }
            } else {
                /* Standard select menu -- ??? should do coordinated with input */
                $inp = $( '<select class="argument form-select form-select-sm"></select>' );
                if ( parm.optional ) {
                    $( '<option></option>' ).val( "" )
                        .text( _T('(not specified)') )
                        .prependTo( $inp );
                }
                values.forEach( val => {
                    $opt = $( '<option></option>' );
                    if ( null !== val && "object" === typeof( val ) ) {
                        Object.keys( val ).forEach( z => {
                            $opt.val( String(z) );
                            $opt.text( String( val[z] ) + ( val[z] === parm.default ? " *" : "" ) );
                        });
                    } else {
                        $opt.val( String( val ) );
                        $opt.text( String( val ) + ( val === parm.default ? " *" : "" ) );
                    }
                    $inp.append( $opt );
                });
                /* Add variables */
                if ( ! parm.novars ) {
                    this.appendVariables( $inp );
                }
                /* As a default, just choose the first option, unless specified & required */
                if ( undefined !== parm.default && !parm.optional ) {
                    $inp.val( parm.default );
                } else {
                    $( 'option:first', $inp ).prop( 'selected', true );
                }
            }
        } else if ( parm.slider && parm.type === "ui1" && parm.min !== undefined && parm.max !== undefined ) {
            $inp = $('<div class="argument tbslider"></div>');
            $inp.slider({
                min: parm.min, max: parm.max, step: parm.step || 1,
                range: "min",
                stop: function ( /* ev, ui */ ) {
                    // DeusExMachinaII.changeDimmerSlider( $(this), ui.value );
                },
                slide: function( ev, ui ) {
                    $( 'a.ui-slider-handle', $( this ) ).text( ui.value );
                },
                change: function( ev, ui ) {
                    $( 'a.ui-slider-handle', $( this ) ).text( ui.value );
                }
            });
            $inp.slider("option", "disabled", false);
            $inp.slider("option", "value", parm.default || parm.min ); /* parm.min always defined in this block */
        } else if ( parm.type.match( "^(real|u?int)$" ) || parm.type.match(/^(u?i)[1248]$/i ) ) {
            let t = { ...autoGrowDefaults };
            t.maxWidth = Math.floor( $container.innerWidth() - 54 );
            $inp = $( '<input class="argument form-control form-control-sm w-100">' )
                .inputAutogrow( t );
            if ( ! parm.novars ) {
                $inp.attr( 'list', 'reactorvarlist' );
            }
            $inp.attr( 'placeholder', pname );
            $inp.val( parm.optional ? "" : Common.coalesce( parm.default, parm.min, 0 ) );
        } else if ( "object" === parm.type ) {
            $inp = $( '<textarea class="argument form-control form-control-sm w-100 code" wrap="soft" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>' )
                .attr( 'placeholder', _T("YAML or JSON") );
        } else {
            if ( "string" !== parm.type && "any" !== parm.type ) {
                console.warn("getArgumentField: using default (string) presentation for type " +
                    String(parm.type) + " " + String(pname) );
            }
            if ( parm.bigtext ) {
                $inp = $( '<textarea class="argument form-control form-control-sm w-100" wrap="soft" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>' );
            } else {
                let t = { ...autoGrowDefaults };
                t.maxWidth = Math.floor( $container.innerWidth() - 54 );
                $inp = $( '<input class="argument form-control form-control-sm w-100" autocomplete="off">' )
                    .inputAutogrow( t );
                if ( ! parm.novars ) {
                    $inp.attr( 'list', 'reactorvarlist' );
                }
            }
            $inp.attr( 'placeholder', pname );
            $inp.val( parm.optional ? "" : Common.coalesce( parm.default, "" ) );
        }
        if ( parm.tip ) {
            $inp.attr( 'title', parm.tip );
        }
        if ( parm.uiclass ) {
            $inp.addClass( parm.uiclass );
        }

        return $inp;
    }

    changeNotifyActionMethod( $row, method, actionData ) {
        const self = this;
        api.getNotifiers().then( notifiers => {
            const ninfo = notifiers[ method ] || { uidata: {} };
            $( "select.re-method", $row ).val( method ); /* override */
            $( 'div.re-extrafields', $row ).remove();
            /*  Do not clear message */
            if ( false !== ninfo.uidata.usesProfiles ) {
                $( '.re-profile-group', $row ).show();
                let $mm = $( "select.re-profile", $row ).empty();
                ( ninfo.profiles || [] ).forEach( p => {
                    $( '<option></option>' ).val( p.id ).text( p.description || p.id ).appendTo( $mm );
                });
                Common.menuSelectDefaultFirst( $mm, ( actionData || {} ).profile );
            } else {
                $( ".re-profile-group", $row ).hide();
                $( "select.re-profile", $row ).empty();
            }
            if ( ninfo.uidata.extra ) {
                const $container = $( 'div.actiondata', $row );
                const $extra = $( '<div class="re-extrafields"></div>' )
                    .appendTo( $container );
                ninfo.uidata.extra.forEach( function( fld ) {
                    let xf;
                    if ( "select" === fld.type ) {
                        xf = $( '<select class="form-select form-select-sm"></select>' );
                        if ( fld.values ) {
                            const lv = fld.values.length;
                            for ( let vi=0; vi<lv; vi++ ) {
                                const pm = fld.values[vi].match( /^([^=]*)=(.*)$/ );
                                if ( pm ) {
                                    $( '<option></option>' ).val( pm[1] ).text( pm[2] )
                                        .appendTo( xf );
                                }
                            }
                        }
                        if ( fld.rpc ) {
                            // Use an RPC to get the list of values for this selector
                            api.sendrpc( `notifier.${fld.rpc}`, { method: method, profile: ( actionData || {} ).profile || "default" } ).then( data => {
                                //console.log("RPC response", data);
                                if ( "object" === typeof data && null !== data ) {
                                    // ??? maybe not remove the current selection?
                                    $( 'option', xf ).remove();
                                    if ( Array.isArray( data ) ) {
                                        for ( let val of data ) {
                                            const m = String( val ).match( /^([^=]*)=(.*)$/ );
                                            $( '<option></option>' ).val( m[1] ).text( m[2] ).appendTo( xf );
                                        }
                                    } else {
                                        for ( let key in data ) {
                                            $( '<option></option>' ).val( key ).text( data[key] ).appendTo( xf );
                                        }
                                    }
                                    if ( ! isEmpty( actionData[ fld.id ] ) ) {
                                        xf.val( actionData[ fld.id ] );
                                    } else if ( ! isEmpty( fld.default ) ) {
                                        xf.val( fld.default );
                                    }
                                }
                            }).catch( err => {
                                console.error(`RPC notifier.${fld.rpc} failed:`, err );
                            });
                        }
                    } else if ( "textarea" === fld.type ) {
                        xf = $( '<textarea class="form-control form-control-sm" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>' );
                    } else {
                        let t = { ...autoGrowDefaults };
                        t.maxWidth = Math.floor( $container.innerWidth() - 54 );
                        xf = $( '<input class="form-control form-control-sm w-100" autocomplete="off">' )
                            .inputAutogrow( t );
                    }
                    if ( ! isEmpty( fld.default ) ) {
                        xf.val( fld.default );
                    }
                    xf.data( 'field', fld.id ).attr( 'data-field', fld.id )
                        .addClass( 're-notify-extra re-extra-' + fld.id )
                        .on( 'change.reactor', self.handleActionValueChange.bind( self ) );
                    let $fg = $( '<div class="form-group"></div>' ).appendTo( $extra );
                    $( '<label></label>' ).text( fld.label || fld.id ).appendTo( $fg );
                    xf.appendTo( $fg );
                    if ( fld.placeholder ) {
                        $( '<small class="form-text text-muted"></small>' ).text( fld.placeholder )
                            .appendTo( $fg );
                    }

                    /* Supply default value */
                    $( '.re-extra-' + fld.id, $row ).val( (actionData || {})[fld.id] || fld.default || "" );
                });
            }
        });
    }

    handleNotifyActionMethodChange( ev ) {
        const $row = $( ev.currentTarget ).closest( '.actionrow' );
        const val = $( ev.currentTarget ).val() || "";
        this.changeNotifyActionMethod( $row, val );
        return this.changeActionRow( $row );
    }

    changeActionAction( $row, newVal ) {
        // assert( row.hasClass( 'actionrow' ) );
        /* If action isn't changing, don't obliterate filled fields (i.e. device changes, same action) */
        const prev = $row.data( 'prev-action' ) || "";
        if ( !isEmpty(prev) && prev === newVal ) {
            return;
        }
        /* Load em up... */
        $row.data( 'prev-action', newVal ).attr('data-prev-action', newVal); /* save for next time */
        const pfx = $row.attr( 'id' );
        const $ct = $( 'div.actiondata', $row );
        $( '.arguments,.re-flex-break', $ct ).remove();
        if ( isEmpty( newVal ) ) {
            return;
        }
        let action;
        const eid = entitypicker.getEntity( $( 'div.entity-selector', $row ) );
        const entity = api.getEntity( eid );
        if ( entity ) {
            action = entity.getActionDef( newVal );
        }
        //console.log( 'ACTION', newVal, action );
        if ( action ) {
            if ( action.arguments ) {
                const alist = Object.keys( action.arguments );
                alist.sort( function( a, b ) {
                    const n1 = action.arguments[ a ].sort || 32767;
                    const n2 = action.arguments[ b ].sort || 32767;
                    if ( n1 === n2 ) {
                        if ( a === b ) {
                            return 0;
                        }
                        return (a < b) ? -1 : 1;
                    }
                    return (n1 < n2) ? -1 : 1;
                });
                const lk = alist.length;
                $ct.addClass( 'flex-wrap' );
                const $cap = $( 'div.re-capture-group', $row );
                const $fg = $( '<div class="arguments w-100 d-flex flex-column"></div>', $row );
                if ( $cap.length ) {
                    $( '<div class="re-flex-break"></div>' ).insertBefore( $cap ); /* Force break flex row */
                    $fg.insertBefore( $cap );
                } else {
                    $( '<div class="re-flex-break"></div>' ).appendTo( $ct );  /* Force break flex row */
                    $fg.appendTo( $ct );
                }
                for ( let k=0; k<lk; ++k ) {
                    const pname = alist[k];
                    const parm = action.arguments[pname];
                    if ( parm.hidden ) {
                        continue; /* or hidden parameters */
                    }
                    if ( "undefined" !== typeof parm.value ) {
                        continue; /* fixed value -- ??? hidden field now? */
                    }
                    const $inp = this.getArgumentField( entity, newVal, pname, parm, $fg )
                        .attr('id', pfx + '-' + pname )
                        .on( 'change.reactor', this.handleActionValueChange.bind(this) );
                    // const $fg = $( '<div class="form-group w-100"></div>' ).appendTo( $ct );
                    /* If there is more than one parameter, add a label */
                    const $div = $( '<div></div>' ).appendTo( $fg );
                    $("<label></label>").attr("for", pfx + '-' + pname )
                        .text( ( parm.label || pname ) + ":" )
                        .toggleClass( 'reqarg', !parm.optional )
                        .toggleClass( 'optarg', parm.optional )
                        .appendTo( $div );
                    $inp.appendTo( $div );
                    if ( parm.hint ) {
                        $( '<div class="re-action-arg-hint"><div>' ).text( parm.hint).appendTo( $div );
                    }
                }
            }

            $( 'div.re-capture-group', $row ).toggle( !!action.response );

            $( 'div.re-action-message', $row ).remove();
            if ( action.warning ) {
                $( '<div class="re-flex-break re-action-message"></div>' ).appendTo( $ct );
                $( '<div></div>' ).addClass( 're-action-message re-action-warning' )
                    .text( action.warning )
                    .appendTo( $ct );
            }
        }
    }

    handleActionActionChange( ev ) {
        const $el = $( ev.currentTarget );
        const newact = $el.val() || "";
        const $row = $el.closest( 'div.actionrow' );
        this.changeActionAction( $row, newact );
        this.changeActionRow( $row );
    }

    changeActionEntity( $row, eid ) {
        const entity = api.getEntity( eid );
        const $am = $( 'select.re-actionmenu', $row );
        const selected = $am.val() || "";
        $am.empty();
        if ( !entity ) {
            $('<option value="">(invalid--entity not found)</option>').appendTo( $am );
            $am.val( "" );
        } else {
            entity.getActions().sort().forEach( function( act ) {
                let t = act;
                let adef = entity.getActionDef( act );
                if ( adef ) {
                    if ( "string" === typeof adef.deprecated ) {
                        t += ' ' + _T( ['#deprecated-action-use','(deprecated; use {0:q})'], adef.deprecated );
                    } else if ( adef.deprecated ) {
                        t += ' ' + _T( ['#deprecated-action','(deprecated with no replacement)'] );
                    }
                }
                $( '<option></option>' ).val( act ).text( t ).appendTo( $am );
            });

            /* Try to re-select same action if it's present on new devuce */
            let $opt = $( 'option[value=' + Common.quot( selected ) + ']', $am );
            if ( 0 === $opt.length ) {
                Common.menuSelectDefaultFirst( $am );
            } else {
                $am.val( selected );
            }
        }
        this.changeActionAction( $row, $am.val() );
    }

    handleActionEntityChange( event, result ) {
        console.log("entity change", result);
        const eid = result.id;
        const $el = $( event.currentTarget );
        const $row = $el.closest( 'div.actionrow' );
        this.changeActionEntity( $row, eid );
        this.changeActionRow( $row );
    }

    /** Make an action group sortable. */
    makeSortableActionGroup( $ag ) {
        // console.log(this.$editor, 'makeSortableActionGroup', $ag, $ag.hasClass('action-group') );
        $ag.addClass( "re-sortable" ).sortable({
            // helper: 'original',
            helper: () => {
                return $( '<div class="ui-sortable-helper"></div>' );
            },
            cursorAt: { left: 64, top: 16 },  // our ui-sortable-helper is 128x32
            handle: '.draghandle',
            cancel: '', /* so draghandle can be button */
            items: '> *:not(.nodrag)',
            dropOnEmpty: true,
            placeholder: "re-insertionpt",
            connectWith: 'div.action-group',
            containment: this.$editor,
            /* https://stackoverflow.com/questions/15724617/jQuery-dragmove-but-leave-the-original-if-ctrl-key-is-pressed */
            start: function( ev, ui ) {
                if ( ev.ctrlKey ) {
                    const $clone = ui.item.clone().insertBefore( ui.item );
                    $clone.css({position:"static"});
                    /* The clone doesn't have selections restored ??? */
                }
            },
            receive: this.handleNodeReceive.bind( this ),  /* between cond-lists */
            update: this.handleNodeUpdate.bind( this )     /* within one cond-list */
        });
        return $ag;
    }

    /**
     * Set up the fields for the new action type
     */
    async changeActionType( $row, newVal ) {
        const self = this;
        const pfx = $row.attr( 'id' ) + '-';
        let $m, $fs, $lb;
        const $ct = $('div.actiondata', $row);
        $ct.empty().removeClass( 'd-flex flex-row' );
        $( 'div.action-group-container', $row ).remove(); /* Remove for group/while conditions */
        $( 'button[data-action="tryaction"],button[data-action="import"]', $row ).toggle( "run" === newVal );
        // $( 'button[data-action="clone"]', $row ).toggle( "group" !== newVal ); // no clone for groups

        switch ( newVal ) {
            case "group":
            case "while":
                {
                    let $gr = $( '<div class="action-group-container mx-1"></div>' ).appendTo( $ct );
                    let $eg = $( '<div class="editor-group"></div>' ).appendTo( $gr );
                    let const_editor = new RuleEditor(
                        {
                            id: pfx + 'cons',
                            name: _T([`#default-${newVal}-name`, "Group Constraints"]),
                            type: 'group',
                            op: 'and',
                            conditions: []
                        },
                        $eg,
                        {
                            contextRule: this.options.contextRule,
                            constraints: true,
                            inReaction: true,
                            stateless: true
                        }
                    );
                    const_editor.editor()
                        // .addClass( "collapse" )
                        .on( 'update-controls', function( ev, ui ) {
                            if ( ui.modified ) {
                                self.signalModified();
                            } else {
                                self.updateActionControls();
                            }
                        }).on( 'modified', function( ev, ui ) {
                            ui.editor.$editor.toggleClass( 'tberror', ui.errors );
                            if ( ui.modified ) {
                                self.changeActionRow( $row );
                            }
                        }).on( 'ready', function( /* ev, ui */ ) {
                            // ui.editor.editor().collapse( "show" );
                            // console.log("reaction editor ready", this);
                        });

                    let $ag = $( '<div class="action-group"></div>' ).appendTo( $gr );

                    /* Force a single new condition (comment default) */
                    self.getActionRow()
                        .addClass( 'tbmodified' )
                        .appendTo( $ag );

                    this.makeSortableActionGroup( $ag );

                    $fs = $( '<div class="row buttonrow"></div>' ).appendTo( $gr );
                    $( `<div class="col-auto"><button class="btn btn-sm btn-primary"><i class="bi bi-plus-lg me-1"></i>${_T(['#reaction-button-addaction','Add Action'])}</button></div>` )
                        .on( 'click.reactor', async ( event ) => {
                        const $buttonrow = $( event.currentTarget ).parent();
                        const $row = $buttonrow.closest( 'div.action-group-container' ).closest( 'div.actionrow' );
                        const $ct = $buttonrow.siblings( 'div.action-group' );
                        const $newrow = self.getActionRow()
                            .addClass( 'tbmodified' )
                            .appendTo( $ct );
                        $row.addClass( 'tbmodified' );  /* flag the parent, too */
                        self.updateActionControls();
                        self.changeActionRow( $newrow );
                    }).appendTo( $fs );

                    if ( "while" === newVal ) {
                        $( '<label class="col-auto col-form-label ms-3 pt-1"></label>' )
                            .attr( 'for', $fs.closest( 'div.actionrow' ).attr( 'id' ) + '-maxiter' )
                            .text( _T( "Maximum iterations:" ) )
                            .appendTo( $fs );
                        let $el = $( '<div class="col-auto"></div>' ).appendTo( $fs );
                        $( '<input type="number" min="0" class="form-control form-control-sm re-maxiter narrow">' )
                            .attr( 'id', $fs.closest( 'div.actionrow' ).attr( 'id' ) + '-maxiter' )
                            .attr( 'placeholder', "0" )
                            .attr( 'title', _T( "Integer > 0, or 0/blank for no limit (default)" ) )
                            .on( 'change.reactor', this.handleActionValueChange.bind(this) )
                            .appendTo( $el );
                    }

                    const_editor.edit(); /* start the editor */
                }
                break;

            case "comment":
                $ct.addClass( 'd-flex flex-row' );
                $fs = $('<textarea class="form-control form-control-sm re-comment" wrap="soft"></textarea>')
                    .text( _T('Enter comment text') )
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) );
                $ct.append( this.divwrap( $fs ).addClass( "flex-grow-1") );
                break;

            case "delay":
                $ct.addClass( 'd-flex flex-row' );
                $fs = $( `<label class="mt-1 mx-1"></label>` )
                    .text( _T(['#reaction-delay-for','for']) )
                    .attr( 'for', `${pfx}delay` );
                $ct.append( this.divwrap( $fs ) );
                $fs = $( '<input type="text" id="${pfx}delay" class="argument form-control form-control-sm ms-1 w-100">' )
                    .attr( {
                        id: `${pfx}delay`,
                        list: "reactorvarlist",
                        title: _T('Enter delay time in seconds, or MM:SS, or HH:MM:SS')
                    } );
                $ct.append( this.divwrap( $fs ) );
                $fs = $( `<select class="form-select form-select-sm re-delaytype">
  <option value="">${_T('from this point')}</option>
  <option value="start">${_T('from start of reaction')}</option>
</select>` )
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) );
                $ct.append( this.divwrap( $fs ) );
                $( 'input', $ct )
                    .inputAutogrow( { minWidth: 32, maxWidth: Math.floor( $ct.innerWidth() - 54 ) } )
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) );
                break;

            case "entity":
                {
                    $ct.removeClass( 'd-flex flex-row' );
                    let $ic = $( '<div class="d-flex flex-row"></div>' ).appendTo( $ct );
                    let $ep = entitypicker.getPickerControl().appendTo( $ic );
                    $ep.on( 'change.reactor', this.handleActionEntityChange.bind(this) );
                    $fs = $('<select class="form-select form-select-sm re-actionmenu"></select>')
                        .on( 'change.reactor', this.handleActionActionChange.bind(this) );
                    $ic.append( this.divwrap( $fs ) );
                    $( 'button[data-action="tryaction"]', $row ).show();

                    $fs = $( '<div class="form-group re-capture-group"></div>' ).toggle( false ).appendTo( $ct );
                    $lb = $( '<label class="form-label"></label>' ).text( _T('Capture response to:') ).appendTo( $fs );
                    $m = await this.makeExprMenu();
                    $m.addClass( 're-resp-target' )
                        .on( "change.reactor", this.handleActionValueChange.bind(this) )
                        .appendTo( $lb );
                    $( 'option[value=""]', $m ).text( _T('(ignore/discard response)') );
                    /* Changes to menu items are handled by handler established in edit() */
                }
                break;

            case "script":
                $ct.removeClass( 'd-flex flex-row' );
                $fs = $( '<div class="form-group"></div>' ).appendTo( $ct );
                $( '<textarea class="form-control re-script-expr" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>' )
                    .attr( 'placeholder', _T('Enter script/expression') )
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) )
                    .appendTo( $fs );
                break;

            case "run":
            case "stop":
                $ct.addClass( 'd-flex flex-row' );
                $m = await this.makeReactionMenu();
                $ct.append( this.divwrap( $m ) );
                $m.on( 'change.reactor', this.handleActionValueChange.bind(this) );
                // ??? Common.getWiki( "Run-Scene-Action" ).appendTo( $ct );
                if ( "run" === newVal ) {
                    $( '<option></option>' ).val( "" ).text( _T('#opt-choose') )
                        .prependTo( $m );
                    $m = Common.getCheckbox( Common.getUID(), "1",
                        _T( "Wait for completion" ),
                        "", "Run-Reaction-Action" ).addClass( "ms-2" );
                    $( 'input', $m ).addClass("tb-run-inline")
                        .prop( 'checked', false )
                        .on( 'change.reactor', this.handleActionValueChange.bind(this) );
                    $m.appendTo( $ct );
                } else {
                    $( '<option></option>' ).val( "" ).text( _T( ['#opt-current-reaction','(this reaction)'] ) )
                        .prependTo( $m );
                }
                break;

            case "setvar":
                $ct.addClass( 'd-flex flex-row flex-wrap' );
                $m = await this.makeExprMenu();
                $ct.append( this.divwrap( $m ) );
                $m.on( 'change.reactor', this.handleActionValueChange.bind(this) );
                /* Updates to menu are handled by global event handler established in edit() method */

                $ct.append( '<div class="mt-1 mx-2">=</div>' );
                $fs = $( '<input class="form-control form-control-sm w-100" list="reactorvarlist">' )
                    .attr( 'title', _T("Enter value to be assigned to this variable.") )
                    .attr( 'id', pfx + "value" )
                    .inputAutogrow( { minWidth: 32, maxWidth: Math.floor( $ct.innerWidth() - 54 ) } )
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) );
                $ct.append( this.divwrap( $fs ) );
                /* ??? DEPRECATED: reeval checkbox.
                 *     Preserve any existing value in a hidden field for now. If we really remove this,
                 *     it all goes.

                $m = Common.getCheckbox( Common.getUID("reeval"), "1",
                    _T("Force re-evaluation of expressions and conditions"),
                    "", "Set-Variable-Action" ).addClass( "ms-2" );
                $( 'input', $m ).addClass("tbreeval")
                    .prop( 'checked', false )
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) );
                */
                $m = $( '<input type="hidden" class="tbreeval">' ).attr( 'id', Common.getUID("reeval") )
                    .val("");
                $m.appendTo( $ct );
                break;

            case "Xresetlatch":
                break;

            case "notify":
                {
                    $ct.removeClass( 'd-flex flex-row' );
                    $fs = $( '<div class="form-group"></div>' ).appendTo( $ct );
                    $m = $( '<select class="form-select form-select-sm re-method"></select>' );
                    $( '<option></option>').val( "" ).text( _T(['#opt-choose','--choose--']) )
                        .appendTo( $m );
                    let notifiers = await api.getNotifiers();
                    Object.keys( notifiers || {} ).forEach( mid => {
                        let method = notifiers[ mid ];
                        $( '<option></option>' ).val( mid )
                            .text( method.uidata.name )
                            .appendTo( $m );
                    });
                    Common.menuSelectDefaultFirst( $m, "" );
                    $fs.append( $m );
                    $m.on( 'change.reactor', this.handleNotifyActionMethodChange.bind(this) );

                    $m = $( '<label class="re-profile-group"></label>' ).text( 'Profile:' ).appendTo( $ct );
                    $( '<select class="form-select form-select-sm re-profile"></select>' )
                        .on( 'change.reactor', this.handleActionValueChange.bind( this ) )
                        .appendTo( $m );

                    $fs = $( '<div class="form-group"></div>' ).appendTo( $ct );
                    $( '<label></label>' ).text( _T('Message:') ).appendTo( $fs );
                    $('<textarea class="form-control form-control-sm re-message" wrap="soft" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>')
                        .on( 'change.reactor', this.handleActionValueChange.bind(this) )
                        .appendTo( $fs );

                    this.changeNotifyActionMethod( $ct, $m.val() );
                }
                break;

            case "request":
                $ct.removeClass( 'd-flex flex-row' );
                $fs = $( '<div class="form-group"></div>' ).appendTo( $ct );
                $m = $( '<select class="form-select form-select-sm re-method"></select>' );
                ([ "GET", "POST", "PUT", "HEAD", "PATCH", "OPTIONS", "DELETE" ]).forEach( opt => {
                    $( '<option></option>' ).val( opt ).text( opt ).appendTo( $m );
                });
                Common.menuSelectDefaultFirst( $m, "GET" );
                $m.on( 'change.reactor', this.handleActionValueChange.bind(this) )
                    .appendTo( $fs );

                $lb = $( '<label class="form-label w-100"></label>' ).text( _T('Request URL:') )
                    .appendTo( $fs );
                $( '<textarea class="form-control re-reqfield re-requrl" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>' )
                    .attr( 'placeholder', 'http://')
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) )
                    .appendTo( $lb );

                $m = Common.getCheckbox( Common.getUID("ignorecert"), "1",
                    _T('Ignore self-signed/invalid SSL certificates'), "",
                    "HTTP-Request-Action" );
                $( 'input', $m ).addClass("re-ignorecert")
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) );
                $m.addClass( 'mb-1' ).appendTo( $fs );

                $lb = $( '<label class="form-label w-100"></label>' ).text( _T('Request Headers:') )
                    .appendTo( $fs );
                $( '<textarea class="form-control re-reqfield re-reqheads" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>' )
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) )
                    .appendTo( $lb );

                $fs = $( '<div class="form-group re-reqdatafs"></div>' ).hide().appendTo( $ct );
                $lb = $( '<label class="form-label w-100"></label>' ).text( _T('Request Body:') ).appendTo( $fs );
                $( '<textarea class="form-control re-reqfield re-reqdata" autocorrect="off" autocapitalize="none" autocomplete="off" spellcheck="false"></textarea>' )
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) )
                    .appendTo( $lb );

                $fs = $( '<div class="form-group"></div>' ).appendTo( $ct );
                $lb = $( '<label></label>' ).text( _T( "HTTP Auth Type:" ) ).appendTo( $fs );
                $( '<select class="re-reqauth form-select form-select-sm">\
<option value="">None</option><option value="basic">Basic</option><option value="digest">Digest</option>\
</select>' )
                    .on( "change.reactor", this.handleActionValueChange.bind(this) )
                    .appendTo( $lb );
                $lb = $( '<label></label>' ).text( _T( "Username:" ) ).appendTo( $fs );
                $( '<input class="re-requser re-reqauthdata form-control form-control-sm">' )
                    .on( "change.reactor", this.handleActionValueChange.bind( this ) )
                    .appendTo( $lb );
                $lb = $( '<label></label>' ).text( _T( "Password:" ) ).appendTo( $fs );
                $( '<input class="re-reqpass re-reqauthdata form-control form-control-sm">' )
                    .on( "change.reactor", this.handleActionValueChange.bind( this ) )
                    .appendTo( $lb );


                $fs = $( '<div class="form-group"></div>' ).appendTo( $ct );
                $lb = $( '<label class="form-label"></label>' ).text( _T('Capture response to:') ).appendTo( $fs );
                $m = await this.makeExprMenu();
                $m.addClass( 're-reqtarget' )
                    .on( "change.reactor", this.handleActionValueChange.bind(this) )
                    .appendTo( $lb );
                $( 'option[value=""]', $m ).text( _T('(ignore/discard response)') );
                /* Changes to menu items are handled by handler established in edit() */

                $m = Common.getCheckbox( Common.getUID("reqadv"), "1",
                    _T('Store captured response in advanced form'), "",
                    "HTTP-Request-Action" )
                    .appendTo( $fs );
                $( 'input', $m ).addClass("re-reqadv")
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) );

                $m = Common.getCheckbox( Common.getUID("reqwait"), "1",
                    _T('Wait until request completes'), "",
                    "HTTP-Request-Action" )
                    .addClass( "mt-1" )
                    .appendTo( $fs );
                $( 'input', $m ).addClass("re-reqwait")
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) );

                $m = Common.getCheckbox( Common.getUID("reqquiet"), "1",
                    _T('Suppress alerts on HTTP errors'), "",
                    "HTTP-Request-Action" )
                    .appendTo( $fs );
                $( 'input', $m ).addClass("re-reqquiet")
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) );

                $('<div></div>').html( _T( "#http-req-subst-notice" ) )
                    .addClass( 'mt-1' )
                    .appendTo( $ct );
                break;

            case "shell":
                $ct.removeClass( 'd-flex flex-row' );
                $( '<input class="re-shellcmd form-control form-control-sm w-100" list="reactorvarlist">' )
                    .attr( 'id', pfx + "shellcmd" )
                    .attr( 'title', _T('Enter shell command to be executed.') )
                    .inputAutogrow( { minWidth: 32, maxWidth: Math.floor( $ct.innerWidth() - 54 ) } )
                    .on( 'change.reactor', this.handleActionValueChange.bind(this) )
                    .appendTo( $ct );

                $fs = $( '<div class="form-group"></div>' ).appendTo( $ct );
                $lb = $( '<label class="form-label"></label>' )
                    .text( _T('Capture output (stdout) to:') )
                    .appendTo( $fs );
                $m = await this.makeExprMenu();
                $m.addClass( 're-shelltarget' )
                    .on( "change.reactor", this.handleActionValueChange.bind(this) )
                    .appendTo( $lb );
                $( 'option[value=""]', $m ).text( _T('(ignore/discard output)') );
                $lb = Common.getCheckbox( pfx + "ignoreexit", "1",
                    _T('Ignore shell/command exit code'),
                    "re-ignoreexit", "Shell-Command-Action" )
                    .addClass( "mt-1" )
                    .on( 'change.reactor', this.handleActionValueChange.bind( this ) )
                    .appendTo( $fs );
                /* Changes to expr menu are handled by event handler established in edit() */
                break;

            default:
                $( '<input type="hidden">' ).attr( 'id', pfx + 'unrecdata' )
                    .appendTo( $ct );
                $( '<div class="flex-full">This action is not editable.</div>' ).appendTo( $ct );
                /* See loadActions() */
        }
    }

    async handleActionTypeChange( ev ) {
        const $sel = $( ev.currentTarget );
        const $row = $sel.closest( 'div.actionrow' );
        const newVal = $sel.val() || "comment";
        await this.changeActionType( $row, newVal );
        this.changeActionRow( $row );
    }

    getActionRow() {
        const $row = $( `<div class="actionrow d-flex flex-row flex-nowrap">
  <div>
    <select class="form-select form-select-sm re-actiontype">
      <option value="comment">${_T(['#reaction-comment','Comment'])}</option>
      <option value="entity">${_T(['#reaction-entity','Entity Action'])}</option>
      <option value="script">${_T(['#reaction-script','Script Action'])}</option>
      <option value="delay">${_T(['#reaction-delay','Delay'])}</option>
      <option value="run">${_T(['#reaction-run','Run Reaction'])}</option>
      <option value="stop">${_T(['#reaction-stop','Stop Reaction'])}</option>
      <option value="setvar">${_T(['#reaction-setvar','Set Variable'])}</option>
      <option value="notify">${_T(['#reaction-notify','Notify'])}</option>
      <option value="request">${_T(['#reaction-request','HTTP Request'])}</option>
      <option value="shell">${_T(['#reaction-shell','Shell Command'])}</option>
      <option value="group">${_T(['#reaction-group','Group'])}</option>
      <option value="while">${_T(['#reaction-while','Repeat While...'])}</option>
    </select>
  </div>
  <div class="actiondata mx-1 flex-grow-1 align-items-start"></div>
  <div class="controls ms-auto text-end text-nowrap">
    <button class="btn bi-btn bi-btn-white" data-action="import" title="${_T(['#reaction-button-import-spec','Import selected reaction'])}"><i class="bi bi-download"></i></button>
    <button class="btn bi-btn bi-btn-white" data-action="tryaction" title="${_T(['#reaction-button-tryone','Try this action'])}"><i class="bi bi-play"></i></button>
    <button class="btn bi-btn bi-btn-white" data-action="clone" title="${_T(['#reaction-button-clone','Clone action'])}"><i class="bi bi-stickies"></i></button>
    <button class="btn bi-btn bi-btn-white draghandle" title="${_T(['#reaction-button-drag','Drag to move/reorder'])}"><i class="bi bi-arrow-down-up"></i></button>
    <button class="btn bi-btn bi-btn-white" data-action="delete" title="${_T(['#reaction-button-remove','Remove action'])}"><i class="bi bi-x text-danger"></i></button>
  </div>
</div>` )
            .attr( 'id', Common.getUID() );
        $( 'button.bi-btn', $row ).on( 'click.reactor', this.handleActionControlClick.bind(this) );
        $( 'button[data-action="tryaction"],button[data-action="import"]', $row ).hide();
        $( 'select.re-actiontype', $row ).val( 'comment' )
            .on( 'change.reactor', this.handleActionTypeChange.bind(this) );
        this.changeActionType( $row, "comment" );
        return $row;
    }

    handleAddActionClick( event ) {
        const $container = $( event.currentTarget ).closest( 'div.buttonrow' ).siblings( 'div.action-group' );
        const $newrow = this.getActionRow();
        $newrow.appendTo( $container );
        $container.addClass( 'tbmodified' );
        $newrow.addClass( 'tbmodified' );
        this.updateActionControls();
        this.changeActionRow( $newrow );
        return false;
    }

    async loadActions( $parent, $insertionPoint, actions ) {
        const newrows = new Set();
        for ( let act of actions ) {
            let $m;
            const $newrow = this.getActionRow();
            if ( isGroup( act ) && act.id ) {
                $newrow.attr( 'id', act.id.replace( this.data.id + '-', '' ) );
            } else if ( act.id ) {
                $newrow.attr( 'id', act.id );  /* If we have it, use it */
            } else {
                /* Add generated ID from new action row */
                act.id = $newrow.attr( 'id' );
            }
            /* Insert early so functions that depend on DOM ancestors can get them. In particular, the rule editor
             * embedded in a group action with constraints is going to need DOM parent access.
             */
            if ( $insertionPoint ) {
                $newrow.insertBefore( $insertionPoint );
            } else if ( $parent ) {
                $newrow.appendTo( $parent );
            } else {
                $( 'div.activity-group', this.$editor ).append( $newrow );
            }
            const rid = $newrow.attr( 'id' );
            $( 'select.re-actiontype', $newrow).val( act.type || "comment" );
            await this.changeActionType( $newrow, act.type || "comment" );
            const p = act.data || {};
            switch ( act.type ) {
                case "group":
                case "while":
                    {
                        const $child = $newrow.find( 'div.action-group:first' );
                        $child.empty();
                        await this.loadActions( $child, false, act.actions || [] );
                        const editor = $( 'div.editor-group div.re-embeddable-editor', $newrow ).data( 'embeddable-editor' );
                        editor.reset( act.constraints || { id: 'cons', type: 'group', op: 'and', conditions: [] } );
                        editor.edit();
                        $( 'input.re-maxiter', $newrow ).val( Common.coalesce( parseInt( act.max_iterations ), "" ) );
                    }
                    break;

                case "comment":
                    $( 'textarea.re-comment', $newrow ).val( p.comment || "" );
                    break;

                case "delay":
                    $( 'input', $newrow ).val( p.delay ).trigger( "autogrow" );
                    Common.menuSelectDefaultInsert( $( 'select.re-delaytype', $newrow ), p.from || "" );
                    break;

                case "entity":
                    {
                        const $es = $( 'div.entity-selector', $newrow );
                        const e = api.getEntity( p.entity );
                        let name = e ? e.getName() : "?unknown?";
                        entitypicker.setEntity( $es, p.entity, name );
                        this.changeActionEntity( $newrow, p.entity );
                        Common.menuSelectDefaultInsert( $( 'select.re-actionmenu', $newrow ), p.action );
                        this.changeActionAction( $newrow, p.action );
                        for ( let arg of Object.keys( p.args || {} ) ) {
                            let vv = Common.coalesce( p.args[arg], "" );
                            let vt = typeof( vv );
                            if ( "number" === vt ) {
                                if ( String(vt).match( /^-?[0-9]+$/ ) ) {
                                    vt = "int";
                                } else {
                                    vt = "real";
                                }
                            } else if ( "bool" === vt || "boolean" === vt ) {
                                vv = String( vv );
                                vt = "bool";
                            } else if ( "object" === vt ) {
                                // Legacy. Objects are no longer stored in action data, but this is left to catch
                                // legacy actions that haven't been edited. It can be removed some day... 2022-09-06
                                // ??? See related code and comments in Engine.js and HassController.js
                                if ( "string" !== typeof vv ) {
                                    vv = JSON.stringify( vv, undefined, 4 );
                                }
                            }
                            const fid = rid + "-" + arg;
                            const $f = $( '#' + Common.idSelector( fid ) + '.argument', $newrow );
                            if ( 0 === $f.length ) {
                                console.log(fid, "- no field");
                                const $ct = $( 'div.actiondata', $newrow );
                                $( '<label></label>' ).attr( 'for', fid )
                                    .text( arg + " (extension):" )
                                    .appendTo( $ct );
                                this.getArgumentField( e, p.action, arg, { "type": vt }, $ct )
                                    .attr( 'id', fid )
                                    .data( 'sourcetype', vt ).attr( 'data-sourcetype', vt )
                                    .val( vv )
                                    .on( 'change.reactor', this.handleActionValueChange.bind(this) )
                                    .appendTo( $ct );
                            } else {
                                $f.val( String( vv ) );
                            }
                        }
                        // Restore target for entity response (if any).
                        Common.menuSelectDefaultInsert( $( 'select.re-resp-target', $newrow ),
                            ( p.rule ? `${p.rule}:` : "" ) + ( p.rvar || "" ) );
                    }
                    break;

                case "script":
                    $( 'textarea.re-script-expr', $newrow ).val( p.expr || "" );
                    break;

                case "run":
                case "stop":
                    Common.menuSelectDefaultInsert( $( 'select.re-scene', $newrow), p.r || "" );
                    $( 'input.tb-run-inline', $newrow ).prop( "checked", "inline" === p.mode );
                    break;

                case "setvar":
                    {
                        /* The menu values are "rule:name" for rule-based, or just "name" for global */
                        let nsel = p.rule ? ( p.rule + ":" + p.var ) : p.var;
                        Common.menuSelectDefaultInsert( $( 'select.re-variable', $newrow ), nsel );
                        $( 'input#' + idSelector( rid + "-value"), $newrow )
                            .val( Common.coalesce( p.value, "" ) );
                        /* ??? DEPRECATED. Temporarily using hidden field.
                        $( 'input.tbreeval', $newrow ).prop( "checked", !!p.reeval );
                        */
                        $( 'input.tbreeval', $newrow ).val( p.reeval ? "1" : "" );
                    }
                    break;

                case "Xresetlatch":
                    entitypicker.setEntity( $( 'div.entity-selector', $newrow ), p.entity );
                    /* ??? Needs work, can use menuSelectDefaultInsert? */
                    $m = $( 'select.re-group', $newrow );
                    $( 'option.groupoption', $m ).remove();
                    this.makeRuleGroupMenu( p.entity, $m );
                    Common.menuSelectDefaultInsert( $m, p.group );
                    break;

                case "notify":
                    {
                        $m = $( 'select.re-method', $newrow );
                        if ( 0 === $( 'option[value="' + (p.method || "") + '"]', $m ).length ) {
                            $( '<option></option>' ).val( p.method )
                                .text( p.method + "? (unrecognized)" )
                                .appendTo( $m );
                            $m.addClass( 'tbwarn' ).show();
                        }
                        $m.val( p.method || "" );
                        $( '.re-message', $newrow ).val( p.message || "" );
                        this.changeNotifyActionMethod( $newrow, p.method, p );
                    }
                    break;

                case "request":
                    {
                        Common.menuSelectDefaultInsert( $( 'select.re-method', $newrow ), p.method || "GET",
                            String(p.method) + "?" );
                        $( 'textarea.re-requrl', $newrow ).val( p.url || "http://" );
                        $( 'input.re-ignorecert', $newrow ).prop( 'checked', !!p.ignore_cert )
                            .prop( 'disabled', ( p.url || "http://" ).startsWith( 'http://' ) );
                        let ht = [];
                        if ( Array.isArray( p.headers ) ) {
                            for ( let head of p.headers ) {
                                ht.push( head.key + ": " + head.value );
                            }
                        } else if ( null !== p.headers && "object" === typeof p.headers ) {
                            /* ??? DEPRECATION -- old storage form as dict/obj; see buildActionList() */
                            for ( let key in p.headers ) {
                                ht.push( String( key ) + ": " + String( p.headers[ key ] ) );
                            }
                        }
                        $( 'textarea.re-reqheads', $newrow ).val( ht.join( "\n" ) );
                        $( 'textarea.re-reqdata', $newrow ).val( p.reqdata || "" );
                        $( 'div.re-reqdatafs', $newrow ).toggle( "GET" !== p.method );
                        let varkey = p.rvar || "";
                        if ( p.rvar && p.rule ) {
                            varkey = p.rule + ":" + varkey;
                        }
                        Common.menuSelectDefaultInsert( $( 'select.re-reqtarget', $newrow ), varkey );
                        let auth = Common.menuSelectDefaultFirst( $( 'select.re-reqauth', $newrow ), p.http_auth || "" );
                        $( 'input.re-requser', $newrow ).val( p.username || "" );
                        $( 'input.re-reqpass', $newrow ).val( p.password || "" );
                        $( '.authdata', $newrow ).prop( 'disabled', "" === auth );
                        $( 'input.re-reqadv', $newrow )
                            .prop( 'disabled', !p.rvar )
                            .prop( 'checked', !!p.advanced );
                        $( 'input.re-reqwait', $newrow )
                            .prop( 'checked', !!p.wait || !!p.rvar )
                            .prop( 'disabled', !!p.rvar );
                        $( 'input.re-reqquiet', $newrow ).prop( 'checked', !!p.quiet );
                    }
                    break;

                case "shell":
                    {
                        $( 'input.re-shellcmd', $newrow ).val( p.command || "#" );
                        let varkey = p.rvar;
                        if ( p.rule ) {
                            varkey = p.rule + ":" + varkey;
                        }
                        Common.menuSelectDefaultInsert( $( 'select.re-shelltarget', $newrow ), varkey );
                        $( 'input.re-ignoreexit', $newrow ).prop( 'checked', !!p.ignore_exit );
                    }
                    break;

                default:
                    console.log("loadActions: what's a", act.type, "? Skipping it!");
                    Common.showSysModal({
                        title: _T("Unsupported Action Type"),
                        body: _T("Action type {0:q} unrecognized.", act.type)
                    });
                    Common.menuSelectDefaultInsert( $( 'select.re-actiontype', $newrow ), act.type );
                    $( 'input#' + idSelector( rid + '-unrecdata' ), $newrow )
                        .val( JSON.stringify( act ) );
            }

            this.validateActionRow( $newrow );

            /* Adjust textarea sizes (must be done after insertion into document */
            $( 'textarea', $newrow ).each( ( ix, obj ) => {
                let h = $( obj ).innerHeight();
                let sh = $( obj ).prop( 'scrollHeight' );
                if ( sh > h ) {
                    $( obj ).css( 'height', Math.max( 32, Math.min( 320, sh + 2 ) ) + "px" );
                }
            });
            $( '.autogrow' ).trigger( 'autogrow' );

            newrows.add( $newrow );
        }

        return newrows;
    }

    async handleTitleChange( event ) {
        const $input = $( event.currentTarget );
        const newname = ($input.val() || "").trim();
        $input.removeClass( 'tberror' );
        if ( newname !== this.data.name ) {
            /* Name check */
            let valid = newname.length >= 1;
            const reactions = await Reaction.getGlobalReactions();
            const t = newname.toLocaleLowerCase( /* locale */ );
            for ( const k of Object.keys( reactions || {} ) ) {
                if ( ( reactions[k].name || "" ).toLocaleLowerCase( /* locale */ ) === t ) {
                    valid = false;
                    break;
                }
            }
            if ( !valid ) {
                event.preventDefault();
                event.stopPropagation();
                $( 'button.saveconf' ).prop( 'disabled', true ); // ???
                $input.addClass( 'tberror' );
                $input.focus();
                return false;
            }

            /* Update config */
            this.data.name = newname;
            this.signalModified();
        }

        /* Remove edit block and replace displayed title text */
        const $ct = $input.closest( 'div.re-actions-header' );
        $( 'div.re-title-edit', $ct ).remove();
        $( 'span.re-title', $ct ).text( newname );
        $ct.children().show();
        this.updateSaveControls();
    }

    handleTitleClick( event ) {
        /* N.B. Click can be on span or icon */
        const $el = $( event.currentTarget );
        const $p = $el.closest( 'div.re-actions-header', this.$editor );
        $p.children().hide();
        const $ct = $( '<div class="re-title-edit"></div>' ).prependTo( $p );
        $( '<input class="form-control form-control-sm">' )
            .val( this.data.name || this.data.id || Common.getUID( "re-" ) )
            .appendTo( $ct )
            .on( 'change', this.handleTitleChange.bind(this) )
            .on( 'blur', this.handleTitleChange.bind(this) )
            .focus();
    }

    handleNodeReceive( /* ev, ui */ ) {
        // console.log( 'handleNodeReceive', ev, ui );
    }

    handleNodeUpdate( ev, ui ) {
        // console.log( 'handleNodeUpdate', ev, ui );
        const $el = $( ui.item );
        // const $target = $( ev.target ); /* receiving .activity-group */
        // const $from = $( ui.sender );    }
        this.changeActionRow( $el );
    }

    async loadActivitiesList() {
        let first = true;

        const $list = $( 'ul.re-activities-list', this.$editor );

        const allglobal = await Reaction.getGlobalReactions();
        const reactions = allglobal.filter( r => ! r.ruleset ).sort( function( a, b ) {
            return (a.name || a.id).localeCompare( b.name || b.id, undefined, { sensitivity: 'base' } );
        });
        for ( const re of reactions ) {
            if ( re.id !== this.data.id ) {
                let $li = $( '<li></li>' ).appendTo( $list );
                if ( first ) {
                    $( '<h6 class="dropdown-header"></h6>')
                        .text( _T('Global Reactions') )
                        .appendTo( $li );
                    first = false;
                    $li = $( '<li></li>' ).appendTo( $list );  // another!!!
                }
                $( '<a class="dropdown-item" href="#"></a>' )
                    .text( re.name || re.id ).attr( 'id', re.id )
                    .appendTo( $li );
            }
        }

        $( '<li><hr class="dropdown-divider"></li>' ).appendTo( $list );

        const r = await Rulesets.getRulesets();
        first = true;
        for ( const set of r ) {
            if ( set.hidden ) {
                continue;
            }
            const rules = await set.getRules();
            const reactions = await set.getReactions();
            if ( rules.length > 0 || reactions.length > 0 ) {
                const $li = $( '<li></li>' ).appendTo( $list );
                $( '<h6 class="dropdown-header"></h6>' ).text( set.name )
                    .appendTo( $li );
            }
            for ( const rule of rules ) {
                const $li = $( '<li></li>' ).appendTo( $list );
                if ( rule.react_set && rule.react_set.id !== this.data.id ) {
                    $( '<a class="dropdown-item" href="#"></a>' )
                        .attr( 'id', rule.react_set.id )
                        .text( _T('{0} - Set', rule.name) )
                        .appendTo( $li );
                }
                if ( rule.react_reset && rule.react_reset.id !== this.data.id ) {
                    $( '<a class="dropdown-item" href="#"></a>' )
                        .attr( 'id', rule.react_reset.id )
                        .text( _T('{0} - Reset', rule.name) )
                        .appendTo( $li );
                }
            }
            for ( const reaction of reactions ) {
                const $li = $( '<li></li>' ).appendTo( $list );
                $( '<a class="dropdown-item" href="#"></a>' )
                    .attr( 'id', reaction.id )
                    .text( reaction.name || reaction.id )
                    .appendTo( $li );
            }
        }

        $( 'a.dropdown-item', $list ).on( 'click', async (ev) => {
            const $el = $( ev.currentTarget );
            const rid = $el.attr( 'id' );
            await this.importReaction( rid, false ).then( () => {
                this.updateActionList(); /* signals modified */
            });
        });
    }

    async redraw() {
        const self = this;

        if ( 0 === $( 'link#reaction-editor-styles' ).length ) {
            $('head').append( '<link id="reaction-editor-styles" rel="stylesheet" href="lib/css/reaction-editor.css"></link>' );
        }
        this.$editor.empty();
        let $row = $( '<div class="row re-actions-header align-middle g-0 p-1"></div>' ).appendTo( this.$editor );
        const $head = $( `<div class="col d-flex flex-row align-items-center">
  <div class="re-titleblock">
    <span class="re-title mx-2"></span>
    <button class="btn bi-btn bi-btn-white re-editname" title="${_T('Change Reaction Name')}"><i class="bi bi-pencil"></i></button>
    <button class="btn bi-btn bi-btn-white re-tryreaction" title="${_T('Run Reaction Now')}"><i class="bi bi-play"></i></button>
  </div>
  <div class="re-titlemessage ms-2"></div>
</div>` )
            .appendTo( $row );

        if ( this.options.contextRule ) {
            /* Show "Wait for Completion" for SET and RESET reactions on rules. */
            let $el = Common.getCheckbox( Common.getUID(), "1", _T( "Wait for completion" ), "re-reaction-wait" )
                .addClass( "ms-2" )
                .insertAfter( $( 'div.re-titleblock', $row ) );
            $( 'input', $el ).prop( 'checked', !!this.data.wait_completion );
            $el.on( 'change.reactor', () => {
                if ( self.data.wait_completion ) {
                    delete self.data.wait_completion;
                } else {
                    self.data.wait_completion = true;
                }
                self.signalModified();
            });
        } else {
            /* Not an option for other reactions; remove flag to fix imported from rule */
            if ( self.data.wait_completion ) {
                delete self.data.wait_completion;
                self.signalModified();
            }
        }

        if ( "undefined" !== typeof this.options.fixedName ) {
            $( 'span.re-title', $head ).text( this.options.fixedName );
            $( 'button.re-editname', $head ).hide();
        } else {
            $( 'span.re-title', $head ).text( this.data.name || this.data.id )
                .on( 'click.reactor', this.handleTitleClick.bind(this) );
            $( 'button.re-editname', $head ).on( 'click.reactor', this.handleTitleClick.bind( this ) );
        }

        $( 'button.re-tryreaction', $head ).on( 'click', function( event ) {  // eslint-disable-line no-unused-vars
            //const $el = $( event.currentTarget );
            api.startReaction( self.data.id, self.data.rule );
        });

        /* activity-group is container for actionrows but not buttonrow */
        let $el = $( '<div class="activity-group action-group w-100"></div>' ).appendTo( this.$editor );
        $row = $( '<div class="row buttonrow g-0"></div>' ).insertAfter( $el );
        $( `<div class="col">
    <button class="addaction btn btn-sm btn-primary"><i class="bi bi-plus-lg me-1"></i>${_T(['#reaction-button-addaction','Add Action'])}</button>
    <div id="importReaction" class="btn-group dropdown">
      <button class="btn btn-sm btn-primary dropdown-toggle" type="button" id="importReactionButton"
        data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
        title="${_T(['#reaction-button-import-tip','Import another Reaction into this one'])}">
        <i class="bi bi-clipboard-plus me-1"></i>${_T(['#reaction-button-import','Copy From'])} <span class="caret"></span>
      </button>
      <ul class="dropdown-menu re-activities-list" aria-labelledby="importReactionButton"></ul>
    </div>
  </div>` )
            .appendTo( $row );

        $( 'button.addaction', $row ).on( 'click', this.handleAddActionClick.bind(this) );

        this.loadActivitiesList();  /* Don't need to wait for this. */

        /* Make the top-level group sortable */
        this.makeSortableActionGroup( $el );

        /* Load the actions */
        await this.loadActions( $el, false, this.data.actions || [] );

        /* Refresh all sortables */
        $( ".re-sortable", this.$editor ).sortable( "refresh" );

        this.updateActionControls();
    }
}
