Vertical menu tree in perspective

Hi guys,

Any idea how to set a vertical menu tree like when I click Menu Item then display the sub-menu in the same view when I click this Menu Item again and then collapse this sub-menu in perspective?

Same as that dropdown.

image

Thanks,
Priyanka

I don't think this component can do what you want, but it's easy enough to make yourself.
The simplest way would be to use flex containers to host your navigation buttons, and make those containers basis depend on a custom property on the menu button.
You can even add a transition: flex-basis 500ms style prop to the container to make it animate nicely.

Here's the json of an example based on what I use:

{
  "custom": {
    "current_category": "debug"
  },
  "params": {},
  "propConfig": {
    "custom.current_category": {
      "binding": {
        "config": {
          "path": "page.props.primaryView"
        },
        "transforms": [
          {
            "code": "\ttry:\n\t\treturn value.split(\u0027/\u0027)[-2]\n\texcept:\n\t\treturn \"debug\"",
            "type": "script"
          }
        ],
        "type": "property"
      },
      "persistent": true
    }
  },
  "props": {},
  "root": {
    "children": [
      {
        "children": [
          {
            "custom": {
              "view": "some_category/mainpage"
            },
            "events": {
              "component": {
                "onActionPerformed": {
                  "config": {
                    "params": {},
                    "view": "{this.custom.view}"
                  },
                  "scope": "C",
                  "type": "nav"
                }
              }
            },
            "meta": {
              "name": "main_button"
            },
            "position": {
              "basis": "34px",
              "shrink": 0
            },
            "propConfig": {
              "props.style.classes": {
                "binding": {
                  "config": {
                    "expression": "if ({view.custom.current_category} \u003d {parent.custom.category},\r\n\t\"button_selected\",\r\n\t\"button\"\r\n)"
                  },
                  "type": "expr"
                }
              }
            },
            "props": {
              "justify": "start",
              "style": {
                "borderColor": "#808080",
                "fontWeight": "normal"
              },
              "text": "some category"
            },
            "type": "ia.input.button"
          },
          {
            "custom": {
              "view": "some_category/page_1"
            },
            "events": {
              "component": {
                "onActionPerformed": {
                  "config": {
                    "params": {},
                    "view": "{this.custom.view}"
                  },
                  "scope": "C",
                  "type": "nav"
                }
              }
            },
            "meta": {
              "name": "page1"
            },
            "position": {
              "basis": "34px",
              "shrink": 0
            },
            "propConfig": {
              "props.style.classes": {
                "binding": {
                  "config": {
                    "expression": "if ({this.custom.view} \u003d {page.props.primaryView},\r\n\t\"button_selected\",\r\n\t\"button\"\r\n)"
                  },
                  "type": "expr"
                }
              }
            },
            "props": {
              "justify": "start",
              "style": {
                "borderColor": "#808080",
                "fontWeight": "normal"
              },
              "text": "\u003e page 1"
            },
            "type": "ia.input.button"
          },
          {
            "custom": {
              "view": "some_category/page_2"
            },
            "events": {
              "component": {
                "onActionPerformed": {
                  "config": {
                    "params": {},
                    "view": "{this.custom.view}"
                  },
                  "scope": "C",
                  "type": "nav"
                }
              }
            },
            "meta": {
              "name": "page2"
            },
            "position": {
              "basis": "34px",
              "shrink": 0
            },
            "propConfig": {
              "props.style.classes": {
                "binding": {
                  "config": {
                    "expression": "if ({this.custom.view} \u003d {page.props.primaryView},\r\n\t\"button_selected\",\r\n\t\"button\"\r\n)"
                  },
                  "type": "expr"
                }
              }
            },
            "props": {
              "justify": "start",
              "style": {
                "borderColor": "#808080",
                "fontWeight": "normal"
              },
              "text": "\u003e page 2"
            },
            "type": "ia.input.button"
          },
          {
            "custom": {
              "view": "some_category/page_3"
            },
            "events": {
              "component": {
                "onActionPerformed": {
                  "config": {
                    "params": {},
                    "view": "{this.custom.view}"
                  },
                  "scope": "C",
                  "type": "nav"
                }
              }
            },
            "meta": {
              "name": "page3"
            },
            "position": {
              "basis": "34px",
              "shrink": 0
            },
            "propConfig": {
              "props.style.classes": {
                "binding": {
                  "config": {
                    "expression": "if ({this.custom.view} \u003d {page.props.primaryView},\r\n\t\"button_selected\",\r\n\t\"button\"\r\n)"
                  },
                  "type": "expr"
                }
              }
            },
            "props": {
              "justify": "start",
              "style": {
                "borderColor": "#808080",
                "fontWeight": "normal"
              },
              "text": "\u003e page 3"
            },
            "type": "ia.input.button"
          }
        ],
        "custom": {
          "category": "some_category"
        },
        "meta": {
          "name": "some_category"
        },
        "position": {
          "shrink": 0
        },
        "propConfig": {
          "position.basis": {
            "binding": {
              "config": {
                "expression": "if ({view.custom.current_category} \u003d {this.custom.category},\r\n\t\u0027148px\u0027,\r\n\t\u002734px\u0027\r\n)"
              },
              "type": "expr"
            }
          }
        },
        "props": {
          "direction": "column",
          "style": {
            "gap": "4px",
            "margin": "6px",
            "overflow": "hidden",
            "transition": "flex-basis 500ms"
          }
        },
        "type": "ia.container.flex"
      },
      {
        "children": [
          {
            "custom": {
              "view": "other_category/mainpage"
            },
            "events": {
              "component": {
                "onActionPerformed": {
                  "config": {
                    "params": {},
                    "view": "{this.custom.view}"
                  },
                  "scope": "C",
                  "type": "nav"
                }
              }
            },
            "meta": {
              "name": "main_button"
            },
            "position": {
              "basis": "34px",
              "shrink": 0
            },
            "propConfig": {
              "props.style.classes": {
                "binding": {
                  "config": {
                    "expression": "if ({view.custom.current_category} \u003d {parent.custom.category},\r\n\t\"button_selected\",\r\n\t\"button\"\r\n)"
                  },
                  "type": "expr"
                }
              }
            },
            "props": {
              "justify": "start",
              "style": {
                "borderColor": "#808080",
                "fontWeight": "normal"
              },
              "text": "other category"
            },
            "type": "ia.input.button"
          },
          {
            "custom": {
              "view": "other_category/page_1"
            },
            "events": {
              "component": {
                "onActionPerformed": {
                  "config": {
                    "params": {},
                    "view": "{this.custom.view}"
                  },
                  "scope": "C",
                  "type": "nav"
                }
              }
            },
            "meta": {
              "name": "page1"
            },
            "position": {
              "basis": "34px",
              "shrink": 0
            },
            "propConfig": {
              "props.style.classes": {
                "binding": {
                  "config": {
                    "expression": "if ({this.custom.view} \u003d {page.props.primaryView},\r\n\t\"button_selected\",\r\n\t\"button\"\r\n)"
                  },
                  "type": "expr"
                }
              }
            },
            "props": {
              "justify": "start",
              "style": {
                "borderColor": "#808080",
                "fontWeight": "normal"
              },
              "text": "\u003e page 1"
            },
            "type": "ia.input.button"
          },
          {
            "custom": {
              "view": "other_category/page_2"
            },
            "events": {
              "component": {
                "onActionPerformed": {
                  "config": {
                    "params": {},
                    "view": "{this.custom.view}"
                  },
                  "scope": "C",
                  "type": "nav"
                }
              }
            },
            "meta": {
              "name": "page2"
            },
            "position": {
              "basis": "34px",
              "shrink": 0
            },
            "propConfig": {
              "props.style.classes": {
                "binding": {
                  "config": {
                    "expression": "if ({this.custom.view} \u003d {page.props.primaryView},\r\n\t\"button_selected\",\r\n\t\"button\"\r\n)"
                  },
                  "type": "expr"
                }
              }
            },
            "props": {
              "justify": "start",
              "style": {
                "borderColor": "#808080",
                "fontWeight": "normal"
              },
              "text": "\u003e page 2"
            },
            "type": "ia.input.button"
          },
          {
            "custom": {
              "view": "other_category/page_3"
            },
            "events": {
              "component": {
                "onActionPerformed": {
                  "config": {
                    "params": {},
                    "view": "{this.custom.view}"
                  },
                  "scope": "C",
                  "type": "nav"
                }
              }
            },
            "meta": {
              "name": "page3"
            },
            "position": {
              "basis": "34px",
              "shrink": 0
            },
            "propConfig": {
              "props.style.classes": {
                "binding": {
                  "config": {
                    "expression": "if ({this.custom.view} \u003d {page.props.primaryView},\r\n\t\"button_selected\",\r\n\t\"button\"\r\n)"
                  },
                  "type": "expr"
                }
              }
            },
            "props": {
              "justify": "start",
              "style": {
                "borderColor": "#808080",
                "fontWeight": "normal"
              },
              "text": "\u003e page 3"
            },
            "type": "ia.input.button"
          }
        ],
        "custom": {
          "category": "other_category"
        },
        "meta": {
          "name": "other_category"
        },
        "position": {
          "shrink": 0
        },
        "propConfig": {
          "position.basis": {
            "binding": {
              "config": {
                "expression": "if ({view.custom.current_category} \u003d {this.custom.category},\r\n\t\u0027148px\u0027,\r\n\t\u002734px\u0027\r\n)"
              },
              "type": "expr"
            }
          }
        },
        "props": {
          "direction": "column",
          "style": {
            "gap": "4px",
            "margin": "6px",
            "overflow": "hidden",
            "transition": "flex-basis 500ms"
          }
        },
        "type": "ia.container.flex"
      }
    ],
    "meta": {
      "name": "root"
    },
    "props": {
      "direction": "column"
    },
    "type": "ia.container.flex"
  }
}

This assumes folders some_category and other_category, each containing the views mainpage, page_1, page_2 and page_3.
And also the styles button and button_selected, because I like highlighting the button that corresponds to the category and the page I'm in.

How it works:
The view itself has a custom property current_category that uses the page.primaryView property to determine what category the user is currently in.
Each category container has a category custom prop.
Those 2 properties are compared in a binding on the mainpage button's style classes, to selected the proper class.
The category containers have a binding on the basis property to make it grow or shrink based on whether the currently displayed page matches the category. And as a bonus there's a transition style prop to make it look nice.
Each button has a view custom property that is the path it navigates to when clicked.
That property is also used in a binding to select a style class.
And finally each button has an onActionPerformed navigation event to navigate to the view specified in the custom property.

This will need customization to match your needs but it's a start.

edit: forgot the json ;p

1 Like

image

Can use this tree component for navigation in perspective and any idea how to set view?

There are a LOT of issues with the built in menu component. My team started using the tree component for navigation a few months back and it has never given us any issues.

Any idea how to use this?

Menu_2023-06-29_1126.zip (11.3 KB)

This is what we are using. There might be some stuff in there that you don't need.

1 Like

@ToMakPo Thank you so much I will try.

Here's how I did it.
Page configuration
Figure 1. Do the Page Configuration.


Figure 2. Create the menu structure in text. I do this in the actual application. Note the use of the '|' pipe character to separate the menu and URL.

Bind the tree's items property to the text and apply a script transform on it:

The 'items' binding script transform.
def transform(self, value, quality, timestamp):
	# Update the test tree with the structure.
	#	Anchors and path URLs to be provided in form shown below using '|' (vertical bar) as separator.
	#	A001            |/Home
	#	A001/B001       |/Area/1/Bath/1
	#	A001/B001/C001  |/Area/1/Bath/1/Clamp1
	# 2023-06-29 Transistor
	
	items = []
	treeExpand = self.view.params.treeExpand
	
	for line in value.splitlines():
		if line.strip():						# Ignore empty lines.
			if line[0] != "#":				# Lines can be commented out with #.
				current = items
				branch, url = line.split('|')
	
				for part in branch.strip().split("/"):		# Strip off leading and trailing spaces.
					folderExists = False					# Check if the current folder exists in our items list.
					for itemsPointerItem in current:
						if part == itemsPointerItem['label']:
							folderExists = True
							current = itemsPointerItem['items']
			
					if not folderExists:
						item = {
							"label": part,
							"expanded": treeExpand,
							"items": [],
							"data": {"url": url.strip()}
						}
						current.append(item)
						current = item['items']
	
	return items

The selected item is available in selectionData.0.value.url

[
  {
    "itemPath": "3/0",
    "value": {
      "url": "/machine/Sealer03"
    }
  }
]

On the menu tree add an onItemClicked event, Script:

def runAction(self, event):
	pagePath = self.props.selectionData[0].value.url
	system.perspective.navigate(pagePath)

Note that I've used a script to do the navigation. If found that if I used a navigation event that the navigation happened before selectionData[0].value.url had been updated and so a second click was required each time.

Have fun!

@ToMakPo in Your example there is a property pageId within the page, im assuming this should be an integer. I am encountering the error.....
Error running kanoa/core/docks/navigation/menu (1)@D/root.onMessageReceived(self, payload): Traceback (most recent call last): File "function:onMessageReceived", line 14, in onMessageReceived TypeError: com.inductiveautomation.perspective.gateway.script.PropertyTreeScriptWrapper$ArrayWrapper indices must be integers
Do you have any idea what might be affecting this?

Show us the script. Except for "You're trying to index an array with something that's not an int", there's not much we can say without the script.

@pascal.fragnoud It is the script that @ToMakPo uploaded in his attached zip. It is in update navigation event handler(within the root container). Which passes in a string- pageId, Into the array, which is causing the exception.

self.page.props.pageId is not an int.
I suspect you created self.session.custom.pages as an array, when it should really be a dict/object.

also, take a look at this:

@pascal.fragnoud

def onMessageReceived(self, payload):
	```
	system.perspective.print("updateNavigation() msg rcvd with " + system.util.jsonEncode(payload))
	# - implement your handler here
	# - any navigation action will call this, ex: menu tree, home button, any links
	# - this function adds new history items
	# - if this function is called when currentIndex is not pointing to the end of history,
	# delete every entry after the currentIndex, then add the new history item
	# - afterwards, navigate to the newly added history item/view
	

	
	
	if payload['type']	!= 'url':	#urls will be opended in a new tab so we don't need to store history
		logger = system.util.getLogger('log1')
    		logger.info("this is page iD " + self.page.props.pageId)
		pageId = self.page.props.pageId
		if '/' in pageId: pageId = 'designer'		#For debug in designer
		pageNavigation = self.session.custom.pages[pageId].navigation
		history = history = pageNavigation.history
		currentIndex = pageNavigation.currentIndex
		
		if currentIndex < len(history) - 1:
			self.session.custom.pages[pageId].navigation.history = history[:currentIndex + 1]
			
		if history[currentIndex]['path'] != payload['path']:
			newHistoryItem = {
				"path": payload['path'],
				"name": payload['name'],
				"type": payload['type'],
				"params": system.util.jsonEncode(payload['params'])
			}
			self.session.custom.pages[pageId].navigation['history'].append(newHistoryItem)
			
			if len(self.session.custom.pages[pageId].navigation["history"]) > 30:
				self.session.custom.pages[pageId].navigation["history"] = self.session.custom.pages[pageId].navigation["history"][1:]
			else:
				self.session.custom.pages[pageId].navigation["currentIndex"] += 1
		else:
			return
	
	self.navigateTo(payload['type'], payload['path'], payload['params'])

	returnPreformatted text

I changed 'pages' from an array to a object and the error has gone(I had done this earlier today but had reverted it as again on line 14 I received this error, which I thought was a result of sending the wrong data . )

com.inductiveautomation.ignition.common.script.JythonExecException
Traceback (most recent call last):
  File "<function:onMessageReceived>", line 18, in onMessageReceived
KeyError: 'designer'

	at org.python.core.Py.KeyError(Py.java:220)
	at com.inductiveautomation.perspective.gateway.script.PropertyTreeScriptWrapper$ObjectWrapper.__finditem__(PropertyTreeScriptWrapper.java:464)
	at com.inductiveautomation.ignition.common.script.abc.AbstractJythonMap.__finditem__(AbstractJythonMap.java:54)
	at org.python.core.PyObject.__getitem__(PyObject.java:717)
	at org.python.pycode._pyx237.onMessageReceived$1(<function:onMessageReceived>:43)
	at org.python.pycode._pyx237.call_function(<function:onMessageReceived>)
	at org.python.core.PyTableCode.call(PyTableCode.java:173)
	at org.python.core.PyBaseCode.call(PyBaseCode.java:306)
	at org.python.core.PyFunction.function___call__(PyFunction.java:474)
	at org.python.core.PyFunction.__call__(PyFunction.java:469)
	at org.python.core.PyFunction.__call__(PyFunction.java:464)
	at com.inductiveautomation.ignition.common.script.ScriptManager.runFunction(ScriptManager.java:847)
	at com.inductiveautomation.ignition.common.script.ScriptManager.runFunction(ScriptManager.java:829)
	at com.inductiveautomation.ignition.gateway.project.ProjectScriptLifecycle$TrackingProjectScriptManager.runFunction(ProjectScriptLifecycle.java:868)
	at com.inductiveautomation.ignition.common.script.ScriptManager$ScriptFunctionImpl.invoke(ScriptManager.java:1010)
	at com.inductiveautomation.ignition.gateway.project.ProjectScriptLifecycle$AutoRecompilingScriptFunction.invoke(ProjectScriptLifecycle.java:950)
	at com.inductiveautomation.perspective.gateway.script.ScriptFunctionHelper.invoke(ScriptFunctionHelper.java:161)
	at com.inductiveautomation.perspective.gateway.script.ScriptFunctionHelper.invoke(ScriptFunctionHelper.java:98)
	at com.inductiveautomation.perspective.gateway.model.MessageHandlerCollection$MessageHandlerImpl$1.lambda$invoke$0(MessageHandlerCollection.java:86)
	at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
	at com.inductiveautomation.perspective.gateway.threading.BlockingWork$BlockingWorkRunnable.run(BlockingWork.java:58)
	at java.base/java.lang.Thread.run(Unknown Source)
Caused by: org.python.core.PyException
Traceback (most recent call last):
  File "<function:onMessageReceived>", line 18, in onMessageReceived
KeyError: 'designer'

	... 24 more

Ignition v8.1.39 (b2024040909)
Java: Azul Systems, Inc. 17.0.10

Do you have any idea why this would be thrown?

Well, clearly you don't have any entry under the 'designer' key in the session.custom.pages prop.

I couldn't tell you why, I have no idea what should add those entries.