]> source.dussan.org Git - vaadin-framework.git/commitdiff
Add item level validator support to Binder
authorDenis Anisimov <denis@vaadin.com>
Thu, 25 Aug 2016 11:53:09 +0000 (14:53 +0300)
committerArtur Signell <artur@vaadin.com>
Fri, 2 Sep 2016 12:13:19 +0000 (15:13 +0300)
An item level validator is run on the item (bean) after field validators
have passed. A failed item level validator will block save operations,
just like field level validators.

Change-Id: I3b918b33371ceef07cdfbd0a8b6d477d4ac26b85

documentation/datamodel/datamodel-forms.asciidoc
server/src/main/java/com/vaadin/data/BeanBinder.java
server/src/main/java/com/vaadin/data/Binder.java
server/src/main/java/com/vaadin/data/ValidationError.java
server/src/test/java/com/vaadin/data/BeanBinderTest.java
server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java
server/src/test/java/com/vaadin/data/BinderTest.java

index fc212263589d4067a6b9dae7899dfe414c9a9137..5ec548e8be34256f8d210d1ebb73066b26d2a91a 100644 (file)
@@ -584,30 +584,41 @@ binder.setStatusHandler((List<BinderResult> results) -> {
 });
 ----
 
-[classname]#BeanBinder# will automatically run bean-level validation based on the used bean instance if it has been bound using the [methodname]#bind# method.
+We can add custom form validators to [classname]#Binder#. These will be run on the updated item instance (bean) after field validators have succeeded and the item has been updated. If item level validators fail, the values updated in the item instance will be reverted, i.e. the bean will temporarily contain new values but after a call to [methodname]#save# or [methodname]#saveIfValid#, the bean will only contain the new values if all validators passed.
 
-If we are using the [methodname]#load# and [methodname]#save# methods, then the binder will not have any bean instance to use for bean-level validation.
-We must use a copy of the bean for running bean-level validation if we want to make sure no changes are done to the original bean before we know that validation passes.
+[classname]#BeanBinder# will automatically add bean-level validation based on the used bean instance and its annotations.
 
 [source, java]
 ----
-Button saveButton = new Button("Save", event -> {
-  // Create non-shared copy to use for validation
-  Person copy = new Person(person);
+BeanBinder<Person> binder = new BeanBinder<Person>(
+        Person.class);
 
-  List<ValidationError<?>> errors = binder.validateWithBean(copy);
-  if (errors.isEmpty()) {
-    // Write new values to the actual bean
+// Phone or email has to be specified for the bean
+Validator<Person> phoneOrEmail = Validator.from(
+        personBean -> !"".equals(personBean.getPhone())
+        || !"".equals(personBean.getEmail()),
+        "A person must have either a phone number or an email address");
+binder.withValidator(phoneOrEmail);
 
-    // Using saveIfValid to avoid the try-catch block that is
-    // needed if using the regular save method
-    binder.saveIfValid(person);
+binder.forField(emailField).bind("email");
+binder.forField(phoneField).bind("phone");
 
-    // TODO: Do something with the updated person instance
+Person person = // e.g. JPA entity or bean from Grid
+// Load person data to a form
+binder.load(person);
+
+Button saveButton = new Button("Save", event -> {
+  // Using saveIfValid to avoid the try-catch block that is
+  // needed if using the regular save method
+  if (binder.saveIfValid(person)) {
+    // Person is valid and updated
+    // TODO Store in the database
   }
-})
+});
 ----
 
+If we want to ensure that the [classname]#Person# instance is not even temporarily updated, we should make a clone and use that with [methodname]#saveIfValid#.
+
 == Using Binder with Vaadin Designer
 We can use [classname]#Binder# to connect data to a form that is designed using Vaadin Designer.
 
index 18985bc90cb897b9d55506c38a0f399d516634e3..63ad9ef04f93a0e22fa1f247d372dd08546c2294 100644 (file)
@@ -310,6 +310,11 @@ public class BeanBinder<BEAN> extends Binder<BEAN> {
         forField(field).bind(propertyName);
     }
 
+    @Override
+    public BeanBinder<BEAN> withValidator(Validator<? super BEAN> validator) {
+        return (BeanBinder<BEAN>) super.withValidator(validator);
+    }
+
     @Override
     protected <FIELDVALUE, TARGET> BeanBindingImpl<BEAN, FIELDVALUE, TARGET> createBinding(
             HasValue<FIELDVALUE> field, Converter<FIELDVALUE, TARGET> converter,
@@ -318,4 +323,5 @@ public class BeanBinder<BEAN> extends Binder<BEAN> {
         Objects.requireNonNull(converter, "converter cannot be null");
         return new BeanBindingImpl<>(this, field, converter, handler);
     }
+
 }
index 094afd2bd8da3eaccb0f27f9c6d35157a27dfa92..7fb1e47ed7890206425cecc8509efa74e68f113f 100644 (file)
@@ -17,15 +17,19 @@ package com.vaadin.data;
 
 import java.io.Serializable;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.BiConsumer;
 import java.util.function.Function;
 import java.util.function.Predicate;
+import java.util.stream.Collectors;
 
 import com.vaadin.data.util.converter.Converter;
 import com.vaadin.data.util.converter.StringToIntegerConverter;
@@ -42,13 +46,25 @@ import com.vaadin.ui.Label;
  * explicit logic needed to move data between the UI and the data layers of the
  * application.
  * <p>
- * A binder is a collection of <i>bindings</i>, each representing the
- * association of a single field and a backing property.
+ * A binder is a collection of <i>bindings</i>, each representing the mapping of
+ * a single field, through converters and validators, to a backing property.
  * <p>
  * A binder instance can be bound to a single bean instance at a time, but can
  * be rebound as needed. This allows usage patterns like a <i>master-details</i>
  * view, where a select component is used to pick the bean to edit.
  * <p>
+ * Bean level validators can be added using the
+ * {@link #withValidator(Validator)} method and will be run on the bound bean
+ * once it has been updated from the values of the bound fields. Bean level
+ * validators are also run as part of {@link #save(Object)} and
+ * {@link #saveIfValid(Object)} if all field level validators pass.
+ * <p>
+ * Note: For bean level validators, the item must be updated before the
+ * validators are run. If a bean level validator fails in {@link #save(Object)}
+ * or {@link #saveIfValid(Object)}, the item will be reverted to the previous
+ * state before returning from the method. You should ensure that the
+ * getters/setters in the item do not have side effects.
+ * <p>
  * Unless otherwise specified, {@code Binder} method arguments cannot be null.
  *
  * @author Vaadin Ltd.
@@ -484,14 +500,12 @@ public class Binder<BEAN> implements Serializable {
         private void bind(BEAN bean) {
             setFieldValue(bean);
             onValueChange = getField()
-                    .addValueChangeListener(e -> storeFieldValue(bean));
+                    .addValueChangeListener(e -> storeFieldValue(bean, true));
         }
 
         @Override
         public Result<TARGET> validate() {
-            FIELDVALUE fieldValue = field.getValue();
-            Result<TARGET> dataValue = converterValidatorChain.convertToModel(
-                    fieldValue, ((AbstractComponent) field).getLocale());
+            Result<TARGET> dataValue = getTargetValue();
             fireStatusChangeEvent(dataValue);
             return dataValue;
         }
@@ -503,7 +517,10 @@ public class Binder<BEAN> implements Serializable {
          *         describing an error
          */
         private Result<TARGET> getTargetValue() {
-            return validate();
+            FIELDVALUE fieldValue = field.getValue();
+            Result<TARGET> dataValue = converterValidatorChain.convertToModel(
+                    fieldValue, ((AbstractComponent) field).getLocale());
+            return dataValue;
         }
 
         private void unbind() {
@@ -530,18 +547,35 @@ public class Binder<BEAN> implements Serializable {
 
         /**
          * Saves the field value by invoking the setter function on the given
-         * bean, if the value passes all registered validators.
+         * bean, if the value passes all registered validators. Optionally runs
+         * item level validators if all field validators pass.
          *
          * @param bean
          *            the bean to set the property value to
+         * @param runBeanLevelValidation
+         *            <code>true</code> to run item level validators if all
+         *            field validators pass, <code>false</code> to always skip
+         *            item level validators
          */
-        private void storeFieldValue(BEAN bean) {
+        private void storeFieldValue(BEAN bean,
+                boolean runBeanLevelValidation) {
             assert bean != null;
             if (setter != null) {
-                getTargetValue().ifOk(value -> setter.accept(bean, value));
+                getTargetValue().ifOk(value -> setBeanValue(bean, value));
+            }
+            if (runBeanLevelValidation && !getBinder().bindings.stream()
+                    .map(BindingImpl::getTargetValue)
+                    .anyMatch(Result::isError)) {
+                List<ValidationError<?>> errors = binder.validateItem(bean);
+                // TODO: Pass errors to Binder statusChangeHandler once that is
+                // available
             }
         }
 
+        private void setBeanValue(BEAN bean, TARGET value) {
+            setter.accept(bean, value);
+        }
+
         private void fireStatusChangeEvent(Result<TARGET> result) {
             ValidationStatusChangeEvent event = new ValidationStatusChangeEvent(
                     getField(),
@@ -594,7 +628,9 @@ public class Binder<BEAN> implements Serializable {
 
     private BEAN bean;
 
-    private Set<BindingImpl<BEAN, ?, ?>> bindings = new LinkedHashSet<>();
+    private final Set<BindingImpl<BEAN, ?, ?>> bindings = new LinkedHashSet<>();
+
+    private final List<Validator<? super BEAN>> validators = new ArrayList<>();
 
     /**
      * Returns an {@code Optional} of the bean that has been bound with
@@ -683,6 +719,14 @@ public class Binder<BEAN> implements Serializable {
      * corresponding getter functions. Any changes to field values are reflected
      * back to their corresponding property values of the bean as long as the
      * bean is bound.
+     * <p>
+     * Any change made in the fields also runs validation for the field
+     * {@link Binding} and bean level validation for this binder (bean level
+     * validators are added using {@link Binder#withValidator(Validator)}.
+     *
+     * @see #load(Object)
+     * @see #save(Object)
+     * @see #saveIfValid(Object)
      *
      * @param bean
      *            the bean to edit, not null
@@ -694,24 +738,6 @@ public class Binder<BEAN> implements Serializable {
         bindings.forEach(b -> b.bind(bean));
     }
 
-    /**
-     * Validates the values of all bound fields and returns the result of the
-     * validation as a set of validation errors.
-     * <p>
-     * Validation is successful if the resulting set is empty.
-     *
-     * @return the validation result.
-     */
-    public List<ValidationError<?>> validate() {
-
-        List<ValidationError<?>> resultErrors = new ArrayList<>();
-        for (BindingImpl<?, ?, ?> binding : bindings) {
-            binding.validate().ifError(errorMessage -> resultErrors.add(
-                    new ValidationError<>(binding.getField(), errorMessage)));
-        }
-        return resultErrors;
-    }
-
     /**
      * Unbinds the currently bound bean if any. If there is no bound bean, does
      * nothing.
@@ -725,9 +751,15 @@ public class Binder<BEAN> implements Serializable {
 
     /**
      * Reads the bound property values from the given bean to the corresponding
-     * fields. The bean is not otherwise associated with this binder; in
-     * particular its property values are not bound to the field value changes.
-     * To achieve that, use {@link #bind(BEAN)}.
+     * fields.
+     * <p>
+     * The bean is not otherwise associated with this binder; in particular its
+     * property values are not bound to the field value changes. To achieve
+     * that, use {@link #bind(BEAN)}.
+     *
+     * @see #bind(Object)
+     * @see #saveIfValid(Object)
+     * @see #save(Object)
      *
      * @param bean
      *            the bean whose property values to read, not null
@@ -739,10 +771,15 @@ public class Binder<BEAN> implements Serializable {
 
     /**
      * Saves changes from the bound fields to the given bean if all validators
-     * pass.
+     * (binding and bean level) pass.
+     * <p>
+     * If any field binding validator fails, no values are saved and a
+     * {@code ValidationException} is thrown.
      * <p>
-     * If any field binding validator fails, no values are saved and an
-     * exception is thrown.
+     * If all field level validators pass, the given bean is updated and bean
+     * level validators are run on the updated item. If any bean level validator
+     * fails, the bean updates are reverted and a {@code ValidationException} is
+     * thrown.
      *
      * @see #saveIfValid(Object)
      * @see #load(Object)
@@ -762,12 +799,16 @@ public class Binder<BEAN> implements Serializable {
 
     /**
      * Saves changes from the bound fields to the given bean if all validators
-     * pass.
+     * (binding and bean level) pass.
      * <p>
      * If any field binding validator fails, no values are saved and
      * <code>false</code> is returned.
+     * <p>
+     * If all field level validators pass, the given bean is updated and bean
+     * level validators are run on the updated item. If any bean level validator
+     * fails, the bean updates are reverted and <code>false</code> is returned.
      *
-     * @see #saveIfValid(Object)
+     * @see #save(Object)
      * @see #load(Object)
      * @see #bind(Object)
      *
@@ -782,21 +823,128 @@ public class Binder<BEAN> implements Serializable {
 
     /**
      * Saves the field values into the given bean if all field level validators
-     * pass.
+     * pass. Runs bean level validators on the bean after saving.
      *
      * @param bean
      *            the bean to save field values into
-     * @return a list of field validation errors
+     * @return a list of field validation errors if such occur, otherwise a list
+     *         of bean validation errors.
      */
     private List<ValidationError<?>> doSaveIfValid(BEAN bean) {
         Objects.requireNonNull(bean, "bean cannot be null");
         // First run fields level validation
-        List<ValidationError<?>> errors = validate();
+        List<ValidationError<?>> errors = validateBindings();
         // If no validation errors then update bean
-        if (errors.isEmpty()) {
-            bindings.forEach(binding -> binding.storeFieldValue(bean));
+        if (!errors.isEmpty()) {
+            return errors;
+        }
+
+        // Save old bean values so we can restore them if validators fail
+        Map<Binding<BEAN, ?, ?>, Object> oldValues = new HashMap<>();
+        bindings.forEach(binding -> oldValues.put(binding,
+                binding.convertDataToFieldType(bean)));
+
+        bindings.forEach(binding -> binding.storeFieldValue(bean, false));
+        // Now run bean level validation against the updated bean
+        List<ValidationError<?>> itemValidatorErrors = validateItem(bean);
+        if (!itemValidatorErrors.isEmpty()) {
+            // Item validator failed, revert values
+            bindings.forEach((BindingImpl binding) -> binding.setBeanValue(bean,
+                    oldValues.get(binding)));
+        }
+        return itemValidatorErrors;
+    }
+
+    /**
+     * Adds an item level validator.
+     * <p>
+     * Item level validators are applied on the item instance after the item is
+     * updated. If the validators fail, the item instance is reverted to its
+     * previous state.
+     *
+     * @see #save(Object)
+     * @see #saveIfValid(Object)
+     *
+     * @param validator
+     *            the validator to add, not null
+     * @return this binder, for chaining
+     */
+    public Binder<BEAN> withValidator(Validator<? super BEAN> validator) {
+        Objects.requireNonNull(validator, "validator cannot be null");
+        validators.add(validator);
+        return this;
+    }
+
+    /**
+     * Validates the values of all bound fields and returns the result of the
+     * validation as a list of validation errors.
+     * <p>
+     * If all field level validators pass, and {@link #bind(Object)} has been
+     * used to bind to an item, item level validators are run for that bean.
+     * Item level validators are ignored if there is no bound item or if any
+     * field level validator fails.
+     * <p>
+     * Validation is successful if the returned list is empty.
+     *
+     * @return a list of validation errors or an empty list if validation
+     *         succeeded
+     */
+    public List<ValidationError<?>> validate() {
+        List<ValidationError<?>> errors = validateBindings();
+        if (!errors.isEmpty()) {
+            return errors;
+        }
+
+        if (bean != null) {
+            return validateItem(bean);
+        }
+
+        return Collections.emptyList();
+    }
+
+    /**
+     * Validates the bindings and returns the result of the validation as a list
+     * of validation errors.
+     * <p>
+     * If all validators pass, the resulting list is empty.
+     * <p>
+     * Does not run bean validators.
+     *
+     * @see #validateItem(Object)
+     *
+     * @return a list of validation errors or an empty list if validation
+     *         succeeded
+     */
+    private List<ValidationError<?>> validateBindings() {
+        List<ValidationError<?>> resultErrors = new ArrayList<>();
+        for (BindingImpl<?, ?, ?> binding : bindings) {
+            binding.validate().ifError(errorMessage -> resultErrors
+                    .add(new ValidationError<>(binding,
+                            binding.getField().getValue(), errorMessage)));
         }
-        return errors;
+        return resultErrors;
+    }
+
+    /**
+     * Validates the {@code item} using item validators added using
+     * {@link #withValidator(Validator)} and returns the result of the
+     * validation as a list of validation errors.
+     * <p>
+     * If all validators pass, the resulting list is empty.
+     *
+     * @see #withValidator(Validator)
+     *
+     * @param bean
+     *            the bean to validate
+     * @return a list of validation errors or an empty list if validation
+     *         succeeded
+     */
+    private List<ValidationError<?>> validateItem(BEAN bean) {
+        Objects.requireNonNull(bean, "bean cannot be null");
+        return validators.stream().map(validator -> validator.apply(bean))
+                .filter(Result::isError).map(res -> new ValidationError<>(this,
+                        bean, res.getMessage().get()))
+                .collect(Collectors.toList());
     }
 
     /**
index 32d9b71c31f17f94876538aa5f1ccdc0f98077c8..1555ecad2cdff7f21cb64460571c8c3b9536c819 100644 (file)
@@ -17,44 +17,67 @@ package com.vaadin.data;
 
 import java.io.Serializable;
 import java.util.Objects;
+import java.util.Optional;
+
+import com.vaadin.data.Binder.Binding;
 
 /**
- * Represents a validation error. An error contains a reference to a field whose
- * value is invalid and a message describing a validation failure.
+ * Represents a validation error.
+ * <p>
+ * A validation error is either connected to a field validator (
+ * {@link #getField()} returns a non-empty optional) or to an item level
+ * validator ({@link #getField()} returns an empty optional).
  *
  * @author Vaadin Ltd
  * @since 8.0
  *
  * @param <V>
- *            the field value type
+ *            the value type
  */
 public class ValidationError<V> implements Serializable {
 
-    private HasValue<V> field;
-    private String message;
+    /**
+     * This is either a {@link Binding} or a {@link Binder}.
+     */
+    private final Object source;
+    private final String message;
+    /**
+     * This is either HasValue<V> value (in case of Binding) or bean (in case of
+     * Binder).
+     */
+    private final V value;
 
     /**
-     * Creates a new instance of ValidationError with provided validated field
-     * and error message.
+     * Creates a new instance of ValidationError using the provided source
+     * ({@link Binding} or {@link Binder}), value and error message.
      *
-     * @param field
-     *            the validated field
+     * @param source
+     *            the validated binding or the binder
+     * @param value
+     *            the invalid value
      * @param message
      *            the validation error message, not {@code null}
      */
-    public ValidationError(HasValue<V> field, String message) {
+    public ValidationError(Object source, V value, String message) {
         Objects.requireNonNull(message, "message cannot be null");
-        this.field = field;
+        this.source = source;
         this.message = message;
+        this.value = value;
     }
 
     /**
-     * Returns a reference to the validated field.
+     * Returns a reference to the validated field or an empty optional if the
+     * validation was not related to a single field.
      *
-     * @return the validated field
+     * @return the validated field or an empty optional
      */
-    public HasValue<V> getField() {
-        return field;
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    public Optional<HasValue<V>> getField() {
+        if (source instanceof Binding) {
+            return Optional.of(((Binding) source).getField());
+        } else {
+            return Optional.empty();
+        }
     }
 
     /**
@@ -65,4 +88,17 @@ public class ValidationError<V> implements Serializable {
     public String getMessage() {
         return message;
     }
+
+    /**
+     * Returns the invalid value.
+     * <p>
+     * This is either the field value (if the validator error comes from a field
+     * binding) or the bean (for item validators).
+     *
+     * @return the source value
+     */
+    public V getValue() {
+        return value;
+    }
+
 }
index 6b535c53a670dcbb7c75124a75ac42f295096e55..1788c3a9e8674578f7798837249d64182dd33732 100644 (file)
@@ -13,12 +13,12 @@ import com.vaadin.ui.TextField;
 
 public class BeanBinderTest {
 
-    BeanBinder<BeanToValidate> binder;
+    private BeanBinder<BeanToValidate> binder;
 
-    TextField nameField;
-    TextField ageField;
+    private TextField nameField;
+    private TextField ageField;
 
-    BeanToValidate p = new BeanToValidate();
+    private BeanToValidate p = new BeanToValidate();
 
     @Before
     public void setUp() {
@@ -165,7 +165,7 @@ public class BeanBinderTest {
     private void assertInvalid(HasValue<?> field, String message) {
         List<ValidationError<?>> errors = binder.validate();
         assertEquals(1, errors.size());
-        assertSame(field, errors.get(0).getField());
+        assertSame(field, errors.get(0).getField().get());
         assertEquals(message, errors.get(0).getMessage());
     }
 }
index a50e06d10f1de0d665b927852890beea4f68fa3b..6ed2170fa77b49f6f0ae7dc41068908ee62684e5 100644 (file)
@@ -28,10 +28,11 @@ import com.vaadin.data.Binder.Binding;
 import com.vaadin.data.util.converter.StringToIntegerConverter;
 import com.vaadin.data.validator.EmailValidator;
 import com.vaadin.server.AbstractErrorMessage;
-import com.vaadin.ui.AbstractField;
+import com.vaadin.ui.Button;
 import com.vaadin.ui.Label;
 import com.vaadin.ui.PopupDateField;
 import com.vaadin.ui.Slider;
+import com.vaadin.ui.TextField;
 
 /**
  * Book of Vaadin tests.
@@ -41,25 +42,23 @@ import com.vaadin.ui.Slider;
  */
 public class BinderBookOfVaadinTest {
 
-    static class TextField extends AbstractField<String> {
-
-        String value = "";
+    private static class BookPerson {
+        private String lastName;
+        private String email, phone, title;
+        private int yearOfBirth, salaryLevel;
 
-        @Override
-        public String getValue() {
-            return value;
+        public BookPerson(int yearOfBirth, int salaryLevel) {
+            this.yearOfBirth = yearOfBirth;
+            this.salaryLevel = salaryLevel;
         }
 
-        @Override
-        protected void doSetValue(String value) {
-            this.value = value;
+        public BookPerson(BookPerson origin) {
+            this(origin.yearOfBirth, origin.salaryLevel);
+            lastName = origin.lastName;
+            email = origin.email;
+            phone = origin.phone;
+            title = origin.title;
         }
-    }
-
-    private static class BookPerson {
-        private String lastName;
-        private String email;
-        private int yearOfBirth, salaryLevel;
 
         public String getLastName() {
             return lastName;
@@ -69,11 +68,6 @@ public class BinderBookOfVaadinTest {
             this.lastName = lastName;
         }
 
-        public BookPerson(int yearOfBirth, int salaryLevel) {
-            this.yearOfBirth = yearOfBirth;
-            this.salaryLevel = salaryLevel;
-        }
-
         public int getYearOfBirth() {
             return yearOfBirth;
         }
@@ -98,6 +92,22 @@ public class BinderBookOfVaadinTest {
             this.email = email;
         }
 
+        public String getPhone() {
+            return phone;
+        }
+
+        public void setPhone(String phone) {
+            this.phone = phone;
+        }
+
+        public String getTitle() {
+            return title;
+        }
+
+        public void setTitle(String title) {
+            this.title = title;
+        }
+
     }
 
     public static class Trip {
@@ -115,11 +125,15 @@ public class BinderBookOfVaadinTest {
     private Binder<BookPerson> binder;
 
     private TextField field;
+    private TextField phoneField;
+    private TextField emailField;
 
     @Before
     public void setUp() {
         binder = new Binder<>();
         field = new TextField();
+        phoneField = new TextField();
+        emailField = new TextField();
     }
 
     @Test
@@ -421,4 +435,45 @@ public class BinderBookOfVaadinTest {
         Assert.assertEquals(field, evt.getSource());
     }
 
+    @Test
+    public void binder_saveIfValid() {
+        BeanBinder<BookPerson> binder = new BeanBinder<BookPerson>(
+                BookPerson.class);
+
+        // Phone or email has to be specified for the bean
+        Validator<BookPerson> phoneOrEmail = Validator.from(
+                personBean -> !"".equals(personBean.getPhone())
+                        || !"".equals(personBean.getEmail()),
+                "A person must have either a phone number or an email address");
+        binder.withValidator(phoneOrEmail);
+
+        binder.forField(emailField).bind("email");
+        binder.forField(phoneField).bind("phone");
+
+        // Person person = // e.g. JPA entity or bean from Grid
+        BookPerson person = new BookPerson(1900, 5);
+        person.setEmail("Old Email");
+        // Load person data to a form
+        binder.load(person);
+
+        Button saveButton = new Button("Save", event -> {
+            // Using saveIfValid to avoid the try-catch block that is
+            // needed if using the regular save method
+            if (binder.saveIfValid(person)) {
+                // Person is valid and updated
+                // TODO Store in the database
+            }
+        });
+
+        emailField.setValue("foo@bar.com");
+        Assert.assertTrue(binder.saveIfValid(person));
+        // Person updated
+        Assert.assertEquals("foo@bar.com", person.getEmail());
+
+        emailField.setValue("");
+        Assert.assertFalse(binder.saveIfValid(person));
+        // Person updated because phone and email are both empty
+        Assert.assertEquals("foo@bar.com", person.getEmail());
+    }
+
 }
index be408d61424fe51726bb87e85e0b0713c73ece95..4c59773b2fb6256e8086a7378edb54b293791cd9 100644 (file)
@@ -5,7 +5,9 @@ import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertSame;
 
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
 
@@ -217,10 +219,46 @@ public class BinderTest {
         try {
             binder.save(person);
         } finally {
+            // Bean should not have been updated
             Assert.assertEquals(firstName, person.getFirstName());
         }
     }
 
+    @Test(expected = ValidationException.class)
+    public void save_beanValidationErrors() throws ValidationException {
+        Binder<Person> binder = new Binder<>();
+        binder.forField(nameField).withValidator(new NotEmptyValidator<>("a"))
+                .bind(Person::getFirstName, Person::setFirstName);
+
+        binder.withValidator(Validator.from(person -> false, "b"));
+
+        Person person = new Person();
+        nameField.setValue("foo");
+        try {
+            binder.save(person);
+        } finally {
+            // Bean should have been updated for item validation but reverted
+            Assert.assertNull(person.getFirstName());
+        }
+    }
+
+    @Test
+    public void save_fieldsAndBeanLevelValidation() throws ValidationException {
+        Binder<Person> binder = new Binder<>();
+        binder.forField(nameField).withValidator(new NotEmptyValidator<>("a"))
+                .bind(Person::getFirstName, Person::setFirstName);
+
+        binder.withValidator(
+                Validator.from(person -> person.getLastName() != null, "b"));
+
+        Person person = new Person();
+        person.setLastName("bar");
+        nameField.setValue("foo");
+        binder.save(person);
+        Assert.assertEquals(nameField.getValue(), person.getFirstName());
+        Assert.assertEquals("bar", person.getLastName());
+    }
+
     @Test
     public void saveIfValid_fieldValidationErrors() {
         Binder<Person> binder = new Binder<>();
@@ -250,6 +288,60 @@ public class BinderTest {
         Assert.assertEquals("bar", person.getFirstName());
     }
 
+    @Test
+    public void saveIfValid_beanValidationErrors() {
+        Binder<Person> binder = new Binder<>();
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+
+        String msg = "foo";
+        binder.withValidator(Validator.<Person> from(
+                prsn -> prsn.getAddress() != null || prsn.getEmail() != null,
+                msg));
+
+        Person person = new Person();
+        person.setFirstName("foo");
+        nameField.setValue("");
+        Assert.assertFalse(binder.saveIfValid(person));
+
+        Assert.assertEquals("foo", person.getFirstName());
+    }
+
+    @Test
+    public void save_validationErrors_exceptionContainsErrors()
+            throws ValidationException {
+        Binder<Person> binder = new Binder<>();
+        String msg = "foo";
+        Binding<Person, String, String> nameBinding = binder.forField(nameField)
+                .withValidator(new NotEmptyValidator<>(msg));
+        nameBinding.bind(Person::getFirstName, Person::setFirstName);
+
+        Binding<Person, String, Integer> ageBinding = binder.forField(ageField)
+                .withConverter(stringToInteger).withValidator(notNegative);
+        ageBinding.bind(Person::getAge, Person::setAge);
+
+        Person person = new Person();
+        nameField.setValue("");
+        ageField.setValue("-1");
+        try {
+            binder.save(person);
+            Assert.fail();
+        } catch (ValidationException exception) {
+            List<ValidationError<?>> validationErrors = exception
+                    .getValidationError();
+            Assert.assertEquals(2, validationErrors.size());
+            ValidationError<?> error = validationErrors.get(0);
+            Assert.assertEquals(nameField, error.getField().get());
+            Assert.assertEquals(msg, error.getMessage());
+            Assert.assertEquals("", error.getValue());
+
+            error = validationErrors.get(1);
+            Assert.assertEquals(ageField, error.getField().get());
+            Assert.assertEquals("Value must be positive", error.getMessage());
+            Assert.assertEquals(ageField.getValue(), error.getValue());
+        }
+    }
+
     @Test
     public void load_bound_fieldValueIsUpdated() {
         Binder<Person> binder = new Binder<>();
@@ -328,6 +420,7 @@ public class BinderTest {
         Assert.assertTrue(errorMessages.contains(msg1));
 
         Set<?> fields = errors.stream().map(ValidationError::getField)
+                .filter(Optional::isPresent).map(Optional::get)
                 .collect(Collectors.toSet());
         Assert.assertEquals(1, fields.size());
         Assert.assertTrue(fields.contains(nameField));
@@ -637,4 +730,139 @@ public class BinderTest {
         });
     }
 
+    @Test
+    public void validate_failedBeanValidatorWithoutFieldValidators() {
+        Binder<Person> binder = new Binder<>();
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+
+        String msg = "foo";
+        binder.withValidator(Validator.from(bean -> false, msg));
+        Person person = new Person();
+        binder.bind(person);
+
+        List<ValidationError<?>> errors = binder.validate();
+        Assert.assertEquals(1, errors.size());
+        Assert.assertFalse(errors.get(0).getField().isPresent());
+    }
+
+    @Test
+    public void validate_failedBeanValidatorWithFieldValidator() {
+        String msg = "foo";
+
+        Binder<Person> binder = new Binder<>();
+        Binding<Person, String, String> binding = binder.forField(nameField)
+                .withValidator(new NotEmptyValidator<>(msg));
+        binding.bind(Person::getFirstName, Person::setFirstName);
+
+        binder.withValidator(Validator.from(bean -> false, msg));
+        Person person = new Person();
+        binder.bind(person);
+
+        List<ValidationError<?>> errors = binder.validate();
+        Assert.assertEquals(1, errors.size());
+        ValidationError<?> error = errors.get(0);
+        Assert.assertEquals(msg, error.getMessage());
+        Assert.assertTrue(error.getField().isPresent());
+        Assert.assertEquals(nameField.getValue(), error.getValue());
+    }
+
+    @Test
+    public void validate_failedBothBeanValidatorAndFieldValidator() {
+        String msg1 = "foo";
+
+        Binder<Person> binder = new Binder<>();
+        Binding<Person, String, String> binding = binder.forField(nameField)
+                .withValidator(new NotEmptyValidator<>(msg1));
+        binding.bind(Person::getFirstName, Person::setFirstName);
+
+        String msg2 = "bar";
+        binder.withValidator(Validator.from(bean -> false, msg2));
+        Person person = new Person();
+        binder.bind(person);
+
+        List<ValidationError<?>> errors = binder.validate();
+        Assert.assertEquals(1, errors.size());
+
+        ValidationError<?> error = errors.get(0);
+
+        Assert.assertEquals(msg1, error.getMessage());
+        Assert.assertEquals(nameField, error.getField().get());
+        Assert.assertEquals(nameField.getValue(), error.getValue());
+    }
+
+    @Test
+    public void validate_okBeanValidatorWithoutFieldValidators() {
+        Binder<Person> binder = new Binder<>();
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+
+        String msg = "foo";
+        binder.withValidator(Validator.from(bean -> true, msg));
+        Person person = new Person();
+        binder.bind(person);
+
+        List<ValidationError<?>> errors = binder.validate();
+        Assert.assertEquals(0, errors.size());
+    }
+
+    @Test
+    public void binder_saveIfValid() {
+        String msg1 = "foo";
+        Binder<Person> binder = new Binder<>();
+        Binding<Person, String, String> binding = binder.forField(nameField)
+                .withValidator(new NotEmptyValidator<>(msg1));
+        binding.bind(Person::getFirstName, Person::setFirstName);
+
+        String beanValidatorErrorMessage = "bar";
+        binder.withValidator(
+                Validator.from(bean -> false, beanValidatorErrorMessage));
+        Person person = new Person();
+        String firstName = "first name";
+        person.setFirstName(firstName);
+        binder.load(person);
+
+        nameField.setValue("");
+        Assert.assertFalse(binder.saveIfValid(person));
+        // check that field level-validation failed and bean is not updated
+        Assert.assertEquals(firstName, person.getFirstName());
+
+        nameField.setValue("new name");
+
+        Assert.assertFalse(binder.saveIfValid(person));
+        // Bean is updated but reverted
+        Assert.assertEquals(firstName, person.getFirstName());
+    }
+
+    @Test
+    public void updateBoundField_bindingValdationFails_beanLevelValidationIsNotRun() {
+        bindAgeWithValidatorConverterValidator();
+        bindName();
+
+        AtomicBoolean beanLevelValidationRun = new AtomicBoolean();
+        binder.withValidator(Validator.<Person> from(
+                bean -> beanLevelValidationRun.getAndSet(true), ""));
+
+        ageField.setValue("not a number");
+
+        Assert.assertFalse(beanLevelValidationRun.get());
+
+        nameField.setValue("foo");
+        Assert.assertFalse(beanLevelValidationRun.get());
+    }
+
+    @Test
+    public void updateBoundField_bindingValdationSuccess_beanLevelValidationIsRun() {
+        bindAgeWithValidatorConverterValidator();
+        bindName();
+
+        AtomicBoolean beanLevelValidationRun = new AtomicBoolean();
+        binder.withValidator(Validator.<Person> from(
+                bean -> beanLevelValidationRun.getAndSet(true), ""));
+
+        ageField.setValue(String.valueOf(12));
+
+        Assert.assertTrue(beanLevelValidationRun.get());
+    }
+
 }