]> source.dussan.org Git - vaadin-framework.git/commitdiff
Add support for binder status change events (#208).
authorDenis Anisimov <denis@vaadin.com>
Mon, 3 Oct 2016 12:42:10 +0000 (15:42 +0300)
committerAleksi Hietanen <aleksi@vaadin.com>
Thu, 6 Oct 2016 08:44:50 +0000 (08:44 +0000)
Change-Id: Ic8dee407569ee310f007ebe32660a1d2922e9493

server/src/main/java/com/vaadin/data/Binder.java
server/src/main/java/com/vaadin/data/StatusChangeEvent.java [new file with mode: 0644]
server/src/main/java/com/vaadin/data/StatusChangeListener.java [new file with mode: 0644]
server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java
server/src/test/java/com/vaadin/data/BinderStatusChangeTest.java [new file with mode: 0644]

index d2114e81208c9324ea4dc2808a813be6d556187c..7a59b90946646b370e450a273d35e7505d30c20d 100644 (file)
@@ -34,6 +34,7 @@ import java.util.stream.Collectors;
 
 import com.vaadin.data.util.converter.Converter;
 import com.vaadin.data.util.converter.StringToIntegerConverter;
+import com.vaadin.event.EventRouter;
 import com.vaadin.server.ErrorMessage;
 import com.vaadin.server.UserError;
 import com.vaadin.shared.Registration;
@@ -435,6 +436,7 @@ public class Binder<BEAN> implements Serializable {
             this.setter = setter;
             getBinder().bindings.add(this);
             getBinder().getBean().ifPresent(this::bind);
+            getBinder().fireStatusChangeEvent(false);
         }
 
         @Override
@@ -533,6 +535,7 @@ public class Binder<BEAN> implements Serializable {
             getBinder().getValidationStatusHandler()
                     .accept(new BinderValidationStatus<>(getBinder(),
                             Arrays.asList(status), Collections.emptyList()));
+            getBinder().fireStatusChangeEvent(status.isError());
             return status;
         }
 
@@ -544,8 +547,8 @@ public class Binder<BEAN> implements Serializable {
          */
         private ValidationStatus<TARGET> doValidation() {
             FIELDVALUE fieldValue = field.getValue();
-            Result<TARGET> dataValue = converterValidatorChain.convertToModel(
-                    fieldValue, findLocale());
+            Result<TARGET> dataValue = converterValidatorChain
+                    .convertToModel(fieldValue, findLocale());
             return new ValidationStatus<>(this, dataValue);
         }
 
@@ -566,8 +569,8 @@ public class Binder<BEAN> implements Serializable {
         }
 
         private FIELDVALUE convertDataToFieldType(BEAN bean) {
-            return converterValidatorChain.convertToPresentation(
-                    getter.apply(bean), findLocale());
+            return converterValidatorChain
+                    .convertToPresentation(getter.apply(bean), findLocale());
         }
 
         /**
@@ -577,7 +580,7 @@ public class Binder<BEAN> implements Serializable {
          *            the new value
          */
         private void handleFieldValueChange(BEAN bean) {
-            binder.setHasChanges(true);
+            getBinder().setHasChanges(true);
             // store field value if valid
             ValidationStatus<TARGET> fieldValidationStatus = storeFieldValue(
                     bean);
@@ -585,14 +588,15 @@ public class Binder<BEAN> implements Serializable {
             // if all field level validations pass, run bean level validation
             if (!getBinder().bindings.stream().map(BindingImpl::doValidation)
                     .anyMatch(ValidationStatus::isError)) {
-                binderValidationResults = binder.validateItem(bean);
+                binderValidationResults = getBinder().validateItem(bean);
             } else {
                 binderValidationResults = Collections.emptyList();
             }
-            binder.getValidationStatusHandler()
-                    .accept(new BinderValidationStatus<>(binder,
-                            Arrays.asList(fieldValidationStatus),
-                            binderValidationResults));
+            BinderValidationStatus<BEAN> status = new BinderValidationStatus<>(
+                    binder, Arrays.asList(fieldValidationStatus),
+                    binderValidationResults);
+            getBinder().getValidationStatusHandler().accept(status);
+            getBinder().fireStatusChangeEvent(status.hasErrors());
         }
 
         /**
@@ -668,6 +672,8 @@ public class Binder<BEAN> implements Serializable {
 
     private final List<Validator<? super BEAN>> validators = new ArrayList<>();
 
+    private EventRouter eventRouter;
+
     private Label statusLabel;
 
     private BinderValidationStatusHandler statusHandler;
@@ -742,9 +748,9 @@ public class Binder<BEAN> implements Serializable {
             @Override
             public Registration addValueChangeListener(
                     ValueChangeListener<? super SELECTVALUE> listener) {
-                return select.addSelectionListener(e -> listener.accept(
-                        new ValueChange<>(select, getValue(), e
-                                .isUserOriginated())));
+                return select.addSelectionListener(
+                        e -> listener.accept(new ValueChange<>(select,
+                                getValue(), e.isUserOriginated())));
             }
         });
     }
@@ -916,7 +922,7 @@ public class Binder<BEAN> implements Serializable {
      * <pre>
      * class Feature {
      *     public enum Browser { CHROME, EDGE, FIREFOX, IE, OPERA, SAFARI }
-
+    
      *     public Set&lt;Browser> getSupportedBrowsers() { ... }
      *     public void setSupportedBrowsers(Set&lt;Browser> title) { ... }
      * }
@@ -964,13 +970,14 @@ public class Binder<BEAN> implements Serializable {
      */
     public void bind(BEAN bean) {
         Objects.requireNonNull(bean, "bean cannot be null");
-        unbind();
+        doUnbind(false);
         this.bean = bean;
         bindings.forEach(b -> b.bind(bean));
         // if there has been field value change listeners that trigger
         // validation, need to make sure the validation errors are cleared
         getValidationStatusHandler()
                 .accept(BinderValidationStatus.createUnresolvedStatus(this));
+        fireStatusChangeEvent(false);
     }
 
     /**
@@ -978,13 +985,7 @@ public class Binder<BEAN> implements Serializable {
      * nothing.
      */
     public void unbind() {
-        setHasChanges(false);
-        if (bean != null) {
-            bean = null;
-            bindings.forEach(BindingImpl::unbind);
-        }
-        getValidationStatusHandler()
-                .accept(BinderValidationStatus.createUnresolvedStatus(this));
+        doUnbind(true);
     }
 
     /**
@@ -1009,6 +1010,7 @@ public class Binder<BEAN> implements Serializable {
 
         getValidationStatusHandler()
                 .accept(BinderValidationStatus.createUnresolvedStatus(this));
+        fireStatusChangeEvent(false);
     }
 
     /**
@@ -1073,6 +1075,7 @@ public class Binder<BEAN> implements Serializable {
      * @return a list of field validation errors if such occur, otherwise a list
      *         of bean validation errors.
      */
+    @SuppressWarnings({ "rawtypes", "unchecked" })
     private BinderValidationStatus<BEAN> doSaveIfValid(BEAN bean) {
         Objects.requireNonNull(bean, "bean cannot be null");
         // First run fields level validation
@@ -1080,6 +1083,7 @@ public class Binder<BEAN> implements Serializable {
         // If no validation errors then update bean
         if (bindingStatuses.stream().filter(ValidationStatus::isError).findAny()
                 .isPresent()) {
+            fireStatusChangeEvent(true);
             return new BinderValidationStatus<>(this, bindingStatuses,
                     Collections.emptyList());
         }
@@ -1092,8 +1096,9 @@ public class Binder<BEAN> implements Serializable {
         bindings.forEach(binding -> binding.storeFieldValue(bean));
         // Now run bean level validation against the updated bean
         List<Result<?>> binderResults = validateItem(bean);
-        if (binderResults.stream().filter(Result::isError).findAny()
-                .isPresent()) {
+        boolean hasErrors = binderResults.stream().filter(Result::isError)
+                .findAny().isPresent();
+        if (hasErrors) {
             // Item validator failed, revert values
             bindings.forEach((BindingImpl binding) -> binding.setBeanValue(bean,
                     oldValues.get(binding)));
@@ -1101,6 +1106,7 @@ public class Binder<BEAN> implements Serializable {
             // Save successful, reset hasChanges to false
             setHasChanges(false);
         }
+        fireStatusChangeEvent(hasErrors);
         return new BinderValidationStatus<>(this, bindingStatuses,
                 binderResults);
     }
@@ -1150,6 +1156,7 @@ public class Binder<BEAN> implements Serializable {
                     bindingStatuses, validateItem(bean));
         }
         getValidationStatusHandler().accept(validationStatus);
+        fireStatusChangeEvent(validationStatus.hasErrors());
         return validationStatus;
     }
 
@@ -1273,6 +1280,43 @@ public class Binder<BEAN> implements Serializable {
                 .orElse(this::handleBinderValidationStatus);
     }
 
+    /**
+     * Adds status change listener to the binder.
+     * <p>
+     * The {@link Binder} status is changed whenever any of the following
+     * happens:
+     * <ul>
+     * <li>if it's bound and any of its bound field or select has been changed
+     * <li>{@link #save(Object)} or {@link #saveIfValid(Object)} is called
+     * <li>{@link #load(Object)} is called
+     * <li>{@link #bind(Object)} is called
+     * <li>{@link #unbind(Object)} is called
+     * <li>{@link Binding#bind(Function, BiConsumer)} is called
+     * <li>{@link Binder#validate()} or {@link Binding#validate()} is called
+     * </ul>
+     * 
+     * @see #load(Object)
+     * @see #save(Object)
+     * @see #saveIfValid(Object)
+     * @see #bind(Object)
+     * @see #unbind()
+     * @see #forField(HasValue)
+     * @see #forSelect(AbstractMultiSelect)
+     * @See {@link #validate()}
+     * @see Binding#validate()
+     * @see Binding#bind(Object)
+     * 
+     * @param listener
+     *            status change listener to add, not null
+     * @return a registration for the listener
+     */
+    public Registration addStatusChangeListener(StatusChangeListener listener) {
+        getEventRouter().addListener(StatusChangeEvent.class, listener,
+                StatusChangeListener.class.getDeclaredMethods()[0]);
+        return () -> getEventRouter().removeListener(StatusChangeEvent.class,
+                listener);
+    }
+
     /**
      * Creates a new binding with the given field.
      *
@@ -1400,4 +1444,35 @@ public class Binder<BEAN> implements Serializable {
     public boolean hasChanges() {
         return hasChanges;
     }
+
+    /**
+     * Returns the event router for this binder.
+     * 
+     * @return the event router, not null
+     */
+    protected EventRouter getEventRouter() {
+        if (eventRouter == null) {
+            eventRouter = new EventRouter();
+        }
+        return eventRouter;
+    }
+
+    private void doUnbind(boolean fireStatusEvent) {
+        setHasChanges(false);
+        if (bean != null) {
+            bean = null;
+            bindings.forEach(BindingImpl::unbind);
+        }
+        getValidationStatusHandler()
+                .accept(BinderValidationStatus.createUnresolvedStatus(this));
+        if (fireStatusEvent) {
+            fireStatusChangeEvent(false);
+        }
+    }
+
+    private void fireStatusChangeEvent(boolean hasValidationErrors) {
+        getEventRouter()
+                .fireEvent(new StatusChangeEvent(this, hasValidationErrors));
+    }
+
 }
diff --git a/server/src/main/java/com/vaadin/data/StatusChangeEvent.java b/server/src/main/java/com/vaadin/data/StatusChangeEvent.java
new file mode 100644 (file)
index 0000000..7a73fb5
--- /dev/null
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2000-2016 Vaadin Ltd.
+ *
+ * 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.
+ */
+package com.vaadin.data;
+
+import java.util.EventObject;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+import com.vaadin.data.Binder.Binding;
+
+/**
+ * Binder status change event.
+ * <p>
+ * The {@link Binder} status is changed whenever any of the following happens:
+ * <ul>
+ * <li>if it's bound and any of its bound field or select has been changed
+ * <li>{@link #save(Object)} or {@link #saveIfValid(Object)} is called
+ * <li>{@link #load(Object)} is called
+ * <li>{@link #bind(Object)} is called
+ * <li>{@link #unbind(Object)} is called
+ * <li>{@link Binding#bind(Function, BiConsumer)} is called
+ * <li>{@link Binder#validate()} or {@link Binding#validate()} is called
+ * </ul>
+ * 
+ * @see StatusChangeListener#statusChange(StatusChangeEvent)
+ * @see Binder#addStatusChangeListener(StatusChangeListener)
+ * 
+ * @author Vaadin Ltd
+ *
+ */
+public class StatusChangeEvent extends EventObject {
+
+    private final boolean hasValidationErrors;
+
+    /**
+     * Create a new status change event for given {@code binder} using its
+     * current validation status.
+     * 
+     * @param binder
+     *            the event source binder
+     * @param hasValidationErrors
+     *            the binder validation status
+     */
+    public StatusChangeEvent(Binder<?> binder, boolean hasValidationErrors) {
+        super(binder);
+        this.hasValidationErrors = hasValidationErrors;
+    }
+
+    /**
+     * Gets the binder validation status.
+     * 
+     * @return {@code true} if the binder has validation errors, {@code false}
+     *         otherwise
+     */
+    public boolean hasValidationErrors() {
+        return hasValidationErrors;
+    }
+
+    @Override
+    public Binder<?> getSource() {
+        return (Binder<?>) super.getSource();
+    }
+
+    /**
+     * Gets the binder.
+     * 
+     * @return the binder
+     */
+    public Binder<?> getBinder() {
+        return getSource();
+    }
+
+}
diff --git a/server/src/main/java/com/vaadin/data/StatusChangeListener.java b/server/src/main/java/com/vaadin/data/StatusChangeListener.java
new file mode 100644 (file)
index 0000000..cb6afea
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2000-2016 Vaadin Ltd.
+ *
+ * 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.
+ */
+package com.vaadin.data;
+
+import java.io.Serializable;
+
+/**
+ * Listener interface for {@link StatusChangeEvent}s.
+ * 
+ * @see StatusChangeEvent
+ * @author Vaadin Ltd
+ *
+ */
+@FunctionalInterface
+public interface StatusChangeListener extends Serializable {
+
+    /**
+     * Notifies the listener about status change {@code event}.
+     * 
+     * @param event
+     *            a status change event, not null
+     */
+    void statusChange(StatusChangeEvent event);
+}
index 0b55bc8cadeaa7fbb1da14ca2be4558673e99297..f80f0781c222c06f0fead655a049db164c90b68f 100644 (file)
@@ -18,11 +18,13 @@ package com.vaadin.data;
 import java.time.LocalDate;
 import java.util.List;
 import java.util.Locale;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
 
 import org.junit.Assert;
 import org.junit.Before;
+import org.junit.Ignore;
 import org.junit.Test;
 
 import com.vaadin.data.Binder.Binding;
@@ -30,6 +32,7 @@ import com.vaadin.data.ValidationStatus.Status;
 import com.vaadin.data.util.converter.Converter;
 import com.vaadin.data.util.converter.StringToIntegerConverter;
 import com.vaadin.data.validator.EmailValidator;
+import com.vaadin.data.validator.StringLengthValidator;
 import com.vaadin.server.AbstractErrorMessage;
 import com.vaadin.ui.Button;
 import com.vaadin.ui.DateField;
@@ -712,4 +715,120 @@ public class BinderBookOfVaadinTest {
                 formStatusLabel.getValue());
 
     }
+
+    @Test
+    @Ignore
+    public void statusChangeListener_binderIsNotBound() {
+        Button saveButton = new Button();
+        Button resetButton = new Button();
+
+        AtomicBoolean eventIsFired = new AtomicBoolean(false);
+
+        binder.addStatusChangeListener(event -> {
+            boolean isValid = !event.hasValidationErrors();
+            boolean hasChanges = event.getBinder().hasChanges();
+            eventIsFired.set(true);
+
+            saveButton.setEnabled(hasChanges && isValid);
+            resetButton.setEnabled(hasChanges);
+        });
+        binder.forField(field)
+                .withValidator(new StringLengthValidator("", 1, 3))
+                .bind(BookPerson::getLastName, BookPerson::setLastName);
+        // no changes
+        Assert.assertFalse(saveButton.isEnabled());
+        Assert.assertFalse(resetButton.isEnabled());
+        verifyEventIsFired(eventIsFired);
+
+        BookPerson person = new BookPerson(2000, 1);
+        binder.load(person);
+        // no changes
+        Assert.assertFalse(saveButton.isEnabled());
+        Assert.assertFalse(resetButton.isEnabled());
+        verifyEventIsFired(eventIsFired);
+
+        field.setValue("a");
+        // binder is not bound, no event fired
+        // no changes: see #375. There should be a change and enabled state
+        Assert.assertTrue(saveButton.isEnabled());
+        Assert.assertTrue(resetButton.isEnabled());
+        Assert.assertTrue(eventIsFired.get());
+
+        binder.saveIfValid(person);
+        // no changes
+        Assert.assertFalse(saveButton.isEnabled());
+        Assert.assertFalse(resetButton.isEnabled());
+        verifyEventIsFired(eventIsFired);
+
+        binder.validate();
+        // no changes
+        Assert.assertFalse(saveButton.isEnabled());
+        Assert.assertFalse(resetButton.isEnabled());
+        verifyEventIsFired(eventIsFired);
+
+        field.setValue("");
+        // binder is not bound, no event fired
+        // no changes: see #375. There should be a change and disabled state for
+        // save button because of failed validation
+        Assert.assertFalse(saveButton.isEnabled());
+        Assert.assertTrue(resetButton.isEnabled());
+        Assert.assertTrue(eventIsFired.get());
+    }
+
+    @Test
+    public void statusChangeListener_binderIsBound() {
+        Button saveButton = new Button();
+        Button resetButton = new Button();
+
+        AtomicBoolean eventIsFired = new AtomicBoolean(false);
+
+        binder.addStatusChangeListener(event -> {
+            boolean isValid = !event.hasValidationErrors();
+            boolean hasChanges = event.getBinder().hasChanges();
+            eventIsFired.set(true);
+
+            saveButton.setEnabled(hasChanges && isValid);
+            resetButton.setEnabled(hasChanges);
+        });
+        binder.forField(field)
+                .withValidator(new StringLengthValidator("", 1, 3))
+                .bind(BookPerson::getLastName, BookPerson::setLastName);
+        // no changes
+        Assert.assertFalse(saveButton.isEnabled());
+        Assert.assertFalse(resetButton.isEnabled());
+        verifyEventIsFired(eventIsFired);
+
+        BookPerson person = new BookPerson(2000, 1);
+        binder.bind(person);
+        // no changes
+        Assert.assertFalse(saveButton.isEnabled());
+        Assert.assertFalse(resetButton.isEnabled());
+        verifyEventIsFired(eventIsFired);
+
+        field.setValue("a");
+        // there are valid changes
+        Assert.assertTrue(saveButton.isEnabled());
+        Assert.assertTrue(resetButton.isEnabled());
+        verifyEventIsFired(eventIsFired);
+
+        field.setValue("");
+        // there are invalid changes
+        Assert.assertFalse(saveButton.isEnabled());
+        Assert.assertTrue(resetButton.isEnabled());
+        verifyEventIsFired(eventIsFired);
+
+        // set valid value
+        field.setValue("a");
+        verifyEventIsFired(eventIsFired);
+        binder.saveIfValid(person);
+        // there are no changes.
+        Assert.assertFalse(saveButton.isEnabled());
+        Assert.assertFalse(resetButton.isEnabled());
+        verifyEventIsFired(eventIsFired);
+    }
+
+    private void verifyEventIsFired(AtomicBoolean flag) {
+        Assert.assertTrue(flag.get());
+        flag.set(false);
+    }
 }
diff --git a/server/src/test/java/com/vaadin/data/BinderStatusChangeTest.java b/server/src/test/java/com/vaadin/data/BinderStatusChangeTest.java
new file mode 100644 (file)
index 0000000..42550a0
--- /dev/null
@@ -0,0 +1,423 @@
+/*
+ * Copyright 2000-2016 Vaadin Ltd.
+ *
+ * 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.
+ */
+package com.vaadin.data;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.vaadin.data.Binder.Binding;
+import com.vaadin.data.util.converter.StringToIntegerConverter;
+import com.vaadin.tests.data.bean.Person;
+
+/**
+ * @author Vaadin Ltd
+ *
+ */
+public class BinderStatusChangeTest
+        extends BinderTestBase<Binder<Person>, Person> {
+
+    private AtomicReference<StatusChangeEvent> event;
+
+    @Before
+    public void setUp() {
+        binder = new Binder<>();
+        item = new Person();
+        event = new AtomicReference<>();
+    }
+
+    @Test
+    public void bindBinding_unbound_eventWhenBoundEndnoEventsBeforeBound() {
+        binder.addStatusChangeListener(this::statusChanged);
+
+        Binding<Person, String, String> binding = binder.forField(nameField);
+
+        nameField.setValue("");
+        Assert.assertNull(event.get());
+
+        binding.bind(Person::getFirstName, Person::setFirstName);
+        verifyEvent();
+    }
+
+    @Test
+    public void bindBinder_unbound_singleEventWhenBound() {
+        binder.addStatusChangeListener(this::statusChanged);
+
+        Assert.assertNull(event.get());
+
+        binder.bind(item);
+
+        verifyEvent();
+    }
+
+    @Test
+    public void unbindBinder_bound_singleEventWhenBound() {
+        binder.bind(item);
+
+        unbindBinder_unbound_singleEventWhenBound();
+    }
+
+    @Test
+    public void unbindBinder_unbound_singleEventWhenBound() {
+        binder.addStatusChangeListener(this::statusChanged);
+
+        Assert.assertNull(event.get());
+        binder.unbind();
+        verifyEvent();
+    }
+
+    @Test
+    public void setValue_bound_singleEventOnSetValue() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.bind(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+
+        Assert.assertNull(event.get());
+        nameField.setValue("foo");
+        verifyEvent();
+    }
+
+    @Test
+    public void setValue_severalBoundFieldsAndBoundBinder_singleEventOnSetValue() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.forField(ageField)
+                .withConverter(new StringToIntegerConverter(""))
+                .bind(Person::getAge, Person::setAge);
+        binder.bind(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+
+        Assert.assertNull(event.get());
+        nameField.setValue("foo");
+        verifyEvent();
+    }
+
+    @Test
+    public void setInvalidValue_bound_singleEventOnSetValue() {
+        binder.forField(nameField).withValidator(name -> false, "")
+                .bind(Person::getFirstName, Person::setFirstName);
+        binder.bind(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+
+        Assert.assertNull(event.get());
+        nameField.setValue("foo");
+        verifyEvent(true);
+    }
+
+    @Test
+    public void setInvalidBean_bound_singleEventOnSetValue() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.bind(item);
+
+        binder.withValidator(Validator.from(bean -> false, ""));
+
+        binder.addStatusChangeListener(this::statusChanged);
+
+        Assert.assertNull(event.get());
+        nameField.setValue("foo");
+        verifyEvent(true);
+    }
+
+    @Test
+    public void load_hasBindings_singleEventOnLoad() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.load(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void load_hasSeveralBindings_singleEventOnLoad() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.forField(ageField)
+                .withConverter(new StringToIntegerConverter(""))
+                .bind(Person::getAge, Person::setAge);
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.load(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void load_hasNoBindings_singleEvent() {
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.load(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void save_hasNoBindings_singleEvent() throws ValidationException {
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.save(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void saveIfValid_hasNoBindings_singleEvent() {
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.saveIfValid(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void save_hasBindings_singleEvent() throws ValidationException {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.load(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.save(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void save_hasSeveralBindings_singleEvent()
+            throws ValidationException {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.forField(ageField)
+                .withConverter(new StringToIntegerConverter(""))
+                .bind(Person::getAge, Person::setAge);
+        binder.load(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.save(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void saveIfValid_hasBindings_singleEvent() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.load(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.saveIfValid(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void saveIfValid_hasSeveralBindings_singleEvent() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.forField(ageField)
+                .withConverter(new StringToIntegerConverter(""))
+                .bind(Person::getAge, Person::setAge);
+        binder.load(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.saveIfValid(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void saveInvalidValue_hasBindings_singleEvent() {
+        binder.forField(nameField).withValidator(name -> false, "")
+                .bind(Person::getFirstName, Person::setFirstName);
+        binder.load(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        try {
+            binder.save(item);
+        } catch (ValidationException ignore) {
+        }
+        verifyEvent(true);
+    }
+
+    @Test
+    public void saveIfValid_invalidValueAndBinderHasBindings_singleEvent() {
+        binder.forField(nameField).withValidator(name -> false, "")
+                .bind(Person::getFirstName, Person::setFirstName);
+        binder.load(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.saveIfValid(item);
+        verifyEvent(true);
+    }
+
+    @Test
+    public void saveIfValid_invalidValueAndBinderHasSeveralBindings_singleEvent() {
+        binder.forField(nameField).withValidator(name -> false, "")
+                .bind(Person::getFirstName, Person::setFirstName);
+        binder.forField(ageField)
+                .withConverter(new StringToIntegerConverter(""))
+                .bind(Person::getAge, Person::setAge);
+        binder.load(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.saveIfValid(item);
+        verifyEvent(true);
+    }
+
+    @Test
+    public void saveInvalidBean_hasBindings_singleEvent() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.load(item);
+        binder.withValidator(Validator.from(person -> false, ""));
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        try {
+            binder.save(item);
+        } catch (ValidationException ignore) {
+        }
+        verifyEvent(true);
+    }
+
+    @Test
+    public void saveIfValid_invalidBeanAndBinderHasBindings_singleEvent() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.load(item);
+        binder.withValidator(Validator.from(person -> false, ""));
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.saveIfValid(item);
+        verifyEvent(true);
+    }
+
+    @Test
+    public void saveValidBean_hasBindings_singleEvent()
+            throws ValidationException {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.load(item);
+        binder.withValidator(Validator.from(person -> true, ""));
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.save(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void saveIfValid_validBeanAndBinderHasBindings_singleEvent() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.load(item);
+        binder.withValidator(Validator.from(person -> true, ""));
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+        binder.saveIfValid(item);
+        verifyEvent();
+    }
+
+    @Test
+    public void validateBinder_noValidationErrors_statusEventWithoutErrors() {
+        binder.forField(nameField).bind(Person::getFirstName,
+                Person::setFirstName);
+        binder.forField(ageField)
+                .withConverter(new StringToIntegerConverter(""))
+                .bind(Person::getAge, Person::setAge);
+        binder.bind(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+
+        binder.validate();
+        verifyEvent();
+    }
+
+    @Test
+    public void validateBinder_validationErrors_statusEventWithError() {
+        binder.forField(nameField).withValidator(name -> false, "")
+                .bind(Person::getFirstName, Person::setFirstName);
+        binder.forField(ageField)
+                .withConverter(new StringToIntegerConverter(""))
+                .bind(Person::getAge, Person::setAge);
+        binder.bind(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+
+        binder.validate();
+        verifyEvent(true);
+    }
+
+    @Test
+    public void validateBinding_noValidationErrors_statusEventWithoutErrors() {
+        Binding<Person, String, String> binding = binder.forField(nameField);
+        binding.bind(Person::getFirstName, Person::setFirstName);
+        binder.forField(ageField)
+                .withConverter(new StringToIntegerConverter(""))
+                .bind(Person::getAge, Person::setAge);
+        binder.bind(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+
+        binding.validate();
+        verifyEvent();
+    }
+
+    @Test
+    public void validateBinding_validationErrors_statusEventWithError() {
+        Binding<Person, String, String> binding = binder.forField(nameField)
+                .withValidator(name -> false, "");
+        binding.bind(Person::getFirstName, Person::setFirstName);
+        binder.forField(ageField)
+                .withConverter(new StringToIntegerConverter(""))
+                .bind(Person::getAge, Person::setAge);
+        binder.bind(item);
+
+        binder.addStatusChangeListener(this::statusChanged);
+        Assert.assertNull(event.get());
+
+        binding.validate();
+        verifyEvent(true);
+    }
+
+    private void verifyEvent() {
+        verifyEvent(false);
+    }
+
+    private void verifyEvent(boolean validationErrors) {
+        StatusChangeEvent statusChangeEvent = event.get();
+        Assert.assertNotNull(statusChangeEvent);
+        Assert.assertEquals(binder, statusChangeEvent.getBinder());
+        Assert.assertEquals(binder, statusChangeEvent.getSource());
+        Assert.assertEquals(validationErrors,
+                statusChangeEvent.hasValidationErrors());
+    }
+
+    private void statusChanged(StatusChangeEvent evt) {
+        Assert.assertNull(event.get());
+        event.set(evt);
+    }
+}