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 * <p/>
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 * <p/>
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}