FlexDash Custom Widget tutorial

One of the most requested features since the earliest versions of FlexDash has been a ui_template equivalent. I just released a first version of this and thought I'd cook up an example that represents some of the most common uses of ui_template, which I believe is to iterate through some data structure and display it.

I did write up a tutorial in the docs on how to clone one of the built-in FlexDash widgets into a custom widget so one can easily make tweaks to it. But that's a different use case from the typical ui_template.

Now, I'm not a creative user of ui_template, so I decided to write this up here in a more interactive manner: I'll write some of the tutorial and hope to get feedback on what to add next and then I'll go back and write the next part accordingly.

Sooo, here we go! :rocket:

Unfortunately with a caveat: as I've been preparing this post I've been having occasional trouble with the hot-reload, i.e., when updating the widget instead of seeing the new result I get a pile of obscure errors in the browser console (for FlexDash). Looks like I need to figure out the dirty tricks on how one does do these on-the-fly code updates... The fix is to reload the browser window.

Lift-off

This tutorial assumes that you have FlexDash installed and that you have something like the hello world example going, i.e. you have a tab and a grid into which you can add a new custom widget.

What I did is to grab some boring weather data for a couple of cities, put that into a function node, and route it into a FD custom node. I then wrote a very simple template to display an HTML table with the data. Here are the 3 nodes that you can insert into a flow of yours:

[{"id":"a7c070cd4d145da9","type":"function","z":"451d9acba8e52c2f","name":"data","func":"let payload = [\n    [\"Amsterdam\",\"Fri 7: 39 pm\",\"Ice fog.Chilly.\",32],\n    [\"Oslo\",\"Fri 7: 39 pm\",\"Clear.Cold.\",12],\n    [\"Athens\",\"Fri 8: 39 pm\",\"Mild.\",64],\n    [\"Paris\",\"Fri 7: 39 pm\",\"Low clouds.Chilly.\",32],\n    [\"Belgrade\",\"Fri 7: 39 pm\",\"Overcast.Cool.\",56],\n    [\"Podgorica\",\"Fri 7: 39 pm\",\"Light rain.Mostly cloudy.Cool.\",57],\n    [\"Berlin\",\"Fri 7: 39 pm\",\"Low clouds.Chilly.\",28],\n    [\"Prague\",\"Fri 7: 39 pm\",\"Light snow.Ice fog.Chilly.\",30],\n]\npayload = payload.map(d => ({\n    city: d[0], time: d[1], weather: d[2], temp: d[3]\n}))\n\nreturn { payload }","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":150,"y":640,"wires":[["5021eeaf697e64c2"]]},{"id":"22531105076fbf39","type":"inject","z":"451d9acba8e52c2f","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":120,"y":600,"wires":[["a7c070cd4d145da9"]]},{"id":"5021eeaf697e64c2","type":"flexdash custom","z":"451d9acba8e52c2f","fd_container":"28d1859702cdf729","fd_cols":"4","fd_rows":"3","fd_array":false,"fd_array_max":10,"fd_output_topic":"","fd_loopback":false,"name":"Weather table","sfc_source":"<template>\n  <table>\n    <tr>\n      <th>City</th>\n      <th>Time</th>\n      <th>Weather</th>\n      <th>Temperature</th>\n    </tr>\n    <tr v-for=\"d in payload\" key=\"d.city\">\n      <td>{{d.city}}</td>\n      <td>{{d.time}}</td>\n      <td>{{d.weather}}</td>\n      <td>{{d.temp}}°F</td>\n    </tr>\n  </table>\n</template>\n\n<style scoped>\n  th { font-weight: 700; }\n  td { padding: 0px 4px; }\n</style>\n\n<script scoped>\nexport default {\n  props: {\n    payload: { type: Array, default: [] },\n  },\n}\n</script>\n","x":220,"y":680,"wires":[[]]},{"id":"28d1859702cdf729","type":"flexdash container","name":"misc","kind":"StdGrid","fd_children":",d2dbe97c44f6fb5a,d2b3883a74adc563,ab904c40d22f83e1,527401ee602f4386,4f31b9c704e0b947,a8dfa00ae270ac88,b27685878ffe3711,8713c375979525c8,5021eeaf697e64c2","title":"","tab":"9f23b0158f8280e3","min_cols":"1","max_cols":"20","parent":"","solid":false,"cols":"1","rows":"1"},{"id":"9f23b0158f8280e3","type":"flexdash tab","name":"chart","icon":"mdi-chart-line","title":"","fd_children":",28d1859702cdf729","fd":"e8f5aea52ab49500"}]

This should give you the following three nodes:
image

The function node has:

let payload = [
    ["Amsterdam","Fri 7: 39 pm","Ice fog.Chilly.",32],
    ["Oslo","Fri 7: 39 pm","Clear.Cold.",12],
    ["Athens","Fri 8: 39 pm","Mild.",64],
    ["Paris","Fri 7: 39 pm","Low clouds.Chilly.",32],
    ["Belgrade","Fri 7: 39 pm","Overcast.Cool.",56],
    ["Podgorica","Fri 7: 39 pm","Light rain.Mostly cloudy.Cool.",57],
    ["Berlin","Fri 7: 39 pm","Low clouds.Chilly.",28],
    ["Prague","Fri 7: 39 pm","Light snow.Ice fog.Chilly.",30],
]
payload = payload.map(d => ({
    city: d[0], time: d[1], weather: d[2], temp: d[3]
}))

return { payload }

And the custom widget code to turn that payload into data is:

<template>
  <table>
    <tr>
      <th>City</th>
      <th>Time</th>
      <th>Weather</th>
      <th>Temperature</th>
    </tr>
    <tr v-for="d in payload" key="d.city">
      <td>{{d.city}}</td>
      <td>{{d.time}}</td>
      <td>{{d.weather}}</td>
      <td>{{d.temp}}°F</td>
    </tr>
  </table>
</template>

<style scoped>
  th { font-weight: 700; }
  td { padding: 0px 4px; }
</style>

<script scoped>
export default {
  props: {
    payload: { type: Array, default: [] },
  },
}
</script>

And for completeness, the general tab of the custom widget has:

If you look at the <template> section of the custom widget code you'll see pretty basic HTML to form a table. The only thing that is odd is the <tr> that represents the data rows:

 <tr v-for="d in payload" key="d.city">

Attributes of the from "v-xxx" are special Vue attributes. Vue calls them built-in directives and v-for causes the element to which it is applied (and all its children) to be repeated. Similar to ng-repeat in angular.

The d in payload form iterates through the payload array, makes a copy of the <tr> element and its children for each item, and assigns the item to d, which can be used in the child elements.

From the code in the function node we can gather that each item in the payload array consists of city, time, weather, and temp, so we simply destructure each one into a <td> by writing, e.g., <td>{{d.city}}</td>. The familiar {{ ... }} mustache notation is replaced by the value of the javascript expression contained within.

The custom widget code further contains a <style> element, which has CSS to be applied to the widget. The scoped attribute causes the CSS to be scoped just to the elements in this widget. I.e., the CSS that makes <th> content bold-face is only applied to this table here, not to <th> elements elsewhere on the page.

Finally, the <script> element contains the javascript code for this widget, or more properly stated, it contains an "options object" in the style of Vue's "options API". Here, this is really minimal and only contains the definition of the "props", which are the properties that can be input to the widget. We need to get the payload with the data into the widget somehow!

Hitting "deploy" should display the Weather Table in FlexDash:

Simple calculations

Let's make one change: it's kind'a perverse to display the temperature for European cities in Farenheit, we can fix that! The quick & dirty fix is to change the expression in the <td> to something like <td>{{(d.temp-32)*5/9}}°C</td> and that works, but the result isn't great (you can simply edit the code, hit done&deploy, and the dashboard updates):

In general it is frowned upon to put much more than a variable reference into the template because pretty quick it becomes an unreadable jumble of HTML and javascript. Better to separate HTML and code.

What we need is an array of temperatures that parallels the array of city data. So for cities 1..N we have temperatures 1..N in Centigrade/Celsius. Let's expand the script:

<script scoped>
export default {
  props: {
    payload: { type: Array, default: [] },
  },

  computed: {
    temp_c() {
      return this.payload.map(pl => ((pl.temp-32)*5/9).toFixed(1))
    },
  },
}
</script>

The new computed section contains functions to define reactive variables. In this case, the temp_c function accesses this.payload and returns an array (of temperatures in centigrade printed with one decimal). The effect of this function in the "computed" section causes a variable this.temp_c to be set to the returned array.

In addition, Vue takes note of what is accessed by the function (this.payload in this case) and every time this.payload changes it will re-run the function to recalculate this.temp_c. This is the most fundamental feature that makes Vue a pleasure to use: one doesn't have to sprinkle trigger calls everywhere to update other stuff, it happens automatically.

Now all that's left is to use the temp_c array in the template:

    <tr v-for="(d,ix) in payload" key="d.city">
      <td>{{d.city}}</td>
      <td>{{d.time}}</td>
      <td>{{d.weather}}</td>
      <td>{{temp_c[ix]}}°C</td>
    </tr>

The result:

Computed styles

Something else we can do is to color the temperatures in blue below 0 degrees. For that we want to compute a CSS style and apply it to the appropriate <td>. Let's compute that style:

  computed: {
    temp_c() {
      return this.payload.map(pl => ((pl.temp-32)*5/9).toFixed(1))
    },

    temp_color() {
      return this.payload.map(pl => {
        // color blue if < 0C/32F, red if >= 30C/86F, black otherwise
        const color = pl.temp < 32 ? "blue" : pl.temp >= 86 ? "red" : "black"
        return { color }
      })
    },
  },

This produces this.temp_color with something like:

[ {color: "blue"}, {color: "black"}, ...]

with the intent that each one of those objects is a set of CSS styles, one for each city.

Now we need to apply it. For this we can use a style attribute on the td element:

      <td :style="temp_color[ix]">{{temp_c[ix]}}°C</td>

The : in front of the style attribute is important: it tells Vue that temp_color[ix] is not a literal value but a javascript expression. In our case that expression yields objects like {color: "blue"}. The result looks as follows:

Next?

Maybe next we could map the weather description text (e.g. "Ice fog. Chilly.") to some icons? And then make the icons clickable and pop-up some additional info on click? Other suggestions?

5 Likes

Simple things;

  1. Use filter for the temperature conversion & addition of °C
  2. Align the decimal places
  3. Add boarders

Like the idea of icons and pop-up additional data

What do you mean by "use filter"? Vue doesn't have a notation using "|" to pipe data structures through functions. I could have written the 'computed' stuff differently, which would have been more filter-style. Something like:

computed: {
  data(): {
    return this.payload.map(p => ({ ...p, temp: <calculate C>, color: <figure color> })
  }
}

Then I could have replaced the original loop with v-for="d in data" and not have to introduce ix.

Dealing with the alignment and borders is primarily a CSS thing. Let's do it... Start by going back to a simpler template loop and injecting some CSS classes:

    <tr v-for="d in data" key="d.city" :class="d.row_classes">
      <td>{{d.city}}</td>
      <td>{{d.time}}</td>
      <td>{{d.weather}}</td>
      <td :class="d.temp_classes">{{d.temp}}</td>
    </tr>

For colors. we'll use Vuetify's material design color classes so we get consistent color shades throughout the dashboard (you will want to click through to the actual definition of the colors). Instead of borders we can alternate light and dark backgrounds using more Vuetify color classes with a bg- prefix.

Finally, for the right-alignment I opted to use flex layout in the td:

<style scoped>
  ...
  .right { display: flex; margin-right: 1em; justify-content: end; }
</style>

Now replace the temp_c and temp_color with a single "filter-like" transformation:

  computed: {
    data() {
      return this.payload.map((pl, ix) => {
        // color blue if < 0C/32F, red if >= 30C/86F, black otherwise
        const color =
          pl.temp < 32 ? "text-blue-darken-2" :
          pl.temp >= 86 ? "text-red-lighten-2" : "text-green"
        return {
          ...pl,
          temp: ((pl.temp-32)*5/9).toFixed(1) + "°C",
          temp_classes: [color, "right"],
          row_classes: ix&1 ? "bg-transparent" : "bg-grey-lighten-3",
        }
      })
    },
  },

And this produces:

2 Likes

Filters;

The function would have been the same but I was looking at this

Vue.js allows you to define filters that can be used to apply common text formatting. Filters are usable in two places: mustache interpolations and v-bind expressions (the latter supported in 2.1.0+). Filters should be appended to the end of the JavaScript expression, denoted by the “pipe” symbol:

<!-- in mustaches -->
{{ message | capitalize }}
You can define local filters in a component’s options:

filters: {
  capitalize: function (value) {
    if (!value) return ''
    value = value.toString()
    return value.charAt(0).toUpperCase() + value.slice(1)
  }
}

Apologies if this is not part of a widget, 'cause I have to confess that I do not actually have FlexDash loaded, but looking at your widgets I am seriously thinking about it. :thinking:

1 Like

Interesting! Learned something new! I see the feature in Vue2 but I'm not seeing it in Vue3... Ah: removed.

So it has, sorry about that but I have only used Vue 2. Strange reason for removing them

1 Like