/* (c) 2016 Open Source Geospatial Foundation - all rights reserved * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.web.treeview; import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.HashMap; import java.util.Map; import java.util.Set; import org.apache.wicket.Component; import org.apache.wicket.ajax.AjaxEventBehavior; import org.apache.wicket.ajax.AjaxRequestTarget; import org.apache.wicket.ajax.attributes.AjaxRequestAttributes; import org.apache.wicket.ajax.markup.html.form.AjaxCheckBox; import org.apache.wicket.behavior.AttributeAppender; import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.form.FormComponentLabel; import org.apache.wicket.markup.html.panel.Panel; import org.apache.wicket.markup.repeater.RepeatingView; import org.apache.wicket.model.IModel; import org.apache.wicket.model.Model; import org.apache.wicket.model.util.CollectionModel; import org.apache.wicket.request.cycle.RequestCycle; /** * A component to display a tree that can be navigated through (folders can be expanded and closed, items can be selected) * * @author Niels Charlier * */ public class TreeView<T> extends Panel { private static final long serialVersionUID = 2683470514874500599L; /** * Behaviour for selected node */ protected static final AttributeAppender SELECTED_BEHAVIOR = new AttributeAppender("class", new Model<String>("selected"), " "); /** * Implement this to listen to selection events * */ public static interface SelectionListener<T> extends Serializable { public void onSelect(AjaxRequestTarget target); } /** * The selection listeners */ protected List<SelectionListener<T>> selectionListeners = new ArrayList<SelectionListener<T>>(); /** * Model for selected node */ protected IModel<Collection<TreeNode<T>>> selectedNodeModel; /** * Custom marked items */ protected Map<String, Mark> marks = new HashMap<String, Mark>(); /** * Constructor * * @param id component id * @param root the root node (creates model automatically) */ public TreeView(String id, TreeNode<T> root) { this(id, new Model<TreeNode<T>>(root)); } /** * Constructor * * @param id component id * @param rootModel model of the root node */ public TreeView(String id, IModel<TreeNode<T>> rootModel) { this(id, rootModel, new CollectionModel<TreeNode<T>>(new HashSet<TreeNode<T>>())); } /** * Constructor * * @param id component id * @param rootModel model of the root node * @param selectedNodeModel model of the selected node */ public TreeView(String id, IModel<TreeNode<T>> rootModel, IModel<Collection<TreeNode<T>>> selectedNodeModel) { super(id, rootModel); this.selectedNodeModel = selectedNodeModel; final RepeatingView rootView = new RepeatingView("rootView"); rootView.add(createTreeNodeView(rootModel.getObject().getUniqueId(), rootModel)); add(rootView); setOutputMarkupId(true); } /** * Get the root Node. */ @SuppressWarnings("unchecked") public TreeNode<T> getRootNode() { return (TreeNode<T>) getDefaultModelObject(); } /** * Get the selected Node Model. */ public IModel<? extends Collection<TreeNode<T>>> getSelectedNodeModel() { return selectedNodeModel; } /** * Get the selected Nodes. */ public Collection<TreeNode<T>> getSelectedNodes() { return Collections.unmodifiableCollection(selectedNodeModel.getObject()); } /** * Get the selected Node if it is single, null otherwise. * */ public TreeNode<T> getSelectedNode() { if (selectedNodeModel.getObject().size() == 1) { return selectedNodeModel.getObject().iterator().next(); } return null; } /** * Get the view for the selected node. Refreshing this view refreshes all child nodes as well. * It is efficient to only refresh the necessary node(s) on ajax requests, rather than the whole tree. */ public Panel[] getSelectedViews() { List<Panel> views = new ArrayList<Panel>(); if (!selectedNodeModel.getObject().isEmpty()) { for (TreeNode<T> selectedNode : selectedNodeModel.getObject()) { views.add(getNearestViewInternal(selectedNode)); } return views.toArray(new Panel[views.size()]); } else { return new Panel[] {}; } } /** * Get the view for a node, if there is not yet a view for this node, it will return the nearest parent node. * Refreshing this view refreshes all child nodes as well. * It is efficient to only refresh the necessary node(s) on ajax requests, rather than the whole tree. */ public Panel getNearestView(TreeNode<T> node) { return getNearestViewInternal(node); } /** * Add a selection listener */ public void addSelectionListener(SelectionListener<T> listener) { selectionListeners.add(listener); } /** * Change selected node, expand if necessary. Events aren't called. Caller is responsible for refreshing the view(s). * */ public void setSelectedNodes(Collection<TreeNode<T>> selectedNodes) { setSelectedNodes(selectedNodes, null); } /** * Change selected node, expand if necessary, fire events, and refresh the whole treeview. * */ public void setSelectedNodes(Collection<TreeNode<T>> selectedNodes, AjaxRequestTarget target) { //expand if necessary for (TreeNode<T> selectedNode : selectedNodes) { if (selectedNode != null) { TreeNode<T> node = selectedNode.getParent(); while (node != null) { node.getExpanded().setObject(true); node = node.getParent(); } } } setSelectedNodesInternal(selectedNodes, target); if (target != null) { target.add(this); } } /** * Change selected node (single), expand if necessary. Events aren't called. Caller is responsible for refreshing the view(s). * */ public void setSelectedNode(TreeNode<T> selectedNode) { setSelectedNode(selectedNode, null); } /** * Change selected node (single), expand if necessary, fire events, and refresh the whole treeview. * */ public void setSelectedNode(TreeNode<T> selectedNode, AjaxRequestTarget target) { setSelectedNodes(selectedNode == null ? Collections.emptySet() : Collections.singleton(selectedNode), target); } /** * Test if node has been selected. * * @param node node to test. * @return whether node is selected or not. */ public boolean isSelected(TreeNode<T> node) { for (TreeNode<T> selectedNode : selectedNodeModel.getObject()) { if (selectedNode.isSameAs(node)) { return true; } } return false; } /** * Register a customised mark. The style of the mark can be defined in CSS with ".css-treeview a.{markName}" * * @param markName name of the mark */ public void registerMark(String markName) { marks.put(markName, new Mark(markName)); } /** * Add marked node to a mark. * * @param markName name of the mark * @param node node to be added */ public void addMarked(String markName, TreeNode<T> node) { marks.get(markName).getMarked().add(node.getUniqueId()); } /** * Clear all marked nodes from a mark. * * @param markName name of the mark to be cleared */ public void clearMarked(String markName) { marks.get(markName).getMarked().clear(); } /** * Check if a node has a mark * * @param markName name of the mark * @param node the node that may or may not be marked * @return whether the mode is marked */ public boolean hasMark(String markName, TreeNode<T> node) { return marks.get(markName).getMarked().contains(node.getUniqueId()); } //------------------- internal methods/classes /** * * Select the node without automatic expansion but with event dispatching. * * @param selectedNode * @param target */ protected void setSelectedNodesInternal(Collection<TreeNode<T>> selectedNodes, AjaxRequestTarget target) { selectedNodeModel.setObject(selectedNodes); if (target != null) { for (SelectionListener<T> listener : selectionListeners) { listener.onSelect(target); } } } /** * Get the view for a node, if there is not yet a view for this node, it will return the nearest parent node. * Refreshing this view refreshes all child nodes as well. * It is efficient to only refresh the necessary node(s) on ajax requests, rather than the whole tree. * * @param node * @return */ protected TreeNodeView getNearestViewInternal(TreeNode<T> node) { TreeNode<T> parent = node.getParent(); if (parent == null) { return getRoot(); } else { TreeNodeView parentView = getNearestViewInternal(parent); if (parentView.getNode().isSameAs(parent)) { TreeNodeView childView = parentView.getChildView(node.getUniqueId()); if (childView != null) { return childView; } } return parentView; } } /** * * Get the root view. * * @return */ @SuppressWarnings("unchecked") protected TreeNodeView getRoot() { return (TreeNodeView) get("rootView").get(getRootNode().getUniqueId()); } /** * * Create a view from a node * * @param id wicket id for view * @param node the node * @return the view */ protected TreeNodeView createTreeNodeView(String id, IModel<TreeNode<T>> node) { if (node.getObject().isLeaf()) { return new TreeLeafView(id, node); } else { return new TreeExpandableNodeView(id, node); } } /** * View for one tree node (base) * * @author Niels Charlier * */ protected abstract class TreeNodeView extends Panel { private static final long serialVersionUID = 2940674057639126436L; protected Component selectableLabel; public TreeNodeView(String id, IModel<TreeNode<T>> nodeModel) { super(id, nodeModel); setOutputMarkupId(true); } @SuppressWarnings("unchecked") public TreeNode<T> getNode() { return (TreeNode<T>) getDefaultModelObject(); } protected Component createSelectableLabel() { return selectableLabel = new Label("selectableLabel", getNode().getLabel()).add( new AjaxEventBehavior("click") { private static final long serialVersionUID = -3705747320247194977L; @Override protected void updateAjaxAttributes(AjaxRequestAttributes attributes) { super.updateAjaxAttributes(attributes); attributes.getDynamicExtraParameters().add("return {'ctrl' : attrs.event.ctrlKey, 'shift' : attrs.event.shiftKey}"); attributes.setPreventDefault(true); } @Override public void onEvent(AjaxRequestTarget target) { boolean shift = RequestCycle.get().getRequest().getRequestParameters().getParameterValue("shift").toBoolean(); boolean ctrl = RequestCycle.get().getRequest().getRequestParameters().getParameterValue("ctrl").toBoolean(); if (ctrl) { //toggle selection of this node Set<TreeNode<T>> newSelectedNodes = new HashSet<TreeNode<T>>(); if (isSelected(getNode())) { for (TreeNode<T> selectedNode : getSelectedNodes()) { if (!selectedNode.isSameAs(getNode())) { newSelectedNodes.add(selectedNode); } } } else { newSelectedNodes.addAll(getSelectedNodes()); newSelectedNodes.add(getNode()); } setSelectedNodesInternal(newSelectedNodes, target); } else if (shift) { //group select to nearest sibling boolean select = false; boolean moveOn = false; Set<TreeNode<T>> newSelectedNodes = new HashSet<TreeNode<T>>(getSelectedNodes()); for (TreeNode<T> sibling : getNode().getParent().getChildren()) { if (!select && (sibling.isSameAs(getNode()) || isSelected(sibling))) { select = true; moveOn = !sibling.isSameAs(getNode()); //we _must_ move on to clicked node } else if (select && (sibling.isSameAs(getNode()) || (!moveOn && isSelected(sibling)))) { select = false; break; } if (select) { newSelectedNodes.add(sibling); target.add(getNearestViewInternal(sibling)); } } if (!select) { newSelectedNodes.add(getNode()); setSelectedNodesInternal(newSelectedNodes, target); } //if we never went out of select, there was no selected sibling to being with and we are //just going to ignore this. } else { //replace selection, old one is removed target.add(getSelectedViews()); setSelectedNodesInternal(Collections.singleton(getNode()), target); } target.add(TreeNodeView.this); } }).setOutputMarkupId(true); } public TreeNodeView getChildView(String id) { return null; } @Override protected void onBeforeRender() { super.onBeforeRender(); if (selectableLabel.getBehaviors().contains(SELECTED_BEHAVIOR)) { if (!isSelected(getNode())) { selectableLabel.remove(SELECTED_BEHAVIOR); } } else { if (isSelected(getNode())) { selectableLabel.add(SELECTED_BEHAVIOR); } } for (Mark mark : marks.values()) { if (selectableLabel.getBehaviors().contains(mark.getBehaviour())) { if (!mark.getMarked().contains(getNode().getUniqueId())) { selectableLabel.remove(mark.getBehaviour()); } } else { if (mark.getMarked().contains(getNode().getUniqueId())) { selectableLabel.add(mark.getBehaviour()); } } } } } /** * View for an expandable tree node (directory node) * */ protected class TreeExpandableNodeView extends TreeNodeView { private static final long serialVersionUID = 2940674057639126436L; public TreeExpandableNodeView(String id, IModel<TreeNode<T>> nodeModel) { super(id, nodeModel); final AjaxCheckBox cbExpand = new AjaxCheckBox("cbExpand", nodeModel.getObject().getExpanded()) { private static final long serialVersionUID = 7602857423814264211L; @Override protected void onUpdate(AjaxRequestTarget target) { if (!getModelObject() && !selectedNodeModel.getObject().isEmpty()) { //if any nodes in the current selection become hidden, we unselect them automatically Set<TreeNode<T>> newSelectedNodes = new HashSet<TreeNode<T>>(); for (TreeNode<T> selectedNode : selectedNodeModel.getObject()) { TreeNode<T> node = selectedNode.getParent(); boolean selectionHidden = false; while (!selectionHidden && node != null) { //loop through parents if (node.isSameAs(nodeModel.getObject())) { selectionHidden = true; } node = node.getParent(); } if (!selectionHidden) { newSelectedNodes.add(selectedNode); } } if (newSelectedNodes.size() != selectedNodeModel.getObject().size()) { setSelectedNodesInternal(newSelectedNodes, target); target.add(TreeExpandableNodeView.this); } } } }; add(cbExpand); add(new FormComponentLabel("label", cbExpand).add(createSelectableLabel())); //add(new FormComponentLabel("label", cbExpand)); //add(createSelectableLabel()); add(new RepeatingView("children")); } @Override protected void onBeforeRender() { super.onBeforeRender(); final RepeatingView children = (RepeatingView) get("children"); children.removeAll(); for (TreeNode<T> child : getNode().getChildren()) { children.add(createTreeNodeView(child.getUniqueId(), new Model<TreeNode<T>>(child))); } } @SuppressWarnings("unchecked") public TreeNodeView getChildView(String id) { return (TreeNodeView) ((RepeatingView) get("children")).get(id); } } /** * View for an tree node leaf * */ protected class TreeLeafView extends TreeNodeView { private static final long serialVersionUID = 2940674057639126436L; public TreeLeafView(String id, IModel<TreeNode<T>> nodeModel) { super(id, nodeModel); add(createSelectableLabel()); } } /** * Custom mark data * */ protected static class Mark implements Serializable { private static final long serialVersionUID = -827616908801489309L; private AttributeAppender behaviour; private Set<String> marked = new HashSet<String>(); public Mark(String behaviourName) { this.behaviour = new AttributeAppender("class", new Model<String>(behaviourName), " "); } public AttributeAppender getBehaviour() { return behaviour; } public Set<String> getMarked() { return marked; } } }