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