Dashboard 2 - Multi-state switch

I made some changes to support string/boolean/numeric values:

options:[
   {label:"first",value:"first",icon:"mdi-basketball",color:"green"},
   {label:"second", value:"second", icon:"mdi-basketball",color:"red"},
   {label:"1",value:1, color:"blue",icon:"mdi-car-wrench"},
   {label:"2", value:2, color:"orange",icon:"mdi-car-wrench"},
   {label:"true",value:true,icon:"mdi-antenna",color:"green"},
   {label:"false",value:false,icon:"mdi-antenna",color:"green"}
],

Short demo of those 3 types:

different types

[{"id":"de1b3163bf3facec","type":"ui-template","z":"0bff6d05f60fe1e6","group":"9fa39c19a26ce99e","page":"","ui":"","name":"","order":0,"width":0,"height":0,"head":"","format":"<template>\n    <div class=\"mss-wrapper\">\n        <v-chip variant=\"text\">\n            {{label}}\n        </v-chip>\n        <v-btn-toggle mandatory divided rounded=\"xl\" :variant=\"variant\" :color=\"selectedColor\" v-model=\"selection\">\n            <v-btn v-for=\"(option, index) in options\" :key='index' :class=\"option.label ? '' : 'icon-only'\">\n                <template v-if=\"option.icon\" v-slot:prepend>\n                    <v-icon size=\"x-large\"> {{option.icon}} </v-icon>\n                </template>\n                {{option.label}}\n            </v-btn>\n        </v-btn-toggle>\n    </div>\n</template>\n\n<script>\n    export default {\n        data() {\n            return {\n                //define me here\n                label:\"Multistate Prototype\",                \n                options:[\n                    {label:\"first\",value:\"first\",icon:\"mdi-basketball\",color:\"green\"},\n                    {label:\"second\", value:\"second\", icon:\"mdi-basketball\",color:\"red\"},\n                    {label:\"1\",value:1, color:\"blue\",icon:\"mdi-car-wrench\"},\n                    {label:\"2\", value:2, color:\"orange\",icon:\"mdi-car-wrench\"},\n                    {label:\"true\",value:true,icon:\"mdi-antenna\",color:\"green\"},\n                    {label:\"false\",value:false,icon:\"mdi-antenna\",color:\"green\"}\n                ],\n                // \"outlined\" (if the site bg is white or very light)\"\n                // \"default\" (if the site bg is dark)\"\n                // (\"text\" or \"plain\" also available but requires some styling)\n                look:\"outlined\",              \n                \n                // no need to change those\n                selection: null,\n                changeByInput:false, // in case of input - render but don't send the msg out\n               \n            }\n        },\n      \n        watch: {\n            msg: function(){        \n                if(this.isValidValue(this.msg.payload)){\n                    this.changeByInput = true\n                    debugger\n                    this.selection = this.options.findIndex(option => option.value === this.msg.payload )\n                }\n            },\n            selection:function(){                \n                if(this.changeByInput == true){\n                    this.changeByInput = false\n                }\n                else{\n                    this.send(this.getOutputMessage())\n                }\n            }\n        },\n\n        methods: {\n            isValidValue:function (val){\n                if(val === null){\n                    return false\n                }\n                if(val === undefined){\n                    return false\n                }\n                if(val === \"\"){\n                    return false\n                }\n                if(!['number', 'string', 'boolean'].includes(typeof value)){\n                    return false\n                }\n                if(this.options.findIndex(option => option.value === val ) == -1){\n                    return false\n                }\n                return true\n\n            },\n            findOptionByValue:function(val){\n                let opt = this.options.find(option => option.value === val)\n                if(opt){\n                    return opt\n                }\n                return null\n            },\n            getOutputMessage:function(){                \n                return {payload:this.options[this.selection].value,topic:'multistate'}                \n            }\n        },\n        \n        computed: {            \n            selectedColor:function(){\n                if(this.selection == null){\n                    return \"\"\n                }\n                debugger;\n                return this.options[this.selection].color ?? \"rgb(var(--v-theme-primary))\"\n            },\n            variant:function(){\n                return this.look == \"default\"  ? null : this.look\n            }\n        }\n    }\n</script>\n\n<style>\n    .mss-wrapper {\n    display:grid;\n    grid-template-columns:1fr 1fr;\n    align-items:center;\n    }\n    .mss-wrapper .v-chip__content{\n    white-space:normal;\n    }\n    .mss-wrapper .v-btn-group{\n    width:max-content;\n    border-color:rgba(var(--v-border-color),.3);\n    }\n    .mss-wrapper .v-btn--variant-elevated, .mss-wrapper .v-btn--variant-outlined{\n    color:#cccccc;\n    }\n    .mss-wrapper .icon-only .v-btn__prepend{\n    margin-inline:0;\n    }\n</style>","storeOutMessages":true,"passthru":true,"resendOnRefresh":true,"templateScope":"local","className":"","x":860,"y":600,"wires":[["2c1a39c087425d5d"]]},{"id":"2c1a39c087425d5d","type":"debug","z":"0bff6d05f60fe1e6","name":"Selected option","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1040,"y":600,"wires":[]},{"id":"7e65879baf2ea2ac","type":"inject","z":"0bff6d05f60fe1e6","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"first","payloadType":"str","x":690,"y":600,"wires":[["de1b3163bf3facec"]]},{"id":"b2a986f8ccf3775c","type":"inject","z":"0bff6d05f60fe1e6","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"second","payloadType":"str","x":690,"y":640,"wires":[["de1b3163bf3facec"]]},{"id":"7b5206f29c3d9e08","type":"inject","z":"0bff6d05f60fe1e6","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"1","payloadType":"num","x":690,"y":680,"wires":[["de1b3163bf3facec"]]},{"id":"2a2d4d5001b533b5","type":"inject","z":"0bff6d05f60fe1e6","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"2","payloadType":"num","x":690,"y":720,"wires":[["de1b3163bf3facec"]]},{"id":"793c4d2072e8a8f0","type":"inject","z":"0bff6d05f60fe1e6","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"true","payloadType":"bool","x":690,"y":760,"wires":[["de1b3163bf3facec"]]},{"id":"3bb55094503fdd7d","type":"inject","z":"0bff6d05f60fe1e6","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"false","payloadType":"bool","x":690,"y":800,"wires":[["de1b3163bf3facec"]]},{"id":"9fa39c19a26ce99e","type":"ui-group","name":"Alarm systeem","page":"8604ea49340f9d41","width":"6","height":"1","order":-1,"showTitle":true,"className":"","visible":"true","disabled":"false"},{"id":"8604ea49340f9d41","type":"ui-page","name":"Home","ui":"be29745a6e568f30","path":"/page1","icon":"home","layout":"grid","theme":"092547d34959327c","order":-1,"className":"","visible":"true","disabled":"false"},{"id":"be29745a6e568f30","type":"ui-base","name":"UI Name","path":"/dashboard","includeClientData":true,"acceptsClientConfig":["ui-notification","ui-control"],"showPathInSidebar":false},{"id":"092547d34959327c","type":"ui-theme","name":"Theme Name","colors":{"surface":"#ffffff","primary":"#0094ce","bgPage":"#eeeeee","groupBg":"#ffffff","groupOutline":"#cccccc"}}]
5 Likes

Bart, if you make the screen narrower do you see the text 'Alarm System' getting cut off?




Screenshot 2024-02-07 at 3.53.49 AM

Sorry Paul,
That is CSS stuff. Not my cup of tea unfortunately...

2 cups of tea if you please :slight_smile:

:teapot: If to create it as for dedicated use, it is possible to use container query and change the layout quite a lot.

:teapot: But for common usage the outcome may be not so pleasant. Query may be clever but the break point depends on states you have configured - character count, if icons .... So it is not predictable.

Something to play with:

<template>
    <div :style="`--container:${container};`" class="mss-widget">
        <div class="mss-wrapper">
            <header>
               {{label}}              
            </header>
            <v-btn-toggle mandatory divided rounded="xl" :variant="variant" :color="selectedColor" v-model="selection">
                <v-btn v-for="(option, index) in options" :key='index' :class="option.label ? '' : 'icon-only'">
                    <template v-if="option.icon" v-slot:prepend>
                        <v-icon size="x-large"> {{option.icon}} </v-icon>
                    </template>
                    {{option.label}}
                </v-btn>
            </v-btn-toggle>
        </div>
    </div>
</template>

<script>
    export default {
        data() {
            return {
                //define me here
                label:"Multistate Prototype",
                options:[
                    {label:"first",value:"first",icon:"mdi-basketball",color:"green"},
                    {label:"second", value:"second", icon:"mdi-basketball",color:"red"},
                   /* {label:"1",value:1, color:"blue",icon:"mdi-car-wrench"},
                    {label:"2", value:2, color:"orange",icon:"mdi-car-wrench"},*/
                    {label:"TRUE FALSE",value:true,color:"green"},
                    {label:"false",value:false,icon:"mdi-antenna",color:"green"}
                ],
                // "outlined" (if the site bg is white or very light)"
                // "default" (if the site bg is dark)"
                // ("text" or "plain" also available but requires some styling)
                look:"outlined",
                container:"four",//name for container query (four and six available, different break points)
                
                // no need to change those
                selection: null,
                changeByInput:false, // in case of input - render but don't send the msg out
               
            }
        },
      
        watch: {
            msg: function(){        
                if(this.isValidValue(this.msg.payload)){
                    this.changeByInput = true
                    this.selection = this.options.findIndex(option => option.value === this.msg.payload )
                }
            },
            selection:function(){                
                if(this.changeByInput == true){
                    this.changeByInput = false
                }
                else{
                    this.send(this.getOutputMessage())
                }
            }
        },

        methods: {
            isValidValue:function (val){
                if(val === null){
                    return false
                }
                if(val === undefined){
                    return false
                }
                if(val === ""){
                    return false
                }
                if(!['number', 'string', 'boolean'].includes(typeof value)){
                    return false
                }
                if(this.options.findIndex(option => option.value === val ) == -1){
                    return false
                }
                return true

            },
            findOptionByValue:function(val){
                let opt = this.options.find(option => option.value === val)
                if(opt){
                    return opt
                }
                return null
            },
            getOutputMessage:function(){                
                return {payload:this.options[this.selection].value,topic:'multistate'}                
            }
        },
        
        computed: {            
            selectedColor:function(){
                if(this.selection == null){
                    return ""
                }
                return this.options[this.selection].color ?? "rgb(var(--v-theme-primary))"
            },
            variant:function(){
                return this.look == "default"  ? null : this.look
            }
        }
    }
</script>

<style>
    .mss-widget{
    container-type:inline-size;
    container-name:var(--container);
    }
    .mss-wrapper {
    display:grid;
    grid-template-columns:1fr 1fr;
    align-items:center;
    }
    .mss-wrapper header{
    white-space:normal;
    padding-right:var(--layout-gap);
    }
    .mss-wrapper header i{
    display:none;
    }
    .mss-wrapper .v-chip.v-chip--size-default{
    padding:0;
    }
    .mss-wrapper .v-btn-group{
    width:max-content;
    border-color:rgba(var(--v-border-color),.3);
    }
    .mss-wrapper .v-btn--variant-elevated, .mss-wrapper .v-btn--variant-outlined{
    color:#cccccc;
    }
    .mss-wrapper .icon-only .v-btn__prepend{
    margin-inline:0;
    }
    @container four (max-width: 500px){
    .mss-wrapper {
    grid-template-columns:none;
    grid-template-rows:1fr 2fr;
    align-items:center;
    justify-content:center;
    text-align:center;
    }
    .mss-wrapper .v-btn__prepend{
    margin-inline:0;
    }
    .mss-wrapper .v-btn__content{
    white-space: normal;
    max-width: min-content;
    }
    .mss-wrapper .v-btn:has(.v-btn__prepend) .v-btn__content{
    display:none;
    }
    }
    @container six (max-width: 650px){
    .mss-wrapper {
    grid-template-columns:none;
    grid-template-rows:1fr 2fr;
    align-items:center;
    justify-content:center;
    text-align:center;
    }
    .mss-wrapper .v-btn__prepend{
    margin-inline:0;
    }
    .mss-wrapper .v-btn__content{
    white-space: normal;
    max-width: min-content;
    }
    .mss-wrapper .v-btn:has(.v-btn__prepend) .v-btn__content{
    display:none;
    }
    }
</style>

And if you like to have something stronger than tea:

:tumbler_glass: Hi, I have problem, I configured 12 states for my multistate switch with nice long names and icons. Can you figure out why it doesn't fit into my mobile screen

3 Likes

Or same thing but with less of layout shift

CSS

.mss-widget{
        container-type:inline-size;
        container-name:var(--container);
    }
    .mss-wrapper {
        display:grid;
        grid-template-columns:1fr 1fr;
        align-items:center;                
    }
    .mss-wrapper header{
        white-space:normal;
        padding-right:var(--layout-gap);        
    }
    .mss-wrapper header i{
        display:none;
    }
    .mss-wrapper .v-chip.v-chip--size-default{
        padding:0;
    }
    .mss-wrapper .v-btn-group{
        width:max-content;
        border-color:rgba(var(--v-border-color),.3);
        justify-self: end;
    }    
    .mss-wrapper .v-btn--variant-elevated, .mss-wrapper .v-btn--variant-outlined{
        color:#cccccc;
    }
    .mss-wrapper .icon-only .v-btn__prepend{
        margin-inline:0;
    }
    @container four (max-width: 500px){
        .mss-wrapper .v-btn__prepend{
            margin-inline:0;
        }
        .mss-wrapper .v-btn__content{
            white-space: normal;
            max-width: min-content;
        }        
        .mss-wrapper .v-btn:has(.v-btn__prepend) .v-btn__content{
            display:none;
        }
    }
    @container six (max-width: 650px){

        .mss-wrapper .v-btn__prepend{
            margin-inline:0;
        }
        .mss-wrapper .v-btn__content{
            white-space: normal;
            max-width: min-content;
        }        
        .mss-wrapper .v-btn:has(.v-btn__prepend) .v-btn__content{
            display:none;
        }
    }

Seems there was another question about somebody about the multi-state switch availability in D2. So let's summarize the status: I need this widget myself to be able to migrate to D2. But even if I would have time, I am not tempted to create such a third-party widget again. Main reason is that most of the work was CSS related, so I had to ask @hotnipi over and over again to do the hard work. Which he did very well of course, but I don't like to plan someone else his free time activities...

I could do a pull request for a very primitive Button-group widget, if that can be of any help to @joepavitt who is currently very heavily loaded by all kind of Github issues from this community. But a very basic one, because I am very bad with CSS and have very few free time. Just let me know if that can be of any help!

But in that case I want to be very sure that this will become a core ui widget. Because I also had agreed with Joe that my heatmap node would become a core ui node, but suddenly a discussion popped up in my pull request that it should become a custom node (instead of a core). Which would mean I have to restart developing my old heatmap node for the third time already :frowning_face:. And secondly it would mean that I would be stuck in my custom repo with all the (Vue specific?) CSS issues myself. I don't have time to keep on spending my precious little free time refactoring old nodes. I am not a software company...

In my opinion the Button-Group really needs to be a core node. But if the community decides otherwise about that, so be it...

1 Like

I'm still very motivated to help whenever you or somebody struggles with CSS. Specially if creating nodes for community. You don't waist my time anyhow if the requirements are given as detailed as possible. In which you my friend have always been perfect :slight_smile:

6 Likes

I (and FlowFuse's development philosophy) is Iterative Improvement

Any form of PR, no matter how draft it is, is always useful.

As previosuly mentioned, I'm absolutely on board with button-group being a core widget, and given we already have a foundation in Vuetify to build from, it should be fairly trivial to build.

Regarding your heatmap work, its a tricky one, I see both sides, and as you know, had been very pro core widget, but Paul's argument was a good one, so it has made me hesitate.

As a slightly larger point of discussion, I have been conaidering breaking the Dashboard 2 widgets into two categories in the side menu - "UI" and "Visualisation". With ui-chart mainting its existing types, but pie, donut, and other vis types each getting their own node, as managing all of those types within a single node is a painful development experience.

1 Like

Can I just comment here that one of the difficulties when creating something complex for Node-RED is trying to find the right balance between single-node complexity and making the palette nigh-on impossible to easily use due to the number of nodes listed.

When designing UIBUILDER, I wanted to keep the number of nodes controlled. But the uib-element node - which creates lots of different element analogous to many of the ui-.... nodes - faces the same issue you are seeing. Because of this, one of the things on my backlog is to split the code for that node into a main module and separate modules for each element type. That way I can keep the code simple (each element type is actually quite simple code) and separate from the core logic. I already have a mechanism that allows each element type to have its own custom settings (in a separate html file that is included as needed). This means that I'm not reinventing the wheel for the common settings for each type and I'm not polluting the palette with hundreds of nodes. But at the same time, I keep the node's logic quite straight-forwards and each element's code is also simple.

Another future upgrade will be to make it easy for other people to add elements to the node without needing to write a complete node with all the associated module overheads each time. I think this approach would be super helpful to people like Bart who might want to contribute but have limited time.

Currently, like D1/D2, folk can contribute additional nodes to uibuilder and there is an example module but just creating a node is quite a bit of friction and complexity for a lot of people.

Anyway, just some thoughts to share and consider.

1 Like

Also worth noting the issue for this is Widget: Button Group · Issue #547 · FlowFuse/node-red-dashboard · GitHub

I'll get a primitive PR opened.

2 Likes

Hey Joe,
Now you have to support my lousy english...

So this means I can create a primitive PR, based on our old multistate-switch node? Then you can focus on more important stuff.

I think it's a pointer that multistate switch is officially welcome to be core widget. Take my prototype , modify to follow core widgets standards and create necessary files to complete it to being a node. First iteration done.

2 Likes

I did ecacrly that yesterday, although only had a spare 20 mins, so still have some work to do.

I tried combining @hotNipi's template, and @BartButenaers node config, but the config has a lot of options, so may trim back for iterarion #1

1 Like

Trim hard. It was discussed and agreed that major of fancy options are really over-engineering and don't gain good value to widget. For core widget it should be as simple and straightforward as possible.

1 Like

Agreed. Starting point will be list of values, icon and text.

Then passthru and topic.

Nit aure much else is "required" for iteration #1

2 Likes

Maybe still keep colors also cos my prototype covers that clearly ...

1 Like

Not good for my self esteem :yum:

1 Like

Should have expanded "but only had 20 mins, so didn't get very far" :blush:

4 Likes

:sunglasses:

20 minutes is more than enough time to ask ChatGPT, get a comprehensive answer .... and realise that it is rubbish and doesn't work!

2 Likes

It needed another 90 minutes, but draft PR is opened: UI Button Group - Add new widget for multi-state switch by joepavitt · Pull Request #621 · FlowFuse/node-red-dashboard · GitHub

@BartButenaers for what it's worth, I got confused by my own architecture re: loading state and data, I'll definitely be improving the documentation and examples here.

1 Like