diff options
9 files changed, 884 insertions, 56 deletions
diff --git a/documentation/datamodel/datamodel-forms.asciidoc b/documentation/datamodel/datamodel-forms.asciidoc index b2aa60dc0b..3bea6f857c 100644 --- a/documentation/datamodel/datamodel-forms.asciidoc +++ b/documentation/datamodel/datamodel-forms.asciidoc @@ -236,7 +236,7 @@ binder.forField(yearOfBirthField) Slider salaryLevelField = new Slider("Salary level", 1, 10); binder.forField(salaryLevelField) - .withConverter(Integer::doubleValue, Double::intValue) + .withConverter(Double::intValue, Integer::doubleValue) .bind(Person::getSalaryLevel, Person::setSalaryLevel); ---- 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; @@ -154,6 +157,105 @@ public class Binder<BEAN> implements Serializable { Predicate<? super TARGET> predicate, String message); /** + * Maps the binding to another data type using the given + * {@link Converter}. + * <p> + * 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. + * <p> + * For instance, a {@code TextField} can be bound to an integer-typed + * property using an appropriate converter such as a + * {@link StringToIntegerConverter}. + * + * @param <NEWTARGET> + * 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 <NEWTARGET> Binding<BEAN, FIELDVALUE, NEWTARGET> withConverter( + Converter<TARGET, NEWTARGET> converter); + + /** + * Maps the binding to another data type using the mapping functions and + * a possible exception as the error message. + * <p> + * 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. + * <p> + * For instance, a {@code TextField} can be bound to an integer-typed + * property using appropriate functions such as: + * <code>withConverter(Integer::valueOf, String::valueOf);</code> + * + * @param <NEWTARGET> + * 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 <NEWTARGET> Binding<BEAN, FIELDVALUE, NEWTARGET> withConverter( + Function<TARGET, NEWTARGET> toModel, + Function<NEWTARGET, TARGET> 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. + * <p> + * 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. + * <p> + * For instance, a {@code TextField} can be bound to an integer-typed + * property using appropriate functions such as: + * <code>withConverter(Integer::valueOf, String::valueOf);</code> + * + * @param <NEWTARGET> + * 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 + * <code>toModel</code> fails + * @return a new binding with the appropriate type + * @throws IllegalStateException + * if {@code bind} has already been called + */ + public default <NEWTARGET> Binding<BEAN, FIELDVALUE, NEWTARGET> withConverter( + Function<TARGET, NEWTARGET> toModel, + Function<NEWTARGET, TARGET> toPresentation, + String errorMessage) { + return withConverter(Converter.from(toModel, toPresentation, + exception -> errorMessage)); + } + + /** * Gets the field the binding uses. * * @return the field for the binding @@ -176,15 +278,19 @@ public class Binder<BEAN> implements Serializable { protected static class BindingImpl<BEAN, FIELDVALUE, TARGET> implements Binding<BEAN, FIELDVALUE, TARGET> { - private Binder<BEAN> binder; + private final Binder<BEAN> binder; - private HasValue<FIELDVALUE> field; + private final HasValue<FIELDVALUE> field; private Registration onValueChange; private Function<BEAN, TARGET> getter; private BiConsumer<BEAN, TARGET> setter; - private List<Validator<? super TARGET>> validators = new ArrayList<>(); + /** + * Contains all converters and validators chained together in the + * correct order. + */ + private Converter<FIELDVALUE, TARGET> converterValidatorChain; /** * Creates a new binding associated with the given field. @@ -194,9 +300,28 @@ public class Binder<BEAN> implements Serializable { * @param field * the field to bind */ + @SuppressWarnings("unchecked") protected BindingImpl(Binder<BEAN> binder, HasValue<FIELDVALUE> field) { - this.binder = binder; + this(binder, field, + (Converter<FIELDVALUE, TARGET>) 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<BEAN> binder, HasValue<FIELDVALUE> field, + Converter<FIELDVALUE, TARGET> converterValidatorChain) { this.field = field; + this.binder = binder; + this.converterValidatorChain = converterValidatorChain; } @Override @@ -216,7 +341,11 @@ public class Binder<BEAN> implements Serializable { Validator<? super TARGET> validator) { checkUnbound(); Objects.requireNonNull(validator, "validator cannot be null"); - validators.add(validator); + + Converter<TARGET, TARGET> validatorAsConverter = new ValidatorAsConverter<>( + validator); + converterValidatorChain = converterValidatorChain + .chain(validatorAsConverter); return this; } @@ -226,20 +355,38 @@ public class Binder<BEAN> implements Serializable { return withValidator(Validator.from(predicate, message)); } + @Override + public <NEWTARGET> Binding<BEAN, FIELDVALUE, NEWTARGET> withConverter( + Converter<TARGET, NEWTARGET> converter) { + checkUnbound(); + Objects.requireNonNull(converter, "converter cannot be null"); + + BindingImpl<BEAN, FIELDVALUE, NEWTARGET> 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<ValidationError<FIELDVALUE>> 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<TARGET> validate() { + FIELDVALUE fieldValue = field.getValue(); + Result<TARGET> 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<TARGET> getTargetValue() { + return validate().getValue(); } private void unbind() { @@ -255,7 +402,13 @@ public class Binder<BEAN> 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<BEAN> 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<BEAN> implements Serializable { } } + /** + * Wraps a validator as a converter. + * <p> + * The type of the validator must be of the same type as this converter or a + * super type of it. + * + * @param <T> + * the type of the converter + */ + private static class ValidatorAsConverter<T> implements Converter<T, T> { + + private Validator<? super T> validator; + + /** + * Creates a new converter wrapping the given validator. + * + * @param validator + * the validator to wrap + */ + public ValidatorAsConverter(Validator<? super T> validator) { + this.validator = validator; + } + + @Override + public Result<T> convertToModel(T value, Locale locale) { + Result<? super T> 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<BindingImpl<BEAN, ?, ?>> bindings = new LinkedHashSet<>(); @@ -396,12 +589,13 @@ public class Binder<BEAN> implements Serializable { public List<ValidationError<?>> validate() { List<ValidationError<?>> resultErrors = new ArrayList<>(); for (BindingImpl<BEAN, ?, ?> binding : bindings) { - clearError(binding.getField()); - List<? extends ValidationError<?>> 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<BEAN> implements Serializable { * the field to bind * @return the new incomplete binding */ - protected <FIELDVALUE> Binding<BEAN, FIELDVALUE, FIELDVALUE> createBinding( + protected <FIELDVALUE> BindingImpl<BEAN, FIELDVALUE, FIELDVALUE> createBinding( HasValue<FIELDVALUE> field) { Objects.requireNonNull(field, "field cannot be null"); - BindingImpl<BEAN, FIELDVALUE, FIELDVALUE> b = new BindingImpl<>(this, - field); + BindingImpl<BEAN, FIELDVALUE, FIELDVALUE> b = new BindingImpl<BEAN, FIELDVALUE, FIELDVALUE>( + 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<R> extends Serializable { /** * Returns a successful result wrapping the given value. - * + * * @param <R> * the result value type * @param value @@ -53,7 +53,7 @@ public interface Result<R> extends Serializable { /** * Returns a failure result wrapping the given error message. - * + * * @param <R> * the result value type * @param message @@ -70,7 +70,7 @@ public interface Result<R> 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 <R> * the result value type * @param supplier @@ -96,7 +96,7 @@ public interface Result<R> 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 <S> * the type of the mapped value * @param mapper @@ -112,7 +112,7 @@ public interface Result<R> 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 <S> * the type of the mapped value * @param mapper @@ -124,7 +124,7 @@ public interface Result<R> 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<R> 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<R> 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<R> 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<String> getMessage(); + + /** + * Returns an Optional of the value, or an empty Optional if none. + * + * @return the optional value + */ + public Optional<R> 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<R> implements Result<R> { * <p> * 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 @@ -81,6 +81,11 @@ class SimpleResult<R> implements Result<R> { } @Override + public Optional<R> getValue() { + return Optional.ofNullable(value); + } + + @Override public boolean isError() { return message != null; } @@ -93,4 +98,5 @@ class SimpleResult<R> implements Result<R> { 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. + * <p> + * Override and overwrite {@link #getFormat(Locale)} to use a different format. + * </p> + * + * @author Vaadin Ltd + * @since 8.0 + */ +public abstract class AbstractStringToNumberConverter<T> + implements Converter<String, T> { + + /** + * 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. + * <p> + * Converters must not have any side effects (never update UI from inside a + * converter). + * + * @param <PRESENTATION> + * The presentation type. + * @param <MODEL> + * The model type. + * @author Vaadin Ltd. + * @since 8.0 + */ +public interface Converter<PRESENTATION, MODEL> extends Serializable { + + /** + * Converts the given value from model type to presentation type. + * <p> + * 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<MODEL> convertToModel(PRESENTATION value, Locale locale); + + /** + * Converts the given value from presentation type to model type. + * <p> + * 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 <T> + * the input and output type + * @return an identity converter + */ + public static <T> Converter<T, T> 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 <P> + * the presentation type + * @param <M> + * 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 <P, M> Converter<P, M> from(Function<P, M> toModel, + Function<M, P> toPresentation, + Function<Exception, String> onError) { + + return from(val -> Result.of(() -> toModel.apply(val), onError), + toPresentation); + } + + /** + * Constructs a converter from a filter and a function. + * + * @param <P> + * the presentation type + * @param <M> + * 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 <P, M> Converter<P, M> from(Function<P, Result<M>> toModel, + Function<M, P> toPresentation) { + return new Converter<P, M>() { + + @Override + public Result<M> 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. + * <p> + * 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. + * <p> + * 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 <T> + * the model type of the resulting converter + * @param other + * the converter to chain, not null + * @return a chained converter + */ + public default <T> Converter<PRESENTATION, T> chain( + Converter<MODEL, T> other) { + return new Converter<PRESENTATION, T>() { + @Override + public Result<T> convertToModel(PRESENTATION value, Locale locale) { + Result<MODEL> 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. + * <p> + * Override and overwrite {@link #getFormat(Locale)} to use a different format. + * </p> + * + * @author Vaadin Ltd + * @since 8.0 + */ +public class StringToIntegerConverter + extends AbstractStringToNumberConverter<Integer> { + + 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<Integer> 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<Person> 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<BookPerson> 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<ValidationError<?>> 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<ValidationError<?>> 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<ValidationError<?>> 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<BookPerson, String, String> b1 = binder + .forField(yearOfBirthField); + Binding<BookPerson, String, Integer> b2 = b1.withConverter( + new StringToIntegerConverter("Must enter a number")); + b2.bind(BookPerson::getYearOfBirth, BookPerson::setYearOfBirth); + + Binding<BookPerson, Double, Double> salaryBinding1 = binder + .forField(salaryLevelField); + Binding<BookPerson, Double, Integer> 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<Person> binder; TextField nameField; + TextField ageField; Person p = new Person(); + Validator<String> notEmpty = Validator.from(val -> !val.isEmpty(), + "Value cannot be empty"); + Converter<String, Integer> stringToInteger = Converter.from( + Integer::valueOf, String::valueOf, e -> "Value must be a number"); + Validator<Integer> 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<ValidationError<?>> errors = binder.validate(); - Assert.assertEquals(2, errors.size()); + Assert.assertEquals(1, errors.size()); Set<String> 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<Person> binder = new Binder<>(); + Validator<Number> 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<ValidationError<?>> 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<StatusBean> binder = new Binder<StatusBean>(); + + Binding<StatusBean, String, String> 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); + } + } |