From fa3d2566ecb9f5a67a5d2505d62b6387bed6ed18 Mon Sep 17 00:00:00 2001 From: Teemu Suo-Anttila Date: Wed, 15 Jun 2016 19:14:47 +0300 Subject: Add Binder for binding field values to bean properties Change-Id: I509f02261a36fcef276d2a1c5590a06bc28e8ed2 --- server/src/main/java/com/vaadin/data/Binder.java | 314 +++++++++++++++++++++ .../src/test/java/com/vaadin/data/BinderTest.java | 136 +++++++++ 2 files changed, 450 insertions(+) create mode 100644 server/src/main/java/com/vaadin/data/Binder.java create mode 100644 server/src/test/java/com/vaadin/data/BinderTest.java diff --git a/server/src/main/java/com/vaadin/data/Binder.java b/server/src/main/java/com/vaadin/data/Binder.java new file mode 100644 index 0000000000..4bf56ed068 --- /dev/null +++ b/server/src/main/java/com/vaadin/data/Binder.java @@ -0,0 +1,314 @@ +/* + * Copyright 2000-2014 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; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import com.vaadin.event.Registration; + +/** + * Connects one or more {@code Field} components to properties of a backing data + * type such as a bean type. With a binder, input components can be grouped + * together into forms to easily create and update business objects with little + * explicit logic needed to move data between the UI and the data layers of the + * application. + *

+ * A binder is a collection of bindings, each representing the + * association of a single field and a backing property. + *

+ * 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 master-details + * view, where a select component is used to pick the bean to edit. + *

+ * Unless otherwise specified, {@code Binder} method arguments cannot be null. + * + * @author Vaadin Ltd. + * + * @param + * the bean type + * + * @see Binding + * @see HasValue + * + * @since + */ +public class Binder implements Serializable { + + /** + * Represents the binding between a single field and a property. + * + * @param + * the item type + * @param + * the field value type + * + * @see Binder#forField(HasValue) + */ + public interface Binding extends Serializable { + + /** + * Completes this binding using the given getter and setter functions + * representing a backing bean property. The functions are used to + * update the field value from the property and to store the field value + * to the property, respectively. + *

+ * When a bean is bound with {@link Binder#bind(T)}, the field value is + * set to the return value of the given getter. The property value is + * then updated via the given setter whenever the field value changes. + * The setter may be null; in that case the property value is never + * updated and the binding is said to be read-only. + *

+ * If the Binder is already bound to some item, the newly bound field is + * associated with the corresponding bean property as described above. + *

+ * The getter and setter can be arbitrary functions, for instance + * implementing user-defined conversion or validation. However, in the + * most basic use case you can simply pass a pair of method references + * to this method as follows: + * + *

+         * class Person {
+         *     public String getName() { ... }
+         *     public void setName(String name) { ... }
+         * }
+         * 
+         * TextField nameField = new TextField();
+         * binder.forField(nameField).bind(Person::getName, Person::setName);
+         * 
+ * + * @param getter + * the function to get the value of the property to the + * field, not null + * @param setter + * the function to save the field value to the property or + * null if read-only + * @throws IllegalStateException + * if {@code bind} has already been called on this binding + */ + public void bind(Function getter, BiConsumer setter); + } + + /** + * An internal implementation of {@code Binding}. + * + * @param + * the value type + */ + protected class BindingImpl implements Binding { + + private HasValue field; + private Registration onValueChange; + + private Function getter; + private BiConsumer setter; + + /** + * Creates a new binding associated with the given field. + * + * @param field + * the field to bind + */ + protected BindingImpl(HasValue field) { + this.field = field; + } + + @Override + public void bind(Function getter, BiConsumer setter) { + checkUnbound(); + Objects.requireNonNull(getter, "getter cannot be null"); + + this.getter = getter; + this.setter = setter; + bindings.add(this); + if (bean != null) { + bind(bean); + } + } + + private void bind(T bean) { + setFieldValue(bean); + onValueChange = field.addValueChangeListener(e -> storeFieldValue( + bean)); + } + + private void unbind() { + onValueChange.remove(); + } + + /** + * Sets the field value by invoking the getter function on the given + * bean. + * + * @param bean + * the bean to fetch the property value from + */ + private void setFieldValue(T bean) { + assert bean != null; + field.setValue(getter.apply(bean)); + } + + /** + * Saves the field value by invoking the setter function on the given + * bean, if the value passes all registered validators. + * + * @param bean + * the bean to set the property value to + */ + private void storeFieldValue(T bean) { + assert bean != null; + if (setter != null) { + setter.accept(bean, field.getValue()); + } + } + + private void checkUnbound() { + if (this.getter != null) { + throw new IllegalStateException( + "cannot modify binding: already bound to a property"); + } + } + } + + private T bean; + + private Set> bindings = new LinkedHashSet<>(); + + /** + * Returns an {@code Optional} of the bean that has been bound with + * {@link #bind}, or an empty optional if a bean is not currently bound. + * + * @return the currently bound bean if any + */ + public Optional getBean() { + return Optional.ofNullable(bean); + } + + /** + * Creates a new binding for the given field. The returned binding may be + * further configured before invoking + * {@link Binding#bind(Function, BiConsumer) Binding.bind} which completes + * the binding. Until {@code Binding.bind} is called, the binding has no + * effect. + * + * @param + * the value type of the field + * @param field + * the field to be bound, not null + * @return the new binding + */ + public Binding forField(HasValue field) { + return createBinding(field); + } + + /** + * Binds a field to a bean property represented by the given getter and + * setter pair. The functions are used to update the field value from the + * property and to store the field value to the property, respectively. + *

+ * Use the {@link #forField(HasValue)} overload instead if you want to + * further configure the new binding. + *

+ * When a bean is bound with {@link Binder#bind(T)}, the field value is set + * to the return value of the given getter. The property value is then + * updated via the given setter whenever the field value changes. The setter + * may be null; in that case the property value is never updated and the + * binding is said to be read-only. + *

+ * If the Binder is already bound to some item, the newly bound field is + * associated with the corresponding bean property as described above. + *

+ * The getter and setter can be arbitrary functions, for instance + * implementing user-defined conversion or validation. However, in the most + * basic use case you can simply pass a pair of method references to this + * method as follows: + * + *

+     * class Person {
+     *     public String getName() { ... }
+     *     public void setName(String name) { ... }
+     * }
+     * 
+     * TextField nameField = new TextField();
+     * binder.bind(nameField, Person::getName, Person::setName);
+     * 
+ * + * @param + * the value type of the field + * @param field + * the field to bind, not null + * @param getter + * the function to get the value of the property to the field, + * not null + * @param setter + * the function to save the field value to the property or null + * if read-only + */ + public void bind(HasValue field, Function getter, + BiConsumer setter) { + forField(field).bind(getter, setter); + } + + /** + * Binds the given bean to all the fields added to this Binder. To remove + * the binding, call {@link #unbind()}. + *

+ * When a bean is bound, the field values are updated by invoking their + * 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. + * + * @param bean + * the bean to edit, not null + */ + public void bind(T bean) { + Objects.requireNonNull(bean, "bean cannot be null"); + unbind(); + this.bean = bean; + bindings.forEach(b -> b.bind(bean)); + } + + /** + * Unbinds the currently bound bean if any. If there is no bound bean, does + * nothing. + */ + public void unbind() { + if (bean != null) { + bean = null; + bindings.forEach(b -> b.unbind()); + } + } + + /** + * Creates a new binding with the given field. + * + * @param + * the field value type + * @param field + * the field to bind + * @return the new incomplete binding + */ + protected BindingImpl createBinding(HasValue field) { + Objects.requireNonNull(field, "field cannot be null"); + BindingImpl b = new BindingImpl<>(field); + return b; + } +} diff --git a/server/src/test/java/com/vaadin/data/BinderTest.java b/server/src/test/java/com/vaadin/data/BinderTest.java new file mode 100644 index 0000000000..013113e33f --- /dev/null +++ b/server/src/test/java/com/vaadin/data/BinderTest.java @@ -0,0 +1,136 @@ +package com.vaadin.data; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; + +import org.junit.Before; +import org.junit.Test; + +import com.vaadin.tests.data.bean.Person; +import com.vaadin.ui.AbstractField; + +public class BinderTest { + + class TextField extends AbstractField { + + String value = ""; + + @Override + public String getValue() { + return value; + } + + @Override + protected void doSetValue(String value) { + this.value = value; + } + } + + Binder binder; + + TextField nameField; + + Person p = new Person(); + + @Before + public void setUp() { + binder = new Binder<>(); + p.setFirstName("Johannes"); + nameField = new TextField(); + } + + @Test(expected = NullPointerException.class) + public void bindingNullBeanThrows() { + binder.bind(null); + } + + @Test(expected = NullPointerException.class) + public void bindingNullFieldThrows() { + binder.forField(null); + } + + @Test(expected = NullPointerException.class) + public void bindingNullGetterThrows() { + binder.bind(nameField, null, Person::setFirstName); + } + + @Test + public void fieldValueUpdatedOnBeanBind() { + binder.forField(nameField).bind(Person::getFirstName, + Person::setFirstName); + binder.bind(p); + assertEquals("Johannes", nameField.getValue()); + } + + @Test + public void fieldValueUpdatedWithShortcutBind() { + bindName(); + assertEquals("Johannes", nameField.getValue()); + } + + @Test + public void fieldValueUpdatedIfBeanAlreadyBound() { + binder.bind(p); + binder.bind(nameField, Person::getFirstName, Person::setFirstName); + + assertEquals("Johannes", nameField.getValue()); + nameField.setValue("Artur"); + assertEquals("Artur", p.getFirstName()); + } + + @Test + public void getBeanReturnsBoundBeanOrNothing() { + assertFalse(binder.getBean().isPresent()); + binder.bind(p); + assertSame(p, binder.getBean().get()); + binder.unbind(); + assertFalse(binder.getBean().isPresent()); + } + + @Test + public void fieldValueSavedToPropertyOnChange() { + bindName(); + nameField.setValue("Henri"); + assertEquals("Henri", p.getFirstName()); + } + + @Test + public void fieldValueNotSavedAfterUnbind() { + bindName(); + nameField.setValue("Henri"); + binder.unbind(); + nameField.setValue("Aleksi"); + assertEquals("Henri", p.getFirstName()); + } + + @Test + public void bindNullSetterIgnoresValueChange() { + binder.bind(nameField, Person::getFirstName, null); + binder.bind(p); + nameField.setValue("Artur"); + assertEquals(p.getFirstName(), "Johannes"); + } + + @Test + public void bindToAnotherBeanStopsUpdatingOriginalBean() { + bindName(); + nameField.setValue("Leif"); + + Person p2 = new Person(); + p2.setFirstName("Marlon"); + binder.bind(p2); + assertEquals("Marlon", nameField.getValue()); + assertEquals("Leif", p.getFirstName()); + assertSame(p2, binder.getBean().get()); + + nameField.setValue("Ilia"); + assertEquals("Ilia", p2.getFirstName()); + assertEquals("Leif", p.getFirstName()); + } + + private void bindName() { + binder.bind(nameField, Person::getFirstName, Person::setFirstName); + binder.bind(p); + } +} -- cgit v1.2.3