Substitutions in widgets

Hi, All

when making complex widgets where you need to calculate a color or icon depending on conditions, you have to describe the same thing many times

and
in continuation of the topic Using vars in custom widgets: when are vars updated?

I implemented this via substitutions (with nested substitutions)

Ok, my suggestion is:

in config block of root component I added new block “subst” like defineVars

component: <component>
config:
  subst:
    sub1: <text or expression>
    sub2: <text or expression>
    ...
    subN: <text or expression>

sub1, sub2, subN - names can be anything except reserved words
- see examples

if the substitution contains only text, then you should use $subst.subN without “=” at the beginning

if the substitution contains expression then you should use =$subst.subN with “=” at the beginning

Examples

component: oh-list-item
config:
  subst:
    sub1: props.prop1?props.prop1 + " and something text":"nothing"
    sub2: '(props.item) ? "State of " + props.prop1 : "Set props to test!"'
    icon: if:mdi:door
    color: props.prop2?(Number.parseFloat(props.prop)2>10?"blue":"green"):"red"
    isClosed: '@@(props.item)==="CLOSED"?"true":"false"'
    bagde: $subst.isClosed

As you can see there are nested substitutions “badge”

How it use:

uid: test222
tags: []
props:
  parameters:
    - description: A text prop
      label: Prop 1
      name: prop1
      required: false
      type: TEXT
    - context: item
      description: An item to control
      label: Item
      name: item
      required: false
      type: TEXT
    - description: A test number
      label: Test number
      name: prop2
      required: false
      type: TEXT
  parameterGroups: []
timestamp: Oct 12, 2023, 11:07:56 AM
component: oh-list-item
config:
  subst:
    sub1: props.prop1?props.prop1 + " and something text":"nothing"
    sub2: '(props.item) ? "State of " + props.prop1 : "Set props to test!"'
    icon: if:mdi:door
    color: props.prop2?(Number.parseFloat(props.prop)2>10?"blue":"green"):"red"
    isClosed: '@@(props.item)==="CLOSED"?"true":"false"'
    bagde: $subst.isClosed
  footer: =$subst.sub1
  title: =$subst.sub2
  icon: $subst.icon
  iconColor: =$subst.color
  badgeColor: =$subst.color
  badge: =$subst.bagde
  subtitle: =props.prop2

if props.prop2 = 10, color is green
if props.prop2 = 101, color is blue


Another example

config:
  subst:
    sub1: props.prop1?props.prop1 + " and something text":"nothing"
    sub2: '(props.item) ? "State of " + props.prop1 : "Set props to test!"'
    icon: ($subst.isClosed)?"if:mdi:door":"if:mdi:door-open"
    color: props.item?(@@(props.item)==="OPEN"?"orange":"blue"):"red"
    isClosed: '@@(props.item)==="CLOSED"'
    bagde: ($subst.isClosed)?"closed":"open"
  footer: =$subst.sub1
  title: =$subst.sub2
  icon: =$subst.icon
  iconColor: =$subst.color
  badgeColor: =$subst.color
  badge: =$subst.bagde
  subtitle: =($subst.isClosed)?"--CLOSED--":"--OPEN--"


It works dinamicaly, color, icon, subtitle changes when window closed or open

The implemetation is non optimal as I think, but it works. Maybe this could be done differently through ‘jse-eval’ and its methods (expr.addUnaryOp or others), but I could not do it correctly.
I tried expr.addUnaryOp - $ - but it just replace my substition with text, no expression

Maybe someone has the desire and can improve my solution because i am newbie in javascript/vue

The source code is here GitHub - d51x/openhab-webui at widget_substitutions

@JustinG What do you think about it?

2 Likes

The topic you have linked is only one of several where this limitation has come up, so I agree this is a needed feature. I’m delighted to see someone thinking about it.

This is not at all how I would have thought to approach this issue but it’s an interesting way of looking at it. A small comment, and it could be that I’m just not used to reading it yet, is that it appears cluttered and decreases the readability of the widgets yaml somewhat. The real issues I see with this as it currently works are:

  1. it doesn’t really offer a performance gain over the current situation so the only problem it is solving is how often the widget creator has to type some expression and it’s a lot of added complexity to replace what can also be done with cut and paste
  2. (I’m not sure how to express this clearly) this is going to be very hard for most users to understand and therefore use without frustration. In order to really use these it requires the users to understand how the expressions are string that are parsed by jse-eval and that this is a layer of string substitution happens prior to parsing. That’s not a huge issue, because this will mostly be a system used by more advanced widget creators, but anything that raises the complexity of usability should be very carefully considered before it’s added.

Of the top of my head here are a couple of other ways that might also work and would address the two concerns above:

  1. The last comment from @ysc in the topic you’ve cited, points out that the vars object is not dynamically updated otherwise this feature would already fairly easy to add. If the vars object is updated instead of creating this whole new additional subst object, usability and readability would increase dramatically. Then a defineVars section for widgets could be addded that already matches what’s available for pages and would be adding this feature wholly within the framework that already exists.
  2. The jse-eval implementation for the widgets includes the arrow function plugin. So, it would be more complicated, but there is probably a way to make these user defined functions instead, then while the complexity would be increased you would definitely be adding a feature that doesn’t exist yet. Maybe something like:
confg:
  func:
    isClosed: (window) => { @@(window)==="CLOSED" }
  subtitle: =( func.isClosed(props.item) )?"--CLOSED--":"--OPEN--"

This gives you the same reduction in repetitive code, but also more flexibility as now isClosed isn’t just restricted to a single item but can be used throughout the widget.

At the moment, I was only thinking about substitutions instead of writing the same construction in several places for calculating something, for example, color for icon and badge

it’s very interesting and flexible

Thanks for the mention and the discussion, it is an interesting one.

When you define things at a component level, remember that components along with slots define a tree, so there’s the question of how to propagate (or not) configuration defined at a component level to its subcomponents.

At first sight it’s easy, there’s the component tree, from the root component page to the “leaf” widgets, so the sensible approach would be to propagate everything unless it’s redefined; but then there’s also the case where we “inject” components into the tree (e.g. a personal/custom widget into a page).
In this case do we reset the context (because the personal widget should be context-agnostic wrt. the parent page) and only use the config/props to configure the widget, or do we allow the parent context to be not only accessible but modifiable?

For vars it was a dilemma (as you might want a subcomponent to redefine a var for its parent component - and in fact you can), but in this case it might be simpler.

in my case subst works on root component level in widget

OK, I was wrong about this. It’s been stuck in my head since I typed that previous post, so I had to just give it a quick look this morning. It’s actually dead simple and works pretty much as I thought.

Adding a simple computed func property to the widget mixin like this:

    func () {
      if (!this.context || !this.context.component) return null
      let evalFunc = {}
      const sourceFunc = this.context.component.func || {}
      if (sourceFunc) {
        if (typeof sourceFunc !== 'object') return {}
        for (const key in sourceFunc) {
          this.$set(evalFunc, key, this.evaluateExpression(key, sourceFunc[key]))
        }
      }
      return evalFunc
    }

And, of course, adding this.func to the expression evaluation context means that you can do this:

uid: functionTest
props: []
tags: []
component: f7-card
func:
  lightDisplay: =(lightName) => `${lightName} is ${items[lightName].state}`
config:
  title: Function Test
  content: =func.lightDisplay('Switch_OfficeFanLight_OnOff')

image

This simple proof of concept doesn’t add it to the context so it doesn’t propagate down the tree which would be important, but it does show that this is a viable strategy.

The more I think about it, the more I believe this is a really good idea and the way to go here. And although I haven’t seen the latest status of the Main Widget, I suspect that @hmerk could probably find a few dozen uses for this feature.

2 Likes

Main_Widget itself is „frozen“.
We are working heavily on a new oh4 version which will also get a new name.
Hope to be able to publish it soon….
I definitely will check the here discussed feature…

I can’t repeat your example

I think you just need to replace ctx.component.func with this.func in the expression context.

Here’s a slightly more advanced version of where func is in the full widget context so that it is available throughout the entire widget tree:

I’m not sure this is ready for a PR quite yet; it should probably be tested a little more since I just threw it together out of curiosity. But, it’s probably close to what you’re looking for.

also not working, or rather, the func block is not saved when press Save widget

it only saves like that, func in config of component

component: f7-card
config:
  func:
    lightDisplay: (lightName) => `${lightName} is ${items[lightName].state}`
  title: '=(props.item) ? "State of " + props.item : "Set props to test!"'
  footer: =func.lightDisplay(props.item)
  content: =func.lightDisplay('bedroom1_light')

the result is

image

That’s a good point, the downside to the way I formulated my version is that I suspect it would require a modification to the openhab core component definitions to recognize func as an allowed property of a widget before the widget could be saved. (To be honest, I’ve never looked into what that modification might be and I don’t know how simple or complex it is. I am sure one of the devs could quickly tell you what was needed there.)

As soon as you move the func definition inside config, you don’t have to worry about the modifications to the core, as the whole of the config object is saved and arbitrary properties don’t cause the ui API to throw an error. It is, however, going to be slightly more complicated, when func is in the config object to 1) make sure that the order of evaluation is correct (the functions have to to be parsed first) and 2) that the functions wind up in the overall context. Both of these are possible, I just went for the easier route for my proof of concept.

If you modify the func property to to use config.func like this:

    func () {
      if (!this.context || !this.context.component || !this.context.component.config) return {}
      if (this.context.component.config.func) {
        let evalFunc = {}
        const sourceFunc = this.context.component.config.func || {}
        if (sourceFunc) {
          if (typeof sourceFunc !== 'object') return {}
          for (const key in sourceFunc) {
            this.$set(evalFunc, key, this.evaluateExpression(key, sourceFunc[key]))
          }
        }
        return Object.assign({}, evalFunc, this.context.func || {})
      } else {
        return this.context.func || {}
      }
    },

And then add func to the list of keys that are skipped during config evaluation:

if (key === 'visible' || key === 'visibleTo' || key === 'stylesheet' || key === 'func') continue

Then I think you get the same functionality while putting func into the config object and allowing saving of the widget.

It’s not’s quite as pleasing as having func be a separate widget key from config, but it does bypass the need to make modifications to the core repo.

It occurred to me that actually this capability basically already exists with one moderately reasonable workaround. The elements in a repeater array can be objects and those objects are already evaluated by the parser. So as long as your repeater array is one element long and that element is an object with keys for arrow functions you get the exact same effect.

This is just with 4.1M1:

uid: repeater_functions
props: {}
tags: []
component: oh-repeater
config:
  for: func
  sourceType: array
  fragment: true
  in:
    - lightDisplay: =(lightName) => `${lightName} is ${items[lightName].state}`
      isClosed: =(door) => items[door].state === 'CLOSED'
slots:
  default:
    - component: f7-card
      config:
        title: Function Test
        content: =loop.func.lightDisplay('Switch_OfficeFanLight_OnOff')
        footer: =loop.func.isClosed('Door_BackDoor_Status').toString()

image

Really the only downside to this is that loop.func.functionName is ever so slightly less readable than func.functionName.

It makes me wonder if some new base component wouldn’t be a better way to approach this instead of modifying the underlying widget code. Something like this:

component: oh-widget-context
config:
  functions:
    lightDisplay: =(lightName) => `${lightName} is ${items[lightName].state}`
    isClosed: =(door) => items[door].state === 'CLOSED'
slots:
  default:
    - component: f7-card
      config:
        title: Function Test
        content: =func.lightDisplay('Switch_OfficeFanLight_OnOff')
        footer: =func.isClosed('Door_BackDoor_Status').toString()

If we’re really focused on improving code readability, it could even be expanded to include other useful things in the widget context, e.g. maybe constants:

component: oh-widget-context
config:
  constants:
    rootUrl: 'https://somebiglong.url/to/your/image/files'
    userColor:
      dad: red
      mom: blue
      child: green
  functions:
    lightDisplay: =(lightName) => `${lightName} is ${items[lightName].state}`
    isClosed: =(door) => items[door].state === 'CLOSED'
slots:
  default:
    - component: f7-card
      config:
        textColor: =const.userColor[user.name]
        title: Function Test
        content: =func.lightDisplay('Switch_OfficeFanLight_OnOff')
        footer: =func.isClosed('Door_BackDoor_Status').toString()

Edit:

I think it makes more sense to move this discussion to the UI repo at this point:

1 Like