Skip to content

Examples Vue Complex Home Dashboard

Sergio Rius edited this page Sep 9, 2019 · 3 revisions

I'm afraid that to actually use this example directly, you would need to have my complex flow that builds the data from a combination of my Drayton Wiser smart home heating system and various custom sensors and controls. So I've not bothered to post the flow that controls all of this but it may be of use if you are struggling with how to use VueJS with Node-RED and uibuilder.

This code is valid with uibuilder v1.2.2. It uses VueJS, and bootstrap-vue. Note the use of Vue components to break up the complexity of the page structure.

HTML

<!doctype html>
<html lang="en" manifest="uibuilder.appcache">

    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes">

        <!-- See https://goo.gl/OOhYW5 -->
        <link rel="manifest" href="./manifest.json">
        <meta name="theme-color" content="#3f51b5">

        <!-- Used if adding to homescreen for Chrome on Android. Fallback for manifest.json -->
        <meta name="mobile-web-app-capable" content="yes">
        <meta name="application-name" content="Home Dashboard">

        <!-- Used if adding to homescreen for Safari on iOS -->
        <meta name="apple-mobile-web-app-capable" content="yes">
        <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
        <meta name="apple-mobile-web-app-title" content="Home Dashboard">

        <!-- Homescreen icons for Apple mobile use if required
        <link rel="apple-touch-icon" href="/images/manifest/icon-48x48.png">
        <link rel="apple-touch-icon" sizes="72x72" href="/images/manifest/icon-72x72.png">
        <link rel="apple-touch-icon" sizes="96x96" href="/images/manifest/icon-96x96.png">
        <link rel="apple-touch-icon" sizes="144x144" href="/images/manifest/icon-144x144.png">
        <link rel="apple-touch-icon" sizes="192x192" href="/images/manifest/icon-192x192.png">
        -->

        <title>Node-RED UI Builder</title>
        <meta name="description" content="Home Dashboard">

        <link rel="icon" href="./images/node-blue.ico">

        <link type="text/css" rel="stylesheet" href="../uibuilder/vendor/bootstrap/dist/css/bootstrap.min.css" />
        <link type="text/css" rel="stylesheet" href="../uibuilder/vendor/bootstrap-vue/dist/bootstrap-vue.css" />
        <link rel="stylesheet" href="./index.css">

    </head>

    <body>
        <script type="text/x-template" id="lights-tab-template">
            <div>
                <h5>Room Switches</h5>
                <div v-for="room in homeData" :key="room.Name" v-if="room.switches">
                    <b-row class="my-2">
                        <b-col cols="6" sm="3" md="2">
                            {{ room.Name }}
                        </b-col>
                        <b-col>
                            <b-button-group >
                                <b-button v-for="sw in switches" :key="sw.id"
                                        v-if="sw.room === room.Name"
                                        :variant="sw.status === 'On' ? 'success' : ''"
                                        @click="switchClick([sw.id, sw.status])"
                                        v-b-popover.focus.hover.bottomright="{content:`Last Update: ${fmtTime(sw.lastUpdate)}`}">
                                    {{ sw.id.replace('SWITCH','') }} - {{ _.capitalize(sw.status) }}
                                </b-button>
                            </b-button-group>
                        </b-col>
                    </b-row>
                </div>
            </div>
        </script>
        <script type="text/x-template" id="demand-card-template">
            <b-card id="demand_card" header-tag="header" footer-tag="footer" class="text-center shadow"
                    v-b-popover.focus.hover.bottomright="{content:'Bar shows overall % demand. See room details for room demands.'}"
                    >
                <h6 slot="header">Demand</h6>
                <b-progress :max="demandMax" height="2rem">
                    <b-progress-bar :value="percentageDemand" :variant="demandLevel">{{percentageDemand}}%</b-progress-bar>
                </b-progress>
                <div slot="footer">
                    <span :class="classDemandActive">
                        Boiler {{demandOnOffOutput}}
                    </span>
                    ,
                    <span :class="classIsBoosted">
                        Boost {{ isBoostedText }}
                    </span>
                </div>
            </b-card>
        </script>
        <script type="text/x-template" id="device-tab-template">
            <div>
                <h5>Devices</h5>
                <div v-for="device in orderedDevices" :key="device.id">
                    <b-row class="my-2">
                        <b-col cols="6" sm="3" md="2">
                                {{ device.id }}
                        </b-col>
                        <b-col>
                            <b-button :variant="device.status === 'Online' ? 'success' : 'warning'"
                                    v-b-popover.focus.hover.bottomright="{content:`Last Update: ${fmtTime(device.lastUpdate)}`}">
                                {{ _.capitalize(device.status) }}{{ device.room ? ' - ' : '' }}{{ device.room }}
                            </b-button>
                        </b-col>
                        <b-col>
                                {{ fmtTime(device.lastUpdate) }}
                        </b-col>
                    </b-row>
                </div>
            </div>

        </script>

        <!-- The "app" element is where the code for dynamic updates is attached -->
        <div id="app">
            <b-container id="app_container">
                <b-navbar toggleable="md" type="dark" variant="dark">
                    <b-navbar-toggle target="nav_collapse"></b-navbar-toggle>

                    <b-navbar-brand href="#" v-b-popover.focus.hover.bottomright="{content:'Heating information and controls.'}">
                        Home
                    </b-navbar-brand>

                    <b-collapse is-nav id="nav_collapse">
                        <b-navbar-nav>
                            <b-nav-text
                                    v-b-popover.focus.hover.bottomright="{title:'Last update',content:'A warning will appear if no updates have been received in 2 minutes.'}"
                                    >
                                {{lastUpdate}}
                            </b-nav-text>
                            <b-nav-text v-if="demandOnOffOutput === 'On'"
                                    v-b-popover.focus.hover.bottomright="{content:`Boiler is ${demandOnOffOutput}, Boost is ${isBoostedText}`}"
                                    >
                                <svg height="24" style="margin-left:1em" class="octicon octicon-flame" viewBox="0 0 12 16" version="1.1" width="24" aria-hidden="true">
                                    <path :style="isBoostedFill" fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path>
                                </svg>
                            </b-nav-text>
                        </b-navbar-nav>

                        <!-- Right aligned nav items -->
                        <b-navbar-nav class="ml-auto">
                            <b-nav-item-dropdown right
                            v-b-popover.focus.hover.bottomright="{content:'Links to other dashboards.'}">
                                <template slot="button-content">
                                    <em>Dashboards</em>
                                </template>
                                <b-dropdown-item href="/ui">Quick Dashboard</b-dropdown-item>
                                <b-dropdown-item href="https://pi3.knightnet.co.uk:3000/" onclick="javascript:window.location.port=3000">Detailed
                                    Dashboard</b-dropdown-item>
                            </b-nav-item-dropdown>
                            <b-nav-item-dropdown right
                            v-b-popover.focus.hover.bottomright="{content:'Links to admin web pages.'}">
                                <template slot="button-content">
                                    <em>Admin</em>
                                </template>
                                <b-dropdown-item href="/red">Administration</b-dropdown-item>
                            </b-nav-item-dropdown>
                            <b-nav-item-dropdown right
                                    v-b-popover.focus.hover.bottomright="{content:'Links to direct device web pages.'}">
                                <template slot="button-content">
                                    <em>Devices</em>
                                </template>
                                <b-dropdown-item href="http://192.168.1.152/status">D1M02</b-dropdown-item>
                                <b-dropdown-item href="http://192.168.1.187/">D1M04</b-dropdown-item>
                                <b-dropdown-item href="http://192.168.1.188/">D1M05</b-dropdown-item>
                                <b-dropdown-item href="http://192.168.1.159">POW1</b-dropdown-item>
                            </b-nav-item-dropdown>
                        </b-navbar-nav>

                    </b-collapse>
                </b-navbar>

                <b-container id="warnings">
                    <b-alert variant="danger" :show="showNoUpdAlert" @dismissed="showNoUpdAlert=false">
                        <h4 class="alert-heading">Heating Warning:</h4>
                        <p>
                            No heating data update received in over 2 minutes.
                        </p>
                        <hr>
                        <p>
                            Check that the controller (on kitchen wall) is on and isn't showing red lights.
                        </p>
                        <p>
                            If any red lights showing, gently pull forwards the bottom of the controller until the
                            lights go off, wait 30sec then push the bottom back. The lights should go green after about
                            a minute.
                        </p>
                        This alert will go away when data is received again.
                    </b-alert>
                </b-container>

                <b-card no-body id="main">
                    <b-tabs card id="tabs" v-model="tabIndex" @input="changeTab">
                        <b-tab title="Lights">
                            <lights-tab :home-data="homeData" :switches="switches"></lights-tab>
                        </b-tab>

                        <b-tab title="Heating">
                            Sorry, not ready yet
                        </b-tab>

                        <b-tab title="Details">
                            <b-row>
                                <b-col cols="3">
                                    <demand-card
                                        :percentage-demand="percentageDemand"
                                        :demand-level="demandLevel"
                                        :demand-max="demandMax"
                                        :demand-on-off-output="demandOnOffOutput"
                                        :is-boosted="isBoosted">
                                    </demand-card>
                                </b-col>

                                <b-col>
                                    <b-card id="rooms_card" class="shadow">
                                        <b-table responsive flex hover head-variant="dark" small stacked="sm" outlined
                                                :items="homeData" :fields="homeDataFields"
                                                :filter="currentRoomsTblFilter" @row-clicked="onRoomsRowClicked">
                                            <template slot="override" slot-scope="row">
                                                <p class="my-0"
                                                    v-b-popover.focus.hover.bottomright="{content:`Override: ${row.value}, Setpoint Origin: ${row.item.SetPointOrigin}`, title:'Heating Override Active?'}"
                                                    >
                                                    <b-form-checkbox v-model="row.value" disabled></b-form-checkbox>
                                                </p>
                                            </template>
                                            <template slot="details" slot-scope="row" @click="row.toggleDetails">
                                                <b-form-checkbox @click.native.stop @change="row.toggleDetails"
                                                        v-model="row.detailsShowing"
                                                        v-b-popover.focus.hover.bottomright="{content:`Show Details for ${row.item.Name}`}">
                                                </b-form-checkbox>
                                            </template>
                                            <template slot="row-details" slot-scope="row">
                                                <b-card>
                                                    <b-card v-if="row.item.ControlOutputState" border-variant="light">
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right"><b>% Demand:</b></b-col>
                                                            <b-col>{{ row.item.percentageDemand }}</b-col>
                                                        </b-row>
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right"><b>Ctrl Output State:</b></b-col>
                                                            <b-col>{{ row.item.ControlOutputState }}</b-col>
                                                        </b-row>

                                                        <b-row class="mt-2 mb-0">
                                                            <b-col class="text-sm-right">
                                                                <b>Current/Scheduled Room Setpoint:</b>
                                                            </b-col>
                                                            <b-col>
                                                                {{ row.item.DisplayedSetPoint === -200 ? 'OFF' : (row.item.DisplayedSetPoint/10) }}°c /
                                                                {{ row.item.ScheduledSetPoint === -200 ? 'OFF' : (row.item.ScheduledSetPoint/10) }}°c
                                                            </b-col>
                                                        </b-row>
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right">
                                                                <b>Setpoint Origin:</b>
                                                            </b-col>
                                                            <b-col>
                                                                {{ row.item.SetPointOrigin }}
                                                            </b-col>
                                                        </b-row>
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right">
                                                                <b>Override Type:</b>
                                                            </b-col>
                                                            <b-col>
                                                                {{ row.item.OverrideType }}
                                                            </b-col>
                                                        </b-row>
                                                    </b-card>

                                                    <b-card border-variant="light" v-if="row.item.devices.length > 0">
                                                        <b-row class="my-0">
                                                            <b-col>
                                                                <h6>Room Heating Devices</h6>
                                                            </b-col>
                                                        </b-row>                                                    <b-row>
                                                        <b-row>
                                                            <b-col>
                                                                <b-table responsive flex small stacked="sm" class="my-0"
                                                                    :items="row.item.devices" :fields="hdDetailsFields">
                                                                </b-table>
                                                            </b-col>
                                                        </b-row>
                                                    </b-card>

                                                    <b-card border-variant="light" v-if="row.item.sensors">
                                                        <b-row class="my-0">
                                                            <b-col><h6>Room Sensors</h6></b-col>
                                                        </b-row>
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right"><b>Temperature:</b></b-col>
                                                            <b-col>{{ row.item.sensors.Temperature }}°c</b-col>
                                                        </b-row>
                                                        <b-row class="my-0">
                                                            <b-col class="text-sm-right"><b>Humidity:</b></b-col>
                                                            <b-col>{{ row.item.sensors.Humidity }}%</b-col>
                                                        </b-row>
                                                        <b-row v-if="row.item.sensors.Light" class="my-0">
                                                            <b-col class="text-sm-right"><b>Light:</b></b-col>
                                                            <b-col>{{ row.item.sensors.Light }} Lux</b-col>
                                                        </b-row>
                                                    </b-card>

                                                    <b-button slot="footer" size="sm" @click="row.toggleDetails">Hide Details</b-button>
                                                </b-card>
                                            </template>
                                        </b-table>
                                    </b-card>
                                </b-col>
                            </b-row>
                        </b-tab>

                        <b-tab title="Boost">
                            Sorry, not ready yet
                        </b-tab>

                        <b-tab title="Schedules">
                            Sorry, not ready yet
                        </b-tab>

                        <b-tab title="Devices">
                            <device-tab :home-data="homeData" :devices="devices"></device-tab>
                        </b-tab>

                        <b-tab title="Help" v-b-popover.focus.hover.bottomright="{content:'Information on how to use this dashboard.'}">
                            This is a uibuilder test using <a href="http://vuejs.org/">Vue.js</a> as a front-end
                            library.
                            Along with the <a href="https://bootstrap-vue.js.org/docs/">bootstrap-vue</a> component
                            library.
                            See the
                            <a href="https://github.com/TotallyInformation/node-red-contrib-uibuilder">node-red-contrib-uibuilder</a>
                            README and WIKI for details on how to use UIbuilder.
                        </b-tab>
                    </b-tabs>
                </b-card>

                <b-row no-gutters id="footer" class="text-light p-1 bg-dark">
                    <b-col>
                        &nbsp;
                    </b-col>
                </b-row>
            </b-container>
        </div>

        <!-- These MUST be in the right order. -->
        <script src="../uibuilder/vendor/socket.io/socket.io.js"></script>
        <script src="./uibuilderfe.min.js"></script>
        <!-- === Vendor Libraries - Load in the right order === -->
        <script src="https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js"></script>
        <script src="../uibuilder/vendor/vue/dist/vue.js"></script>
        <script src="https://unpkg.com/babel-polyfill@latest/dist/polyfill.min.js"></script>
        <script src="../uibuilder/vendor/bootstrap-vue/dist/bootstrap-vue.js"></script>
        <!-- <script src="../uibuilder/vendor/bootstrap-vue/dist/bootstrap-vue.min.js"></script> -->
        <!-- === Custom code goes in here === -->
        <script src="./index.js"></script>

    </body>

</html>

JavaScript

/*global document,$,window,uibuilder,Vue,_ */
/** Copyright (c) 2019 Julian Knight (Totally Information)

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at

  http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
**/
/** This is the default, template Front-End JavaScript for uibuilder
 * It is usable as is though you will want to add your own code to
 * process incoming and outgoing messages.
 *
 * uibuilderfe.js (or uibuilderfe.min.js) exposes the following global object:
 * @see https://github.com/TotallyInformation/node-red-contrib-uibuilder/wiki/Front-End-Library---available-properties-and-methods
 **/
'use strict'

/** Get a nested property from an object without returning any errors.
 * If the property or property chain doesn't exist, undefined is returned.
 * Property names with spaces may use either dot or bracket "[]" notation.
 * Note that bracketed property names without surrounding quotes will fail the lookup.
 *      e.g. embedded variables are not supported.
 * @param {Object} obj The object to check
 * @param {string} prop The property or property chain to get (e.g. obj.prop1.prop1a or obj['prop1'].prop2)
 * @returns {*|undefined} The value of the objects property or undefined if the property doesn't exist
 */
function getProp(obj, prop) {
    if (typeof obj !== 'object') throw 'getProp: obj is not an object'
    if (typeof prop !== 'string') throw 'getProp: prop is not a string'

    // Replace [] notation with dot notation
    prop = prop.replace(/\[["'`](.*)["'`]\]/g,".$1")

    return prop.split('.').reduce(function(prev, curr) {
        return prev ? prev[curr] : undefined
    }, obj || self)
} // --- end of fn getProp() --- //

// Initialise Bootstrap-Vue: Not needed if loading via CDN
//Vue.use(BootstrapVue)

// Template Components
Vue.component('lights-tab', {
    // NB: prop defined as 'home-data' because it is used as an HTML attribute. BUT use as variable 'homeData'
    props: ['home-data', 'switches'],
    template: '#lights-tab-template',
    data: function() { return {
        dtOpts: {
            timeZone: 'Europe/London',
            weekday: 'short', month: 'short', day: 'numeric',
            hour: 'numeric', minute: 'numeric',
        },
        dtFmt: 'en-GB',
    }},
    computed: {
        // orderedSwitches: function() {
        //     return _.orderBy(this.switches, 'room').filter(function (sw) {
        //         return sw.room === 'NA' ? false : true
        //     })
        // },
    },
    methods: {
        fmtTime: function(t) {
            return new Intl.DateTimeFormat(this.dtFmt, this.dtOpts).format(new Date(t))
        },
        switchClick: function(clickData) {
            let [switchId, switchStatus] = clickData
            //console.log('switchClick', switchId, switchStatus)
            uibuilder.send({
                'topic': `COMMAND/${switchId}`,
                'payload': switchStatus.toLowerCase() === 'on' ? 'Off' : 'On'
            })
        },
    },
})
Vue.component('demand-card', {
    props: [
        'percentage-demand', 'demand-level', 'demand-max',
        'demand-on-off-output', 'is-boosted',
    ],
    template: '#demand-card-template',
    computed: {
        classDemandActive: function() {
            return this.demandOnOffOutput === 'On' ? 'text-danger font-weight-bold': ''
        },
        isBoostedText: function() {
            return this.isBoosted ? 'On' : 'Off'
        },
        classIsBoosted: function() {
            return this.isBoosted ? 'text-danger font-weight-bold': ''
        },
    },
})
Vue.component('device-tab', {
    props: ['home-data', 'devices'],
    template: '#device-tab-template',
    data: function() { return {
        dtOpts: {
            timeZone: 'Europe/London',
            weekday: 'short', month: 'short', day: 'numeric',
            hour: 'numeric', minute: 'numeric',
        },
        dtFmt: 'en-GB',
    }},
    computed: {
        orderedDevices: function() {
            // Use LoDash to reorder the object
            return _.orderBy(this.devices, 'id')
        },
    },
    methods: {
        fmtTime: function(t) {
            return new Intl.DateTimeFormat(this.dtFmt, this.dtOpts).format(new Date(t))
        },
    },
})

// Initialise Vue
new Vue({
    el: "#app",
    // We don't really need a function here but you do in components - keeping things consistent
    data: function() { return {
        // For formatting dates and times
        dtOpts: {
            timeZone: 'Europe/London',
            weekday: 'short', month: 'short', day: 'numeric',
            hour: 'numeric', minute: 'numeric',
        },
        dtFmt: 'en-GB',
        // Which tab should be active?
        tabIndex: 0,
        // heating
        lastUpdate  : '[None]',
        hTimer      : null,
        showNoUpdAlert    : false,
        demand            : undefined,
        percentageDemand  : undefined,
        demandOnOffOutput : 'N/A',
        demandMax         : 100,
        isBoosted         : false,
        homeData          : [],
        // Field definitions - @see https://bootstrap-vue.js.org/docs/components/table#field-definition-reference
        homeDataFields    : [
            {   key: 'floor',
                label: 'Floor',
                sortable: true,
                class: 'border-right text-center',
                thStyle: {width: '2em !important'},
            },
            {   key: 'Name',
                label: 'Room',
                sortable: true,
                class: 'border-right',
                // Variant applies to the whole column, including the header and footer
                //variant: 'danger'
                tdClass: (value, key, item) => {
                    const c = []

                    if ( item.ControlOutputState === 'On' ) c.push('bg-primary')
                    if ( item.outside === true )            c.push('font-italic')

                    return c.join(' ')
                },
                tdAttr: {'title':'Blue BG = Room requesting heat. Italic = room is outside'},
            },
            {   key: 'CalculatedTemperature',
                label: '°c',
                sortable: true,
                class: 'text-right border-right',
                formatter: (value, key, item) => {
                    // -200 or -32768 are unset or invalid
                    if ( value < -99 ) return ''
                    const t = (value/10).toFixed(1)
                    return isNaN(t) ? value : t
                },
                tdClass: (value, key, item) => {
                    const c = []

                    // Highlight if too cold or too hot
                    if ( item.outside === true ) {
                        // Outdoors
                        c.push('font-italic')
                        if ( value < 0 )
                            // Freezing
                            c.push('bg-danger')
                        else if ( value < 20 )
                            // <2
                            c.push('bg-warning')
                        else if ( value < 50 )
                            // <5
                            c.push(['text-white', 'bg-primary'])
                        else if ( value > 300 )
                            // >30
                            c.push('bg-warning')
                    } else {
                        // Indoors
                        if ( value < 100 )
                            // <10
                            c.push('bg-danger')
                        else if ( value > 230 )
                            // >23
                            c.push('bg-warning')
                    }

                    //if ( item.ControlOutputState === 'On' ) c.push('bg-primary')

                    return c.join(' ')
                },
                tdAttr: {'title':"Temperature. Highlighted if too high or too low."},
            },
            {   key: 'CalculatedHumidity',
                label: 'H%',
                sortable: true,
                class: 'text-right',
                formatter: (value, key, item) => {
                    let h = Math.round(value)
                    h = isNaN(h) ? value : h
                    return h === undefined ? '' : h + '%'
                },
                tdClass: (value, key, item) => {
                    const c = []
                    // Highlight if too high or too low
                    if ( item.outside === true ) {
                        // Outdoors
                        c.push('font-italic')
                        if ( value <40 ) c.push(['text-white', 'bg-primary'])
                    } else {
                        // Indoors
                        if ( value <40 ) c.push(['text-white', 'bg-primary'])
                        else if (value >60 ) c.push('bg-warning')
                    }
                    return c.join(' ')
                },
                tdAttr: {'title':"Humidity. Highlighted if too high or too low."},
            },
            // A virtual column with custom formatter
            {   key: 'override',
                label: 'O/ride',
                class: 'border-left text-right',
                thStyle: {width: '2em !important'},
                formatter: (value, key, item) => {
                    if ( item.SetPointOrigin === undefined ) return false
                    else return item.SetPointOrigin!=='FromSchedule' ? true : false
                },
            },
            {   key: 'details',
                label: 'More',
                class: 'text-right',
                thStyle: {width: '2em !important'},
            },
        ],
        // For the details view of homeData table
        hdDetailsFields: [
            'ProductType','BatteryLevel','DisplayedSignalStrength',
            {   key: 'SetPoint',
                label: 'SetPoint °c',
                class: 'text-right',
                formatter: (value, key, item) => {
                    // -200 or -32768 are unset or invalid
                    if ( value < -99 ) return ''
                    const t = (value/10).toFixed(1)
                    return isNaN(t) ? value : t
                },
            },
            {   key: 'MeasuredTemperature',
                label: 'Measured °c',
                class: 'text-right',
                formatter: (value, key, item) => {
                    // -200 or -32768 are unset or invalid
                    if ( value < -99 ) return ''
                    const t = (value/10).toFixed(1)
                    return isNaN(t) ? value : t
                },
            },
            {   key: 'MeasuredHumidity',
                label: 'Measured Humidity',
                class: 'text-right',
                formatter: (value, key, item) => {
                    return value ? (value + '%') : ''
                },
            },
        ],
        // Current Switch Settings
        switches: {},
        // Current Device statuses
        devices: {},
    }}, // --- End of data --- //
    // computed: dynamic data, used as {{ cName }} - cached
    computed: {
        // Set the FG & BG of the demand card if demand is on
        qDemandBg: function() {
            return this.demandOnOffOutput === 'On' ? 'primary': ''
        },
        qDemandFg: function() {
            return this.demandOnOffOutput === 'On' ? 'white': ''
        },
        classDemandActive: function() {
            return this.demandOnOffOutput === 'On' ? 'text-danger': ''
        },
        // colour the demand bar depending on demand level
        demandLevel: function() {
            let a = null
            switch (true) {
                case ( typeof this.percentageDemand === 'string' ):
                    a = 'dark';
                    break;

                case this.percentageDemand <= 30:
                    a = 'success';
                    break;

                case this.percentageDemand <= 60:
                    a = 'warning';
                    break;

                default:
                    a = 'danger';
                    break;
            }
            return a
        },
        // If a room has boost turned on
        isBoostedFill: function() {
            return this.isBoosted ? 'fill:#dc3545' : 'fill:#ffc107'
        },
        isBoostedText: function() {
            return this.isBoosted ? 'On' : 'Off'
        },
    }, // --- End of computed --- //
    // methods:
    methods: {
        /** Return a setInterval timer for the heating update warning
         * @callback cb setInterval function
         * @param {number} timeout The timeout to be passed to the setInterval fn. Optional, defaults to 2 minutes.
         * @returns {cb} setInterval function
         */
        heatingUpdTimer: function(timeout=120000) {
            const viewApp = this
            return setInterval(function(){
                //console.log('Vue:methods:heatingUpdTimer heating update not received in 2 minutes')
                viewApp.showNoUpdAlert = true
            }, timeout)
        },
        /** Handle row-clicked event on rooms table
         * @param {Object} item The Row data for the clicked row
         * @param {number} index The row index for the clicked row
         * @param {Object} event The click event data
         **/
        // TODO: need separate array to maintain display state
        onRoomsRowClicked (item, index, event) {
            item._showDetails = !item._showDetails
        },
        // Filter Fn for heating room table - @see https://bootstrap-vue.js.org/docs/components/table#filtering
        currentRoomsTblFilter: function(item) {
            if ( item.CalculatedTemperature ) return true
            else return false
        },

        /** Invoked when user changes tab - saves current tab - @see mounted
         * @param {number} i Selected tab index number
         */
        changeTab: function(i) {
            // Save to browser's session storage
            sessionStorage.currentTab = i
        },

        // Format date/time
        fmtTime: function(t) {
            return new Intl.DateTimeFormat(this.dtFmt, this.dtOpts).format(new Date(t))
        },

        // return formatted HTML version of JSON object
        syntaxHighlight: function(json) {
            json = JSON.stringify(json, undefined, 4)
            json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
            return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
                var cls = 'number'
                if (/^"/.test(match)) {
                    if (/:$/.test(match)) {
                        cls = 'key'
                    } else {
                        cls = 'string'
                    }
                } else if (/true|false/.test(match)) {
                    cls = 'boolean'
                } else if (/null/.test(match)) {
                    cls = 'null'
                }
                return '<span class="' + cls + '">' + match + '</span>'
            })
        } // --- End of syntaxHighlight --- //

    }, // --- End of methods --- //

    // Available hooks: init,mounted,updated,destroyed
    mounted: function(){
        uibuilder.debug(false) // output uibuilderfe debug messages

        //console.debug('Vue:mounted - setting up uibuilder watchers')

        // Save confusion by keeping a specific reference to this Vue app
        const vueApp = this

        // Start countdown. If lastUpdate not updated in 2 minutes, show a warning.
        vueApp.hTimer = vueApp.heatingUpdTimer()

        // On-load Reset the current tab to the one saved in session storage - strange, stored as number but retrieves as a string
        vueApp.tabIndex =  Number(sessionStorage.currentTab)

        // If msg changes - msg is updated when a standard msg is received from Node-RED over Socket.IO
        // Note that you can also listen for 'msgsReceived' as they are updated at the same time
        // but newVal relates to the attribute being listened to.
        uibuilder.onChange('msg', function(newVal){
            //console.debug('Vue:mounted:UIBUILDER: property msg changed! ', newVal)
            vueApp.msgRecvd = newVal

            // What kind of message did we receive?
            // Use getProp so we don't pollute the original input. Then tidy the topic
            let topic = getProp(newVal, 'topic').replace(/\/SWITCH..$/,'')
            if ( topic.substring(0,8) === 'DEVICES/' ) topic = 'DEVICES'
            switch (topic) {
                // Full homeDetails
                case 'Home Details':
                    //console.debug('UIBUILDER:onChange:msg: homeDetails msg received ', newVal)
                    /** To update the home details, we are expecting a msg like:
                     *  msg = {
                     *      'topic'     : 'Home Details',
                     *      'payload'   : {
                     *          'homeDetails': homeDetails,  // ARRAY
                     *          'demand'    : demand,        // OBJECT
                     *          'lastUpdate': new Date(),
                     *      },
                     *  }
                     */
                    // for convenience
                    const data = newVal.payload

                    // Formatted last update
                    vueApp.lastUpdate = vueApp.fmtTime(data.lastUpdate)

                    // clear and restart countdown. If lastUpdate not updated in 2 minutes, show a warning.
                    vueApp.showNoUpdAlert = false; clearInterval(vueApp.hTimer); vueApp.hTimer = null;
                    vueApp.hTimer = vueApp.heatingUpdTimer()

                    vueApp.demand  = data.demand
                    // for convenience ...
                    vueApp.percentageDemand  = data.demand.PercentageDemand
                    vueApp.demandOnOffOutput = data.demand.DemandOnOffOutput
                    vueApp.isBoosted        = data.demand.isBoosted
                    //vueApp.qDemand = data.demand.DemandOnOffOutput === 'On' ? true : false
                    //vueApp.HeatingRelayState = data.demand.HeatingRelayState
                    //vueApp.IsSmartValvePreventingDemand = data.demand.IsSmartValvePreventingDemand

                    // Sorted array of home data
                    vueApp.homeData = data.homeDetails
                    // vvv NB: The below adds the _showDetails field TOO LATE for it to be
                    //         correctly responsive - now added at source
                    // Add _showDetails:false to all members of the array for the table display
                    //vueApp.homeData.map(item => {item._showDetails = false; return item;})

                    // TODO: Should we null/delete the newVal var? Or would that kill vueApp.homeData as well?
                    break;

                // Individual switch update
                case 'COMMAND':
                    //console.debug('UIBUILDER:onChange:msg: COMMAND/SWITCHnn msg received ', newVal)
                    let sw = newVal.topic.replace('COMMAND/','')
                    vueApp.switches[sw].status = newVal.payload
                    break;

                // Full switch update
                case 'SWITCHES':
                    vueApp.switches = newVal.payload
                    break;

                // Individual device update
                case 'DEVICES':
                    //console.debug('UIBUILDER:onChange:msg: DEVICES/+ msg received ', newVal)
                    let dev = newVal.topic.replace('DEVICES/','')
                    vueApp.devices[dev].status = newVal.payload
                    break;

                // Full devices update
                case 'DEVICESFULL':
                    vueApp.devices = newVal.payload
                    break;

                // Don't process default
                default:
                    //ignore
            }
        }) // ---- End of uibuilder.onChange() watcher function ---- //

    } // --- End of mounted hook --- //

}) // --- End of app1 --- //

// EOF

CSS

pre { background-color: #212121 !important; color: wheat;}
.tcentre { text-align: center; }
.uk-table th {
    position: fixed; top: 0; z-index: 1;
    background-color:black; color:#EEEEEE;
}
pre .string { color: orange; }
.number { color: white; }
.boolean { color: rgb(20, 99, 163); }
.null { color: magenta; }
.key { color: #069fb3;}

card-l-primary, l-primary, btn-l-primary {background-color: #73b7ff !important;}
.nodisplay { display: none; }
Clone this wiki locally