Java tutorial
/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package org.apache.click.extras.tree; import java.io.Serializable; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; import java.util.Set; import java.util.StringTokenizer; import org.apache.click.ActionListener; import org.apache.click.Context; import org.apache.click.Control; import org.apache.click.ActionEventDispatcher; import org.apache.click.control.AbstractControl; import org.apache.click.control.ActionLink; import org.apache.click.control.Decorator; import org.apache.click.element.CssImport; import org.apache.click.element.Element; import org.apache.click.element.JsImport; import org.apache.click.extras.control.SubmitLink; import org.apache.click.util.ClickUtils; import org.apache.click.util.HtmlStringBuffer; import org.apache.commons.lang.ArrayUtils; import org.apache.commons.lang.StringUtils; /** * Provides a tree control for displaying hierarchical data. The tree operates * on a hierarchy of {@link TreeNode}'s. Each TreeNode must provide a * uniquely identified node in the hierarchy. * <p/> * Below is a screenshot of the tree in action. * * <table cellspacing='10'> * <tr> * <td> * <img align='middle' hspace='2' src='tree.png' title='Tree'/> * </td> * </tr> * </table> * * <h3>Tree Example</h3> * * An example tree usage is provided below (this code was used to produce the screenshot): * * <pre class="prettyprint"> * public class PlainTreePage extends BorderPage { * * public PlainTreePage() { * Tree tree = buildTree(); * addControl(tree); * } * * // This method creates a representation of a Windows OS directory. * public Tree buildTree() { * Tree tree = new Tree("tree"); * * // Create a node representing the root directory with the specified * // parameter as the value. Because an id is not specified, a random * // one will be generated by the node. By default the root node is * // not rendered by the tree. This can be changed by calling * // tree.setRootNodeDisplayed(true). * TreeNode root = new TreeNode("c:"); * * // Create a new directory, setting the root directory as its parent. Here * // we do specify a id as the 2nd argument, so no id is generated. * TreeNode dev = new TreeNode("dev","1", root); * * // The following two nodes represent files in the directory. * // The false argument to the constructor below means that these nodes * // does not support child nodes. Makes sense since files cannot contain * // directories or other files * new TreeNode("java.pdf", "2", dev, false); * new TreeNode("ruby.pdf", "3", dev, false); * * TreeNode programFiles = new TreeNode("program files", "4", root); * TreeNode adobe = new TreeNode("Adobe", "5", programFiles); * * TreeNode download = new TreeNode("downloads","6", root); * TreeNode web = new TreeNode("web", "7", download); * new TreeNode("html.pdf", "8", web); * new TreeNode("css.html", "9", web); * * TreeNode databases = new TreeNode("databases", "10", download); * new TreeNode("mysql.html","11",databases); * new TreeNode("oracle.pdf","12",databases); * new TreeNode("postgres","13",databases); * * tree.setRootNode(root); * return tree; * } * } </pre> * * <a name="resources"></a> * <h3>CSS and JavaScript resources</h3> * * The Tree control makes use of the following resources * (which Click automatically deploys to the application directory, <tt>/click/tree</tt>): * * <ul> * <li><tt>click/tree/tree.css</tt></li> * <li><tt>click/tree/tree.js</tt></li> * <li><tt>click/tree/cookie-helper.js</tt></li> * </ul> * * To import these Tree files simply reference the variables * <span class="blue">$headElements</span> and * <span class="blue">$jsElements</span> in the page template. For example: * * <pre class="codeHtml"> * <html> * <head> * <span class="blue">$headElements</span> * </head> * <body> * * <span class="red">$tree</span> * * <span class="blue">$jsElements</span> * </body> * </html> </pre> * * <a name="customization"></a> * <h3>Tree customization</h3> * * The following list of stylesheet classes are used to render the tree * icons. One can easily change the <tt>tree.css</tt> to use a different set of * icons. Note: all CSS classes are set inline in <span> elements. * <ul> * <li><span class=<span class="blue">"leafIcon"</span>> - renders the leaf node of the tree</li> * <li><span class=<span class="blue">"expandedIcon"</span>> - renders the expanded state of a node</li> * <li><span class=<span class="blue">"collapsedIcon"</span>> - renders the collapsed state of a node</li> * </ul> * * <strong>Credit</strong> goes to <a href="http://wicket.apache.org">Wicket</a> * for these images: * <ul> * <li>images/folder-closed.png</li> * <li>images/folder-open.png</li> * <li>images/item.png</li> * </ul> */ public class Tree extends AbstractControl { // Constants -------------------------------------------------------------- /** The tree's expand/collapse parameter name: <tt>"expandTreeNode"</tt>. */ public static final String EXPAND_TREE_NODE_PARAM = "expandTreeNode"; /** The tree's select/deselect parameter name: <tt>"selectTreeNode"</tt>. */ public static final String SELECT_TREE_NODE_PARAM = "selectTreeNode"; /** Indicator for using cookies to implement client side behavior. */ public final static int JAVASCRIPT_COOKIE_POLICY = 1; /** Indicator for using the session to implement client side behavior. */ public final static int JAVASCRIPT_SESSION_POLICY = 2; /** The tree's expand icon name: <tt>"expandedIcon"</tt>. */ protected static final String EXPAND_ICON = "expandedIcon"; /** The tree's collapsed icon name: <tt>"collapsedIcon"</tt>. */ protected static final String COLLAPSE_ICON = "collapsedIcon"; /** The tree's leaf icon name: <tt>"leafIcon"</tt>. */ protected static final String LEAF_ICON = "leafIcon"; /** default serial version id. */ private static final long serialVersionUID = 1L; // Instance Variables ----------------------------------------------------- /** The tree's hierarchical data model. */ protected TreeNode rootNode; /** Array of ids that must be selected or deselected. */ protected String[] selectOrDeselectNodeIds = null; /** Array of ids that must be expanded or collapsed. */ protected String[] expandOrCollapseNodeIds = null; /** The Tree node select / deselect link. */ protected ActionLink selectLink; /** The tree node expand / collapse link. */ protected ActionLink expandLink; /** Callback provider for users to decorate tree nodes. */ private transient Decorator decorator; /** * Specifies if the root node should be displayed, or only its children. * By default this value is false. */ private boolean rootNodeDisplayed = false; /** Specifies if client side javascript functionality are enabled. By default this value is false.*/ private boolean javascriptEnabled = false; /** List of subscribed listeners to tree events.*/ private List<TreeListener> listeners = new ArrayList<TreeListener>(); /** Current javascript policy in effect. */ private int javascriptPolicy = 0; /** Flag indicates if listeners should be notified of any state changes. */ private boolean notifyListeners = true; // Public Constructors ---------------------------------------------------- /** * Create an Tree control for the given name. * <p/> * The constructor also sets the id attribute to * <tt>"tree"</tt> and the css class to <tt>"treestyle"</tt> * to qualify the tree control when styled by tree.css. If the * css class value is changed, ensure to also change the * tree.css selectors that still reference <tt>"treestyle"</tt>. * * @param name the tree name * @throws IllegalArgumentException if the name is null */ public Tree(String name) { setName(name); setAttribute("id", "tree"); setAttribute("class", "treestyle"); } /** * Create a Tree with no name defined. * <p/> * The constructor also sets the id attribute to * <tt>"tree"</tt> and the css class to <tt>"treestyle"</tt> * to qualify the tree control when styled by tree.css. If the * css class value is changed, ensure to also change the * tree.css selectors that still reference <tt>"treestyle"</tt>. * <p/> * <b>Please note</b> the control's name must be defined before it is valid. */ public Tree() { super(); setAttribute("id", "tree"); setAttribute("class", "treestyle"); } // Public Properties ------------------------------------------------------ /** * @see Control#setName(String) * * @param name of the control * @throws IllegalArgumentException if the name is null */ @Override public void setName(String name) { super.setName(name); getExpandLink().setName(name + "-expandLink"); getExpandLink().setLabel(""); getExpandLink().setParent(this); getSelectLink().setName(name + "-selectLink"); getSelectLink().setLabel(""); getSelectLink().setParent(this); } /** * Return the tree's root TreeNode. This method will recalculate * the tree's root node in case a new root node was set. * * @return the tree's root TreeNode. */ public TreeNode getRootNode() { //Calculate the root node dynamically by finding the node where parent == null. //Thus if a new root node was created this method will still return //the correct node if (rootNode == null) { return null; } while ((rootNode.getParent()) != null) { rootNode = rootNode.getParent(); } return rootNode; } /** * Return if tree has a root node. * * @return boolean indicating if the tree's root has been set. */ public boolean hasRootNode() { return getRootNode() != null; } /** * Return if the tree's root node should be displayed or not. * * @return if root node should be displayed */ public boolean isRootNodeDisplayed() { return rootNodeDisplayed; } /** * Sets whether the tree's root node should be displayed or not. * * @param rootNodeDisplayed true if the root node should be displayed, * false otherwise */ public void setRootNodeDisplayed(boolean rootNodeDisplayed) { this.rootNodeDisplayed = rootNodeDisplayed; } /** * Set the tree's root TreeNode. * * @param rootNode node will be set as the root */ public void setRootNode(TreeNode rootNode) { if (rootNode == null) { return; } this.rootNode = rootNode; } /** * Get the tree's decorator. * * @return the tree's decorator. */ public Decorator getDecorator() { return decorator; } /** * Set the tree's decorator which enables a interception point for users to render * the tree nodes. * * @param decorator the tree's decorator */ public void setDecorator(Decorator decorator) { this.decorator = decorator; } /** * Returns if javascript functionality are enabled or not. * * @return true if javascript functions are enabled, false otherwise * @see #setJavascriptEnabled(boolean) */ public boolean isJavascriptEnabled() { return javascriptEnabled; } /** * Enables javascript functionality. * <p/> * If true the tree will be navigable in the browser using javascript, * instead of doing round trips to the server on each operation. * <p/> * With javascript enabled you need to store the values passed from the * browser between requests. The tree currently supports the * following options: * <ul> * <li>{@link #JAVASCRIPT_COOKIE_POLICY} * <li>{@link #JAVASCRIPT_SESSION_POLICY} * </ul> * This method will try and determine which policy should be applied * to the current request by checking the value * {@link javax.servlet.http.HttpServletRequest#isRequestedSessionIdFromCookie()}. * If {@link javax.servlet.http.HttpServletRequest#isRequestedSessionIdFromCookie()} * returns true, {@link #JAVASCRIPT_COOKIE_POLICY} will be used, otherwise * {@link #JAVASCRIPT_SESSION_POLICY}. * <p/> * <strong>Note:</strong> if javascript is enabled, then the entire * tree is rendered even if some nodes are in a collapsed state. This * enables the tree to still be fully navigable in the browser. However * nodes that are in a collapsed state are still displayed as collapsed * using the style <tt>"display:none"</tt>. * * @see #setJavascriptEnabled(boolean, int) * * @param newValue the value to set the javascriptEnabled property to * @throws IllegalArgumentException if the context is null */ public void setJavascriptEnabled(boolean newValue) { if (getContext().getRequest().isRequestedSessionIdFromCookie()) { setJavascriptEnabled(newValue, JAVASCRIPT_COOKIE_POLICY); } else { setJavascriptEnabled(newValue, JAVASCRIPT_SESSION_POLICY); } } /** * Overloads {@link #setJavascriptEnabled(boolean)}. Enables one * to select the javascript policy to apply. * * @see #setJavascriptEnabled(boolean) * * @param newValue the value to set the javascriptEnabled property to * @param javascriptPolicy the current javascript policy * @throws IllegalArgumentException if the context is null */ public void setJavascriptEnabled(boolean newValue, int javascriptPolicy) { this.javascriptEnabled = newValue; if (javascriptEnabled) { javascriptHandler = createJavascriptHandler(javascriptPolicy); addListener(javascriptHandler); this.javascriptPolicy = javascriptPolicy; } else { removeListener(javascriptHandler); this.javascriptPolicy = 0; } } /** * Return the CSS "width" style attribute of the tree, or null if not * defined. * * @return the CSS "width" style attribute of the tree, or null if not * defined */ public String getWidth() { return getStyle("width"); } /** * Set the the CSS "width" style attribute of the tree. For example: * * <pre class="prettyprint"> * Tree tree = new Tree("mytree"); * tree.setWidth("200px"); </pre> * * @param value the CSS "width" style attribute */ public void setWidth(String value) { setStyle("width", value); } /** * Return the CSS "height" style of the tree, or null if not defined. * * @return the CSS "height" style attribute of the tree, or null if not * defined */ public String getHeight() { return getStyle("height"); } /** * Set the the CSS "height" style attribute of the tree. For example: * * <pre class="prettyprint"> * Tree tree = new Tree("mytree"); * tree.setHeight("200px"); </pre> * * @param value the CSS "height" style attribute */ public void setHeight(String value) { setStyle("height", value); } /** * Return the Tree HTML HEAD elements for the following resources: * <p/> * <ul> * <li><tt>click/tree/tree.css</tt></li> * <li><tt>click/tree/tree.js</tt></li> * <li><tt>click/tree/cookie-helper.js</tt></li> * </ul> * * @see org.apache.click.Control#getHeadElements() * * @return the HTML HEAD elements for the control */ @Override public List<Element> getHeadElements() { if (headElements == null) { headElements = super.getHeadElements(); Context context = getContext(); String versionIndicator = ClickUtils.getResourceVersionIndicator(context); headElements.add(new CssImport("/click/tree/tree.css", versionIndicator)); if (isJavascriptEnabled()) { headElements.add(new JsImport("/click/tree/tree.js", versionIndicator)); if (javascriptPolicy == JAVASCRIPT_COOKIE_POLICY) { headElements.add(new JsImport("/click/tree/cookie-helper.js", versionIndicator)); } } headElements.addAll(getExpandLink().getHeadElements()); headElements.addAll(getSelectLink().getHeadElements()); } return headElements; } /** * Return the tree node expand / collapse link. * <p/> * This method returns a {@link org.apache.click.extras.control.SubmitLink} * so that the Tree can function properly when added to a * {@link org.apache.click.control.Form}. * * @return the tree node expand / collapse link */ public ActionLink getExpandLink() { if (expandLink == null) { expandLink = new SubmitLink(); } return expandLink; } /** * Return the tree node select / deselect link. * <p/> * This method returns a {@link org.apache.click.extras.control.SubmitLink} * so that the Tree can function properly when added to a * {@link org.apache.click.control.Form}. * * @return the tree node select / deselect link. */ public ActionLink getSelectLink() { if (selectLink == null) { selectLink = new SubmitLink(); } return selectLink; } // Public Methods --------------------------------------------------------- /** * This method binds the users request of expanded and collapsed nodes to * the tree's nodes. */ public void bindExpandOrCollapseValues() { expandOrCollapseNodeIds = getExpandLink().getParameterValues(EXPAND_TREE_NODE_PARAM); } /** * This method binds the users request of selected nodes to the tree's nodes. */ public void bindSelectOrDeselectValues() { selectOrDeselectNodeIds = getSelectLink().getParameterValues(SELECT_TREE_NODE_PARAM); } /** * Query if the tree will notify its tree listeners of any change * to the tree's model. * * @return true if listeners should be notified of any changes. */ public boolean isNotifyListeners() { return notifyListeners; } /** * Enable or disable if the tree will notify its tree listeners of any change * to the tree's model. * * @param notifyListeners true if the tree will notify its listeners , * false otherwise */ public void setNotifyListeners(boolean notifyListeners) { this.notifyListeners = notifyListeners; } /** * Expand the node with matching id and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify its listeners of any change. * * @param id identifier of the node to be expanded. */ public void expand(String id) { if (id == null) { return; } setExpandState(id, true); } /** * Expand the node and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. * * @param node the node to be expanded. */ public void expand(TreeNode node) { if (node == null) { return; } setExpandState(node, true); } /** * Collapse the node with matching id and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. * * @param id identifier of node to be collapsed. */ public void collapse(String id) { if (id == null) { return; } setExpandState(id, false); } /** * Collapse the node and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. * * @param node the node to be collapsed. */ public void collapse(TreeNode node) { if (node == null) { return; } setExpandState(node, false); } /** * Expand all the nodes of the tree and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. */ public void expandAll() { for (Iterator<TreeNode> it = iterator(); it.hasNext();) { TreeNode node = it.next(); boolean oldValue = node.isExpanded(); node.setExpanded(true); if (isNotifyListeners()) { fireNodeExpanded(node, oldValue); } } } /** * Collapse all the nodes of the tree and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. */ public void collapseAll() { for (Iterator<TreeNode> it = iterator(); it.hasNext();) { TreeNode node = it.next(); boolean oldValue = node.isExpanded(); node.setExpanded(false); if (isNotifyListeners()) { fireNodeCollapsed(node, oldValue); } } } /** * Select the node with matching id and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. * * @param id identifier of node to be selected. */ public void select(String id) { if (id == null) { return; } setSelectState(id, true); } /** * Select the node and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. * * @param node the node to be selected. */ public void select(TreeNode node) { if (node == null) { return; } setSelectState(node, true); } /** * Deselect the node with matching id and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. * * @param id id of node to be deselected. */ public void deselect(String id) { if (id == null) { return; } setSelectState(id, false); } /** * Deselect the node and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. * * @param node the node to be deselected. */ public void deselect(TreeNode node) { if (node == null) { return; } setSelectState(node, false); } /** * Select all the nodes of the tree and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. */ public void selectAll() { for (Iterator<TreeNode> it = iterator(); it.hasNext();) { TreeNode node = it.next(); boolean oldValue = node.isSelected(); node.setSelected(true); if (isNotifyListeners()) { fireNodeSelected(node, oldValue); } } } /** * Deselect all the nodes of the tree and inform any listeners of the change. * If {@link #isNotifyListeners()} returns false, this method will not * notify listeners of any change. */ public void deselectAll() { for (Iterator<TreeNode> it = iterator(); it.hasNext();) { TreeNode node = it.next(); boolean oldValue = node.isSelected(); node.setSelected(false); if (isNotifyListeners()) { fireNodeDeselected(node, oldValue); } } } /** * Returns all the nodes that were expanded. * * @param includeInvisibleNodes indicator if only invisible nodes should be * included * @return list of currently expanded nodes */ public List<TreeNode> getExpandedNodes(boolean includeInvisibleNodes) { List<TreeNode> currentlyExpanded = new ArrayList<TreeNode>(); for (Iterator<TreeNode> it = iterator(); it.hasNext();) { TreeNode node = it.next(); if (node.isExpanded()) { if (includeInvisibleNodes || isVisible(node)) { currentlyExpanded.add(node); } } } return currentlyExpanded; } /** * Returns all the nodes that were selected. * * @param includeInvisibleNodes indicates if invisible nodes should be included. * @return list of currently selected nodes. */ public List<TreeNode> getSelectedNodes(boolean includeInvisibleNodes) { List<TreeNode> currentlySelected = new ArrayList<TreeNode>(); for (Iterator<TreeNode> it = iterator(); it.hasNext();) { TreeNode node = it.next(); if (node.isSelected()) { if (includeInvisibleNodes || isVisible(node)) { currentlySelected.add(node); } } } return currentlySelected; } /** * Provides a TreeNode callback interface. */ protected interface Callback { /** * Callback on the provided tree node. * * @param node the TreeNode to callback */ public void callback(final TreeNode node); } /** * Returns an iterator over all the nodes. * * @return iterator over all elements in the tree */ public Iterator<TreeNode> iterator() { return iterator(getRootNode()); } /** * Returns an iterator over all nodes starting from the specified node. * If null is specified, root node is used instead. * * @param node starting point of nodes to iterator over * @return iterator over all nodes starting form the specified node */ public Iterator<TreeNode> iterator(TreeNode node) { if (node == null) { node = getRootNode(); } return new BreadthTreeIterator(node); } /** * Finds and returns the first node that matches the id. * * @param id identifier of the node to find * @return TreeNode the first node matching the id. * @throws IllegalArgumentException if argument is null. */ public TreeNode find(String id) { if (id == null) { throw new IllegalArgumentException("Argument cannot be null."); } return find(getRootNode(), id); } /** * This method binds any expand/collapse and select/deselect changes from * the request parameters. * <p/> * In other words the node id's of expanded, collapsed, selected and * deselected nodes are retrieved from the request. * * @see #bindExpandOrCollapseValues() * @see #bindSelectOrDeselectValues() */ public void bindRequestValue() { bindExpandOrCollapseValues(); bindSelectOrDeselectValues(); } /** * Processes user request to change state of the tree. * This implementation processes any expand/collapse and select/deselect * changes as requested. * <p/> * Thus expanded nodes will be collapsed and collapsed nodes will be * expanded. Similarly selected nodes will be deselected and deselected * nodes will be selected. * * @see org.apache.click.Control#onProcess() * @see #expandOrCollapse(java.lang.String[]) * @see #selectOrDeselect(java.lang.String[]) * * @return true to continue Page event processing or false otherwise */ @Override public boolean onProcess() { getExpandLink().onProcess(); getSelectLink().onProcess(); bindRequestValue(); ActionEventDispatcher.dispatchActionEvent(this, new ActionListener() { private static final long serialVersionUID = 1L; public boolean onAction(Control source) { return postProcess(); } }); return true; } /** * This method cleans up the {@link #expandLink} and {@link #selectLink}. * @see org.apache.click.Control#onDestroy() */ @Override public void onDestroy() { super.onDestroy(); getExpandLink().onDestroy(); getSelectLink().onDestroy(); } /** * Set the controls event listener. * <p/> * To receive notifications when TreeNodes are selected or expanded please * use {@link #addListener(TreeListener)}. * * @param listener the listener object with the named method to invoke * @param method the name of the method to invoke */ @Override public void setListener(Object listener, String method) { super.setListener(listener, method); } /** * Set the control's action listener. * <p/> * To receive notifications when TreeNodes are selected or expanded please * use {@link #addListener(TreeListener)}. * * @param listener the control's action listener */ @Override public void setActionListener(ActionListener listener) { super.setActionListener(listener); } /** * Adds the listener to start receiving tree events. * * @param listener to add to start receiving tree events. */ public void addListener(TreeListener listener) { listeners.add(listener); } /** * Removes the listener to stop receiving tree events. * * @param listener to be removed to stop receiving tree events. */ public void removeListener(TreeListener listener) { listeners.remove(listener); } // Default Rendering ------------------------------------------------------ /** * @see AbstractControl#getControlSizeEst() * * @return the estimated rendered control size in characters */ @Override public int getControlSizeEst() { return 256; } /** * Utility method that force the Tree to remove any entries it made in the * HttpSession. * <p/> * <b>Note</b> Tree only stores a value in the Session when JavaScript * is enabled and set to {@link #JAVASCRIPT_SESSION_POLICY}. */ public void cleanupSession() { Context context = getContext(); if (context.hasSession()) { context.getSession().removeAttribute(SessionHandler.JS_HANDLER_SESSION_KEY); } } /** * Render the HTML representation of the tree. * * @see #toString() * * @param buffer the specified buffer to render the control's output to */ @Override public void render(HtmlStringBuffer buffer) { buffer.elementStart("div"); buffer.appendAttribute("id", getId()); appendAttributes(buffer); buffer.append(">\n"); if (isRootNodeDisplayed()) { TreeNode temp = new TreeNode(); //Do not use the method temp.add(), because that will //set temp as the parent of the current root node. Temp //will then become the new root node of the tree. temp.addChildOnly(getRootNode()); renderTree(buffer, temp, 0); } else { renderTree(buffer, getRootNode(), 0); } buffer.elementEnd("div"); buffer.append("\n"); if (isJavascriptEnabled()) { //Complete the lifecycle of the javascript handler. javascriptHandler.destroy(); } } /** * Return a HTML rendered Tree string of all the tree's nodes. * * <p/>Note: by default the tree's root node will not be rendered. * However this behavior can be changed by calling * {@link #setRootNodeDisplayed(boolean)} with true. * * @see java.lang.Object#toString() * @return a HTML rendered Tree string */ @Override public String toString() { HtmlStringBuffer buffer = new HtmlStringBuffer(getControlSizeEst()); render(buffer); return buffer.toString(); } /** * Render the children of the specified tree node as html markup and append * the output to the specified buffer. * <p/> * <strong>Note:</strong> only the children of the specified tree node will * be rendered not the treeNode itself. This method is recursive, so the * node's children and their children will be rendered and so on. * * @param buffer string buffer containing the markup * @param treeNode specified node who's children will be rendered * @param indentation current level of the treeNode. The indentation increases each * time the depth of the tree increments. * * @see #setRootNodeDisplayed(boolean) */ protected void renderTree(HtmlStringBuffer buffer, TreeNode treeNode, int indentation) { indentation++; buffer.elementStart("ul"); buffer.append(" class=\""); if (isRootNodeDisplayed() && indentation == 1) { buffer.append("rootLevel level"); } else { buffer.append("level"); } buffer.append(Integer.toString(indentation)); //If javascript is enabled and this is not the first level of <ul> elements, //the css class 'hide' is appended to the <ul> element to ensure the tree //is in a collapsed state on the browser. However, we must query the //javascript handler if it does not perhaps veto the collapsed state. if (indentation > 1 && shouldHideNode(treeNode)) { buffer.append(" hide"); } buffer.append("\">\n"); for (TreeNode child : treeNode.getChildren()) { if (isJavascriptEnabled()) { javascriptHandler.getJavascriptRenderer().init(child); } renderTreeNodeStart(buffer, child, indentation); renderTreeNode(buffer, child, indentation); //check if the child node should be rendered if (shouldRenderChildren(child)) { renderTree(buffer, child, indentation); } renderTreeNodeEnd(buffer, child, indentation); } buffer.append("</ul>\n"); } /** * Check the state of the specified node if its children * should be rendered or not. * * @param treeNode specified node to check * @return true if the child nodes should be rendered, * false otherwise */ protected boolean shouldRenderChildren(TreeNode treeNode) { if (treeNode.isLeaf()) { return false; } if (treeNode.isExpanded()) { return true; } else { //If javascript is enabled, the entire tree has to be rendered //and sent to the browser. So even if the node is not //expanded, we still render the node's children. if (isJavascriptEnabled()) { return true; } } return false; } /** * Interception point to render html before the tree node is rendered. * * @param buffer string buffer containing the markup * @param treeNode specified node to render * @param indentation current level of the treeNode */ protected void renderTreeNodeStart(HtmlStringBuffer buffer, TreeNode treeNode, int indentation) { buffer.append("<li><span class=\""); if (treeNode.isRoot()) { buffer.append("rootNode "); } buffer.append(getExpandClass(treeNode)); buffer.append("\""); if (isJavascriptEnabled()) { //hook to insert javascript specific code javascriptHandler.getJavascriptRenderer().renderTreeNodeStart(buffer); } buffer.appendAttribute("style", "display:block;"); buffer.closeTag(); //Render the node's expand/collapse functionality. //This includes adding a css class for the current expand/collapse state. //In the tree.css file, the css classes are mapped to icons by default. if (treeNode.hasChildren()) { renderExpandAndCollapseAction(buffer, treeNode); } else { buffer.append("<span class=\"spacer\"></span>"); } } /** * Interception point to render html after the tree node was rendered. * * @param buffer string buffer containing the markup * @param treeNode specified node to render * @param indentation current level of the treeNode */ protected void renderTreeNodeEnd(HtmlStringBuffer buffer, TreeNode treeNode, int indentation) { buffer.append("</span></li>\n"); } /** * Render the expand and collapse action of the tree. * <p/> * Default implementation creates a hyperlink that users can click on * to expand or collapse the nodes. * * @param buffer string buffer containing the markup * @param treeNode treeNode to render */ protected void renderExpandAndCollapseAction(HtmlStringBuffer buffer, TreeNode treeNode) { getExpandLink().setParameter(EXPAND_TREE_NODE_PARAM, treeNode.getId()); if (treeNode.isRoot()) { getExpandLink().setAttribute("class", "root spacer"); } else { getExpandLink().setAttribute("class", "spacer"); } if (isJavascriptEnabled()) { //hook to insert javascript specific code javascriptHandler.getJavascriptRenderer().renderExpandAndCollapseAction(buffer); } getExpandLink().render(buffer); } /** * Render the specified treeNode. * <p/> * If a decorator was specified using {@link #setDecorator(Decorator) }, * this method will render using the decorator instead. * * @param buffer string buffer containing the markup * @param treeNode treeNode to render * @param indentation current level of the treeNode */ protected void renderTreeNode(HtmlStringBuffer buffer, TreeNode treeNode, int indentation) { if (getDecorator() != null) { Object value = getDecorator().render(treeNode, getContext()); if (value != null) { buffer.append(value); } return; } renderIcon(buffer, treeNode); buffer.elementStart("span"); if (treeNode.isSelected()) { buffer.appendAttribute("class", "selected"); } else { buffer.appendAttribute("class", "unselected"); } buffer.closeTag(); //renders the node value renderValue(buffer, treeNode); buffer.elementEnd("span"); } /** * Render the node's icon depending on the current state of the node. * * @param buffer string buffer containing the markup * @param treeNode treeNode to render */ protected void renderIcon(HtmlStringBuffer buffer, TreeNode treeNode) { if (treeNode.getIcon() == null) { //render the icon to display buffer.elementStart("span"); buffer.appendAttribute("class", getIconClass(treeNode)); if (isJavascriptEnabled()) { //An id is needed on the element to do quick lookup using javascript //document.getElementById(id) javascriptHandler.getJavascriptRenderer().renderIcon(buffer); } buffer.append(">"); buffer.append("</span>"); } else { buffer.elementStart("img"); buffer.appendAttribute("class", "customIcon"); buffer.appendAttribute("src", treeNode.getIcon()); buffer.closeTag(); } } /** * Render the node's value. * <p/> * Subclasses should override this method to change the rendering of the * node's value. By default the value will be rendered as a hyperlink, * passing its <em>id</em> to the server. * * @param buffer string buffer containing the markup * @param treeNode treeNode to render */ protected void renderValue(HtmlStringBuffer buffer, TreeNode treeNode) { getSelectLink().setParameter(SELECT_TREE_NODE_PARAM, treeNode.getId()); if (treeNode.getValue() != null) { getSelectLink().setLabel(treeNode.getValue().toString()); } getSelectLink().render(buffer); } /** * Query the specified treeNode and check which css class to apply. * <p/> * Possible classes are expanded, collapsed, leaf, expandedLastNode, * collapsedLastNode and leafLastNode. * * @param treeNode the tree node to check for css class * @return string specific css class to apply */ protected String getExpandClass(TreeNode treeNode) { StringBuilder sb = new StringBuilder(); if (isExpandedParent(treeNode)) { sb.append("expanded"); } else if (treeNode.getChildren().size() > 0) { sb.append("collapsed"); } else { sb.append("leaf"); } if (treeNode.isLastChild()) { sb.append("LastNode"); } return sb.toString(); } /** * Query the specified treeNode and check which css class to apply for * the icons. * <p/> * Possible classes are expandedIcon, collapsedIcon and leafIcon. * * @param treeNode the tree node to check for css class * @return string specific css class to apply */ protected String getIconClass(TreeNode treeNode) { if (isExpandedParent(treeNode)) { return EXPAND_ICON; } else if (!treeNode.isExpanded() && treeNode.hasChildren() || treeNode.isChildrenSupported()) { return COLLAPSE_ICON; } else { return LEAF_ICON; } } /** * Helper method indicating if the specified node is both * expanded and has at least 1 child node. * * @param treeNode specified node to check * @return true if the specified node is both expanded and * contains at least 1 child node */ protected boolean isExpandedParent(TreeNode treeNode) { return (treeNode.isExpanded() && treeNode.hasChildren()); } // Protected observer behavior -------------------------------------------- /** * Notifies all listeners currently registered with the tree, about any * expand events. * * @param node specify the TreeNode that was expanded * @param previousState contains the previous expanded state */ protected void fireNodeExpanded(TreeNode node, boolean previousState) { for (TreeListener l : listeners) { l.nodeExpanded(this, node, getContext(), previousState); } } /** * Notifies all listeners currently registered with the tree, about any * collapse events. * * @param node specific the TreeNode that was collapsed * @param previousState contains the previous expanded state */ protected void fireNodeCollapsed(TreeNode node, boolean previousState) { for (TreeListener l : listeners) { l.nodeCollapsed(this, node, getContext(), previousState); } } /** * Notifies all listeners currently registered with the tree, about any * selection events. * * @param node specific the TreeNode that was selected * @param previousState contains the previous selected state */ protected void fireNodeSelected(TreeNode node, boolean previousState) { for (TreeListener l : listeners) { l.nodeSelected(this, node, getContext(), previousState); } } /** * Notifies all listeners currently registered with the tree, about any * deselection events. * * @param node specific the TreeNode that was deselected * @param previousState contains the previous selected state */ protected void fireNodeDeselected(TreeNode node, boolean previousState) { for (TreeListener l : listeners) { l.nodeDeselected(this, node, getContext(), previousState); } } // Protected behavior ----------------------------------------------------- /** * Sets the TreeNode expand state to the new value. * * @param node specifies the TreeNode which expand state will be set * @param newValue specifies the new expand state */ protected void setExpandState(TreeNode node, boolean newValue) { boolean oldValue = node.isExpanded(); node.setExpanded(newValue); if (isNotifyListeners()) { if (newValue) { fireNodeExpanded(node, oldValue); } else { fireNodeCollapsed(node, oldValue); } } } /** * Swaps the expand state of all TreeNodes with specified id's. * Thus if a node's expand state is currently 'true', calling * expandOrCollapse will set the expand state to 'false' and vice versa. * * @param ids array of node id's */ protected void expandOrCollapse(String[] ids) { processNodes(ids, new Callback() { public void callback(TreeNode node) { setExpandState(node, !node.isExpanded()); } }); } /** * Sets the expand state of the TreeNode with specified id to the new value. * * @param id specifies the id of a TreeNode which expand state will be set * @param newValue specifies the new expand state */ protected void setExpandState(final String id, final boolean newValue) { TreeNode node = find(id); if (node == null) { return; } setExpandState(node, newValue); } /** * Sets the TreeNode expand state of each node in the specified collection * to the new value. * * @param nodes specifies the collection of a TreeNodes which expand states will be set * @param newValue specifies the new expand state */ protected void setExpandState(final Collection<TreeNode> nodes, final boolean newValue) { processNodes(nodes, new Callback() { public void callback(TreeNode node) { setExpandState(node, newValue); } }); } /** * Sets the TreeNode select state to the new value. * * @param node specifies the TreeNode which select state will be set * @param newValue specifies the new select state */ protected void setSelectState(TreeNode node, boolean newValue) { boolean oldValue = node.isSelected(); node.setSelected(newValue); if (isNotifyListeners()) { if (newValue) { fireNodeSelected(node, oldValue); } else { fireNodeDeselected(node, oldValue); } } } /** * Swaps the select state of all TreeNodes with specified id's to the new value. * Thus if a node's select state is currently 'true', calling selectOrDeselect * will set the select state to 'false' and vice versa. * * @param ids array of node id's */ protected void selectOrDeselect(String[] ids) { processNodes(ids, new Callback() { public void callback(TreeNode node) { setSelectState(node, !node.isSelected()); } }); } /** * Sets the select state of the TreeNode with specified id to the new value. * * @param id specifies the id of a TreeNode which select state will be set * @param newValue specifies the new select state */ protected void setSelectState(final String id, final boolean newValue) { TreeNode node = find(id); if (node == null) { return; } setSelectState(node, newValue); } /** * Sets the TreeNode select state of each node in the specified collection * to the new value. * * @param nodes specifies the collection of a TreeNodes which select states will be set * @param newValue specifies the new select state */ protected void setSelectState(final Collection<TreeNode> nodes, final boolean newValue) { processNodes(nodes, new Callback() { public void callback(TreeNode node) { setSelectState(node, newValue); } }); } /** * Provides callback functionality for all the specified nodes. * * @param ids the array of nodes to process * @param callback object on which callbacks are made */ protected void processNodes(String[] ids, Callback callback) { if (ids == null) { return; } for (int i = 0, n = ids.length; i < n; i++) { String id = ids[i]; if (id == null || id.length() == 0) { continue; } TreeNode node = find(id); if (node == null) { continue; } callback.callback(node); } } /** * Provides callback functionality for all the specified nodes. * * @param nodes the collection of nodes to process * @param callback object on which callbacks are made */ protected void processNodes(Collection<TreeNode> nodes, Callback callback) { if (nodes == null) { return; } for (TreeNode node : nodes) { callback.callback(node); } } /** * Finds and returns the first node that matches the id, starting the search * from the specified node. * * @param node specifies at which node the search must start from * @param id specifies the id of the TreeNode to find * @return TreeNode the first node matching the id or null if no match was found. */ protected TreeNode find(TreeNode node, String id) { for (Iterator<TreeNode> it = iterator(node); it.hasNext();) { TreeNode result = it.next(); if (result.getId().equals(id)) { return result; } } return null; } /** * Returns the value of the specified named parameter or a empty string * <span class="st">""</span> if not found. * * @param name specifies the parameter to return * @return the specified parameter or a empty string <span class="st">""</span> if not found */ protected String getRequestValue(String name) { String result = getContext().getRequestParameter(name); if (result != null) { return result.trim(); } else { return ""; } } /** * Returns an array of all values of the specified named parameter or null * if the parameter does not exist. * * @param name specifies the parameter to return * @return all matching parameters or null if no parameter was found */ protected String[] getRequestValues(String name) { String[] resultArray = getContext().getRequest().getParameterValues(name); return resultArray; } /** * Return an anchor <a> tag href attribute for the given parameters. * This method will encode the URL with the session ID * if required using <tt>HttpServletResponse.encodeURL()</tt>. * * @param parameters the href parameters * @return the HTML href attribute */ protected String getHref(Map<String, ? extends Object> parameters) { Context context = getContext(); String uri = ClickUtils.getRequestURI(context.getRequest()); HtmlStringBuffer buffer = new HtmlStringBuffer(uri.length() + (parameters.size() * 20)); buffer.append(uri); if (!parameters.isEmpty()) { buffer.append("?"); Iterator<? extends Map.Entry<String, ?>> i = parameters.entrySet().iterator(); while (i.hasNext()) { Map.Entry<String, ?> entry = i.next(); String name = entry.getKey().toString(); String value = entry.getValue().toString(); buffer.append(name); buffer.append("="); buffer.append(ClickUtils.encodeUrl(value, context)); if (i.hasNext()) { buffer.append("&"); } } } return context.getResponse().encodeURL(buffer.toString()); } // Package Private Methods ------------------------------------------------ /** * Expand / collapse and select / deselect the tree nodes. * * @return true to continue Page event processing or false otherwise */ boolean postProcess() { if (isJavascriptEnabled()) { // Populate the javascript handler with its state. This call will // notify any tree listeners about new values. javascriptHandler.init(getContext()); } if (!ArrayUtils.isEmpty(expandOrCollapseNodeIds)) { expandOrCollapse(expandOrCollapseNodeIds); } if (!ArrayUtils.isEmpty(selectOrDeselectNodeIds)) { selectOrDeselect(selectOrDeselectNodeIds); } return true; } // Inner classes ---------------------------------------------------------- /** * Iterate over all the nodes in the tree in a breadth first manner. * * <p/>Thus in a tree with the following nodes (top to bottom): * <pre class="codeHtml"> * <span class="red">root</span> * <span class="blue">node1</span> <span class="blue">node2</span> *node1.1 node1.2 node2.1 node2.2 * </pre> * * <p/>the iterator will return the nodes in the following order: * <pre class="codeHtml"> * <span class="red">root</span> * <span class="blue">node1</span> * <span class="blue">node2</span> * node1.1 * node1.2 * node2.1 * node2.2 * </pre> */ static class BreadthTreeIterator implements Iterator<TreeNode> { /**queue for storing node's. */ private List<TreeNode> queue = new ArrayList<TreeNode>(); /** indicator to iterate collapsed node's. */ private boolean iterateCollapsedNodes = true; /** * Creates a iterator and adds the specified node to the queue. * The specified node will be set as the root of the traversal. * * @param node node will be set as the root of the traversal. */ public BreadthTreeIterator(TreeNode node) { if (node == null) { throw new IllegalArgumentException("Node cannot be null"); } queue.add(node); } /** * Creates a iterator and adds the specified node to the queue. * The specified node will be set as the root of the traversal. * * @param node node will be set as the root of the traversal. * @param iterateCollapsedNodes indicator to iterate collapsed node's */ public BreadthTreeIterator(TreeNode node, boolean iterateCollapsedNodes) { if (node == null) { throw new IllegalArgumentException("Node cannot be null"); } queue.add(node); this.iterateCollapsedNodes = iterateCollapsedNodes; } /** * Returns true if there are more nodes, false otherwise. * * @return boolean true if there are more nodes, false otherwise. */ public boolean hasNext() { return !queue.isEmpty(); } /** * Returns the next node in the iteration. * * @return the next node in the iteration. * @exception NoSuchElementException iteration has no more node. */ public TreeNode next() { try { //remove from the end of queue TreeNode node = queue.remove(queue.size() - 1); if (node.hasChildren()) { if (iterateCollapsedNodes || node.isExpanded()) { push(node.getChildren()); } } return node; } catch (IndexOutOfBoundsException e) { throw new NoSuchElementException("There is no more node's to iterate"); } } /** * Remove operation is not supported. * * @exception UnsupportedOperationException <tt>remove</tt> operation is * not supported by this Iterator. */ public void remove() { throw new UnsupportedOperationException("remove operation is not supported."); } /** * Pushes the specified list of node's to push on the beginning of the queue. * * @param children list of node's to push on the beginning of the queue */ private void push(List<TreeNode> children) { for (TreeNode child : children) { queue.add(0, child); //add to the beginning of queue } } } // Private behavior ------------------------------------------------------- /** * Returns whether the specified node is visible. The semantics of visible * in this context indicates whether the node is currently displayed on the * screen. This means all parent nodes must be expanded for the node to be * visible. * * @param node TreeNode's visibility to check * @return boolean true if the node's parent is visible, false otherwise */ private boolean isVisible(TreeNode node) { while (!node.isRoot()) { if (!node.getParent().isExpanded()) { return false; } node = node.getParent(); } return true; } /** * Returns an array of all the nodes in the hierarchy, starting from the specified * node up to and including the root node. * <p/> * The specified node will be at the start of the array and the root node will be * at the end of the array. Thus array[0] will return the specified node, while * array[n - 1] where n is the size of the array, will return the root node. * * @return list of all nodes from the specified node to the root node */ private TreeNode[] getPathToRoot(TreeNode treeNode) { TreeNode[] nodes = new TreeNode[] { treeNode }; while (treeNode.getParent() != null) { int length = nodes.length; System.arraycopy(nodes, 0, nodes = new TreeNode[length + 1], 0, length); nodes[length] = treeNode = treeNode.getParent(); } return nodes; } // Javascript behavior ---------------------------------------------------- /** * Creates a new JavascriptHandler based on the specified policy. * * @param javascriptPolicy the current javascript policy * @return newly created JavascriptHandler */ protected JavascriptHandler createJavascriptHandler(int javascriptPolicy) { if (javascriptPolicy == JAVASCRIPT_SESSION_POLICY) { return new SessionHandler(getContext()); } else { return new CookieHandler(getContext()); } } /** * Keep track of node id's, as they are selected, deselected, * expanded and collapsed. * * @see JavascriptHandler */ protected transient JavascriptHandler javascriptHandler; /** * <b>Please note</b> this class is <b>not</b> meant for public use. * <p/> * Provides the contract for pluggable javascript renderers for * the Tree. */ protected interface JavascriptRenderer { /** * Called to initialize the renderer. * * @param node the current node rendered */ void init(TreeNode node); /** * Called before a tree node is rendered. Enables the renderer * to add attributes needed by javascript functionality for example * something like: * <pre class="codeJava"> * buffer.appendAttribute(<span class="st">"id"</span>,expandId); * </pre> * The code above adds a id attribute to the element, to enable * the javascript code to lookup the html element by its id. * <p/> * The above attribute is appended to whichever element the * tree is currently rendering at the time renderTreeNodeStart * is called. * * @param buffer string buffer containing the markup */ void renderTreeNodeStart(HtmlStringBuffer buffer); /** * Called when the expand and collapse action is rendered. Enables * the renderer to add attributes needed by javascript functionality * for example something like: * <pre class="codeJava"> * buffer.append(<span class="st">"onclick=\"handleNodeExpansion(this,event)\""</span>); * </pre> * The code above adds a javascript function call to the element. * <p/> * The code above is appended to whichever element the * tree is currently rendering at the time renderTreeNodeStart * is called. * * @param buffer string buffer containing the markup */ void renderExpandAndCollapseAction(HtmlStringBuffer buffer); /** * Called when the tree icon is rendered. Enables the renderer * to add attributes needed by javascript functionality for example * something like: * <pre class="codeJava"> * buffer.appendAttribute(<span class="st">"id"</span>,iconId); * </pre> * The code above adds a id attribute to the element, to enable * the javascript code to lookup the html element by its id. * <p/> * The above attribute is appended to whichever element the * tree is currently rendering at the time renderTreeNodeStart * is called. * * @param buffer string buffer containing the markup */ void renderIcon(HtmlStringBuffer buffer); } /** * <b>Please note</b> this class is <b>not</b> meant for public use. * <p/> * Provides a abstract implementation of JavascriptRenderer that * subclasses can extend from. */ protected abstract class AbstractJavascriptRenderer implements JavascriptRenderer { /** holds the id of the expand html element. */ protected String expandId; /** holds the id of the icon html element. */ protected String iconId; /** holds the javascript call to expand or collapse the node. */ protected String nodeExpansionString; /** * @see #init(TreeNode) * * @param treeNode the current node rendered * @see #init(TreeNode) */ public void init(TreeNode treeNode) { expandId = buildString("e_", treeNode.getId(), ""); iconId = buildString("i_", treeNode.getId(), ""); } /** * @see #renderTreeNodeStart(HtmlStringBuffer) * * @param buffer string buffer containing the markup */ public void renderTreeNodeStart(HtmlStringBuffer buffer) { //An id is needed on the element to do quick lookup using javascript //document.getElementById(id) buffer.appendAttribute("id", expandId); } /** * @see #renderExpandAndCollapseAction(HtmlStringBuffer) * * @param buffer string buffer containing the markup */ public void renderExpandAndCollapseAction(HtmlStringBuffer buffer) { getExpandLink().setAttribute("onclick", nodeExpansionString); } /** * @see #renderIcon(HtmlStringBuffer) * * @param buffer string buffer containing the markup */ public void renderIcon(HtmlStringBuffer buffer) { //An id is needed on the element to do quick lookup using javascript //document.getElementById(id) buffer.appendAttribute("id", iconId); } /** * Builds a new string consisting of a prefix, infix and postfix. * * @param prefix the string to append at the start of new string * @param infix the string to append in the middle of the new string * @param postfix the string to append at the end of the new string * @return the newly create string */ protected String buildString(String prefix, String infix, String postfix) { StringBuilder sb = new StringBuilder(); sb.append(prefix).append(infix).append(postfix); return sb.toString(); } } /** * <strong>Please note</strong> this class is only meant for * developers of this control, not users. * <p/> * Provides the rendering needed when a {@link #JAVASCRIPT_COOKIE_POLICY} * is in effect. */ protected class CookieRenderer extends AbstractJavascriptRenderer { /** Name of the cookie holding the expanded nodes id's. */ private String expandedCookieName; /** Name of the cookie holding the collapsed nodes id's. */ private String collapsedCookieName; /** * Default constructor. * * @param expandedCookieName name of the cookie holding expanded id's * @param collapsedCookieName name of the cookie holding collapsed id's */ public CookieRenderer(String expandedCookieName, String collapsedCookieName) { this.collapsedCookieName = collapsedCookieName; this.expandedCookieName = expandedCookieName; } /** *@see #init(TreeNode) * * @param treeNode the current node rendered * @see #init(TreeNode) */ @Override public void init(TreeNode treeNode) { super.init(treeNode); StringBuilder sb = new StringBuilder(); sb.append("handleNodeExpansion(this,event,'").append(expandId).append("','"); sb.append(iconId).append("'); handleCookie(this,event,'").append(expandId).append("','"); sb.append(treeNode.getId()).append("','"); sb.append(expandedCookieName).append("','"); sb.append(collapsedCookieName).append("'); return false;"); nodeExpansionString = sb.toString(); } } /** * <strong>Please note</strong> this class is only meant for * developers of this control, not users. * <p/> * Provides the rendering needed when a {@link #JAVASCRIPT_SESSION_POLICY} * is in effect. */ protected class SessionRenderer extends AbstractJavascriptRenderer { /** * @see #init(TreeNode) * * @param treeNode the current node rendered * @see #init(TreeNode) */ @Override public void init(TreeNode treeNode) { super.init(treeNode); String tmp = buildString("handleNodeExpansion(this,event,'", expandId, "','"); nodeExpansionString = buildString(tmp, iconId, "'); return false;"); } } /** * <b>Please note</b> this class is <b>not</b> meant for public use. * <p/> * Provides the contract for pluggable javascript handlers. * <p/> * One of the main tasks the handler must perform is keeping track * of which nodes changed state after the user interacted with the * tree in the browser. This is also the reason why the handler * extends {@link TreeListener} to be informed of any changes * to node state via other means. */ protected interface JavascriptHandler extends TreeListener { /** * Initialize the handler state. * * @param context provides information for initializing * the handler. */ void init(Context context); /** * Queries the handler if the specified node should be rendered * as a expanded node. * <p/> * The reason for this is that the handler might be keeping track * of state that the node is not aware of. For example certain state * could be stored in the session or cookies. * * @param treeNode the specified node to query for * @return true if the node should be rendered as if it was * expanded, false otherwise */ boolean renderAsExpanded(TreeNode treeNode); /** * Called to indicate the user request cycle is complete. * Any last minute tasks can be performed here. */ void destroy(); /** * Returns the javascript renderer associated with * this handler. * * @return renderer associated with this handler */ JavascriptRenderer getJavascriptRenderer(); } /** * <strong>Please note</strong> this class is only meant for * developers of this control, not users. * <p/> * This class implements a cookie based javascript handler. * Cookies in the browser tracks the expand and collapse state * of the nodes. When a request is made to the server the cookies * is processed and the state of the nodes are modified accordingly. * <p/> * There are two cookies used to track the state: * <ul> * <li>a cookie tracking the expanded node id's * <li>a cookie tracking the collapsed node id's * </ul> * The cookies are removed between requests. New requests * issue new cookies and update the state of the nodes * accordingly. * <p/> * Note: This class is used in conjunction with cookie-helper.js * which manipulates the cookie values in the browser as the * user navigates the tree. */ protected class CookieHandler implements JavascriptHandler { private static final long serialVersionUID = 1L; /** Cookie value delimiter. */ private final static String DELIM = ","; /** Name of cookie responsible for tracking the expanded node id's. */ protected final String expandedCookieName = "expanded_" + getName(); /** Name of cookie responsible for tracking the expanded node id's. */ protected final String collapsedCookieName = "collapsed_" + getName(); /** Variable holding a javascript renderer. */ protected JavascriptRenderer javascriptRenderer; /** Tracker for the expanded node id's. */ private Set<String> expandTracker; /** Tracker for the collapsed node id's. */ private Set<String> collapsedTracker; /** Value of the cookie responsible for tracking the expanded node id's. */ private String expandedNodeCookieValue = null; /** Value of the cookie responsible for tracking the collapsed node id's. */ private String collapsedNodeCookieValue = null; /** * Creates and initializes a new CookieHandler. * * @param context provides access to the http request, and session */ protected CookieHandler(Context context) { expandedNodeCookieValue = context.getCookieValue(expandedCookieName); collapsedNodeCookieValue = context.getCookieValue(collapsedCookieName); expandedNodeCookieValue = prepareCookieValue(expandedNodeCookieValue); collapsedNodeCookieValue = prepareCookieValue(collapsedNodeCookieValue); } /** * Initialize the handler state from the current cookies. * * @param context provides access to the http request, and session */ public void init(Context context) { //If already initialized if (expandTracker != null || collapsedTracker != null) { return; } expandTracker = new HashSet<String>(); collapsedTracker = new HashSet<String>(); if (context == null) { throw new IllegalArgumentException("context cannot be null"); } //No cookie values to digest if (expandedNodeCookieValue == null && collapsedNodeCookieValue == null) { return; } //build hashes of id's for fast lookup Set<String> expandHash = asSet(expandedNodeCookieValue, DELIM); Set<String> collapsedHash = asSet(collapsedNodeCookieValue, DELIM); for (Iterator<TreeNode> it = iterator(getRootNode()); it.hasNext();) { TreeNode currentNode = it.next(); //If currentNode was expanded by user in browser if (expandHash.contains(currentNode.getId())) { //If currentNode's state is collapsed if (!currentNode.isExpanded()) { //Calling expand(currentNode) will update the expandTracker //because the CookieHandler is a TreeListener as well. expand(currentNode); } else { //If the currentNode is already expanded we should not update the //expandTracker via a call to expand(currentNode), because //other listeners of the tree will receive the event as well. //Instead we update the expandTracker directly. expandTracker.add(currentNode.getId()); } } else if (collapsedHash.contains(currentNode.getId())) { //If currentNode was collapsed by user in browser if (currentNode.isExpanded()) { //Calling collapse(currentNode) will update the expandTracker //because the CookieHandler is a TreeListener as well. collapse(currentNode); } } } } /** * Currently this implementation just calls * {@link #isExpandedParent(TreeNode)}. * <p/> * CookieHandler uses cookies to sync any state change on * the browser with the server, so the handler will not * contain any state outside of the treeNode. * * @param treeNode the specified treeNode to check if it is part of the * users selected paths * @return true if the specified treeNode is part of the users selected * path, false otherwise * @see #renderAsExpanded(TreeNode) */ public boolean renderAsExpanded(TreeNode treeNode) { return isExpandedParent(treeNode); } /** * @see #destroy() */ public void destroy() { //Remove expanded cookies. If the tree is changed //new cookies will be generated. setCookie(null, expandedCookieName); //Remove collapsed cookie. setCookie(null, collapsedCookieName); } /** * @see #getJavascriptRenderer() * * @return currently installed javascript renderer */ public JavascriptRenderer getJavascriptRenderer() { if (javascriptRenderer == null) { javascriptRenderer = new CookieRenderer(expandedCookieName, collapsedCookieName); } return javascriptRenderer; } /** * Adds the specified node to the cookie handler tracker. * * @see TreeListener#nodeExpanded(Tree, TreeNode, Context, boolean) * * @param tree tree the operation was made on * @param node node that was expanded * @param context provides access to {@link org.apache.click.Context} * @param oldValue contains the previous value of expanded state */ public void nodeExpanded(Tree tree, TreeNode node, Context context, boolean oldValue) { expandTracker.add(node.getId()); } /** * Removes the specified node from the cookie handler tracker. * * @see TreeListener#nodeCollapsed(Tree, TreeNode, Context, boolean) * * @param tree tree the operation was made on * @param node node that was collapsed * @param context provides access to {@link org.apache.click.Context} * @param oldValue contains the previous value of selected state */ public void nodeCollapsed(Tree tree, TreeNode node, Context context, boolean oldValue) { expandTracker.remove(node.getId()); } /** * @see TreeListener#nodeSelected(Tree, TreeNode, Context, boolean) * * @param tree tree the operation was made on * @param node node that was selected * @param context provides access to {@link org.apache.click.Context} * @param oldValue contains the previous value of selected state */ public void nodeSelected(Tree tree, TreeNode node, Context context, boolean oldValue) { /* noop */ } /** * @see TreeListener#nodeDeselected(Tree, TreeNode, Context, boolean) * * @param tree tree the operation was made on * @param node node that was selected * @param context provides access to {@link org.apache.click.Context} * @param oldValue contains the previous value of selected state */ public void nodeDeselected(Tree tree, TreeNode node, Context context, boolean oldValue) { /* noop */ } /** * Sets a cookie with the specified name and value to the * http response. * * @param value the cookie's value * @param name the cookie's name */ protected void setCookie(String value, String name) { Context context = getContext(); if (value == null) { ClickUtils.setCookie(context.getRequest(), context.getResponse(), name, value, 0, "/"); } else { ClickUtils.setCookie(context.getRequest(), context.getResponse(), name, value, -1, "/"); } } /** * Does some preparation on the cookie value like * decoding and stripping of unneeded characters. * * @param value the cookie's value to prepare * @return the prepared value */ protected String prepareCookieValue(String value) { try { if (StringUtils.isNotBlank(value)) { value = URLDecoder.decode(value, "UTF-8"); value = trimStr(value, "\""); } } catch (UnsupportedEncodingException ignore) { //ignore } return value; } /** * Returns the specified string value as a set, tokenizing the * string based on the specified delimiter. * * @param value value to return as set * @param delim delimiter used to tokenize the value * @return set of tokens */ protected Set<String> asSet(String value, String delim) { Set<String> set = new HashSet<String>(); if (value == null) { return set; } StringTokenizer tokenizer = new StringTokenizer(value, delim); while (tokenizer.hasMoreTokens()) { String id = tokenizer.nextToken(); set.add(id); } return set; } } /** * <strong>Please note</strong> this class is only meant for developers of * this control, not users. * <p/> * This class implements a session based javascript handler. It manages the * client side javascript behavior by tracking the client's selected tree paths. * <p/> * <strong>The problem</strong>: when javascript is enabled, the entire * tree must be sent to the browser to be navigable without round trips * to the server. However the tree should not be displayed in an expanded * state so css is used to apply the 'display: none' idiom to 'collapse' the * nodes even though they are really expanded. * <p/> * On the browser as the user expands and collapses nodes he/she will * make selections and deselections of certain nodes. Since the node's value * is rendered as a hyperlink, selecting or deselecting the node will * create a request to the server. * After the round trip to the server the tree will again be rendered in a * collapsed state because the server will apply the css 'display :none' idiom * before returning to the browser. It would be nice if instead of collapsing * the entire tree again, to keep those tree paths that lead to selected nodes * in a expanded state. * <p/> * <strong>The solution</strong>: SessionHandler keeps track of the * <em>selected paths</em> and is queried at rendering time which nodes * should be hidden and which nodes should be displayed. The * <em>selected path</em> are all the node's from the selected node up to the * root node. * <p/> * SessionHandler also keeps track of the <em>overlaid paths</em>. * <em>Overlaid paths</em> comes from two or more selected paths that share * certain common nodes. Overlaid paths are used in determining when a * selected path can be removed from the tracker. * To understand this better here is an example tree (top to bottom): * <p/> * <pre class="codeHtml"> * <span class="red">root</span> * <span class="blue">node1</span> <span class="blue">node2</span> * node1.1 node1.2 node2.1 node2.2 * </pre> * IF node1 is selected, the <em>selected path</em> would include the nodes * "root and node1". If node1.1 is then selected, the <em>selected path</em> would * also include the nodes "root, node1 and node1.1". The same ff node1.2 is selected. * The <em>overlaid path</em> would include the shared nodes of the three selected * paths. Thus the overlaid path would consist of "root" because that node is shared by * all three paths. Overlaid path will also contain "node1" because it is shared by node1.1 * and node1.2. * <p/> * <strong>The implementation</strong>: To keep memory storage to a minimum, * only the hashcode of a node is stored, and it is stored only once for any node on * any selected path. Thus if n nodes in a tree are selected there will only be n * hashcodes stored. * <p/> * The overlaid paths are stored in a object consisting of a counter which increments * and decrements each time a path is selected or deselected. The overlaid path also * stores a boolean indicating if a a node is the last node on the path or not. The * last node in the selected path should not be expanded because we do not want * the children of the last node to be displayed. Lets look at our previous example * again. The overlaid paths would contain the following information: * <ul> * <li>root: int = 3, lastNodeInPath = false</li> * <li>node1: int = 3, lastNodeInPath = false</li> * <li>node1.1: int = 1, lastNodeInPath = true</li> * <li>node1.2: int = 1, lastNodeInPath = true</li> * </ul> * The overlaid paths indicates that the root node was found on three selected paths, * and it is not the last node in the path. It cannot be the last node in the path * because its child, node1 is also found on the selected path. node1 is also found * on three selected paths (remember that node1 was itself selected) and is also * not the last node in the path because both its children are found on selected paths * as well. node1.1 is only found once on a selected path and because it does not * have any children, it is the last node in the path. node1.2 is the same as node1.1. * <p/> * When a user deselects a node, each node on the selected path are decremented * from the overlaid path counter. If a overlaid counter is reduced to 0, the * selected path is also removed from storage. Also as nodes are removed, each node's * lastNodeInPath indicator is updated to reflect if that node is the new last node * on the path. For example if a user deselects node1.1 the overlaid path will look as * follows: * <ul> * <li>root: int = 1, lastNodeInPath = false</li> * <li>node1: int = 1, lastNodeInPath = false</li> * <li>node1.1: int = 1, lastNodeInPath = true</li> * </ul> * Only 1 instance of root and node1 are left on the paths, but both are still not the * last node in the path. Because node1.2 counter was reduced to 0, it was removed * from the <em>selected path</em> storage as well. * <p/> * If the user deselects node1.1 overlaid path will look like this: * <ul> * <li>root: int = 1, lastNodeInPath = false</li> * <li>node1: int = 1, lastNodeInPath = true</li> * </ul> * node1 is now the lastNodeInPath. node1.1 is also removed from the * <em>selected path</em> storage. * <p/> * <strong>Note:</strong> this class stores information between requests * in the javax.servlet.http.HttpSession as a attribute. The attributes prefix * is <tt>js_path_handler_</tt> followed by the name of the tree * {@link Tree#name}. If two tree's in the same session have the same name they * will <strong>overwrite</strong> each others session attribute! */ protected class SessionHandler implements JavascriptHandler { private static final long serialVersionUID = 1L; /** * The reserved session key prefix for the selected paths * <tt>js_path_handler_</tt>. */ private static final String JS_HANDLER_SESSION_KEY = "js_path_handler_"; /** Renders the needed javascript for this handler. */ protected JavascriptRenderer javascriptRenderer; /** * Map of id's of all nodes that are on route to a selected node in the tree. * The value of the map is a Integer indicating the number of times the id * have been added to the map. This helps keep track of the number of * parallel paths. */ private Map<String, Entry> selectTracker = null; /** * This class is dependant on {@link org.apache.click.Context}, so this * constructor enforces a valid context before the handler can be * used. * * @param context provides access to the http request, and session */ protected SessionHandler(Context context) { if (context == null) { throw new IllegalArgumentException("Context cannot be null"); } init(context); } /** * Retrieves the tracker from the http session if it exists. Otherwise * it creates a new tracker and stores it in the http session. * * @param context provides access to the http request, and session */ @SuppressWarnings("unchecked") public void init(Context context) { //If already initialized if (selectTracker != null) { return; } if (context == null) { throw new IllegalArgumentException("context cannot be null"); } StringBuffer buffer = new StringBuffer(JS_HANDLER_SESSION_KEY).append(getName()); String key = buffer.toString(); selectTracker = (Map<String, Entry>) context.getSessionAttribute(key); if (selectTracker == null) { selectTracker = new HashMap<String, Entry>(); context.setSessionAttribute(key, selectTracker); } } /** * Queries the handler if the specified node should be rendered * as expanded or not. * * @param treeNode the specified node to check if it is expanded * or not * @return true if the specified node should be expanded, false * otherwise * @see #renderAsExpanded(TreeNode) */ public boolean renderAsExpanded(TreeNode treeNode) { Entry entry = selectTracker.get(treeNode.getId()); if (entry == null || entry.lastNodeInPath) { return false; } return true; } /** * @see #destroy() */ public void destroy() { /*noop*/ } /** * @see #getJavascriptRenderer() * * @return currently installed javascript renderer */ public JavascriptRenderer getJavascriptRenderer() { if (javascriptRenderer == null) { javascriptRenderer = new SessionRenderer(); } return javascriptRenderer; } /** * Adds all node's that are part of the <tt>selected path</tt> to * the tracker. * * @see TreeListener#nodeSelected(Tree, TreeNode, Context, boolean) * * @param tree tree the operation was made on * @param node node that was selected * @param context provides access to {@link org.apache.click.Context} * @param oldValue contains the previous value of selected state */ public void nodeSelected(Tree tree, TreeNode node, Context context, boolean oldValue) { //Check for duplicate path's. A duplicate path means that the user //selected a node that is already stored in the handler's tracker map. //This can only really happen when the tree is represented with //checkboxes. Each time the form is submitted all the "checked" //checkboxes are submitted but these node's might already have been //submitted in a previous request. So here is a check against the //previous value of the node to ensure it is newly selected. if (oldValue) { return; } TreeNode[] nodes = getPathToRoot(node); //Loop all nodes and check if they should be added or incremented for (int i = 0; i < nodes.length; i++) { TreeNode currentNode = nodes[i]; if (i > 0 && !currentNode.isExpanded()) { // If the node to expand is root and isRootNodeDisplayed is // false, don't notify listeners if (currentNode.isRoot() && !isRootNodeDisplayed() && isNotifyListeners()) { setNotifyListeners(false); expand(currentNode); setNotifyListeners(true); } else { expand(currentNode); } } String id = currentNode.getId(); Entry entry = selectTracker.get(id); //If node is not yet tracked, add it to the selected path tracker, //otherwise increment the overlaid path count if (entry == null) { Entry newEntry = new Entry(); newEntry.lastNodeInPath = i == 0; selectTracker.put(id, newEntry); } else { entry.lastNodeInPath = false; entry.count++; } } } /** * Removes all node's that are part of the <tt>selected path</tt> from * the tracker. * * @see TreeListener#nodeDeselected(Tree, TreeNode, Context, boolean) * * @param tree tree the operation was made on * @param node node that was deselected * @param context provides access to {@link org.apache.click.Context} * @param oldValue contains the previous value of selected state */ public void nodeDeselected(Tree tree, TreeNode node, Context context, boolean oldValue) { if (!oldValue) { return; } TreeNode[] nodes = getPathToRoot(node); //Loop all nodes and check if they should be removed or decremented for (int i = 0; i < nodes.length; i++) { TreeNode currentNode = nodes[i]; String id = currentNode.getId(); Entry entry = selectTracker.get(id); //This node is not tracked, so we continue looping. if (entry == null) { continue; } //If count > 1 currentNode has children if (entry.count > 1) { entry.count--; //After decrement the currentNode might be the last node in the path. //However if currentNode.isSelected == false, there is another sibling //of the currentNode that is the last node. if (entry.count == 1 && currentNode.isSelected()) { entry.lastNodeInPath = true; } } else { if (currentNode.isExpanded()) { // If the node to collapse is root and isRootNodeDisplayed // is false, don't notify listeners if (currentNode.isRoot() && !isRootNodeDisplayed() && isNotifyListeners()) { setNotifyListeners(false); collapse(currentNode); setNotifyListeners(true); } else { collapse(currentNode); } } selectTracker.remove(id); } } } /** * @see TreeListener#nodeExpanded(Tree, TreeNode, Context, boolean) * * @param tree tree the operation was made on * @param node node that was expanded * @param context provides access to {@link org.apache.click.Context} * @param oldValue contains the previous value of expanded state */ public void nodeExpanded(Tree tree, TreeNode node, Context context, boolean oldValue) { /*noop*/ } /** * @see TreeListener#nodeCollapsed(Tree, TreeNode, Context, boolean) * * @param tree tree the operation was made on * @param node node that was collapsed * @param context provides access to {@link org.apache.click.Context} * @param oldValue contains the previous value of selected state */ public void nodeCollapsed(Tree tree, TreeNode node, Context context, boolean oldValue) { /*noop*/ } } /** * Holds information about a selected node's path entry. Each entry corresponds * to a specific tree node. The node's id and corresponding entry is stored in a * java.util.Map, where the node's id is the key and the entry is the value. */ private static final class Entry implements Serializable { /** default serial version id. */ private static final long serialVersionUID = 1L; /** Number of times this entry was found on the path. */ private int count = 1; /** * Indicates if this entry is the last node in the path. At rendering time this * variable is checked, and if it is true, the css class "hide" is * appended, so that the nodes below this entry is not shown. */ private boolean lastNodeInPath = false; /** * Returns a string representation of the Entry instance. * * @return a string representation */ @Override public String toString() { StringBuffer buffer = new StringBuffer("Entry value -> (").append(count).append(")"); buffer.append(" lastNodeInPath -> (").append(lastNodeInPath).append(")"); return buffer.toString(); } } /** * Javascript helper method that checks if the specified tree * node should be hidden or not. * * @param treeNode tree node to check * @return true if the node should be hidden, false otherwise */ private boolean shouldHideNode(TreeNode treeNode) { if (isJavascriptEnabled() && !javascriptHandler.renderAsExpanded(treeNode)) { return true; } return false; } /** * Remove the specified toTrim string from the front and back * of the specified str argument. * * @param str to trim * @param toTrim the specified string to remove * @return trimmed string */ private String trimStr(String str, String toTrim) { if (StringUtils.isBlank(toTrim)) { return str; } if (str.startsWith(toTrim)) { str = str.substring(toTrim.length()); } if (str.endsWith(toTrim)) { str = str.substring(0, str.indexOf(toTrim)); } return str; } }