Creating a Tree View
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:
Static Wiki Syntax Tree
The most easiest way to create a tree is by using the Tree Macro with wiki syntax.
* [[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:
{{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.
The JSON source looks like this:
$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
#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:
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)
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.
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.
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:
- We started handling the ?action=* requests, although for the moment we just return "The specified action is not supported."
- We're handling the ?data=contextMenu request. As you can see the context menu is described using JSON.
- 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.
... | ... | @@ -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.