/*
* #%L
* carewebframework
* %%
* Copyright (C) 2008 - 2016 Regenstrief Institute, Inc.
* %%
* Licensed 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.
*
* This Source Code Form is also subject to the terms of the Health-Related
* Additional Disclaimer of Warranty and Limitation of Liability available at
*
* http://www.carewebframework.org/licensing/disclaimer.
*
* #L%
*/
package org.carewebframework.shell.designer;
import java.beans.PropertyDescriptor;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.carewebframework.shell.layout.UIElementBase;
import org.carewebframework.shell.layout.UIElementProxy;
import org.carewebframework.shell.plugins.PluginDefinition;
import org.carewebframework.shell.plugins.PluginRegistry;
import org.carewebframework.shell.property.PropertyInfo;
import org.carewebframework.ui.zk.TreeUtil;
import org.carewebframework.ui.zk.ZKUtil;
import org.springframework.beans.BeanUtils;
import org.zkoss.zk.ui.Component;
import org.zkoss.zk.ui.WrongValueException;
import org.zkoss.zk.ui.event.Event;
import org.zkoss.zk.ui.event.EventListener;
import org.zkoss.zk.ui.event.Events;
import org.zkoss.zk.ui.util.Clients;
import org.zkoss.zul.Button;
import org.zkoss.zul.Constraint;
import org.zkoss.zul.Textbox;
import org.zkoss.zul.Tree;
import org.zkoss.zul.Treechildren;
import org.zkoss.zul.Treeitem;
/**
* Abstract class for implementing a custom property editor based on a tree view display of child
* elements that can be manipulated and whose properties may be edited.
*
* @param <T> Type of child element.
*/
public abstract class PropertyEditorCustomTree<T extends UIElementBase> extends PropertyEditorCustom {
private static class LabelConstraint implements Constraint {
private Component lastTarget;
@Override
public void validate(Component comp, Object value) throws WrongValueException {
clearMessage();
String realValue = value == null ? null : ((String) value).trim();
lastTarget = (Component) comp.getAttribute(ITEM_ATTR);
if (StringUtils.isEmpty(realValue)) {
throw new WrongValueException(lastTarget, "Label cannot be blank.");
}
}
public void clearMessage() {
if (lastTarget != null) {
Clients.clearWrongValue(lastTarget);
lastTarget = null;
}
}
}
/**
* Subclass UIElementProxy to allow us to synchronize changes to label property with the
* corresponding tree node.
*/
private class Proxy extends UIElementProxy {
private Treeitem item;
public Proxy(T child) {
super(child);
}
public Proxy(PluginDefinition def) {
super(def);
}
public UIElementBase realize() throws Exception {
Treeitem parentItem = item.getParentItem();
UIElementBase parentElement = parentItem == null ? getTarget() : getProxy(parentItem).realize();
realize(parentElement);
return getTarget();
}
public void setItem(Treeitem item) {
this.item = item;
item.setValue(this);
item.setLabel(getLabel());
}
/**
* Returns the label property value.
*
* @return The label text, or null if none.
*/
public String getLabel() {
String label = getProperty(labelProperty);
label = label == null ? item.getLabel() : label;
if (label == null) {
label = getDefaultInstanceName();
setProperty(labelProperty, label);
}
return label;
}
public void setLabel(String label) {
setProperty(labelProperty, label);
item.setLabel(label);
}
/**
* Returns a property value.
*
* @param propertyName The property name.
* @return The property value, or null if none.
*/
private String getProperty(String propertyName) {
return propertyName == null ? null : (String) getPropertyValue(propertyName);
}
private String setProperty(String propertyName, String value) {
return propertyName == null ? null : (String) setPropertyValue(propertyName, value);
}
/**
* Return the name to be assigned to a new instance of the child component.
* <p>
* Subclasses may override to provide an alternate instance name to be given to newly
* created elements.
*
* @return The default instance name.
*/
private String getDefaultInstanceName() {
String name = getDefinition().getName() + " #";
int i = 0;
Tree tree = item.getTree();
while (TreeUtil.findNodeByLabel(tree, name + ++i, false) != null) {}
return name + i;
}
}
private static final Log log = LogFactory.getLog(PropertyEditorCustomTree.class);
private static final String ITEM_ATTR = "EDITED_ITEM";
private static final String LABEL_ATTR = "ITEM_LABEL";
private Button btnUp;
private Button btnDown;
private Button btnRight;
private Button btnLeft;
private Button btnDelete;
private Tree tree;
private Treechildren root;
private Component gridParent;
private final PropertyGrid propertyGrid;
private Treeitem currentItem;
private final boolean hierarchical;
private final String labelProperty;
private boolean hasChanged;
private boolean selectionChanging;
private Event changeEvent;
private final Textbox txtLabel = new Textbox();
private PropertyEditorBase<?> labelEditor;
private final List<Proxy> proxies = new ArrayList<>();
private final Class<T> childClass;
private final PluginDefinition definition;
/**
* Create the editor instance.
*
* @param childClass This is the class of child elements managed by this editor. It is used to
* create new instances of the UI element from within the editor and to restrict the
* selection of child elements to that type only.
* <p>
* The owner class may be null. In this case, there is no restriction on the child
* type. The user is prompted for an element type when creating a new instance.
* @param labelProperty This is the name of the property on the child UI element that will be
* synchronized with the label of the corresponding tree node. It may be null.
* @param hierarchical If true, the editor assumes that child elements may contain other child
* elements.
* @throws Exception Unspecified exception.
*/
public PropertyEditorCustomTree(Class<T> childClass, String labelProperty, boolean hierarchical) throws Exception {
super(DesignConstants.RESOURCE_PREFIX + "PropertyEditorCustomTree.zul");
this.childClass = childClass;
this.labelProperty = labelProperty;
this.hierarchical = hierarchical;
btnRight.setVisible(hierarchical);
btnLeft.setVisible(hierarchical);
propertyGrid = PropertyGrid.create(null, gridParent);
propertyGrid.setClosable(true);
Events.addEventListeners(propertyGrid, this);
txtLabel.setWidth("95%");
txtLabel.setConstraint(new LabelConstraint());
bandpopup.setHeight("400px");
bandpopup.setWidth("600px");
definition = childClass == null ? null : PluginRegistry.getInstance().get(childClass);
EventListener<Event> labelEditorListener = new EventListener<Event>() {
@Override
public void onEvent(Event event) throws Exception {
event.stopPropagation();
editNodeStop();
}
};
txtLabel.addEventListener(Events.ON_CHANGE, labelEditorListener);
txtLabel.addEventListener(Events.ON_BLUR, labelEditorListener);
txtLabel.addEventListener(Events.ON_OK, labelEditorListener);
}
/**
* Returns the plugin definition for the proxied object. This is used to for cases where a proxy
* is created for a child element that does not yet exist. It enables the proxy to defer
* creation of the proxied element until a commit occurs.
* <p>
* Note that if there is no specified plugin definition, the add component dialog will be
* presented, allowing the user to select which definition to use.
*
* @return The plugin definition to use. This may be null.
*/
protected PluginDefinition getTargetDefinition() {
if (definition != null) {
return definition;
}
PluginDefinition def = AddComponent.getDefinition(getTarget());
editor.open();
return def;
}
/**
* Subclasses may override to provide any special proxy initialization.
*
* @param proxy Proxy to be initialized.
*/
protected void initProxy(Proxy proxy) {
}
/**
* Initializes the property editor by constructing the tree view based on the child elements of
* the target element.
*
* @param target Target element whose children will be represented in the tree view.
* @param propInfo The property information associated with the child elements.
* @param propGrid The parent property grid.
*/
@Override
protected void init(UIElementBase target, PropertyInfo propInfo, PropertyGrid propGrid) {
super.init(target, propInfo, propGrid);
changeEvent = new Event(Events.ON_CHANGE, propGrid);
resetTree();
}
/**
* Commits changes to all proxies.
*/
@Override
public boolean commit() {
boolean result = super.commit();
if (result) {
try {
commitProxies();
} catch (Exception e) {
result = false;
setWrongValueException(e);
log.error("Error committing changes.", e);
}
}
return result;
}
/**
* Reverts all changes to the last committed state. This is done by simply discarding all
* proxies and recreating them from the original child elements.
*/
@Override
public boolean revert() {
boolean result = super.revert();
if (result) {
resetTree();
}
return result;
}
/**
* Rebuilds the tree from original target, canceling any pending changes.
*/
private void resetTree() {
currentItem = null;
proxies.clear();
ZKUtil.detachChildren(root);
initTree(getTarget(), root);
selectItem((Treeitem) root.getFirstChild());
hasChanged = false;
}
/**
* Initializes the tree view based on the children of the specified target.
*
* @param target The target UI element whose children will be added to the tree.
* @param parent The parent component to receive the new tree items.
*/
private void initTree(UIElementBase target, Component parent) {
for (UIElementBase child : target.getChildren()) {
if (childClass == null || childClass.isInstance(child)) {
@SuppressWarnings("unchecked")
Treeitem item = addTreeitem((T) child, parent, null);
if (hierarchical) {
initTree(child, getTreechildren(item));
}
}
}
}
/**
* Returns the tree children for the item, creating one if it does not exist.
*
* @param item Item whose tree children property is sought.
* @return Tree children for item.
*/
private Treechildren getTreechildren(Treeitem item) {
Treechildren tc = item.getTreechildren();
if (tc == null) {
item.appendChild(tc = new Treechildren());
}
return tc;
}
/**
* Adds a tree item under the specified parent.
*
* @param child Child to be associated (via a proxy) with the newly created tree item.
* @param parent The tree parent to receive the new tree item.
* @param insertAfter Tree item after which new item will be added (may be null).
* @return The newly created tree item.
*/
protected Treeitem addTreeitem(T child, Component parent, Treeitem insertAfter) {
Proxy proxy = newProxy(child);
Treeitem item = null;
if (proxy != null) {
item = new Treeitem();
parent = parent == null ? (insertAfter == null ? root : insertAfter.getParent()) : parent;
Component refChild = insertAfter == null ? null : insertAfter.getNextSibling();
parent.insertBefore(item, refChild);
proxy.setItem(item);
initProxy(proxy);
if (hasLabelProperty(proxy.getDefinition().getClazz())) {
item.addForward(Events.ON_DOUBLE_CLICK, tree, null);
}
}
return item;
}
/**
* Returns true if the class has a writable label property.
*
* @param clazz The class to check.
* @return True if a writable label property exists.
*/
private boolean hasLabelProperty(Class<?> clazz) {
try {
PropertyDescriptor pd = labelProperty == null ? null : BeanUtils.getPropertyDescriptor(clazz, labelProperty);
return pd != null && pd.getWriteMethod() != null;
} catch (Exception e) {
return false;
}
}
/**
* Commits changes to all proxies to their proxied elements. For insertion and deletion
* operations, the proxied elements are created or deleted at this time. The commit operation
* writes all property settings from the proxy to the proxied element.
*
* @throws Exception Unspecified exception.
*/
private void commitProxies() throws Exception {
for (Proxy proxy : proxies) {
proxy.realize();
proxy.commit();
}
resequenceTargets(root, getTarget());
hasChanged = false;
}
/**
* Resequence all UI elements to match that of the tree view.
*
* @param tc Root of subtree.
* @param parent The parent UI element.
*/
protected void resequenceTargets(Treechildren tc, UIElementBase parent) {
if (tc != null) {
int index = -1;
for (Component child : tc.getChildren()) {
index++;
Treeitem item = (Treeitem) child;
UIElementBase target = getProxy(item).getTarget();
target.setParent(parent);
target.setIndex(index);
resequenceTargets(item.getTreechildren(), target);
}
}
}
/**
* Creates a new proxy for the specified child. If the child is null, a proxy is created for the
* child class.
*
* @param child Element to be proxied. May be null.
* @return The proxy wrapping the specified child.
*/
private Proxy newProxy(T child) {
Proxy proxy;
if (child != null) {
proxy = new Proxy(child);
} else {
PluginDefinition def = getTargetDefinition();
if (def == null) {
return null;
}
proxy = new Proxy(def);
}
proxies.add(proxy);
return proxy;
}
/**
* Occurs when the tree view selection changes.
*/
public void onSelect$tree() {
selectionChanged();
}
/**
* Double-clicking a tree item allows in place editing.
*
* @param event The double click event.
*/
public void onDoubleClick$tree(Event event) {
Component target = ZKUtil.getEventOrigin(event).getTarget();
if (target instanceof Treeitem) {
event.stopPropagation();
editNodeStart((Treeitem) target);
}
}
public void onLabelChange$tree() {
Object label = labelEditor.getValue();
updateLabel(currentItem, label == null ? "" : label.toString());
}
/**
* Place the specified tree item in edit mode. In this mode, the node's label is replaced with a
* text box containing the label's value.
*
* @param item Target tree item.
*/
private void editNodeStart(Treeitem item) {
txtLabel.setAttribute(ITEM_ATTR, item);
if (item == null) {
txtLabel.detach();
} else {
String label = item.getLabel();
item.setAttribute(LABEL_ATTR, label);
txtLabel.setValue(label);
item.setLabel(null);
item.getTreerow().getFirstChild().appendChild(txtLabel);
txtLabel.setFocus(true);
}
}
/**
* Called after a node's label has been edited. Updates the node's label with the edited value
* and takes the node out of edit mode.
*/
private void editNodeStop() {
Treeitem currentEdit = (Treeitem) txtLabel.getAttribute(ITEM_ATTR);
txtLabel.removeAttribute(ITEM_ATTR);
txtLabel.detach();
if (currentEdit != null) {
String oldLabel = (String) currentEdit.getAttribute(LABEL_ATTR);
String newLabel = txtLabel.isValid() ? txtLabel.getValue() : oldLabel;
currentEdit.setLabel(newLabel);
if (!newLabel.equals(oldLabel)) {
updateLabel(currentEdit, newLabel);
}
}
}
@Override
public void doClose() {
tree.focus();
Events.echoEvent(Events.ON_SELECT, tree, null); // Must be done asynchronously to allow server to sync with client changes
}
/**
* If the property grid is closed, instead close the editor.
*
* @param event The close event.
*/
public void onClose(Event event) {
if (event.getTarget() == propertyGrid) {
editor.close();
doClose();
}
}
/**
* Returns the proxy associated with the given tree item.
*
* @param item A tree item.
* @return The proxy associated with the tree item.
*/
@SuppressWarnings("unchecked")
private Proxy getProxy(Treeitem item) {
return item == null ? null : (Proxy) item.getValue();
}
/**
* Call this to update the grid to reflect a newly selected target.
*/
private void selectionChanged() {
selectionChanging = true;
editNodeStop();
if (currentItem != null) {
propertyGrid.commitChanges(true);
Proxy proxy = getProxy(currentItem);
if (proxy != null) {
currentItem.setLabel(proxy.getLabel());
}
}
currentItem = tree.getSelectedItem();
propertyGrid.setTarget(getProxy(currentItem));
labelEditor = propertyGrid.findEditor(labelProperty);
if (labelEditor != null) {
labelEditor.getComponent().addForward(Events.ON_CHANGE, tree, "onLabelChange");
}
updateControls();
selectionChanging = false;
}
/**
* Updates the label for this node.
*
* @param item A tree item.
* @param label Updated label.
*/
private void updateLabel(Treeitem item, String label) {
Proxy proxy = getProxy(item);
if (proxy != null) {
proxy.setLabel(label);
} else {
item.setLabel(label);
}
if (item == tree.getSelectedItem() && labelEditor != null) {
labelEditor.revert();
}
doChanged(false);
}
/**
* Causes a new tree item to be selected and the display state to be updated.
*
* @param item = Item to be selected.
*/
private void selectItem(Treeitem item) {
tree.setSelectedItem(item);
selectionChanged();
}
/**
* Add a new item.
*/
public void onClick$btnAdd() {
selectItem(addTreeitem(null, null, tree.getSelectedItem()));
doChanged(true);
}
/**
* Delete the selected item. If the associated proxy does not yet contain a proxied target, it
* is simply removed from the proxy list. Otherwise, it is marked as deleted, but remains in the
* list.
*/
public void onClick$btnDelete() {
Treeitem item = tree.getSelectedItem();
if (item != null) {
Proxy proxy = getProxy(item);
if (proxy.getTarget() == null) {
proxies.remove(proxy);
} else {
proxy.setDeleted(true);
}
Treeitem nextItem = (Treeitem) item.getNextSibling();
nextItem = nextItem == null ? (Treeitem) item.getPreviousSibling() : nextItem;
item.detach();
currentItem = null;
selectItem(nextItem);
doChanged(true);
}
}
/**
* Move the selected tree item up one level.
*/
public void onClick$btnUp() {
Treeitem item = tree.getSelectedItem();
Treeitem sib = (Treeitem) item.getPreviousSibling();
swap(sib, item);
}
/**
* Move the selected tree item down one level.
*/
public void onClick$btnDown() {
Treeitem item = tree.getSelectedItem();
Treeitem sib = (Treeitem) item.getNextSibling();
swap(item, sib);
}
/**
* Promote the selected tree item.
*/
public void onClick$btnLeft() {
Treeitem item = tree.getSelectedItem();
Treeitem parent = item.getParentItem();
parent.getParent().insertBefore(item, parent.getNextSibling());
doChanged(true);
updateControls();
}
/**
* Demote the selected tree item.
*/
public void onClick$btnRight() {
Treeitem item = tree.getSelectedItem();
Treeitem sib = (Treeitem) item.getPreviousSibling();
Treechildren treechildren = getTreechildren(sib);
treechildren.appendChild(item);
doChanged(true);
updateControls();
}
/**
* Moves item1 to follow item2.
*
* @param item1 = A tree item.
* @param item2 = A tree item.
*/
private void swap(Treeitem item1, Treeitem item2) {
if (item1 != null && item2 != null) {
Component parent = item2.getParent();
item2.detach();
parent.insertBefore(item2, item1);
doChanged(true);
}
}
/**
* Catches onChange events from the grid.
*
* @param event The onChange event.
*/
public void onChange(Event event) {
doChanged(false);
}
/**
* Update the hasChanged flag. Fires an event to the parent property grid if the state has
* changed.
*
* @param updateControls If true, control states are updated first.
*/
private void doChanged(boolean updateControls) {
if (updateControls) {
updateControls();
}
if (!selectionChanging && !hasChanged) {
hasChanged = true;
Events.sendEvent(changeEvent);
}
}
/**
* Override to enable promotion of items
*
* @param item The item to test.
* @return True if the item can be promoted.
*/
protected boolean canPromote(Treeitem item) {
return hierarchical && item.getPreviousSibling() != null;
}
/**
* Override to enable promotion of items
*
* @param item The item to test.
* @return True if the item can be demoted.
*/
protected boolean canDemote(Treeitem item) {
return hierarchical && item.getParent() != root;
}
/**
* Update toolbar buttons to reflect the current selection state.
*/
private void updateControls() {
TreeUtil.adjustVisibility(tree);
Treeitem item = tree.getSelectedItem();
disableButton(btnDelete, item == null);
disableButton(btnRight, item == null || !canPromote(item));
disableButton(btnLeft, item == null || !canDemote(item));
disableButton(btnUp, item == null || item.getPreviousSibling() == null);
disableButton(btnDown, item == null || item.getNextSibling() == null);
}
private void disableButton(Button btn, boolean disabled) {
btn.setDisabled(disabled);
btn.setStyle("opacity:" + (disabled ? ".4" : "1"));
}
/**
* Returns true if changes have been made since the last commit.
*/
@Override
public boolean hasChanged() {
return hasChanged;
}
}