Creating a Tree View

Last modified by Ecaterina Moraru (Valica) on 2017/09/06

Use case

Display the employee hierarchy within a company using a tree widget. Each employee has an associated XWiki user. In order to describe the hierarchy we have added two new properties to the XWiki.XWikiUsers xclass:

  • manager:String (identifies the manager of an user)
  • jobtitle:String (the position the user has inside the company)

For our use case the XWiki users are synchronized with LDAP so the user profile document has an object of type XWiki.LDAPProfileClass. The relation between an employee and its manager is defined by:

XWikiUsers.manager(Alice) == LDAPProfileClass.dn(Bob) => Bob is manager of Alice

Static Wiki Syntax Tree

The most easiest way to create a tree is by using the Tree Macro with wiki syntax.

{{tree}}
* [[Ludovic Dubost (CEO)>>XWiki.ldubost]]
** [[Vincent Massol (CTO)>>XWiki.vmassol]]
*** [[image:XWiki.mflorea@mflorea.jpg||width="24px"]] [[Marius Florea (R&D Engineer)>>XWiki.mflorea]]
** [[Silvia Rusu (Support & QA Manager)>>XWiki.srusu]]
*** [[Oana Tăbăranu (Support & Documentation Team Leader)>>XWiki.otabaranu]]
** [[Guillaume Lerouge (Sales & Marketing Director)>>XWiki.glerouge]]
{{/tree}}

The syntax is concise and the tree will degrade nicely when JavaScript is disabled, but unfortunately the links and the custom icons don't work. In the future, the tree could, as an improvement, modify the HTML produced by the wiki syntax to match the jsTree (the tree widget used under the hood) expectations, but it's not the case right now.

Static HTML Tree

We can fix the links and the node icons by using HTML:

{{tree}}
{{velocity}}
{{html}}
<ul>
  <li data-jstree='{"icon": "$xwiki.getDocument('XWiki.ldubost').getAttachmentURL('ludovic-dubost-2015v13-profile.jpg', 'download', 'width=24')"}'>
    <a href="$xwiki.getURL('XWiki.ldubost')">Ludovic Dubost (CEO)</a>
    <ul>
      <li data-jstree='{"icon": "$xwiki.getDocument('XWiki.vmassol').getAttachmentURL('vincent.png', 'download', 'width=24')"}'>
        <a href="$xwiki.getURL('XWiki.vmassol')">Vincent Massol (CTO)</a>
        <ul>
          <li data-jstree='{"icon": "$xwiki.getDocument('XWiki.mflorea').getAttachmentURL('mflorea.jpg', 'download', 'width=24')"}'>
            <a href="$xwiki.getURL('XWiki.mflorea')">Marius Florea (R&D Engineer)</a>
          </li>
        </ul>
      </li>
      <li>
        <a href="$xwiki.getURL('XWiki.srusu')">Silvia Rusu (Support & QA Manager)</a>
        <ul>
          <li>
            <a href="$xwiki.getURL('XWiki.otabaranu')">Oana Tăbăranu (Support & Documentation Team Leader)</a>
          </li>
        </ul>
      </li>
      <li>
        <a href="$xwiki.getURL('XWiki.glerouge')">Guillaume Lerouge (Sales & Marketing Director)</a>
      </li>
    </ul>
  </li>
</ul>
{{/html}}
{{/velocity}}
{{/tree}}

As you can see the syntax is more verbose and we also need to use a bit of:

  • Velocity, in order to compute the link/icon URLs
  • JSON, in order to specify the custom node icon

The tree still degrades nicely when JavaScript is disabled but the syntax mix is not appealing. See the HTML source documentation for more details.

Static JSON Tree

If you want to describe the tree structure in a more semantic way then you better use a JSON source.

{{tree reference="StaticJSONTreeSource" openTo="mflorea" /}}

The JSON source looks like this:

{{velocity wiki="false"}}
$response.setContentType('application/json')
$jsontool.serialize({
  'id': 'ldubost',
  'text': 'Ludovic Dubost (CEO)',
  'icon': $xwiki.getDocument('XWiki.ldubost').getAttachmentURL('ludovic-dubost-2015v13-profile.jpg', 'download', 'width=24'),
  'a_attr': {
    'href': $xwiki.getURL('XWiki.ldubost')
  },
  'children': [
    {
      'id': 'vmassol',
      'text': 'Vincent Massol (CTO)',
      'icon': $xwiki.getDocument('XWiki.vmassol').getAttachmentURL('vincent.png', 'download', 'width=24'),
      'a_attr': {
        'href': $xwiki.getURL('XWiki.vmassol')
      },
      'children': [
        {
          'id': 'mflorea',
          'text': 'Marius Florea (R&D Engineer)',
          'icon': $xwiki.getDocument('XWiki.mflorea').getAttachmentURL('mflorea.jpg', 'download', 'width=24'),
          'a_attr': {
            'href': $xwiki.getURL('XWiki.mflorea')
          }
        }
      ]
    },
    {
      'id': 'srusu',
      'text': 'Silvia Rusu (Support & QA Manager)',
      'a_attr': {
        'href': $xwiki.getURL('XWiki.srusu')
      },
      'children': [
        {
          'id': 'otabaranu',
          'text': 'Oana Tăbăranu (Support & Documentation Team Leader)',
          'a_attr': {
            'href': $xwiki.getURL('XWiki.otabaranu')
          }
        }
      ]
    },
    {
      'id': 'glerouge',
      'text': 'Guillaume Lerouge (Sales & Marketing Director)',
      'a_attr': {
        'href': $xwiki.getURL('XWiki.glerouge')
      }
    }
  ]
})
{{/velocity}}

Check the JSON data documentation for more details. We still need a bit of Velocity to compute the URLs and to set the content type to application/json. The source is defined in a different document this time. In the future we may add the ability to put JSON directly in the content of the Tree Macro (with a content type parameter) but for now you need a separate document.

One benefit of using the JSON source is that you can use parameters such as openTo (because we specify node ids in the JSON).

Note that the tree is no longer degrading nicely when JavaScript is disabled because it needs to make an AJAX request to retrieve the JSON. Moreover, the tree won't scale if it's big. The solution is to implement lazy loading.

Dynamic Team Hierarchy Tree v1 (lazy loading)

The key to implement lazy loading is the "children" property: instead of specifying the child nodes explicitly (in place) we can set it to:

  • false: meaning the node doesn't have child nodes
  • true: meaning the node has child nodes but the tree needs to make a separate request to get those child nodes
{{tree reference="TeamHierarchyTreeSourceV1" /}}
{{velocity output="false"}}
#macro (handleTeamHierarchyTreeRequest)
  #if ($request.data == 'children')
    #getChildren($request.id $data)
    $response.setContentType('application/json')
    $jsontool.serialize($data)
  #end
#end

#macro (getChildren $nodeId $return)
  #if ($nodeId == '#')
    ## Get the root nodes.
    #set ($userReference = $NULL)
  #else
    ## Get the child nodes of the specified parent node.
    #set ($userReference = $services.model.createDocumentReference('', 'XWiki', $nodeId))
  #end
  #getChildrenQuery($userReference $childrenQuery)
  #set ($children = [])
  #foreach ($userId in $childrenQuery.execute())
    #set ($userReference = $services.model.resolveDocument($userId))
    #addUserNode($userReference $children)
  #end
  #set ($return = $NULL)
  #setVariable("$return" $children)
#end

#macro (getChildrenQuery $userReference $return)
  #set ($dn = '')
  #if ($userReference)
    #set ($userDocument = $xwiki.getDocument($userReference))
    #set ($dn = $userDocument.getValue('dn'))
  #end
  #set ($query = $services.query.xwql("where doc.object(XWiki.XWikiUsers).manager = :manager"))
  #set ($query = $query.bindValue('manager', $dn))
  #set ($return = $NULL)
  #setVariable("$return" $query)
#end

#macro (addUserNode $userReference $siblings)
  #set ($userDocument = $xwiki.getDocument($userReference))
  #set ($jobTitle = $userDocument.getValue('jobtitle'))
  #set ($userName = $xwiki.getPlainUserName($userReference))
  #getUserAvatarURL($userReference $avatarURL 24)
  #getChildrenQuery($userReference $countQuery)
  #set ($hasChildren = $countQuery.count() > 0)
  #set ($discard = $siblings.add({
    'id': $userReference.name,
    'text': "$userName ($jobTitle)",
    'icon': $avatarURL.url,
    'children': $hasChildren,
    'a_attr': {
      'href': $xwiki.getURL($userReference)
    }
  }))
#end
{{/velocity}}

{{velocity wiki="false"}}
#if ($xcontext.action == 'get')
  #handleTeamHierarchyTreeRequest
#end
{{/velocity}}

As you can see the tree sends an AJAX request with ?data=children&id=<parentNodeId>:

  • when the tree is loading (id=#, because by convention # is the identifier of the tree root; in other words # is the parent of the top level nodes; the # node is not visible)
  • when a tree node is expanded for the fist time (id=expandedNodeId)

All we have to do is to write some Velocity code to get the child nodes of the specified parent node.

Let's see if the openTo parameter still works:

{{tree reference="TeamHierarchyTreeSourceV1" openTo="mflorea" /}}

It doesn't.. which is normal because now the tree is not fully loaded until you expand all the nodes. If we want to open the tree to a node that hasn't been added to the tree yet then we need to specify the node path somehow so that the tree can expand all the ancestors of that node.

Dynamic Team Hierarchy Tree v2 (open to)

{{tree reference="TeamHierarchyTreeSourceV2" openTo="mflorea" /}}

When the tree doesn't find a specified node it sends a new AJAX request to retrieve the path of that node so that it can load the ancestors nodes before the node itself.

... ... @@ -1,9 +1,16 @@
1 1  {{velocity output="false"}}
2 2  #macro (handleTeamHierarchyTreeRequest)
3 + #set ($data = $NULL)
3 3   #if ($request.data == 'children')
4 4   #getChildren($request.id $data)
6 + #elseif ($request.data == 'path')
7 + #getPath($request.id $data)
8 + #end
9 + #if ($data)
5 5   $response.setContentType('application/json')
6 6   $jsontool.serialize($data)
12 + #else
13 + $response.sendError(404);
7 7   #end
8 8  #end
9 9  
... ... @@ -54,6 +54,31 @@
54 54   }
55 55   }))
56 56  #end
64 +
65 +#macro (getPath $nodeId $return)
66 + #set ($path = [])
67 + #if ($nodeId != '#')
68 + #set ($userReference = $services.model.createDocumentReference('', 'XWiki', $nodeId))
69 + #getUserPath($userReference $path)
70 + #set ($discard = $collectionstool.reverse($path))
71 + #end
72 + #set ($return = $NULL)
73 + #setVariable("$return" $path)
74 +#end
75 +
76 +#macro (getUserPath $userReference $path)
77 + #addUserNode($userReference $path)
78 + #set ($userDocument = $xwiki.getDocument($userReference))
79 + #set ($manager = $userDocument.getValue('manager'))
80 + #if ("$!manager" != '')
81 + #set ($query = $services.query.xwql('where doc.object(XWiki.LDAPProfileClass).dn = :dn'))
82 + #set ($results = $query.bindValue('dn', $manager).setLimit(1).execute())
83 + #if ($results.size() > 0)
84 + #set ($managerReference = $services.model.resolveDocument($results.get(0)))
85 + #getUserPath($managerReference $path)
86 + #end
87 + #end
88 +#end
57 57  {{/velocity}}
58 58  
59 59  {{velocity wiki="false"}}

Dynamic Team Hierarchy Tree v3 (finder)

Next step is to implement a finder for our tree. The idea is to display a text input above the tree that provides suggestions as you type. When a suggestion is selected the tree is opened up to the associated node.

{{tree reference="TeamHierarchyTreeSourceV2" finder="true" /}}

For this we need to handle the ?data=suggestions request sent by the tree when the user types in the finder text input.

... ... @@ -1,3 +1,5 @@
1 +{{include reference="platform:XWiki.SuggestSolrMacros"/}}
2 +
1 1  {{velocity output="false"}}
2 2  #macro (handleTeamHierarchyTreeRequest)
3 3   #set ($data = $NULL)
... ... @@ -5,6 +5,8 @@
5 5   #getChildren($request.id $data)
6 6   #elseif ($request.data == 'path')
7 7   #getPath($request.id $data)
10 + #elseif ($request.data == 'suggestions')
11 + #getSuggestions($data)
8 8   #end
9 9   #if ($data)
10 10   $response.setContentType('application/json')
... ... @@ -86,6 +86,38 @@
86 86   #end
87 87   #end
88 88  #end
93 +
94 +#macro (getSuggestions $return)
95 + #set ($text = "$!request.query")
96 + #searchUsersSolr($text 6 $userReferences)
97 + #set ($suggestions = [])
98 + #foreach ($userReference in $userReferences)
99 + #addUserNode($userReference $suggestions)
100 + #end
101 + #set ($return = $NULL)
102 + #setVariable("$return" $suggestions)
103 +#end
104 +
105 +#macro (searchUsersSolr $text $limit $return)
106 + #set ($params = $stringtool.join([
107 + 'fq=type:DOCUMENT',
108 + "fq=wiki:$xcontext.database",
109 + 'fq=class:XWiki.XWikiUsers',
110 + 'qf=property.XWiki.XWikiUsers.first_name^6 property.XWiki.XWikiUsers.last_name^6 name^3 objcontent^0.5',
111 + 'fl=name',
112 + 'facet=false',
113 + 'hl=false'
114 + ], $util.newline))
115 + #createSearchSuggestQuery($params $text $query)
116 + #set ($discard = $query.setLimit($limit))
117 + #set ($userReferences = [])
118 + #foreach ($result in $query.execute()[0].results)
119 + #set ($userReference = $services.model.createDocumentReference('', 'XWiki', $result.name))
120 + #set ($discard = $userReferences.add($userReference))
121 + #end
122 + #set ($return = $NULL)
123 + #setVariable("$return" $userReferences)
124 +#end
89 89  {{/velocity}}
90 90  
91 91  {{velocity wiki="false"}}

As you can see we're using Solr to retrieve the suggestions.

Dynamic Team Hierarchy Tree v4 (context menu)

The tree looks good but we cannot perform any action on the tree nodes. Let's start by adding a context menu that will expose some of the actions.

{{tree reference="TeamHierarchyTreeSourceV3" finder="true" contextMenu="true" /}}

This time we need to handle the ?data=contextMenu request.

... ... @@ -2,19 +2,31 @@
2 2  
3 3  {{velocity output="false"}}
4 4  #macro (handleTeamHierarchyTreeRequest)
5 - #set ($data = $NULL)
6 - #if ($request.data == 'children')
7 - #getChildren($request.id $data)
8 - #elseif ($request.data == 'path')
9 - #getPath($request.id $data)
10 - #elseif ($request.data == 'suggestions')
11 - #getSuggestions($data)
12 - #end
13 - #if ($data)
14 - $response.setContentType('application/json')
15 - $jsontool.serialize($data)
5 + #if ($request.action)
6 + #if ($services.csrf.isTokenValid($request.form_token))
7 + $response.sendError(400, 'The specified action is not supported.')
8 + #elseif ($isAjaxRequest)
9 + $response.sendError(403, 'The CSRF token is missing.')
10 + #else
11 + $response.sendRedirect($services.csrf.getResubmissionURL())
12 + #end
16 16   #else
17 - $response.sendError(404);
14 + #set ($data = $NULL)
15 + #if ($request.data == 'children')
16 + #getChildren($request.id $data)
17 + #elseif ($request.data == 'path')
18 + #getPath($request.id $data)
19 + #elseif ($request.data == 'suggestions')
20 + #getSuggestions($data)
21 + #elseif ($request.data == 'contextMenu')
22 + #getContextMenu($data)
23 + #end
24 + #if ($data)
25 + $response.setContentType('application/json')
26 + $jsontool.serialize($data)
27 + #else
28 + $response.sendError(404);
29 + #end
18 18   #end
19 19  #end
20 20  
... ... @@ -60,6 +60,13 @@
60 60   'text': "$userName ($jobTitle)",
61 61   'icon': $avatarURL.url,
62 62   'children': $hasChildren,
75 + 'data': {
76 + 'id': "$userReference",
77 + 'type': 'user',
78 + 'hasContextMenu': true,
79 + 'canRename': true,
80 + 'canDelete': true
81 + },
63 63   'a_attr': {
64 64   'href': $xwiki.getURL($userReference)
65 65   }
... ... @@ -122,6 +122,33 @@
122 122   #set ($return = $NULL)
123 123   #setVariable("$return" $userReferences)
124 124  #end
144 +
145 +#macro (getContextMenu $return)
146 + #set ($contextMenu = {
147 + 'openLink': {
148 + 'label': 'View User Profile',
149 + 'icon': 'fa fa-external-link'
150 + },
151 + 'refresh': {
152 + 'label': 'Refresh',
153 + 'icon': 'fa fa-refresh'
154 + },
155 + 'rename': {
156 + 'separator_before': true,
157 + 'label': 'Rename...',
158 + 'icon': 'fa fa-pencil-square-o'
159 + },
160 + 'remove': {
161 + 'label': 'Delete',
162 + 'icon': 'fa fa-trash-o',
163 + 'parameters': {
164 + 'confirmationMessage': 'Are you sure you want to delete this user?'
165 + }
166 + }
167 + })
168 + #set ($return = $NULL)
169 + #setVariable("$return" {'user': $contextMenu})
170 +#end
125 125  {{/velocity}}
126 126  
127 127  {{velocity wiki="false"}}

We made 3 important changes:

  1. We started handling the ?action=* requests, although for the moment we just return "The specified action is not supported."
  2. We're handling the ?data=contextMenu request. As you can see the context menu is described using JSON.
  3. We added more data to the node JSON. This is needed because the tree supports actions only on the nodes that have an associated entity. Note that the additional node data can be used to restrict some actions based on the access rights of the current user.

Dynamic Team Hierarchy Tree v5 (drag & drop)

The final step is to implement the actions. We'll show how to implement move using drag & drop.

{{tree reference="TeamHierarchyTreeSourceV3" finder="true" contextMenu="true" dragAndDrop="true" /}}
... ... @@ -4,7 +4,11 @@
4 4  #macro (handleTeamHierarchyTreeRequest)
5 5   #if ($request.action)
6 6   #if ($services.csrf.isTokenValid($request.form_token))
7 - $response.sendError(400, 'The specified action is not supported.')
7 + #if ($request.action == 'move')
8 + #moveUser($request.id $request.parent)
9 + #else
10 + $response.sendError(400, 'The specified action is not supported.')
11 + #end
8 8   #elseif ($isAjaxRequest)
9 9   $response.sendError(403, 'The CSRF token is missing.')
10 10   #else
... ... @@ -75,9 +75,13 @@
75 75   'data': {
76 76   'id': "$userReference",
77 77   'type': 'user',
82 + 'validChildren': ['user'],
83 + 'draggable': true,
78 78   'hasContextMenu': true,
79 79   'canRename': true,
80 - 'canDelete': true
86 + 'canDelete': true,
87 + 'canMove': true,
88 + 'canCopy': true
81 81   },
82 82   'a_attr': {
83 83   'href': $xwiki.getURL($userReference)
... ... @@ -168,6 +168,15 @@
168 168   #set ($return = $NULL)
169 169   #setVariable("$return" {'user': $contextMenu})
170 170  #end
179 +
180 +#macro (moveUser $userId $parentId)
181 + #set ($userReference = $services.model.createDocumentReference('', 'XWiki', $userId))
182 + #set ($userDocument = $xwiki.getDocument($userReference))
183 + #set ($parentReference = $services.model.createDocumentReference('', 'XWiki', $parentId))
184 + #set ($parentDocument = $xwiki.getDocument($parentReference))
185 + #set ($discard = $userDocument.set('manager', $parentDocument.getValue('dn')))
186 + #set ($discard = $userDocument.save('Changed manager.'))
187 +#end
171 171  {{/velocity}}
172 172  
173 173  {{velocity wiki="false"}}

As you can see we need to handle the ?action=move request. Note that we had to add more meta data to the node JSON. When performing drag & drop the tree needs to know whether the new parent is allowed to have the new child node. We use the type and validChildren properties for this. You can also restrict the move action based on the access rights of the current use, by using the canMove property.

Get Connected