From e606d947e35bc276b1f7dd12ffdb00fbbb5283c9 Mon Sep 17 00:00:00 2001 From: Vaadin Bot Date: Fri, 17 Jun 2022 08:25:05 +0200 Subject: [PATCH] feat: enable components to notify binder when validation state changes (#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 --- .../com/vaadin/flow/data/binder/Binder.java | 11 ++ .../vaadin/flow/data/binder/HasValidator.java | 88 ++++++++++++ .../binder/ValidationStatusChangeEvent.java | 50 +++++++ .../ValidationStatusChangeListener.java | 57 ++++++++ ...derValidationStatusChangeListenerTest.java | 130 ++++++++++++++++++ .../TestHasValidatorDatePicker.java | 69 ++++++++++ 6 files changed, 405 insertions(+) create mode 100644 flow-data/src/main/java/com/vaadin/flow/data/binder/ValidationStatusChangeEvent.java create mode 100644 flow-data/src/main/java/com/vaadin/flow/data/binder/ValidationStatusChangeListener.java create mode 100644 flow-data/src/test/java/com/vaadin/flow/data/binder/BinderValidationStatusChangeListenerTest.java create mode 100644 flow-data/src/test/java/com/vaadin/flow/data/binder/testcomponents/TestHasValidatorDatePicker.java diff --git a/flow-data/src/main/java/com/vaadin/flow/data/binder/Binder.java b/flow-data/src/main/java/com/vaadin/flow/data/binder/Binder.java index d1b9a7128ae..544391ed25e 100644 --- a/flow-data/src/main/java/com/vaadin/flow/data/binder/Binder.java +++ b/flow-data/src/main/java/com/vaadin/flow/data/binder/Binder.java @@ -318,6 +318,11 @@ public interface BindingBuilder 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. *

+ * 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. + *

* 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 @@ -966,6 +971,12 @@ public Binding bind(ValueProvider getter, } this.binding = binding; + if (field instanceof HasValidator) { + HasValidator hasValidatorField = (HasValidator) field; + hasValidatorField.addValidationStatusChangeListener( + event -> this.binding.validate()); + } + return binding; } diff --git a/flow-data/src/main/java/com/vaadin/flow/data/binder/HasValidator.java b/flow-data/src/main/java/com/vaadin/flow/data/binder/HasValidator.java index b12dc19bfc9..19418e84bff 100644 --- a/flow-data/src/main/java/com/vaadin/flow/data/binder/HasValidator.java +++ b/flow-data/src/main/java/com/vaadin/flow/data/binder/HasValidator.java @@ -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. @@ -41,4 +44,89 @@ public interface HasValidator extends Serializable { default Validator getDefaultValidator() { return Validator.alwaysPass(); } + + /** + * Enables the implementing components to notify changes in their validation + * status to the observers. + *

+ * Note: 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. + *

+ * This method primarily designed for notifying the Binding about the + * validation status changes of a bound component at the client-side. + * WebComponents such as <vaadin-date-picker> 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: + * + *

+     * @Tag("date-picker-demo")
+     * public class DatePickerDemo implements HasValidator<LocalDate> {
+     *
+     *     boolean clientSideValid = true;
+     *
+     *     /**
+     *      * Note how clientSideValid 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.
+     *      */
+     *     @Override
+     *     public Validator getDefaultValidator() {
+     *          return 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);
+     *     }
+     *
+     *     // Each web-component has a way to communicate its validation status
+     *     // to its server-side component instance which can update
+     *     // this.clientSideValid state.
+     *
+     *     private void fireValidationStatusChangeEvent(
+     *             boolean newValidationStatus) {
+     *         if (this.clientSideValid != newValidationStatus) {
+     *             this.clientSideValid = newValidationStatus;
+     *             var event = new ValidationStatusChangeEvent<>(this,
+     *                     newValidationStatus);
+     *             validationStatusListeners.forEach(
+     *                     listener -> listener.validationStatusChanged(event));
+     *         }
+     *     }
+     * }
+     * 
+ * + * @see com.vaadin.flow.data.binder.Binder.BindingBuilderImpl#bind(ValueProvider, + * Setter) + * @since 2.7 + * + * @return Registration of the added listener. + */ + default Registration addValidationStatusChangeListener( + ValidationStatusChangeListener listener) { + return null; + } } diff --git a/flow-data/src/main/java/com/vaadin/flow/data/binder/ValidationStatusChangeEvent.java b/flow-data/src/main/java/com/vaadin/flow/data/binder/ValidationStatusChangeEvent.java new file mode 100644 index 00000000000..90bff516ab9 --- /dev/null +++ b/flow-data/src/main/java/com/vaadin/flow/data/binder/ValidationStatusChangeEvent.java @@ -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 + * the value type + */ +public class ValidationStatusChangeEvent implements Serializable { + + private final HasValue source; + private final boolean newStatus; + + public ValidationStatusChangeEvent(HasValue source, + boolean newStatus) { + this.source = source; + this.newStatus = newStatus; + } + + public HasValue getSource() { + return source; + } + + public boolean getNewStatus() { + return newStatus; + } +} diff --git a/flow-data/src/main/java/com/vaadin/flow/data/binder/ValidationStatusChangeListener.java b/flow-data/src/main/java/com/vaadin/flow/data/binder/ValidationStatusChangeListener.java new file mode 100644 index 00000000000..1cd6ff670fc --- /dev/null +++ b/flow-data/src/main/java/com/vaadin/flow/data/binder/ValidationStatusChangeListener.java @@ -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. + *

+ * 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 extends Serializable { + + /** + * Invoked when a ValidationStatusChangeEvent occurs. + * + * @param event + * the event to be processed + */ + void validationStatusChanged(ValidationStatusChangeEvent event); +} diff --git a/flow-data/src/test/java/com/vaadin/flow/data/binder/BinderValidationStatusChangeListenerTest.java b/flow-data/src/test/java/com/vaadin/flow/data/binder/BinderValidationStatusChangeListenerTest.java new file mode 100644 index 00000000000..f905de29c27 --- /dev/null +++ b/flow-data/src/test/java/com/vaadin/flow/data/binder/BinderValidationStatusChangeListenerTest.java @@ -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, Person> { + + private static final String BIRTH_DATE_PROPERTY = "birthDate"; + + private final Map, 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)); + } + +} diff --git a/flow-data/src/test/java/com/vaadin/flow/data/binder/testcomponents/TestHasValidatorDatePicker.java b/flow-data/src/test/java/com/vaadin/flow/data/binder/testcomponents/TestHasValidatorDatePicker.java new file mode 100644 index 00000000000..824d50d8b71 --- /dev/null +++ b/flow-data/src/test/java/com/vaadin/flow/data/binder/testcomponents/TestHasValidatorDatePicker.java @@ -0,0 +1,69 @@ +package com.vaadin.flow.data.binder.testcomponents; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; + +import com.vaadin.flow.data.binder.*; +import com.vaadin.flow.shared.Registration; + +public class TestHasValidatorDatePicker { + + public static final String INVALID_DATE_FORMAT = "Invalid date format"; + + public static class DatePickerHasValidatorDefaults extends TestDatePicker + implements HasValidator { + + protected boolean validationStatus = true; + } + + public static class DataPickerHasValidatorGetDefaultValidatorOverridden + extends DatePickerHasValidatorDefaults { + + @Override + public Validator getDefaultValidator() { + return (value, context) -> validationStatus ? ValidationResult.ok() + : ValidationResult.error(INVALID_DATE_FORMAT); + } + } + + public static class DataPickerHasValidatorAddListenerOverridden + extends DatePickerHasValidatorDefaults { + + private final Collection> validationStatusListeners = new ArrayList<>(); + + @Override + public Registration addValidationStatusChangeListener( + ValidationStatusChangeListener listener) { + validationStatusListeners.add(listener); + return () -> validationStatusListeners.remove(listener); + } + + public void fireValidationStatusChangeEvent( + boolean newValidationStatus) { + if (validationStatus != newValidationStatus) { + validationStatus = newValidationStatus; + var event = new ValidationStatusChangeEvent<>(this, + newValidationStatus); + validationStatusListeners.forEach( + listener -> listener.validationStatusChanged(event)); + } + } + + public ValidationResult customValidation(LocalDate value, + ValueContext context) { + return validationStatus ? ValidationResult.ok() + : ValidationResult.error(INVALID_DATE_FORMAT); + } + } + + public static class DataPickerHasValidatorOverridden + extends DataPickerHasValidatorAddListenerOverridden { + + @Override + public Validator getDefaultValidator() { + return (value, context) -> validationStatus ? ValidationResult.ok() + : ValidationResult.error("Invalid date format"); + } + } +}