From f2bb3c886c0b68c9a4e8212d6625614575148f80 Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Mon, 8 Aug 2016 15:52:05 +0300 Subject: Add converter support to Binder Change-Id: Ibf1223d4842d72f0209231dfd70e1d6c4deb6d30 --- server/src/main/java/com/vaadin/data/Binder.java | 246 ++++++++++++++++++--- server/src/main/java/com/vaadin/data/Result.java | 33 +-- .../main/java/com/vaadin/data/SimpleResult.java | 8 +- .../converter/AbstractStringToNumberConverter.java | 102 +++++++++ .../com/vaadin/data/util/converter/Converter.java | 176 +++++++++++++++ .../util/converter/StringToIntegerConverter.java | 89 ++++++++ .../com/vaadin/data/BinderBookOfVaadinTest.java | 124 +++++++++-- .../src/test/java/com/vaadin/data/BinderTest.java | 160 +++++++++++++- 8 files changed, 883 insertions(+), 55 deletions(-) create mode 100644 server/src/main/java/com/vaadin/data/util/converter/AbstractStringToNumberConverter.java create mode 100644 server/src/main/java/com/vaadin/data/util/converter/Converter.java create mode 100644 server/src/main/java/com/vaadin/data/util/converter/StringToIntegerConverter.java (limited to 'server') diff --git a/server/src/main/java/com/vaadin/data/Binder.java b/server/src/main/java/com/vaadin/data/Binder.java index 0fbacce994..8c637118d6 100644 --- a/server/src/main/java/com/vaadin/data/Binder.java +++ b/server/src/main/java/com/vaadin/data/Binder.java @@ -19,15 +19,18 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; 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; import com.vaadin.event.Registration; +import com.vaadin.server.ErrorMessage; import com.vaadin.server.UserError; import com.vaadin.ui.AbstractComponent; @@ -153,6 +156,105 @@ public class Binder implements Serializable { public Binding withValidator( Predicate predicate, String message); + /** + * Maps the binding to another data type using the given + * {@link Converter}. + *

+ * A converter is capable of converting between a presentation type, + * which must match the current target data type of the binding, and a + * model type, which can be any data type and becomes the new target + * type of the binding. When invoking + * {@link #bind(Function, BiConsumer)}, the target type of the binding + * must match the getter/setter types. + *

+ * For instance, a {@code TextField} can be bound to an integer-typed + * property using an appropriate converter such as a + * {@link StringToIntegerConverter}. + * + * @param + * the type to convert to + * @param converter + * the converter to use, not null + * @return a new binding with the appropriate type + * @throws IllegalStateException + * if {@code bind} has already been called + */ + public Binding withConverter( + Converter converter); + + /** + * Maps the binding to another data type using the mapping functions and + * a possible exception as the error message. + *

+ * The mapping functions are used to convert between a presentation + * type, which must match the current target data type of the binding, + * and a model type, which can be any data type and becomes the new + * target type of the binding. When invoking + * {@link #bind(Function, BiConsumer)}, the target type of the binding + * must match the getter/setter types. + *

+ * For instance, a {@code TextField} can be bound to an integer-typed + * property using appropriate functions such as: + * withConverter(Integer::valueOf, String::valueOf); + * + * @param + * the type to convert to + * @param toModel + * the function which can convert from the old target type to + * the new target type + * @param toPresentation + * the function which can convert from the new target type to + * the old target type + * @return a new binding with the appropriate type + * @throws IllegalStateException + * if {@code bind} has already been called + */ + default public Binding withConverter( + Function toModel, + Function toPresentation) { + return withConverter(Converter.from(toModel, toPresentation, + exception -> exception.getMessage())); + } + + /** + * Maps the binding to another data type using the mapping functions and + * the given error error message if a value cannot be converted to the + * new target type. + *

+ * The mapping functions are used to convert between a presentation + * type, which must match the current target data type of the binding, + * and a model type, which can be any data type and becomes the new + * target type of the binding. When invoking + * {@link #bind(Function, BiConsumer)}, the target type of the binding + * must match the getter/setter types. + *

+ * For instance, a {@code TextField} can be bound to an integer-typed + * property using appropriate functions such as: + * withConverter(Integer::valueOf, String::valueOf); + * + * @param + * the type to convert to + * @param toModel + * the function which can convert from the old target type to + * the new target type + * @param toPresentation + * the function which can convert from the new target type to + * the old target type + * @param errorMessage + * the error message to use if conversion using + * toModel fails + * @return a new binding with the appropriate type + * @throws IllegalStateException + * if {@code bind} has already been called + */ + public default Binding withConverter( + Function toModel, + Function toPresentation, + String errorMessage) { + return withConverter(Converter.from(toModel, toPresentation, + exception -> errorMessage)); + } + /** * Gets the field the binding uses. * @@ -176,15 +278,19 @@ public class Binder implements Serializable { protected static class BindingImpl implements Binding { - private Binder binder; + private final Binder binder; - private HasValue field; + private final HasValue field; private Registration onValueChange; private Function getter; private BiConsumer setter; - private List> validators = new ArrayList<>(); + /** + * Contains all converters and validators chained together in the + * correct order. + */ + private Converter converterValidatorChain; /** * Creates a new binding associated with the given field. @@ -194,9 +300,28 @@ public class Binder implements Serializable { * @param field * the field to bind */ + @SuppressWarnings("unchecked") protected BindingImpl(Binder binder, HasValue field) { - this.binder = binder; + this(binder, field, + (Converter) Converter.identity()); + } + + /** + * Creates a new binding associated with the given field using the given + * converter chain. + * + * @param binder + * the binder this instance is connected to + * @param field + * the field to bind + * @param converterValidatorChain + * the converter/validator chain to use + */ + protected BindingImpl(Binder binder, HasValue field, + Converter converterValidatorChain) { this.field = field; + this.binder = binder; + this.converterValidatorChain = converterValidatorChain; } @Override @@ -216,7 +341,11 @@ public class Binder implements Serializable { Validator validator) { checkUnbound(); Objects.requireNonNull(validator, "validator cannot be null"); - validators.add(validator); + + Converter validatorAsConverter = new ValidatorAsConverter<>( + validator); + converterValidatorChain = converterValidatorChain + .chain(validatorAsConverter); return this; } @@ -226,20 +355,38 @@ public class Binder implements Serializable { return withValidator(Validator.from(predicate, message)); } + @Override + public Binding withConverter( + Converter converter) { + checkUnbound(); + Objects.requireNonNull(converter, "converter cannot be null"); + + BindingImpl newBinding = new BindingImpl<>( + binder, field, converterValidatorChain.chain(converter)); + return newBinding; + } + private void bind(BEAN bean) { setFieldValue(bean); onValueChange = field .addValueChangeListener(e -> storeFieldValue(bean)); } - private List> validate() { - return validators.stream() - .map(validator -> validator - .apply((TARGET) field.getValue())) - .filter(Result::isError) - .map(result -> new ValidationError<>(field, - result.getMessage().orElse(null))) - .collect(Collectors.toList()); + private Result validate() { + FIELDVALUE fieldValue = field.getValue(); + Result dataValue = converterValidatorChain.convertToModel( + fieldValue, ((AbstractComponent) field).getLocale()); + return dataValue; + } + + /** + * Returns the field value run through all converters and validators. + * + * @return an optional containing the validated and converted value or + * an empty optional if a validator or converter failed + */ + private Optional getTargetValue() { + return validate().getValue(); } private void unbind() { @@ -255,7 +402,13 @@ public class Binder implements Serializable { */ private void setFieldValue(BEAN bean) { assert bean != null; - field.setValue((FIELDVALUE) getter.apply(bean)); + field.setValue(convertDataToFieldType(bean)); + } + + private FIELDVALUE convertDataToFieldType(BEAN bean) { + return converterValidatorChain.convertToPresentation( + getter.apply(bean), + ((AbstractComponent) field).getLocale()); } /** @@ -268,12 +421,12 @@ public class Binder implements Serializable { private void storeFieldValue(BEAN bean) { assert bean != null; if (setter != null) { - setter.accept(bean, (TARGET) field.getValue()); + getTargetValue().ifPresent(value -> setter.accept(bean, value)); } } private void checkUnbound() { - if (this.getter != null) { + if (getter != null) { throw new IllegalStateException( "cannot modify binding: already bound to a property"); } @@ -285,6 +438,46 @@ public class Binder implements Serializable { } } + /** + * Wraps a validator as a converter. + *

+ * The type of the validator must be of the same type as this converter or a + * super type of it. + * + * @param + * the type of the converter + */ + private static class ValidatorAsConverter implements Converter { + + private Validator validator; + + /** + * Creates a new converter wrapping the given validator. + * + * @param validator + * the validator to wrap + */ + public ValidatorAsConverter(Validator validator) { + this.validator = validator; + } + + @Override + public Result convertToModel(T value, Locale locale) { + Result validationResult = validator.apply(value); + if (validationResult.isError()) { + return Result.error(validationResult.getMessage().get()); + } else { + return Result.ok(value); + } + } + + @Override + public T convertToPresentation(T value, Locale locale) { + return value; + } + + } + private BEAN bean; private Set> bindings = new LinkedHashSet<>(); @@ -396,12 +589,13 @@ public class Binder implements Serializable { public List> validate() { List> resultErrors = new ArrayList<>(); for (BindingImpl binding : bindings) { - clearError(binding.getField()); - List> errors = binding.validate(); - resultErrors.addAll(errors); - if (!errors.isEmpty()) { - handleError(binding.getField(), errors.get(0).getMessage()); - } + clearError(binding.field); + + binding.validate().ifError(errorMessage -> { + resultErrors.add( + new ValidationError<>(binding.field, errorMessage)); + handleError(binding.field, errorMessage); + }); } return resultErrors; } @@ -456,11 +650,11 @@ public class Binder implements Serializable { * the field to bind * @return the new incomplete binding */ - protected Binding createBinding( + protected BindingImpl createBinding( HasValue field) { Objects.requireNonNull(field, "field cannot be null"); - BindingImpl b = new BindingImpl<>(this, - field); + BindingImpl b = new BindingImpl( + this, field); return b; } diff --git a/server/src/main/java/com/vaadin/data/Result.java b/server/src/main/java/com/vaadin/data/Result.java index 0d6ffad94e..5cf0994943 100644 --- a/server/src/main/java/com/vaadin/data/Result.java +++ b/server/src/main/java/com/vaadin/data/Result.java @@ -1,12 +1,12 @@ /* * 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 @@ -40,7 +40,7 @@ public interface Result extends Serializable { /** * Returns a successful result wrapping the given value. - * + * * @param * the result value type * @param value @@ -53,7 +53,7 @@ public interface Result extends Serializable { /** * Returns a failure result wrapping the given error message. - * + * * @param * the result value type * @param message @@ -70,7 +70,7 @@ public interface Result extends Serializable { * If the supplier returns a value, returns a {@code Result.ok} of the * value; if an exception is thrown, returns the message in a * {@code Result.error}. - * + * * @param * the result value type * @param supplier @@ -96,7 +96,7 @@ public interface Result extends Serializable { * function to the value. Otherwise, returns a Result bearing the same error * as this one. Note that any exceptions thrown by the mapping function are * not wrapped but allowed to propagate. - * + * * @param * the type of the mapped value * @param mapper @@ -112,7 +112,7 @@ public interface Result extends Serializable { * to the value. Otherwise, returns a Result bearing the same error as this * one. Note that any exceptions thrown by the mapping function are not * wrapped but allowed to propagate. - * + * * @param * the type of the mapped value * @param mapper @@ -124,7 +124,7 @@ public interface Result extends Serializable { /** * Invokes either the first callback or the second one, depending on whether * this Result denotes a success or a failure, respectively. - * + * * @param ifOk * the function to call if success * @param ifError @@ -134,7 +134,7 @@ public interface Result extends Serializable { /** * Applies the {@code consumer} if result is not an error. - * + * * @param consumer * consumer to apply in case it's not an error */ @@ -145,7 +145,7 @@ public interface Result extends Serializable { /** * Applies the {@code consumer} if result is an error. - * + * * @param consumer * consumer to apply in case it's an error */ @@ -156,15 +156,22 @@ public interface Result extends Serializable { /** * Returns {@code true} if result is an error. - * + * * @return whether the result is an error */ public boolean isError(); /** * Returns an Optional of the result message, or an empty Optional if none. - * + * * @return the optional message */ public Optional getMessage(); + + /** + * Returns an Optional of the value, or an empty Optional if none. + * + * @return the optional value + */ + public Optional getValue(); } diff --git a/server/src/main/java/com/vaadin/data/SimpleResult.java b/server/src/main/java/com/vaadin/data/SimpleResult.java index 75e9cbef12..2063d8c5bb 100644 --- a/server/src/main/java/com/vaadin/data/SimpleResult.java +++ b/server/src/main/java/com/vaadin/data/SimpleResult.java @@ -37,7 +37,7 @@ class SimpleResult implements Result { *

* If {@code message} is null then {@code value} is ignored and result is an * error. - * + * * @param value * the value of the result, may be {@code null} * @param message @@ -80,6 +80,11 @@ class SimpleResult implements Result { return Optional.ofNullable(message); } + @Override + public Optional getValue() { + return Optional.ofNullable(value); + } + @Override public boolean isError() { return message != null; @@ -93,4 +98,5 @@ class SimpleResult implements Result { return "ok(" + value + ")"; } } + } diff --git a/server/src/main/java/com/vaadin/data/util/converter/AbstractStringToNumberConverter.java b/server/src/main/java/com/vaadin/data/util/converter/AbstractStringToNumberConverter.java new file mode 100644 index 0000000000..a7998443b7 --- /dev/null +++ b/server/src/main/java/com/vaadin/data/util/converter/AbstractStringToNumberConverter.java @@ -0,0 +1,102 @@ +/* + * 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.util.converter; + +import java.text.NumberFormat; +import java.text.ParsePosition; +import java.util.Locale; + +import com.vaadin.legacy.data.util.converter.LegacyConverter.ConversionException; + +/** + * A converter that converts from the number type T to {@link String} and back. + * Uses the given locale and {@link NumberFormat} for formatting and parsing. + * Automatically trims the input string, removing any leading and trailing white + * space. + *

+ * Override and overwrite {@link #getFormat(Locale)} to use a different format. + *

+ * + * @author Vaadin Ltd + * @since 8.0 + */ +public abstract class AbstractStringToNumberConverter + implements Converter { + + /** + * Returns the format used by {@link #convertToPresentation(Object, Locale)} + * and {@link #convertToModel(Object, Locale)}. + * + * @param locale + * The locale to use + * @return A NumberFormat instance + */ + protected NumberFormat getFormat(Locale locale) { + if (locale == null) { + locale = Locale.getDefault(); + } + + return NumberFormat.getNumberInstance(locale); + } + + /** + * Convert the value to a Number using the given locale and + * {@link #getFormat(Locale)}. + * + * @param value + * The value to convert + * @param locale + * The locale to use for conversion + * @return The converted value + * @throws ConversionException + * If there was a problem converting the value + */ + protected Number convertToNumber(String value, Locale locale) + throws ConversionException { + if (value == null) { + return null; + } + + // Remove leading and trailing white space + value = value.trim(); + + // Parse and detect errors. If the full string was not used, it is + // an error. + ParsePosition parsePosition = new ParsePosition(0); + Number parsedValue = getFormat(locale).parse(value, parsePosition); + if (parsePosition.getIndex() != value.length()) { + throw new ConversionException("Could not convert '" + value + "'"); + } + + if (parsedValue == null) { + // Convert "" to null + return null; + } + + return parsedValue; + } + + @Override + public String convertToPresentation(T value, Locale locale) { + if (value == null) { + return null; + } + + return getFormat(locale).format(value); + } + +} diff --git a/server/src/main/java/com/vaadin/data/util/converter/Converter.java b/server/src/main/java/com/vaadin/data/util/converter/Converter.java new file mode 100644 index 0000000000..e71ac97968 --- /dev/null +++ b/server/src/main/java/com/vaadin/data/util/converter/Converter.java @@ -0,0 +1,176 @@ +/* + * 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.util.converter; + +import java.io.Serializable; +import java.util.Locale; +import java.util.function.Function; + +import com.vaadin.data.Binder.Binding; +import com.vaadin.data.Result; +import com.vaadin.legacy.data.util.converter.LegacyConverter.ConversionException; + +/** + * Interface that implements conversion between a model and a presentation type. + *

+ * Converters must not have any side effects (never update UI from inside a + * converter). + * + * @param + * The presentation type. + * @param + * The model type. + * @author Vaadin Ltd. + * @since 8.0 + */ +public interface Converter extends Serializable { + + /** + * Converts the given value from model type to presentation type. + *

+ * A converter can optionally use locale to do the conversion. + * + * @param value + * The value to convert. Can be null + * @param locale + * The locale to use for conversion. Can be null. + * @return The converted value compatible with the source type + */ + public Result convertToModel(PRESENTATION value, Locale locale); + + /** + * Converts the given value from presentation type to model type. + *

+ * A converter can optionally use locale to do the conversion. + * + * @param value + * The value to convert. Can be null + * @param locale + * The locale to use for conversion. Can be null. + * @return The converted value compatible with the source type + */ + public PRESENTATION convertToPresentation(MODEL value, Locale locale); + + /** + * Returns a converter that returns its input as-is in both directions. + * + * @param + * the input and output type + * @return an identity converter + */ + public static Converter identity() { + return from(t -> Result.ok(t), t -> t); + } + + /** + * Constructs a converter from two functions. Any {@code Exception} + * instances thrown from the {@code toModel} function are converted into + * error-bearing {@code Result} objects using the given {@code onError} + * function. + * + * @param

+ * the presentation type + * @param + * the model type + * @param toModel + * the function to convert to model + * @param toPresentation + * the function to convert to presentation + * @param onError + * the function to provide error messages + * @return the new converter + * + * @see Result + * @see Function + */ + public static Converter from(Function toModel, + Function toPresentation, + Function onError) { + + return from(val -> Result.of(() -> toModel.apply(val), onError), + toPresentation); + } + + /** + * Constructs a converter from a filter and a function. + * + * @param

+ * the presentation type + * @param + * the model type + * @param toModel + * the function to convert to model + * @param toPresentation + * the function to convert to presentation + * @return the new converter + * + * @see Function + */ + public static Converter from(Function> toModel, + Function toPresentation) { + return new Converter() { + + @Override + public Result convertToModel(P value, Locale locale) + throws ConversionException { + return toModel.apply(value); + } + + @Override + public P convertToPresentation(M value, Locale locale) + throws ConversionException { + return toPresentation.apply(value); + } + }; + } + + /** + * Returns a converter that chains together this converter with the given + * type-compatible converter. + *

+ * The chained converters will form a new converter capable of converting + * from the presentation type of this converter to the model type of the + * other converter. + *

+ * In most typical cases you should not need this method but instead only + * need to define one converter for a binding using + * {@link Binding#withConverter(Converter)}. + * + * @param + * the model type of the resulting converter + * @param other + * the converter to chain, not null + * @return a chained converter + */ + public default Converter chain( + Converter other) { + return new Converter() { + @Override + public Result convertToModel(PRESENTATION value, Locale locale) { + Result model = Converter.this.convertToModel(value, + locale); + return model.flatMap(v -> other.convertToModel(v, locale)); + } + + @Override + public PRESENTATION convertToPresentation(T value, Locale locale) { + MODEL model = other.convertToPresentation(value, locale); + return Converter.this.convertToPresentation(model, locale); + } + }; + } +} diff --git a/server/src/main/java/com/vaadin/data/util/converter/StringToIntegerConverter.java b/server/src/main/java/com/vaadin/data/util/converter/StringToIntegerConverter.java new file mode 100644 index 0000000000..9f1b3b9e31 --- /dev/null +++ b/server/src/main/java/com/vaadin/data/util/converter/StringToIntegerConverter.java @@ -0,0 +1,89 @@ +/* + * 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.util.converter; + +import java.text.NumberFormat; +import java.util.Locale; + +import com.vaadin.data.Result; +import com.vaadin.legacy.data.util.converter.LegacyConverter.ConversionException; + +/** + * A converter that converts from {@link String} to {@link Integer} and back. + * Uses the given locale and a {@link NumberFormat} instance for formatting and + * parsing. + *

+ * Override and overwrite {@link #getFormat(Locale)} to use a different format. + *

+ * + * @author Vaadin Ltd + * @since 8.0 + */ +public class StringToIntegerConverter + extends AbstractStringToNumberConverter { + + private final String errorMessage; + + /** + * Creates a new converter instance with the given error message. + * + * @param errorMessage + * the error message to use if conversion fails + */ + public StringToIntegerConverter(String errorMessage) { + this.errorMessage = errorMessage; + } + + /** + * Returns the format used by + * {@link #convertToPresentation(Integer, Locale)} and + * {@link #convertToModel(String, Locale)}. + * + * @param locale + * The locale to use + * @return A NumberFormat instance + */ + @Override + protected NumberFormat getFormat(Locale locale) { + if (locale == null) { + locale = Locale.getDefault(); + } + return NumberFormat.getIntegerInstance(locale); + } + + @Override + public Result convertToModel(String value, Locale locale) + throws ConversionException { + Number n = convertToNumber(value, locale); + + if (n == null) { + return null; + } + + int intValue = n.intValue(); + if (intValue == n.longValue()) { + // If the value of n is outside the range of long, the return value + // of longValue() is either Long.MIN_VALUE or Long.MAX_VALUE. The + // above comparison promotes int to long and thus does not need to + // consider wrap-around. + return Result.ok(intValue); + } else { + return Result.error(errorMessage); + } + } + +} diff --git a/server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java b/server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java index 2c516c4086..74cec124a6 100644 --- a/server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java +++ b/server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java @@ -21,16 +21,16 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; -import com.vaadin.data.Binder; -import com.vaadin.data.ValidationError; +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.tests.data.bean.Person; import com.vaadin.ui.AbstractField; +import com.vaadin.ui.Slider; /** * Book of Vaadin tests. - * + * * @author Vaadin Ltd * */ @@ -51,11 +51,53 @@ public class BinderBookOfVaadinTest { } } - private Binder binder; + private static class BookPerson { + private String lastName; + private String email; + private int yearOfBirth, salaryLevel; - private TextField field; + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public BookPerson(int yearOfBirth, int salaryLevel) { + this.yearOfBirth = yearOfBirth; + this.salaryLevel = salaryLevel; + } + + public int getYearOfBirth() { + return yearOfBirth; + } + + public void setYearOfBirth(int yearOfBirth) { + this.yearOfBirth = yearOfBirth; + } + + public int getSalaryLevel() { + return salaryLevel; + } + + public void setSalaryLevel(int salaryLevel) { + this.salaryLevel = salaryLevel; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + } + + private Binder binder; - private Person person = new Person(); + private TextField field; @Before public void setUp() { @@ -69,7 +111,7 @@ public class BinderBookOfVaadinTest { // Explicit validator instance .withValidator(new EmailValidator( "This doesn't look like a valid email address")) - .bind(Person::getEmail, Person::setEmail); + .bind(BookPerson::getEmail, BookPerson::setEmail); field.setValue("not-email"); List> errors = binder.validate(); @@ -91,7 +133,7 @@ public class BinderBookOfVaadinTest { // Validator defined based on a lambda and an error message .withValidator(name -> name.length() >= 3, "Last name must contain at least three characters") - .bind(Person::getLastName, Person::setLastName); + .bind(BookPerson::getLastName, BookPerson::setLastName); field.setValue("a"); List> errors = binder.validate(); @@ -115,15 +157,14 @@ public class BinderBookOfVaadinTest { "This doesn't look like a valid email address")) .withValidator(email -> email.endsWith("@acme.com"), "Only acme.com email addresses are allowed") - .bind(Person::getEmail, Person::setEmail); + .bind(BookPerson::getEmail, BookPerson::setEmail); field.setValue("not-email"); List> errors = binder.validate(); - Assert.assertEquals(2, errors.size()); + // Only one error per field should be reported + Assert.assertEquals(1, errors.size()); Assert.assertEquals("This doesn't look like a valid email address", errors.get(0).getMessage()); - Assert.assertEquals("Only acme.com email addresses are allowed", - errors.get(1).getMessage()); Assert.assertEquals("This doesn't look like a valid email address", ((AbstractErrorMessage) field.getErrorMessage()).getMessage()); @@ -140,4 +181,61 @@ public class BinderBookOfVaadinTest { Assert.assertEquals(0, errors.size()); Assert.assertNull(field.getErrorMessage()); } + + @Test + public void converterBookOfVaadinExample1() { + TextField yearOfBirthField = new TextField(); + // Slider for integers between 1 and 10 + Slider salaryLevelField = new Slider("Salary level", 1, 10); + + Binding b1 = binder + .forField(yearOfBirthField); + Binding b2 = b1.withConverter( + new StringToIntegerConverter("Must enter a number")); + b2.bind(BookPerson::getYearOfBirth, BookPerson::setYearOfBirth); + + Binding salaryBinding1 = binder + .forField(salaryLevelField); + Binding salaryBinding2 = salaryBinding1 + .withConverter(Double::intValue, Integer::doubleValue); + salaryBinding2.bind(BookPerson::getSalaryLevel, + BookPerson::setSalaryLevel); + + // Test that the book code works + BookPerson bookPerson = new BookPerson(1972, 4); + binder.bind(bookPerson); + Assert.assertEquals(4.0, salaryLevelField.getValue().doubleValue(), 0); + Assert.assertEquals("1,972", yearOfBirthField.getValue()); + + bookPerson.setSalaryLevel(8); + binder.load(bookPerson); + Assert.assertEquals(8.0, salaryLevelField.getValue().doubleValue(), 0); + bookPerson.setYearOfBirth(123); + binder.load(bookPerson); + Assert.assertEquals("123", yearOfBirthField.getValue()); + + yearOfBirthField.setValue("2016"); + salaryLevelField.setValue(1.0); + Assert.assertEquals(2016, bookPerson.getYearOfBirth()); + Assert.assertEquals(1, bookPerson.getSalaryLevel()); + } + + @Test + public void converterBookOfVaadinExample2() { + TextField yearOfBirthField = new TextField(); + + binder.forField(yearOfBirthField) + .withConverter(Integer::valueOf, String::valueOf, + // Text to use instead of the NumberFormatException + // message + "Please enter a number") + .bind(BookPerson::getYearOfBirth, BookPerson::setYearOfBirth); + + binder.bind(new BookPerson(1900, 5)); + yearOfBirthField.setValue("abc"); + binder.validate(); + Assert.assertEquals("Please enter a number", + yearOfBirthField.getComponentError().getFormattedHtmlMessage()); + } + } diff --git a/server/src/test/java/com/vaadin/data/BinderTest.java b/server/src/test/java/com/vaadin/data/BinderTest.java index 38d88bed27..d916eb4ffe 100644 --- a/server/src/test/java/com/vaadin/data/BinderTest.java +++ b/server/src/test/java/com/vaadin/data/BinderTest.java @@ -13,6 +13,7 @@ import org.junit.Before; import org.junit.Test; import com.vaadin.data.Binder.Binding; +import com.vaadin.data.util.converter.Converter; import com.vaadin.server.AbstractErrorMessage; import com.vaadin.server.ErrorMessage; import com.vaadin.server.UserError; @@ -36,17 +37,40 @@ public class BinderTest { } } + private static class StatusBean { + private String status; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + } + Binder binder; TextField nameField; + TextField ageField; Person p = new Person(); + Validator notEmpty = Validator.from(val -> !val.isEmpty(), + "Value cannot be empty"); + Converter stringToInteger = Converter.from( + Integer::valueOf, String::valueOf, e -> "Value must be a number"); + Validator notNegative = Validator.from(x -> x >= 0, + "Value must be positive"); + @Before public void setUp() { binder = new Binder<>(); p.setFirstName("Johannes"); + p.setAge(32); nameField = new TextField(); + ageField = new TextField(); } @Test(expected = NullPointerException.class) @@ -239,12 +263,11 @@ public class BinderTest { List> errors = binder.validate(); - Assert.assertEquals(2, errors.size()); + Assert.assertEquals(1, errors.size()); Set errorMessages = errors.stream() .map(ValidationError::getMessage).collect(Collectors.toSet()); Assert.assertTrue(errorMessages.contains(msg1)); - Assert.assertTrue(errorMessages.contains(msg2)); Set fields = errors.stream().map(ValidationError::getField) .collect(Collectors.toSet()); @@ -262,4 +285,137 @@ public class BinderTest { binder.bind(p); } + private void bindAgeWithValidatorConverterValidator() { + binder.forField(ageField).withValidator(notEmpty) + .withConverter(stringToInteger).withValidator(notNegative) + .bind(Person::getAge, Person::setAge); + binder.bind(p); + } + + @Test + public void validatorForSuperTypeCanBeUsed() { + // Validates that a validator for a super type can be used, e.g. + // validator for Number can be used on a Double + + TextField salaryField = new TextField(); + Binder binder = new Binder<>(); + Validator positiveNumberValidator = value -> { + if (value.doubleValue() >= 0) { + return Result.ok(value); + } else { + return Result.error("Number must be positive"); + } + }; + binder.forField(salaryField) + .withConverter(Double::valueOf, String::valueOf) + .withValidator(positiveNumberValidator) + .bind(Person::getSalaryDouble, Person::setSalaryDouble); + + Person person = new Person(); + binder.bind(person); + salaryField.setValue("10"); + Assert.assertEquals(10, person.getSalaryDouble(), 0); + salaryField.setValue("-1"); // Does not pass validator + Assert.assertEquals(10, person.getSalaryDouble(), 0); + } + + @Test + public void convertInitialValue() { + bindAgeWithValidatorConverterValidator(); + assertEquals("32", ageField.getValue()); + } + + @Test + public void convertToModelValidAge() { + bindAgeWithValidatorConverterValidator(); + + ageField.setValue("33"); + assertEquals(33, p.getAge()); + } + + @Test + public void convertToModelNegativeAgeFailsOnFirstValidator() { + bindAgeWithValidatorConverterValidator(); + + ageField.setValue(""); + assertEquals(32, p.getAge()); + assertValidationErrors(binder.validate(), "Value cannot be empty"); + } + + private void assertValidationErrors( + List> validationErrors, + String... errorMessages) { + Assert.assertEquals(errorMessages.length, validationErrors.size()); + for (int i = 0; i < errorMessages.length; i++) { + Assert.assertEquals(errorMessages[i], + validationErrors.get(i).getMessage()); + } + } + + @Test + public void convertToModelConversionFails() { + bindAgeWithValidatorConverterValidator(); + ageField.setValue("abc"); + assertEquals(32, p.getAge()); + assertValidationErrors(binder.validate(), "Value must be a number"); + } + + @Test + public void convertToModelNegativeAgeFailsOnIntegerValidator() { + bindAgeWithValidatorConverterValidator(); + + ageField.setValue("-5"); + assertEquals(32, p.getAge()); + assertValidationErrors(binder.validate(), "Value must be positive"); + } + + @Test + public void convertDataToField() { + bindAgeWithValidatorConverterValidator(); + binder.getBean().get().setAge(12); + binder.load(binder.getBean().get()); + Assert.assertEquals("12", ageField.getValue()); + } + + @Test + public void convertNotValidatableDataToField() { + bindAgeWithValidatorConverterValidator(); + binder.getBean().get().setAge(-12); + binder.load(binder.getBean().get()); + Assert.assertEquals("-12", ageField.getValue()); + } + + @Test(expected = IllegalArgumentException.class) + public void convertInvalidDataToField() { + TextField field = new TextField(); + StatusBean bean = new StatusBean(); + bean.setStatus("1"); + Binder binder = new Binder(); + + Binding binding = binder.forField(field) + .withConverter(presentation -> { + if (presentation.equals("OK")) { + return "1"; + } else if (presentation.equals("NOTOK")) { + return "2"; + } + throw new IllegalArgumentException( + "Value must be OK or NOTOK"); + }, model -> { + if (model.equals("1")) { + return "OK"; + } else if (model.equals("2")) { + return "NOTOK"; + } else { + throw new IllegalArgumentException( + "Value in model must be 1 or 2"); + } + }); + binding.bind(StatusBean::getStatus, StatusBean::setStatus); + binder.bind(bean); + + bean.setStatus("3"); + binder.load(bean); + } + } -- cgit v1.2.3