001// Licensed under the Apache License, Version 2.0 (the "License"); 002// you may not use this file except in compliance with the License. 003// You may obtain a copy of the License at 004// 005// http://www.apache.org/licenses/LICENSE-2.0 006// 007// Unless required by applicable law or agreed to in writing, software 008// distributed under the License is distributed on an "AS IS" BASIS, 009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 010// See the License for the specific language governing permissions and 011// limitations under the License. 012 013package org.apache.tapestry5.corelib.components; 014 015import org.apache.tapestry5.*; 016import org.apache.tapestry5.annotations.*; 017import org.apache.tapestry5.dom.Element; 018import org.apache.tapestry5.func.F; 019import org.apache.tapestry5.func.Flow; 020import org.apache.tapestry5.func.Worker; 021import org.apache.tapestry5.http.Link; 022import org.apache.tapestry5.internal.util.CaptureResultCallback; 023import org.apache.tapestry5.ioc.annotations.Inject; 024import org.apache.tapestry5.json.JSONObject; 025import org.apache.tapestry5.runtime.RenderCommand; 026import org.apache.tapestry5.runtime.RenderQueue; 027import org.apache.tapestry5.services.Heartbeat; 028import org.apache.tapestry5.services.javascript.JavaScriptSupport; 029import org.apache.tapestry5.tree.*; 030 031import java.util.List; 032 033/** 034 * A component used to render a recursive tree structure, with expandable/collapsable/selectable nodes. The data that is displayed 035 * by the component is provided as a {@link TreeModel}. A secondary model, the {@link TreeExpansionModel}, is used 036 * to track which nodes have been expanded. The optional {@link TreeSelectionModel} is used to track node selections (as currently 037 * implemented, only leaf nodes may be selected). 038 * 039 * Tree is <em>not</em> a form control component; all changes made to the tree on the client 040 * (expansions, collapsing, and selections) are propagated immediately back to the server. 041 * 042 * The Tree component uses special tricks to support recursive rendering of the Tree as necessary. 043 * 044 * @tapestrydoc 045 * @since 5.3 046 */ 047@SuppressWarnings( 048 {"rawtypes", "unchecked", "unused"}) 049@Events({EventConstants.NODE_SELECTED, EventConstants.NODE_UNSELECTED}) 050@Import(module = "t5/core/tree") 051public class Tree 052{ 053 /** 054 * The model that drives the tree, determining top level nodes and making revealing the overall structure of the 055 * tree. 056 */ 057 @Parameter(required = true, autoconnect = true) 058 private TreeModel model; 059 060 /** 061 * Allows the container to specify additional CSS class names for the outer DIV element. The outer DIV 062 * always has the class name "tree-container"; the additional class names are typically used to apply 063 * a specific size and width to the component. 064 */ 065 @Parameter(name = "class", defaultPrefix = BindingConstants.LITERAL) 066 private String className; 067 068 /** 069 * Optional parameter used to inform the container about what TreeNode is currently rendering; this 070 * is primarily used when the label parameter is bound. 071 */ 072 @Property 073 @Parameter 074 private TreeNode node; 075 076 /** 077 * Used to control the Tree's expansion model. By default, a persistent field inside the Tree 078 * component stores a {@link DefaultTreeExpansionModel}. This parameter may be bound when more 079 * control over the implementation of the expansion model, or how it is stored, is 080 * required. 081 */ 082 @Parameter(allowNull = false, value = "defaultTreeExpansionModel") 083 private TreeExpansionModel expansionModel; 084 085 /** 086 * Used to control the Tree's selections. When this parameter is bound, then the client-side Tree 087 * will track what is selected or not selected, and communicate this (via Ajax requests) up to 088 * the server, where it will be recorded into the model. On the client-side, the Tree component will 089 * add or remove the {@code selected-leaf-node-label} CSS class from {@code span.tree-label} 090 * for the node. 091 */ 092 @Parameter 093 private TreeSelectionModel selectionModel; 094 095 /** 096 * Optional parameter used to inform the container about the value of the currently rendering TreeNode; this 097 * is often preferable to the TreeNode, and like the node parameter, is primarily used when the label parameter 098 * is bound. 099 */ 100 @Parameter 101 private Object value; 102 103 /** 104 * A renderable (usually a {@link Block}) that can render the label for a tree node. 105 * This will be invoked after the {@link #value} parameter has been updated. 106 */ 107 @Property 108 @Parameter(value = "block:defaultRenderTreeNodeLabel") 109 private RenderCommand label; 110 111 @Environmental 112 private JavaScriptSupport jss; 113 114 @Inject 115 private ComponentResources resources; 116 117 @Persist 118 private TreeExpansionModel defaultTreeExpansionModel; 119 120 private static RenderCommand RENDER_CLOSE_TAG = new RenderCommand() 121 { 122 public void render(MarkupWriter writer, RenderQueue queue) 123 { 124 writer.end(); 125 } 126 }; 127 128 private static RenderCommand RENDER_LABEL_SPAN = new RenderCommand() 129 { 130 public void render(MarkupWriter writer, RenderQueue queue) 131 { 132 writer.element("span", "class", "tree-label"); 133 } 134 }; 135 136 private static RenderCommand MARK_SELECTED = new RenderCommand() 137 { 138 public void render(MarkupWriter writer, RenderQueue queue) 139 { 140 writer.getElement().attribute("class", "selected-leaf-node"); 141 } 142 }; 143 144 @Environmental 145 private Heartbeat heartbeat; 146 147 /** 148 * Renders a single node (which may be the last within its containing node). 149 * This is a mix of immediate rendering, and queuing up various Blocks and Render commands 150 * to do the rest. May recursively render child nodes of the active node. The label part 151 * of the node is rendered inside a {@linkplain org.apache.tapestry5.services.Heartbeat heartbeat}. 152 * 153 * @param node 154 * to render 155 * @param isLast 156 * if true, add "last" attribute to the LI element 157 * @return command to render the node 158 */ 159 private RenderCommand toRenderCommand(final TreeNode node, final boolean isLast) 160 { 161 return new RenderCommand() 162 { 163 public void render(MarkupWriter writer, RenderQueue queue) 164 { 165 // Inform the component's container about what value is being rendered 166 // (this may be necessary to generate the correct label for the node). 167 Tree.this.node = node; 168 169 value = node.getValue(); 170 171 boolean isLeaf = node.isLeaf(); 172 173 writer.element("li"); 174 175 if (isLast) 176 { 177 writer.attributes("class", "last"); 178 } 179 180 if (isLeaf) 181 { 182 writer.getElement().attribute("class", "leaf-node"); 183 } 184 185 Element e = writer.element("span", "class", "tree-icon"); 186 187 if (!isLeaf && !node.getHasChildren()) 188 { 189 e.addClassName("empty-node"); 190 } 191 192 boolean hasChildren = !isLeaf && node.getHasChildren(); 193 boolean expanded = hasChildren && expansionModel.isExpanded(node); 194 195 writer.attributes("data-node-id", node.getId()); 196 197 if (expanded) 198 { 199 // Inform the client side, so it doesn't try to fetch it a second time. 200 e.addClassName("tree-expanded"); 201 } 202 203 writer.end(); // span.tree-icon 204 205 // From here on in, we're pushing things onto the queue. Remember that 206 // execution order is reversed from order commands are pushed. 207 208 queue.push(RENDER_CLOSE_TAG); // li 209 210 if (expanded) 211 { 212 queue.push(new RenderNodes(node.getChildren())); 213 } 214 215 queue.push(RENDER_CLOSE_TAG); 216 final RenderCommand startHeartbeat = new RenderCommand() { 217 218 @Override 219 public void render(MarkupWriter writer, RenderQueue queue) { 220 heartbeat.begin(); 221 } 222 }; 223 224 final RenderCommand endHeartbeat = new RenderCommand() { 225 226 @Override 227 public void render(MarkupWriter writer, RenderQueue queue) { 228 heartbeat.end(); 229 } 230 }; 231 232 queue.push(endHeartbeat); 233 234 queue.push(label); 235 236 queue.push(startHeartbeat); 237 238 if (isLeaf && selectionModel != null && selectionModel.isSelected(node)) 239 { 240 queue.push(MARK_SELECTED); 241 } 242 243 queue.push(RENDER_LABEL_SPAN); 244 245 } 246 }; 247 } 248 249 /** 250 * Renders an <ul> element and renders each node recursively inside the element. 251 */ 252 private class RenderNodes implements RenderCommand 253 { 254 private final Flow<TreeNode> nodes; 255 256 public RenderNodes(List<TreeNode> nodes) 257 { 258 this.nodes = F.flow(nodes).reverse(); 259 } 260 261 public void render(MarkupWriter writer, final RenderQueue queue) 262 { 263 writer.element("ul"); 264 queue.push(RENDER_CLOSE_TAG); 265 266 // TAP5-2745: Support empty tree model 267 if (nodes.isEmpty()) { 268 return; 269 } 270 271 queue.push(toRenderCommand(nodes.first(), true)); 272 273 nodes.rest().each(new Worker<TreeNode>() 274 { 275 public void work(TreeNode element) 276 { 277 queue.push(toRenderCommand(element, false)); 278 } 279 }); 280 } 281 282 } 283 284 public String getContainerClass() 285 { 286 return className == null ? "tree-container" : "tree-container " + className; 287 } 288 289 public Link getTreeActionLink() 290 { 291 return resources.createEventLink("treeAction"); 292 } 293 294 Object onTreeAction(@RequestParameter("t:nodeid") String nodeId, 295 @RequestParameter("t:action") String action) 296 { 297 if (action.equalsIgnoreCase("expand")) 298 { 299 return doExpandChildren(nodeId); 300 } 301 302 if (action.equalsIgnoreCase("markExpanded")) 303 { 304 return doMarkExpanded(nodeId); 305 } 306 307 if (action.equalsIgnoreCase("markCollapsed")) 308 { 309 return doMarkCollapsed(nodeId); 310 } 311 312 if (action.equalsIgnoreCase("select")) 313 { 314 return doUpdateSelected(nodeId, true); 315 } 316 317 if (action.equalsIgnoreCase("deselect")) 318 { 319 return doUpdateSelected(nodeId, false); 320 } 321 322 throw new IllegalArgumentException(String.format("Unexpected action: '%s' for Tree component.", action)); 323 } 324 325 Object doExpandChildren(String nodeId) 326 { 327 TreeNode container = model.getById(nodeId); 328 329 expansionModel.markExpanded(container); 330 331 return new RenderNodes(container.getChildren()); 332 } 333 334 Object doMarkExpanded(String nodeId) 335 { 336 expansionModel.markExpanded(model.getById(nodeId)); 337 338 return new JSONObject(); 339 } 340 341 342 Object doMarkCollapsed(String nodeId) 343 { 344 expansionModel.markCollapsed(model.getById(nodeId)); 345 346 return new JSONObject(); 347 } 348 349 Object doUpdateSelected(String nodeId, boolean selected) 350 { 351 TreeNode node = model.getById(nodeId); 352 353 String event; 354 355 if (selected) 356 { 357 selectionModel.select(node); 358 359 event = EventConstants.NODE_SELECTED; 360 } else 361 { 362 selectionModel.unselect(node); 363 364 event = EventConstants.NODE_UNSELECTED; 365 } 366 367 CaptureResultCallback<Object> callback = CaptureResultCallback.create(); 368 369 resources.triggerEvent(event, new Object[]{nodeId}, callback); 370 371 final Object result = callback.getResult(); 372 373 if (result != null) 374 { 375 return result; 376 } 377 378 return new JSONObject(); 379 } 380 381 public TreeExpansionModel getDefaultTreeExpansionModel() 382 { 383 if (defaultTreeExpansionModel == null) 384 { 385 defaultTreeExpansionModel = new DefaultTreeExpansionModel(); 386 } 387 388 return defaultTreeExpansionModel; 389 } 390 391 /** 392 * Returns the actual {@link TreeExpansionModel} in use for this Tree component, 393 * as per the expansionModel parameter. This is often, but not always, the same 394 * as {@link #getDefaultTreeExpansionModel()}. 395 */ 396 public TreeExpansionModel getExpansionModel() 397 { 398 return expansionModel; 399 } 400 401 /** 402 * Returns the actual {@link TreeSelectionModel} in use for this Tree component, 403 * as per the {@link #selectionModel} parameter. 404 */ 405 public TreeSelectionModel getSelectionModel() 406 { 407 return selectionModel; 408 } 409 410 public Object getRenderRootNodes() 411 { 412 return new RenderNodes(model.getRootNodes()); 413 } 414 415 /** 416 * Clears the tree's {@link TreeExpansionModel}. 417 */ 418 public void clearExpansions() 419 { 420 expansionModel.clear(); 421 } 422 423 public Boolean getSelectionEnabled() 424 { 425 return selectionModel != null ? true : null; 426 } 427}