Validating & Converting User Input
Like individual fields, forms with multiple fields can be validated before saving the input to the bound business objects. Binder
supports validating user input, and converting value types from types used in business objects to types used in bound UI components, and vice versa.
These concepts go hand in hand, because validation can be based on a converted value, and the ability to convert a value is a kind of validation in itself.
Vaadin includes several validators and converters that you can implement.
Validating User Input
It’s typical for applications to restrict the kind of value the user is allowed to enter into certain fields.
Defining Validators
Binder
allows you to define validators for each bound field.
By default, validators run whenever the user changes the field value.
The validation status is also checked when writing to the bean.
You should define the field validator between the forField()
and bind()
code lines when creating the binding.
Example: Defining a validator using a Validator
instance or an inline lambda expression.
binder.forField(emailField)
// Explicit validator instance
.withValidator(new EmailValidator(
"This doesn't look like a valid email address"))
.bind(Person::getEmail, Person::setEmail);
binder.forField(nameField)
// Validator defined based on a lambda
// and an error message
.withValidator(
name -> name.length() >= 3,
"Name must contain at least three characters")
.bind(Person::getName, Person::setName);
binder.forField(titleField)
// Shorthand for requiring the field to be non-empty
.asRequired("Every employee must have a title")
.bind(Person::getTitle, Person::setTitle);
-
Binder.forField()
works like a builder: TheforField()
call starts the process; it’s followed by various configuration calls for the field; andbind()
is the final method of the configuration. -
asRequired()
is used for mandatory fields. With this:-
a visual "required" indicator displays;
-
if the user leaves the field empty, an error message displays.
-
Customizing Validation Error Messages
You can customize the way error messages display by defining a ValidationStatusHandler
or configuring the Label
for each binding.
The label is used to show the status of the field.
The label can be used for validation errors, as well as confirmation and helper messages.
Example: Configuring validation messages for email and minimum-length validation.
Label emailStatus = new Label();
emailStatus.getStyle().set("color", "Red");
binder.forField(emailField)
.withValidator(new EmailValidator(
"This doesn't look like a valid email address"))
// Shorthand that updates the label based on the
// status
.withStatusLabel(emailStatus)
.bind(Person::getEmail, Person::setEmail);
Label nameStatus = new Label();
binder.forField(nameField)
// Define the validator
.withValidator(
name -> name.length() >= 3,
"Name must contain at least three characters")
// Define how the validation status is displayed
.withValidationStatusHandler(status -> {
nameStatus.setText(status
.getMessage().orElse(""));
nameStatus.setVisible(status.isError());
})
// Finalize the binding
.bind(Person::getName, Person::setName);
-
The
withStatusLabel(Label label)
method sets the given label to show an error message if the validation fails.
As an alternative to using labels, you can set a custom validation status handler, using the withValidationStatusHandler()
method.
This allows you to customize how the binder displays error messages and is more flexible than using the status label approach.
Adding Multiple Validators
You can add multiple validators for the same binding.
Example: Defining two validators: first, for the email input, and second, for the expected domain.
binder.forField(emailField)
.withValidator(new EmailValidator(
"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);
Triggering Revalidation
The validation of one field can depend on the value of another field. You can achieve this by saving the binding to a local variable and triggering revalidation when the other field fires a value-change event.
Example: Storing a binding for later revalidation.
Binder<Trip> binder = new Binder<>(Trip.class);
DatePicker departing = new DatePicker();
departing.setLabel("Departing");
DatePicker returning = new DatePicker();
returning.setLabel("Returning");
// Store return date binding so we can
// revalidate it later
Binder.Binding<Trip, LocalDate> returningBinding =
binder
.forField(returning).withValidator(
returnDate -> !returnDate
.isBefore(departing.getValue()),
"Cannot return before departing")
.bind(Trip::getReturnDate, Trip::setReturnDate);
// Revalidate return date when departure date changes
departing.addValueChangeListener(
event -> returningBinding.validate());
Temporarily By-Passing Validation
Validators can be by-passed temporarily on both binder-level and binding-level. In practice, this allows bean writing to succeed even when one or more validators would not pass.
// Disables all validators, both binder-level and binding-level
binder.setValidatorsDisabled(true);
// Disables validators for a single binding
binding.setValidatorsDisabled(true);
Converting User Input
You can bind application data to a UI field component, even if the types don’t match. Examples where this is useful include:
-
an application-specific type for a postal code that the user enters in a
TextField
; -
requesting that the user enter only integers in a
TextField
; -
selecting enumeration values in a
Checkbox
field.
Defining Converters
Like validators, each binding can have one or more converters, with an optional error message.
You can define converters using callbacks (typically lambda expressions), method references, or by implementing the Converter
interface.
Examples: Defining converters.
TextField yearOfBirthField =
new TextField("Year of birth");
binder.forField(yearOfBirthField)
.withConverter(
new StringToIntegerConverter("Not a number"))
.bind(Person::getYearOfBirth,
Person::setYearOfBirth);
// Checkbox for marital status
Checkbox marriedField = new Checkbox("Married");
binder.forField(marriedField).withConverter(
m -> m ? MaritalStatus.MARRIED : MaritalStatus.SINGLE,
MaritalStatus.MARRIED::equals)
.bind(Person::getMaritalStatus,
Person::setMaritalStatus);
Adding Multiple Converters
You can add multiple converters (and validators) for each binding.
Each validator or converter is used in the order defined in the class. The value is passed along until:
-
a final converted value is stored in the business object, or
-
the first validation error or impossible conversion is encountered.
Example: Validator and converter sequence.
binder.forField(yearOfBirthField)
// Validator is run with the String value
// of the field
.withValidator(text -> text.length() == 4,
"Doesn't look like a year")
// Converter is only run for strings
// with 4 characters
.withConverter(new StringToIntegerConverter(
"Must enter a number"))
// Validator is run with the converted value
.withValidator(year -> year >= 1900 && year < 2000,
"Person must be born in the 20th century")
.bind(Person::getYearOfBirth,
Person::setYearOfBirth);
When updating UI components, values from the business object are passed through each converter in reverse order (without validation).
Note
| Although it’s possible to use a converter as a validator, best practice is to use a validator to check the contents of a field, and a converter to modify the value. This improves code clarity and avoids excessive boilerplate code. |
Conversion Error Messages
You can define a custom error message to be used if a conversion throws an unchecked exception.
When using callbacks, you should provide one converter in each direction. If the callback that’s used to convert the user-provided value throws an unchecked exception, the field is marked as invalid, and the exception message is used as the validation error message. Java runtime exception messages are typically written for developers, and may not be suitable for end users.
Example: Defining a custom conversion error message.
binder.forField(yearOfBirthField)
.withConverter(
Integer::valueOf,
String::valueOf,
// Text to use instead of the
// NumberFormatException message
"Enter a number")
.bind(Person::getYearOfBirth,
Person::setYearOfBirth);
Implementing the Converter Interface
You need to implement two methods from the Converter
interface:
-
convertToModel()
receives a value that originates from the user.-
The method returns a
Result
that contains either a converted value or a conversion error message.
-
-
convertToPresentation()
receives a value that originates from the business object.-
This method returns the converted value directly. It’s assumed that the business object contains only valid values.
-
Example: Implementing a String to Integer converter.
class MyConverter
implements Converter<String, Integer> {
@Override
public Result<Integer> convertToModel(
String fieldValue, ValueContext context) {
// Produces a converted value or an error
try {
// ok is a static helper method that
// creates a Result
return Result.ok(Integer.valueOf(
fieldValue));
} catch (NumberFormatException e) {
// error is a static helper method
// that creates a Result
return Result.error("Enter a number");
}
}
@Override
public String convertToPresentation(
Integer integer, ValueContext context) {
// Converting to the field type should
// always succeed, so there is no support for
// returning an error Result.
return String.valueOf(integer);
}
}
// Using the converter
binder.forField(yearOfBirthField)
.withConverter(new MyConverter())
.bind(Person::getYearOfBirth, Person::setYearOfBirth);
-
The provided
ValueContext
can be used to find theLocale
to be used for the conversion.
Binding Automatic Validation Upon Changes in Component Validation Status
When a component such as Date Picker (or any other component that accepts a formatted text as input) is used as an optional field, what happens if the user provides some invalid value for it and tries to save the form?
Since the provided value can’t be parsed correctly, a null
is provided to the binder and, as the field is optional, the binder doesn’t complain and the validation status would be true
.
This behavior can create the illusion for the user that they were able to save an invalid value.
Thus, there must be a way to prevent the form submission until that invalid value is either cleared out or fixed, whether or not the field is required.
There might be different workarounds for such cases, but the preferred solution is the one that keeps Binder Validation Status as the single source of truth when it comes to checking the sanity of the data in the form.
This is why the addValidationStatusChangeListener()
method exists in the HasValidator
interface.
Components that implement the HasValidator
interface and override the default implementation of addValidationStatusChangeListener()
to fire the ValidationStatusChangeEvent
always benefits automatically from an up-to-date validation status of their associated binding.
This is because the associated binding instance (upon creation) registers itself for changes in the component’s validation status and revalidate itself accordingly.
The following code snippet shows how a component can enable the binding instance to subscribe itself to the ValidationStatusChangeEvent
:
@Tag("date-picker-demo")
public class DatePickerDemo implements HasValidator<LocalDate> /*, HasValue<...>*/ {
// Each web component has a way to communicate its validation status
// to its server-side component instance. The following `clientSideValid`
// state is introduced here only for the sake of simplicity of this code
// snippet:
private boolean clientSideValid = true;
/**
* Note how <code>clientSideValid</code> engaged in the definition
* of this method. It's important to reflect this status either
* in the returning validation result of this method or any other
* validation that's associated with this component.
*/
@Override
public Validator getDefaultValidator() { // (1)
return (value, valueContext) -> clientSideValid ? ValidationResult.ok()
: ValidationResult.error("Invalid date format");
}
private final Collection<ValidationStatusChangeListener<LocalDate>>
validationStatusListeners = new ArrayList<>();
/**
* This enables the binding to subscribe for the validation status
* change events that are fired by this component and revalidate
* itself respectively.
*/
@Override
public Registration addValidationStatusChangeListener(
ValidationStatusChangeListener<LocalDate> listener) {
validationStatusListeners.add(listener);
return () -> validationStatusListeners.remove(listener);
}
protected void fireValidationStatusChangeEvent(
boolean newValidationStatus) {
if (this.clientSideValid != newValidationStatus) {
this.clientSideValid = newValidationStatus;
var event = new ValidationStatusChangeEvent<>(this,
newValidationStatus);
validationStatusListeners.forEach(
listener -> listener.validationStatusChanged(event));
}
}
}
-
The validator instance returned by
getDefaultValidator()
gets called every time the binding instance validates/revalidates (as a part of the validator chain of the binding).
For a complete implementation see, for example, the DatePicker
source code.
E3EBE8A9-74B7-4D31-A071-F65EB28119A5