/** * GRANITE DATA SERVICES * Copyright (C) 2006-2015 GRANITE DATA SERVICES S.A.S. * * This file is part of the Granite Data Services Platform. * * *** * * Community License: GPL 3.0 * * This file is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published * by the Free Software Foundation, either version 3 of the License, * or (at your option) any later version. * * This file is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. * * *** * * Available Commercial License: GraniteDS SLA 1.0 * * This is the appropriate option if you are creating proprietary * applications and you are not prepared to distribute and share the * source code of your application under the GPL v3 license. * * Please visit http://www.granitedataservices.com/license for more * details. */ package org.granite.client.javafx.validation; import java.lang.ref.WeakReference; import java.lang.reflect.Array; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.Property; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.control.Skinnable; import javafx.scene.control.TextInputControl; import javax.validation.ConstraintViolation; import javax.validation.groups.Default; import org.granite.client.validation.NotifyingValidator; import org.granite.client.validation.NotifyingValidator.ConstraintViolationsHandler; import org.granite.client.validation.NotifyingValidatorFactory; import org.granite.logging.Logger; /** * @author William DRAI */ public class FormValidator { private static final Logger log = Logger.getLogger(FormValidator.class); public static final String UNHANDLED_VIOLATIONS = "unhandledViolations"; private ObjectProperty<Object> entity = new SimpleObjectProperty<Object>(this, "entity"); private Parent form; private List<Node> inputs = new ArrayList<Node>(); private Map<Node, Property<?>> inputProperties = new IdentityHashMap<Node, Property<?>>(); private Map<Node, Property<?>> entityProperties = new IdentityHashMap<Node, Property<?>>(); private Set<Node> focusedOutOnce = new HashSet<Node>(); private List<ConstraintViolation<?>> violations = new ArrayList<ConstraintViolation<?>>(); private ObservableList<ConstraintViolation<?>> unhandledViolations = FXCollections.observableArrayList(); private final NotifyingValidator validator; /** * The <code>Validator</code> to be used in the validation * process (initialized with the the default instance). */ public FormValidator(NotifyingValidatorFactory validatorFactory) { this.validator = validatorFactory.getValidator(); entity.addListener(new ChangeListener<Object>() { @Override public void changed(ObservableValue<? extends Object> object, Object oldValue, Object newValue) { if (form == null) return; // Reattach validator to same form to update entity setupForm(null); setupForm(form); } }); } public ObjectProperty<Object> entityProperty() { return entity; } public Object getEntity() { return entity; } /** * Should validation be done on the fly? Otherwise, validation will be * only done when an input loses focus. Default is true. */ public BooleanProperty validateOnChangeProperty = new SimpleBooleanProperty(this, "validateOnChange", true); public boolean isValidateOnChange() { return validateOnChangeProperty.get(); } public void setValidateOnChange(boolean validateOnChange) { this.validateOnChangeProperty.set(validateOnChange); } /** * The validation groups to be used, as an array of <code>Class</code> * names. Default is null, meaning that the <code>Default</code> group * will be used. */ public Class<?>[] groups = new Class<?>[] { Default.class }; /** * The form component that contains inputs bound to the entity properties * (may be a <code>Form</code> or any other <code>Container</code> * subclass). */ public Parent getForm() { return form; } public void setForm(Parent form) { if (form == this.form) return; if (this.form != null) setupForm(null); this.form = form; if (this.form != null) setupForm(this.form); } /** * Returns the result of the last global validation as an array of * <code>ConstraintViolation</code>s. * * @return the result of the last global validation as an array of * <code>ConstraintViolation</code>s. */ public List<ConstraintViolation<?>> getViolations() { return violations; } /** * Returns the <i>unhandled</i> violations of the last global validation * as an array of <code>ConstraintViolation</code>s. Unhandled violations * are violations that couldn't be associated to any input during the * last global validation (thus, they couldn't be displayed anywhere). * * @return the <i>unhandled</i> violations of the last global validation * as an array of <code>ConstraintViolation</code>s. */ public List<ConstraintViolation<?>> getUnhandledViolations() { return unhandledViolations; } protected void setupForm(Parent form) { // Untrack child nodes untrackNode(this.form); if (!inputs.isEmpty()) { inputs.clear(); log.warn("Inputs were not cleared correctly"); } if (!inputProperties.isEmpty()) { inputProperties.clear(); log.warn("Input properties were not cleared correctly"); } if (!entityProperties.isEmpty()) { entityProperties.clear(); log.warn("Entity properties were not cleared correctly"); } if (!trackedNodes.isEmpty()) { trackedNodes.clear(); log.warn("Tracked parents were not cleared correctly"); } focusedOutOnce.clear(); if (form != null) trackNode(form); } private ListChangeListener<Node> childChangeListener = new ChildChangeListener(); public class ChildChangeListener implements ListChangeListener<Node> { @Override public void onChanged(ListChangeListener.Change<? extends Node> change) { while (change.next()) { if (change.wasReplaced() && change.getRemovedSize() == 1 && change.getAddedSize() == 1 && change.getAddedSubList().get(0) == change.getRemoved().get(0)) continue; if (change.wasRemoved()) { for (Node node : change.getRemoved()) untrackNode(node); } if (change.wasAdded()) { for (Node node : change.getAddedSubList()) trackNode(node); } if (change.wasPermutated()) { log.debug("Permutation ??"); } } } } private IdentityHashMap<Node, Boolean> trackedNodes = new IdentityHashMap<Node, Boolean>(); protected void trackNode(Node node) { if (form == null) return; if (trackedNodes.containsKey(node)) return; setupNode(node); trackedNodes.put(node, Boolean.TRUE); if (node instanceof Skinnable && ((Skinnable)node).getSkin() != null && ((Skinnable)node).getSkin().getNode() != node) trackNode(((Skinnable)node).getSkin().getNode()); if (node instanceof Parent) { for (Node child : ((Parent)node).getChildrenUnmodifiable()) trackNode(child); ((Parent)node).getChildrenUnmodifiable().addListener(childChangeListener); log.debug("Setup children tracking for parent %s", node); } } protected void untrackNode(Node node) { if (form == null) return; if (!trackedNodes.containsKey(node)) return; unsetupNode(node); trackedNodes.remove(node); if (node instanceof Parent) { ((Parent)node).getChildrenUnmodifiable().removeListener(childChangeListener); log.debug("Unset children tracking for parent %s", node.toString()); for (Node child : ((Parent)node).getChildrenUnmodifiable()) untrackNode(child); } if (node instanceof Skinnable && ((Skinnable)node).getSkin() != null && ((Skinnable)node).getSkin().getNode() != node) untrackNode(((Skinnable)node).getSkin().getNode()); } private void setupNode(Node node) { // If node is already tracked, clear everything in case user did not unbind old data if (inputProperties.containsKey(node)) { Property<?> entityProperty = entityProperties.remove(node); Property<?> inputProperty = inputProperties.remove(node); if (entityProperty != null && entityProperty.getBean() != null) validator.removeConstraintViolationsHandler(entityProperty.getBean(), constraintViolationHandler); inputProperty.removeListener(valueChangeListener); node.focusedProperty().removeListener(inputFocusChangeListener); inputs.remove(node); log.debug("Cleanup old tracking for fantom node %s input %s entity %s", node, inputProperty.getName(), entityProperty); } Property<?> inputProperty = null; if (node instanceof TextInputControl) inputProperty = ((TextInputControl)node).textProperty(); if (inputProperty != null) { Property<?> entityProperty = lookupBindingTarget(inputProperty); if (entityProperty != null) { inputProperties.put(node, inputProperty); entityProperties.put(node, entityProperty); if (entityProperty.getBean() != null) validator.addConstraintViolationsHandler(entityProperty.getBean(), constraintViolationHandler); inputProperty.addListener(valueChangeListener); node.focusedProperty().addListener(inputFocusChangeListener); inputs.add(node); log.debug("Setup tracking for node %s input %s entity %s", node, inputProperty.getName(), entityProperty); } } } private void unsetupNode(Node node) { int idx = inputs.indexOf(node); if (idx >= 0) { Property<?> entityProperty = entityProperties.remove(node); if (entityProperty.getBean() != null) validator.removeConstraintViolationsHandler(entityProperty.getBean(), constraintViolationHandler); node.fireEvent(new ValidationResultEvent(this, node, ValidationResultEvent.VALID, null)); if (node instanceof TextInputControl) ((TextInputControl)node).textProperty().removeListener(valueChangeListener); node.focusedProperty().removeListener(inputFocusChangeListener); Property<?> inputProperty = inputProperties.remove(node); inputs.remove(idx); log.debug("Unsetup tracking for node %s input %s entity %s", node, inputProperty.getName(), entityProperty); } } /* * Ugly hack to determine target of bidirectional binding */ private Property<?> lookupBindingTarget(Property<?> inputProperty) { try { Field fh = inputProperty.getClass().getDeclaredField("helper"); fh.setAccessible(true); Object helper = fh.get(inputProperty); Field fcl = helper.getClass().getDeclaredField("changeListeners"); fcl.setAccessible(true); Object changeListeners = fcl.get(helper); if (changeListeners != null && Array.getLength(changeListeners) > 0) { ChangeListener<?> cl = (ChangeListener<?>)Array.get(changeListeners, 0); try { Field fpr = cl.getClass().getDeclaredField("propertyRef2"); fpr.setAccessible(true); WeakReference<?> ref= (WeakReference<?>)fpr.get(cl); Property<?> p = (Property<?>)ref.get(); return p; } catch (NoSuchFieldException e) { log.debug("Field propertyRef2 not found on " + cl + ", probably not a standard binding", e); return null; } } log.debug("Could not find target binding for property %s", inputProperty); return null; } catch (Exception e) { log.warn(e, "Could not find target binding for property %s", inputProperty); return null; } } private ChangeListener<Boolean> inputFocusChangeListener = new InputFocusChangeListener(); private ChangeListener<Object> valueChangeListener = new ValueChangeListener(); /** * @private */ private class InputFocusChangeListener implements ChangeListener<Boolean> { @Override public void changed(ObservableValue<? extends Boolean> change, Boolean oldValue, Boolean newValue) { if (Boolean.TRUE.equals(oldValue) && Boolean.FALSE.equals(newValue)) validateValue((Node)((ReadOnlyBooleanProperty)change).getBean(), true); } } private class ValueChangeListener implements ChangeListener<Object> { @SuppressWarnings("unchecked") @Override public void changed(ObservableValue<?> change, Object oldValue, Object newValue) { if (validateOnChangeProperty.get()) validateValue((Node)((Property<Object>)change).getBean(), false); } } private ConstraintViolationsHandler<Object> constraintViolationHandler = new ConstraintViolationHandlerImpl(); private class ConstraintViolationHandlerImpl implements ConstraintViolationsHandler<Object> { @Override public void handle(Object entity, Set<ConstraintViolation<Object>> violations) { focusedOutOnce.addAll(inputs); if (violations == null) return; for (ConstraintViolation<?> violation : violations) { Object leafBean = violation.getLeafBean(); String property = null; Iterator<javax.validation.Path.Node> in = violation.getPropertyPath().iterator(); while (in.hasNext()) { javax.validation.Path.Node n = in.next(); property = n.getName(); } String[] path = property.split("\\."); property = path[path.length-1]; Node input = null; for (Entry<Node, Property<?>> me : entityProperties.entrySet()) { if (leafBean != null && leafBean.equals(me.getValue().getBean()) && me.getValue().getName().equals(property)) { input = me.getKey(); break; } } if (input != null) { List<ValidationResult> results = Collections.singletonList(new ValidationResult(true, entityProperties.get(input), "constraintViolation", violation.getMessage())); input.fireEvent(new ValidationResultEvent(this, input, ValidationResultEvent.INVALID, results)); } } } } protected boolean validateValue(Node input, boolean focusOut) { Property<?> entityProperty = entityProperties.get(input); Property<?> inputProperty = inputProperties.get(input); if (entityProperty == null || inputProperty == null) { log.warn("validateValue called for untracked input " + input); return true; } if (focusOut) focusedOutOnce.add(input); boolean nulled = false; Object value = inputProperty.getValue(); if ("".equals(value)) { value = null; nulled = true; } @SuppressWarnings("unchecked") Class<Object> entityClass = (Class<Object>)entityProperty.getBean().getClass(); Set<ConstraintViolation<Object>> violations = validator.validateValue(entityClass, entityProperty.getName(), value, groups); if (violations == null) violations = Collections.emptySet(); if (violations.isEmpty() && !nulled) focusedOutOnce.add(input); else if (!focusedOutOnce.contains(input)) return true; handleViolations(input, violations); return violations.isEmpty(); } /** * @inheritDoc */ protected void handleViolations(Node input, Set<ConstraintViolation<Object>> violations) { List<ValidationResultEvent> resultEvents = new ArrayList<ValidationResultEvent>(); if (input != null) { if (!violations.isEmpty()) { List<ValidationResult> results = new ArrayList<ValidationResult>(); for (ConstraintViolation<?> violation : violations) results.add(new ValidationResult(true, entityProperties.get(input), "constraintViolation", violation.getMessage())); resultEvents.add(new ValidationResultEvent(this, input, ValidationResultEvent.INVALID, results)); } else resultEvents.add(new ValidationResultEvent(this, input, ValidationResultEvent.VALID, null)); } else { Set<ConstraintViolation<?>> unhandledViolations = new HashSet<ConstraintViolation<?>>(violations); for (Node inp : inputs) { List<ValidationResult> results = new ArrayList<ValidationResult>(); Property<?> property = entityProperties.get(inp); Iterator<ConstraintViolation<?>> iv = unhandledViolations.iterator(); while (iv.hasNext()) { ConstraintViolation<?> violation = iv.next(); Iterator<javax.validation.Path.Node> in = violation.getPropertyPath().iterator(); javax.validation.Path.Node n = null; while (in.hasNext()) n = in.next(); if (violation.getLeafBean().equals(property.getBean()) && n.getName().equals(property.getName())) { ValidationResult result = new ValidationResult(true, property, "constraintViolation", violation.getMessage()); results.add(result); iv.remove(); } } if (results.isEmpty()) { // No violation for this input : add a valid result resultEvents.add(new ValidationResultEvent(this, inp, ValidationResultEvent.VALID, null)); } else { resultEvents.add(new ValidationResultEvent(this, inp, ValidationResultEvent.INVALID, results)); } } this.unhandledViolations.clear(); if (!unhandledViolations.isEmpty()) { this.unhandledViolations.addAll(unhandledViolations); List<ValidationResult> unhandledResults = new ArrayList<ValidationResult>(); for (ConstraintViolation<?> violation : unhandledViolations) unhandledResults.add(new ValidationResult(true, null, "constraintViolation", violation.getMessage())); resultEvents.add(new ValidationResultEvent(this, form, ValidationResultEvent.UNHANDLED, unhandledResults)); } } for (ValidationResultEvent resultEvent : resultEvents) { ((Node)resultEvent.getTarget()).fireEvent(resultEvent); } } }