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