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