Building a Collapsible Tree

In this post, I want to provide a solution to a frequently requested UI component. It displays a list of elements, where each element can be expanded to show nested child elements.

Creating the Pieces

This component is a bit more complex as it requires a recursive approach and three collaborating interfaces. To manage internal state, a data structure is passed to the rule input control to all recursive calls. It stores the collapsing state for each item.

EXP_TreeViewItem

This interface is responsible to display an individual item. In my example case, this is a checkbox. Modify this interface to fit your individual requirements. Keep in mind that this cannot be any layout component, as it has to fit into a side-by-side item.

  • data: Any Type
  • value: Any Type
  • saveInto: List of Save
if(
  a!isNullOrEmpty(value: ri!data.children),
  a!checkboxField(
    label: "Checkboxes",
    labelPosition: "COLLAPSED",
    choiceLabels: {
      ri!data.label,
    },
    choiceValues: {
      ri!data.value,
    },
    value: if(
      contains(cast(runtimetypeof({ri!data.value}), ri!value), ri!data.value),
      ri!data.value,
      null
    ),
    saveInto: a!save(
      target: ri!saveInto,
      value: if(
        isnull(save!value),
        difference(cast(runtimetypeof({ri!data.value}), ri!value), ri!data.value),
        reject(fn!isnull(_), union(cast(runtimetypeof({ri!data.value}), ri!value), ri!data.value))
      )
    )
  ),
  a!richTextDisplayField(
    labelPosition: "COLLAPSED",
    value: ri!data.label
  )
)

EXP_TreeViewHelper

The second interface uses a side-by-side layout to display a row for each item. It consists of the indentation, the + or for collapsible items, and the item itself, displayed by the interface above.

In case an item contains nested items, the interface calls itself recursively to display these child items. I increment the passed level to increase the indentation.

  • itemDisplayExpression: Any Type
  • data: Any Type
  • control: Any Type
  • level: Integer
  • iconSize: Text
a!forEach(
  items: ri!data,
  expression: {
    a!sideBySideLayout(
      alignVertical: "MIDDLE",
      items: {
        /* Indentation for each level */
        a!forEach(
          items: if(isnull(ri!level), 0, enumerate(ri!level - 1)),
          expression: a!sideBySideItem(
            width: "MINIMIZE",
            item: a!richTextDisplayField(
              labelPosition: "COLLAPSED",
              value: "    "
            )
          )
        ),
        a!sideBySideItem(
          showWhen: not(a!isNullOrEmpty(fv!item.children)),
          width: "MINIMIZE",
          item: a!richTextDisplayField(
            labelPosition: "COLLAPSED",
            value: {
              if(
                contains(cast(runtimetypeof({fv!item.value}), ri!control), fv!item.value),
                /* Minus icon to collapse */
                a!richTextIcon(
                  icon: "minus",
                  size: ri!iconSize,
                  linkStyle: "STANDALONE",
                  link: a!dynamicLink(
                    saveInto: {
                      a!save(
                        target: ri!control,
                        value: remove(ri!control, lookup(ri!control, fv!item.value))
                      )
                    }
                  )
                ),
                
                /* Plus icon to expand */
                a!richTextIcon(
                  icon: "plus",
                  size: ri!iconSize,
                  linkStyle: "STANDALONE",
                  link: a!dynamicLink(
                    saveInto: {
                      a!save(
                        target: ri!control,
                        value: union(cast(runtimetypeof({fv!item.value}), ri!control), fv!item.value)
                      )
                    }
                  )
                )
              )
            }
          )
        ),
        /* Call item function reference to render element */
        a!sideBySideItem(
          item: ri!itemDisplayExpression(
            data: fv!item
          )
        )
      }
    ),
    /* Recursive call if expanded */
    if(
      contains(cast(runtimetypeof({fv!item.value}), ri!control), fv!item.value),
      rule!EXP_TreeViewHelper(
        itemDisplayExpression: ri!itemDisplayExpression,
        data: fv!item.children,
        control: ri!control,
        level: ri!level + 1,
        iconSize: ri!iconSize
      ),
      {}
    )
  }
)

EXP_TreeView

The main interface of the component. It holds the internal control variable, displays the label and initiates the recursive rendering of the tree.

  • data: Any Type
  • itemDisplayExpression: Any Type
  • label: Text
  • showWhen: Boolean
  • IconSize: Text
if(
  ri!showWhen <> false,
  a!localVariables(
    local!control,
    {
      a!richTextDisplayField(
        labelPosition: "COLLAPSED",
        value: a!richTextItem(
          text: ri!label,
          style: "STRONG"
        )
      ),
      rule!EXP_TreeViewHelper(
        itemDisplayExpression: ri!itemDisplayExpression,
        data: ri!data,
        control: local!control,
        level: 1,
        iconSize: ri!iconSize
      )
    }
  ),
  {}
)

Using the Tree

First, I create a data structure to hold all the nested elements. In case you need to change the field names, make sure to adapt the interface components accordingly.

a!localVariables(
  local!data: {
    {label: "Asia", value: "ASIA", children: {
      {label: "China", value: "CHINA"}, 
      {label: "Japan", value: "JAPAN"}, 
    }},
    {label: "North America", value: "NORTH AMERICA", children: {
      {label: "USA", value: "USA", children: {
        {label: "Alabama", value: "ALABAMA"}, 
        {label: "New York", value: "NEW YORK"}, 
        {label: "California", value: "CALIFORNIA"}, 
      }},
      {label: "Canada", value: "CANADA"}, 
      {label: "Mexico", value: "MEXICO"}, 
    }},
    {label: "South America", value: "SOUTH AMERICA", children: {
      {label: "Three.One", value: 3.1}, 
      {label: "Three.Two", value: 3.2}, 
    }},
    {label: "Europe", value: "EUROPE", children: {
      {label: "Three.One", value: 3.1}, 
      {label: "Three.Two", value: 3.2}, 
    }},
  },
  local!selected,
  {
    rule!EXP_TreeView(
      data: local!data,
      itemDisplayExpression: rule!EXP_TreeViewItem(
        /* The underscore is replaced by the actual item at runtime */
        data:_,
        value: local!selected,
        saveInto: local!selected
      ),
      label: "Select Countries"
    ),
  }
)

When passing the interface to render the individual item, make sure to use the underscore for the data attribute. Using the underscore will turn the interface call into a reference being partially evaluated. The final evaluation will then take place when an item is rendered. Check the Appian documentation for details.

The second interesting detail here is, that this partially evaluated expression references the local variable, local!selected. Thanks to the magic of the Appian expression language, your tree view item’s saveInto can store the values of the individual checkboxes from inside the nested tree to that local variable. Read my post Data in Interfaces for more details.

The third detail is about type casting. There are several spots in the code looking like this:

contains(cast(runtimetypeof({fv!item.value}), ri!control), fv!item.value),

So, ri!control holds the values of all expanded tree elements. The problem to solve, is that the value can be of any data type. Contains requires the same data type for both parameters. My code snippet casts ri!control to a list of the data type of the current item’s value.

Summary

In this post, I showed you how to use the advanced features recursion, partial evaluation and a few other tricks to implement a reusable and customizable interface component. Try to understand how it works and use the implementation details for your own implementations.

Leave a Reply