Skip to content

Commit

Permalink
feat: enable components to notify binder when validation state changes (
Browse files Browse the repository at this point in the history
#13940) (CP: 23.1) (#13993)

Adds the needed API to notify Binder about validation status changes that happen in field components.

Fixes #8242
Related to vaadin/flow-components#1158
  • Loading branch information
vaadin-bot authored Jun 17, 2022
1 parent 00791b2 commit e606d94
Show file tree
Hide file tree
Showing 6 changed files with 405 additions and 0 deletions.
11 changes: 11 additions & 0 deletions flow-data/src/main/java/com/vaadin/flow/data/binder/Binder.java
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,11 @@ public interface BindingBuilder<BEAN, TARGET> extends Serializable {
* If the Binder is already bound to some bean, the newly bound field is
* associated with the corresponding bean property as described above.
* <p>
* If the bound field implements {@link HasValidator}, then the binding
* instance returned by this method will subscribe for field's
* {@code ValidationStatusChangeEvent}s and will {@code validate} itself
* upon receiving them.
* <p>
* The getter and setter can be arbitrary functions, for instance
* implementing user-defined conversion or validation. However, in the
* most basic use case you can simply pass a pair of method references
Expand Down Expand Up @@ -966,6 +971,12 @@ public Binding<BEAN, TARGET> bind(ValueProvider<BEAN, TARGET> getter,
}
this.binding = binding;

if (field instanceof HasValidator) {
HasValidator<FIELDVALUE> hasValidatorField = (HasValidator<FIELDVALUE>) field;
hasValidatorField.addValidationStatusChangeListener(
event -> this.binding.validate());
}

return binding;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

import java.io.Serializable;

import com.vaadin.flow.function.ValueProvider;
import com.vaadin.flow.shared.Registration;

/**
* A generic interface for field components and other user interface objects
* that have a user-editable value that should be validated.
Expand All @@ -41,4 +44,89 @@ public interface HasValidator<V> extends Serializable {
default Validator<V> getDefaultValidator() {
return Validator.alwaysPass();
}

/**
* Enables the implementing components to notify changes in their validation
* status to the observers.
* <p>
* <strong>Note:</strong> This method can be overridden by the implementing
* classes e.g. components, to enable the associated {@link Binder.Binding}
* instance subscribing for their validation change events and revalidate
* itself.
* <p>
* This method primarily designed for notifying the Binding about the
* validation status changes of a bound component at the client-side.
* WebComponents such as <code>&lt;vaadin-date-picker&gt;</code> or any
* other component that accept a formatted text as input should be able to
* communicate their invalid status to their server-side instance, and a
* bound server-side component instance must notify its binding about this
* validation status change as well. When the binding instance revalidates,
* a chain of validators and convertors get executed one of which is the
* default validator provided by {@link HasValidator#getDefaultValidator()}.
* Thus, In order for the binding to be able to show/clear errors for its
* associated bound field, it is important that implementing components take
* that validation status into account while implementing any validator and
* converter including {@link HasValidator#getDefaultValidator()}. Here is
* an example:
*
* <pre>
* &#64;Tag("date-picker-demo")
* public class DatePickerDemo implements HasValidator&lt;LocalDate&gt; {
*
* boolean clientSideValid = true;
*
* /**
* * Note how <code>clientSideValid</code> engaged in the definition
* * of this method. It is important to reflect this status either
* * in the returning validation result of this method or any other
* * validation that is associated with this component.
* *&#47;
* &#64;Override
* public Validator getDefaultValidator() {
* return clientSideValid ? ValidationResult.ok()
* : ValidationResult.error("Invalid date format");
* }
*
* private final Collection&lt;ValidationStatusChangeListener&lt;LocalDate&gt;&gt;
* validationStatusListeners = new ArrayList&lt;&gt;();
*
* /**
* * This enables the binding to subscribe for the validation status
* * change events that are fired by this component and revalidate
* * itself respectively.
* *&#47;
* &#64;Override
* public Registration addValidationStatusChangeListener(
* ValidationStatusChangeListener&lt;LocalDate&gt; listener) {
* validationStatusListeners.add(listener);
* return () -&gt; validationStatusListeners.remove(listener);
* }
*
* // Each web-component has a way to communicate its validation status
* // to its server-side component instance which can update
* // <code>this.clientSideValid</code> state.
*
* private void fireValidationStatusChangeEvent(
* boolean newValidationStatus) {
* if (this.clientSideValid != newValidationStatus) {
* this.clientSideValid = newValidationStatus;
* var event = new ValidationStatusChangeEvent&lt;&gt;(this,
* newValidationStatus);
* validationStatusListeners.forEach(
* listener -&gt; listener.validationStatusChanged(event));
* }
* }
* }
* </pre>
*
* @see com.vaadin.flow.data.binder.Binder.BindingBuilderImpl#bind(ValueProvider,
* Setter)
* @since 2.7
*
* @return Registration of the added listener.
*/
default Registration addValidationStatusChangeListener(
ValidationStatusChangeListener<V> listener) {
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2000-2022 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.flow.data.binder;

import com.vaadin.flow.component.HasValue;

import java.io.Serializable;

/**
* The event to be processed when
* {@link ValidationStatusChangeListener#validationStatusChanged(ValidationStatusChangeEvent)}
* invoked.
*
* @since 2.7
*
* @param <V>
* the value type
*/
public class ValidationStatusChangeEvent<V> implements Serializable {

private final HasValue<?, V> source;
private final boolean newStatus;

public ValidationStatusChangeEvent(HasValue<?, V> source,
boolean newStatus) {
this.source = source;
this.newStatus = newStatus;
}

public HasValue<?, V> getSource() {
return source;
}

public boolean getNewStatus() {
return newStatus;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright 2000-2022 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.flow.data.binder;

import java.io.Serializable;

import com.vaadin.flow.function.ValueProvider;

/**
* The listener interface for receiving {@link ValidationStatusChangeEvent}
* events. The classes that are interested in processing validation status
* changed events of field components should register implementation of this
* interface via
* {@link HasValidator#addValidationStatusChangeListener(ValidationStatusChangeListener)}
* which are called whenever such event is fired by the component.
* <p>
* This interface is primarily introduced to enable binding instances subscribe
* for their own associated field's validation status change events and
* revalidate after that. However, when all the components implementing
* {@code HasValidator} interface, implement the correct behaviour for adding
* and notifying listeners of the current type, other usages are also become
* possible since the {@link ValidationStatusChangeEvent} payload contains the
* source {@link com.vaadin.flow.component.HasValue} field and the new
* validation status, thus for instance fields or buttons in a view can
* subscribe for each other's validation statuses and enable/disable or clear
* values, etc. respectively.
*
* @since 2.7
*
* @see HasValidator
* @see com.vaadin.flow.data.binder.Binder.BindingBuilderImpl#bind(ValueProvider,
* Setter)
*/
@FunctionalInterface
public interface ValidationStatusChangeListener<V> extends Serializable {

/**
* Invoked when a ValidationStatusChangeEvent occurs.
*
* @param event
* the event to be processed
*/
void validationStatusChanged(ValidationStatusChangeEvent<V> event);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package com.vaadin.flow.data.binder;

import java.util.HashMap;
import java.util.Map;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;

import com.vaadin.flow.data.binder.testcomponents.TestHasValidatorDatePicker;
import com.vaadin.flow.component.HasValue;
import com.vaadin.flow.tests.data.bean.Person;

import static com.vaadin.flow.data.binder.testcomponents.TestHasValidatorDatePicker.INVALID_DATE_FORMAT;

public class BinderValidationStatusChangeListenerTest
extends BinderTestBase<Binder<Person>, Person> {

private static final String BIRTH_DATE_PROPERTY = "birthDate";

private final Map<HasValue<?, ?>, String> componentErrors = new HashMap<>();

@Before
public void setUp() {
binder = new Binder<>(Person.class) {
@Override
protected void handleError(HasValue<?, ?> field,
ValidationResult result) {
componentErrors.put(field, result.getErrorMessage());
}

@Override
protected void clearError(HasValue<?, ?> field) {
super.clearError(field);
componentErrors.remove(field);
}
};
item = new Person();
}

@Test
public void fieldWithHasValidatorDefaults_bindIsCalled_addValidationStatusListenerIsCalled() {
var field = Mockito.spy(
TestHasValidatorDatePicker.DatePickerHasValidatorDefaults.class);
binder.bind(field, BIRTH_DATE_PROPERTY);
Mockito.verify(field, Mockito.times(1))
.addValidationStatusChangeListener(Mockito.any());
}

@Test
public void fieldWithHasValidatorOnlyGetDefaultValidatorOverridden_bindIsCalled_addValidationStatusListenerIsCalled() {
var field = Mockito.spy(
TestHasValidatorDatePicker.DataPickerHasValidatorGetDefaultValidatorOverridden.class);
binder.bind(field, BIRTH_DATE_PROPERTY);
Mockito.verify(field, Mockito.times(1))
.addValidationStatusChangeListener(Mockito.any());
}

@Test
public void fieldWithHasValidatorOnlyAddListenerOverridden_bindIsCalled_addValidationStatusListenerIsCalled() {
var field = Mockito.spy(
TestHasValidatorDatePicker.DataPickerHasValidatorAddListenerOverridden.class);
binder.bind(field, BIRTH_DATE_PROPERTY);
Mockito.verify(field, Mockito.times(1))
.addValidationStatusChangeListener(Mockito.any());
}

@Test
public void fieldWithHasValidatorFullyOverridden_bindIsCalled_addValidationStatusChangeListenerIsCalled() {
var field = Mockito.spy(
TestHasValidatorDatePicker.DataPickerHasValidatorOverridden.class);
binder.bind(field, BIRTH_DATE_PROPERTY);
Mockito.verify(field, Mockito.times(1))
.addValidationStatusChangeListener(Mockito.any());
}

@Test
public void fieldWithHasValidatorFullyOverridden_fieldValidationStatusChangesToFalse_binderHandleErrorIsCalled() {
var field = new TestHasValidatorDatePicker.DataPickerHasValidatorOverridden();
binder.bind(field, BIRTH_DATE_PROPERTY);
Assert.assertEquals(0, componentErrors.size());

field.fireValidationStatusChangeEvent(false);
Assert.assertEquals(1, componentErrors.size());
Assert.assertEquals(INVALID_DATE_FORMAT, componentErrors.get(field));
}

@Test
public void fieldWithHasValidatorFullyOverridden_fieldValidationStatusChangesToTrue_binderClearErrorIsCalled() {
var field = new TestHasValidatorDatePicker.DataPickerHasValidatorOverridden();
binder.bind(field, BIRTH_DATE_PROPERTY);
Assert.assertEquals(0, componentErrors.size());

field.fireValidationStatusChangeEvent(false);
Assert.assertEquals(1, componentErrors.size());
Assert.assertEquals(INVALID_DATE_FORMAT, componentErrors.get(field));

field.fireValidationStatusChangeEvent(true);
Assert.assertEquals(0, componentErrors.size());
Assert.assertNull(componentErrors.get(field));
}

@Test
public void fieldWithHasValidatorOnlyAddListenerOverriddenAndCustomValidation_fieldValidationStatusChangesToFalse_binderHandleErrorIsCalled() {
var field = new TestHasValidatorDatePicker.DataPickerHasValidatorAddListenerOverridden();
binder.forField(field).withValidator(field::customValidation)
.bind(BIRTH_DATE_PROPERTY);

field.fireValidationStatusChangeEvent(false);
Assert.assertEquals(1, componentErrors.size());
Assert.assertEquals(INVALID_DATE_FORMAT, componentErrors.get(field));
}

@Test
public void fieldWithHasValidatorOnlyAddListenerOverriddenAndCustomValidation_fieldValidationStatusChangesToTrue_binderClearErrorIsCalled() {
var field = new TestHasValidatorDatePicker.DataPickerHasValidatorAddListenerOverridden();
binder.forField(field).withValidator(field::customValidation)
.bind(BIRTH_DATE_PROPERTY);

field.fireValidationStatusChangeEvent(false);
Assert.assertEquals(1, componentErrors.size());
Assert.assertEquals(INVALID_DATE_FORMAT, componentErrors.get(field));

field.fireValidationStatusChangeEvent(true);
Assert.assertEquals(0, componentErrors.size());
Assert.assertNull(componentErrors.get(field));
}

}
Loading

0 comments on commit e606d94

Please sign in to comment.