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 assert !nodes.isEmpty(); 259 260 this.nodes = F.flow(nodes).reverse(); 261 } 262 263 public void render(MarkupWriter writer, final RenderQueue queue) 264 { 265 writer.element("ul"); 266 queue.push(RENDER_CLOSE_TAG); 267 268 queue.push(toRenderCommand(nodes.first(), true)); 269 270 nodes.rest().each(new Worker<TreeNode>() 271 { 272 public void work(TreeNode element) 273 { 274 queue.push(toRenderCommand(element, false)); 275 } 276 }); 277 } 278 279 } 280 281 public String getContainerClass() 282 { 283 return className == null ? "tree-container" : "tree-container " + className; 284 } 285 286 public Link getTreeActionLink() 287 { 288 return resources.createEventLink("treeAction"); 289 } 290 291 Object onTreeAction(@RequestParameter("t:nodeid") String nodeId, 292 @RequestParameter("t:action") String action) 293 { 294 if (action.equalsIgnoreCase("expand")) 295 { 296 return doExpandChildren(nodeId); 297 } 298 299 if (action.equalsIgnoreCase("markExpanded")) 300 { 301 return doMarkExpanded(nodeId); 302 } 303 304 if (action.equalsIgnoreCase("markCollapsed")) 305 { 306 return doMarkCollapsed(nodeId); 307 } 308 309 if (action.equalsIgnoreCase("select")) 310 { 311 return doUpdateSelected(nodeId, true); 312 } 313 314 if (action.equalsIgnoreCase("deselect")) 315 { 316 return doUpdateSelected(nodeId, false); 317 } 318 319 throw new IllegalArgumentException(String.format("Unexpected action: '%s' for Tree component.", action)); 320 } 321 322 Object doExpandChildren(String nodeId) 323 { 324 TreeNode container = model.getById(nodeId); 325 326 expansionModel.markExpanded(container); 327 328 return new RenderNodes(container.getChildren()); 329 } 330 331 Object doMarkExpanded(String nodeId) 332 { 333 expansionModel.markExpanded(model.getById(nodeId)); 334 335 return new JSONObject(); 336 } 337 338 339 Object doMarkCollapsed(String nodeId) 340 { 341 expansionModel.markCollapsed(model.getById(nodeId)); 342 343 return new JSONObject(); 344 } 345 346 Object doUpdateSelected(String nodeId, boolean selected) 347 { 348 TreeNode node = model.getById(nodeId); 349 350 String event; 351 352 if (selected) 353 { 354 selectionModel.select(node); 355 356 event = EventConstants.NODE_SELECTED; 357 } else 358 { 359 selectionModel.unselect(node); 360 361 event = EventConstants.NODE_UNSELECTED; 362 } 363 364 CaptureResultCallback<Object> callback = CaptureResultCallback.create(); 365 366 resources.triggerEvent(event, new Object[]{nodeId}, callback); 367 368 final Object result = callback.getResult(); 369 370 if (result != null) 371 { 372 return result; 373 } 374 375 return new JSONObject(); 376 } 377 378 public TreeExpansionModel getDefaultTreeExpansionModel() 379 { 380 if (defaultTreeExpansionModel == null) 381 { 382 defaultTreeExpansionModel = new DefaultTreeExpansionModel(); 383 } 384 385 return defaultTreeExpansionModel; 386 } 387 388 /** 389 * Returns the actual {@link TreeExpansionModel} in use for this Tree component, 390 * as per the expansionModel parameter. This is often, but not always, the same 391 * as {@link #getDefaultTreeExpansionModel()}. 392 */ 393 public TreeExpansionModel getExpansionModel() 394 { 395 return expansionModel; 396 } 397 398 /** 399 * Returns the actual {@link TreeSelectionModel} in use for this Tree component, 400 * as per the {@link #selectionModel} parameter. 401 */ 402 public TreeSelectionModel getSelectionModel() 403 { 404 return selectionModel; 405 } 406 407 public Object getRenderRootNodes() 408 { 409 return new RenderNodes(model.getRootNodes()); 410 } 411 412 /** 413 * Clears the tree's {@link TreeExpansionModel}. 414 */ 415 public void clearExpansions() 416 { 417 expansionModel.clear(); 418 } 419 420 public Boolean getSelectionEnabled() 421 { 422 return selectionModel != null ? true : null; 423 } 424}