summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--documentation/datamodel/datamodel-forms.asciidoc2
-rw-r--r--server/src/main/java/com/vaadin/data/Binder.java246
-rw-r--r--server/src/main/java/com/vaadin/data/Result.java33
-rw-r--r--server/src/main/java/com/vaadin/data/SimpleResult.java8
-rw-r--r--server/src/main/java/com/vaadin/data/util/converter/AbstractStringToNumberConverter.java102
-rw-r--r--server/src/main/java/com/vaadin/data/util/converter/Converter.java176
-rw-r--r--server/src/main/java/com/vaadin/data/util/converter/StringToIntegerConverter.java89
-rw-r--r--server/src/test/java/com/vaadin/data/BinderBookOfVaadinTest.java124
-rw-r--r--server/src/test/java/com/vaadin/data/BinderTest.java160
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&#32;enter&#32;a&#32;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);
+ }
+
}