diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/Element.java b/flow-server/src/main/java/com/vaadin/flow/dom/Element.java index 492e5cc247e..d2dba9a14c9 100644 --- a/flow-server/src/main/java/com/vaadin/flow/dom/Element.java +++ b/flow-server/src/main/java/com/vaadin/flow/dom/Element.java @@ -848,6 +848,50 @@ public Element setPropertyMap(String name, Map value) { return setPropertyJson(name, JacksonUtils.mapToJson(value)); } + /** + * Binds a {@link Signal}'s value to a given property and keeps the property + * value synchronized with the signal value while the element is in attached + * state. When the element is in detached state, signal value changes have + * no effect. null signal unbinds existing binding. + *

+ * Same rules applies for the property name and value from the bound Signal + * as in {@link #setProperty(String, String)}. + *

+ * While a Signal is bound to a property, any attempt to set property value + * manually throws {@link BindingActiveException}. Same happens when trying + * to bind a new Signal while one is already bound. + *

+ * Supported data types for the signal are the same as for the various + * {@code setProperty} methods in this class: {@link String}, + * {@link Boolean}, {@link Double}, {@link BaseJsonNode}, {@link Object} + * (bean), {@link List} and {@link Map}. + *

+ * Example of usage: + * + *

+     * ValueSignal<String> signal = new ValueSignal<>("");
+     * Element element = new Element("span");
+     * getElement().appendChild(element);
+     * element.bindProperty("mol", signal);
+     * signal.value("42"); // The element now has property mol="42"
+     * 
+ * + * @param name + * the name of the property + * @param signal + * the signal to bind or null to unbind any existing + * binding + * @throws com.vaadin.signals.BindingActiveException + * thrown when there is already an existing binding + * @see #setProperty(String, String) + */ + public Element bindProperty(String name, Signal signal) { + verifySetPropertyName(name); + + getStateProvider().bindPropertySignal(this, name, signal); + return this; + } + /** * Adds a property change listener which is triggered when the property's * value is updated on the server side. @@ -947,10 +991,6 @@ public String getProperty(String name, String defaultValue) { Object value = getPropertyRaw(name); if (value == null || value instanceof NullNode) { return defaultValue; - } else if (value instanceof JsonNode) { - return ((JsonNode) value).toString(); - } else if (value instanceof NullNode) { - return defaultValue; } else if (value instanceof Number) { double doubleValue = ((Number) value).doubleValue(); int intValue = (int) doubleValue; diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/ElementStateProvider.java b/flow-server/src/main/java/com/vaadin/flow/dom/ElementStateProvider.java index 69914ad75c9..04d2747b42d 100644 --- a/flow-server/src/main/java/com/vaadin/flow/dom/ElementStateProvider.java +++ b/flow-server/src/main/java/com/vaadin/flow/dom/ElementStateProvider.java @@ -244,6 +244,8 @@ DomListenerRegistration addEventListener(StateNode node, String eventType, * the property value * @param emitChange * true to create a change event for the client side + * @throws com.vaadin.signals.BindingActiveException + * thrown when a signal binding exists for the property */ void setProperty(StateNode node, String name, Serializable value, boolean emitChange); @@ -255,9 +257,28 @@ void setProperty(StateNode node, String name, Serializable value, * the node containing the data * @param name * the property name, not null + * @throws com.vaadin.signals.BindingActiveException + * thrown when a signal binding exists for the property */ void removeProperty(StateNode node, String name); + /** + * Binds the given signal to the given property. null signal + * unbinds existing binding. + * + * @param owner + * the owner element for which the signal is bound, not + * null + * @param name + * the property name, not null + * @param signal + * the signal to bind or null to unbind any existing + * binding + * @throws com.vaadin.signals.BindingActiveException + * thrown when there is already an existing binding + */ + void bindPropertySignal(Element owner, String name, Signal signal); + /** * Checks if the given property has been set. * diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/impl/AbstractTextElementStateProvider.java b/flow-server/src/main/java/com/vaadin/flow/dom/impl/AbstractTextElementStateProvider.java index 5848ca414f3..7b84bc9f84a 100644 --- a/flow-server/src/main/java/com/vaadin/flow/dom/impl/AbstractTextElementStateProvider.java +++ b/flow-server/src/main/java/com/vaadin/flow/dom/impl/AbstractTextElementStateProvider.java @@ -134,6 +134,12 @@ public void setProperty(StateNode node, String name, Serializable value, throw new UnsupportedOperationException(); } + @Override + public void bindPropertySignal(Element owner, String name, + Signal signal) { + throw new UnsupportedOperationException(); + } + @Override public void removeProperty(StateNode node, String name) { throw new UnsupportedOperationException(); diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java b/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java index e7671c73c67..1318b5ad1a8 100644 --- a/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java +++ b/flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java @@ -57,6 +57,7 @@ import com.vaadin.flow.internal.nodefeature.VirtualChildrenList; import com.vaadin.flow.server.AbstractStreamResource; import com.vaadin.flow.shared.Registration; +import com.vaadin.signals.BindingActiveException; import com.vaadin.signals.Signal; /** @@ -281,16 +282,38 @@ public void setProperty(StateNode node, String name, Serializable value, assert node != null; assert name != null; + if (getPropertyFeature(node).hasSignal(name)) { + throw new BindingActiveException( + "setProperty is not allowed while a binding for the given property exists."); + } + getPropertyFeature(node).setProperty(name, value, emitChange); } + @Override + public void bindPropertySignal(Element owner, String name, + Signal signal) { + assert owner != null; + assert name != null; + + getPropertyFeature(owner.getNode()).bindSignal(owner, name, signal); + } + @Override public void removeProperty(StateNode node, String name) { assert node != null; assert name != null; - getPropertyFeatureIfInitialized(node) - .ifPresent(feature -> feature.removeProperty(name)); + ElementPropertyMap elementPropertyMap = getPropertyFeatureIfInitialized( + node).orElse(null); + if (elementPropertyMap != null) { + if (elementPropertyMap.hasSignal(name)) { + throw new BindingActiveException( + "removeProperty is not allowed while a binding for the given property exists."); + } + + elementPropertyMap.removeProperty(name); + } } @Override diff --git a/flow-server/src/main/java/com/vaadin/flow/dom/impl/ShadowRootStateProvider.java b/flow-server/src/main/java/com/vaadin/flow/dom/impl/ShadowRootStateProvider.java index 43f489e6a8e..9883200c029 100644 --- a/flow-server/src/main/java/com/vaadin/flow/dom/impl/ShadowRootStateProvider.java +++ b/flow-server/src/main/java/com/vaadin/flow/dom/impl/ShadowRootStateProvider.java @@ -146,6 +146,12 @@ public void setProperty(StateNode node, String name, Serializable value, throw new UnsupportedOperationException(); } + @Override + public void bindPropertySignal(Element owner, String name, + Signal signal) { + throw new UnsupportedOperationException(); + } + @Override public void removeProperty(StateNode node, String name) { throw new UnsupportedOperationException(); diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/AbstractPropertyMap.java b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/AbstractPropertyMap.java index 429a475dcb5..53c2818c298 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/AbstractPropertyMap.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/AbstractPropertyMap.java @@ -59,7 +59,13 @@ public void setProperty(String name, Serializable value, assert name != null; assert isValidValueType(value); - put(name, value, emitChange); + if (hasSignal(name)) { + SignalBinding b = (SignalBinding) super.get(name); + put(name, new SignalBinding(b.signal(), b.registration(), value), + emitChange); + } else { + put(name, value, emitChange); + } } /** @@ -130,4 +136,29 @@ public static boolean isValidValueType(Serializable value) { || StateNode.class.isAssignableFrom(type); } + @Override + protected Serializable get(String key) { + Serializable value = super.get(key); + if (value instanceof SignalBinding) { + return ((SignalBinding) value).value(); + } else { + return value; + } + } + + public boolean hasSignal(String key) { + return super.get(key) instanceof SignalBinding binding + && binding.signal() != null && binding.registration() != null; + } + + @Override + public void updateFromClient(String key, Serializable value) { + if (hasSignal(key)) { + SignalBinding b = (SignalBinding) super.get(key); + super.updateFromClient(key, + new SignalBinding(b.signal(), b.registration(), value)); + } else { + super.updateFromClient(key, value); + } + } } diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/ElementPropertyMap.java b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/ElementPropertyMap.java index 208831251c0..8744737a80f 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/ElementPropertyMap.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/ElementPropertyMap.java @@ -29,13 +29,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import tools.jackson.databind.node.BaseJsonNode; import com.vaadin.flow.dom.Element; +import com.vaadin.flow.dom.ElementEffect; import com.vaadin.flow.dom.PropertyChangeEvent; import com.vaadin.flow.dom.PropertyChangeListener; import com.vaadin.flow.function.SerializablePredicate; +import com.vaadin.flow.internal.JacksonUtils; import com.vaadin.flow.internal.StateNode; import com.vaadin.flow.shared.Registration; +import com.vaadin.signals.BindingActiveException; +import com.vaadin.signals.Signal; /** * Map for element property values. @@ -112,6 +117,64 @@ public void setProperty(String name, Serializable value) { setProperty(name, value, true); } + /** + * Binds the given signal to the given property. null signal + * unbinds existing binding. + * + * @param owner + * the element owning the property, not null + * @param name + * the name of the property + * @param signal + * the signal to bind or null to unbind any existing + * binding + */ + public void bindSignal(Element owner, String name, Signal signal) { + SignalBinding previousSignalBinding; + if (super.getProperty(name) instanceof SignalBinding binding) { + previousSignalBinding = binding; + } else { + previousSignalBinding = null; + } + if (signal != null && previousSignalBinding != null + && previousSignalBinding.signal() != null) { + throw new BindingActiveException(); + } + Registration registration = signal != null + ? ElementEffect.bind(owner, signal, + (element, value) -> setPropertyFromSignal(name, value)) + : null; + if (signal == null && previousSignalBinding != null) { + if (previousSignalBinding.registration() != null) { + previousSignalBinding.registration().remove(); + } + put(name, get(name), false); + } else { + put(name, new SignalBinding(signal, registration, get(name)), + false); + } + } + + public void setPropertyFromSignal(String name, Object value) { + assert !forbiddenProperties.contains(name) + : "Forbidden property name: " + name; + + Serializable valueToSet; + if (value == null) { + valueToSet = JacksonUtils.nullNode(); + } else if (value instanceof String || value instanceof Number + || value instanceof Boolean || value instanceof BaseJsonNode) { + valueToSet = (Serializable) value; + } else if (value instanceof List) { + // List type conversion (return type ArrayNode) + valueToSet = JacksonUtils.listToJson((List) value); + } else { + // Map and Bean/Object types conversion (return type ObjectNode) + valueToSet = JacksonUtils.beanToJson(value); + } + setProperty(name, valueToSet, true); + } + /** * Adds a property change listener. * diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeMap.java b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeMap.java index 04bfaf519ab..f60822f3d93 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeMap.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeMap.java @@ -147,7 +147,7 @@ public Stream streamValues() { } } - record SignalBinding(Signal signal, Registration registration, + public record SignalBinding(Signal signal, Registration registration, Serializable value) implements Serializable { } diff --git a/flow-server/src/test/java/com/vaadin/flow/dom/ElementBindPropertyTest.java b/flow-server/src/test/java/com/vaadin/flow/dom/ElementBindPropertyTest.java new file mode 100644 index 00000000000..c2b0ab2dc31 --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/dom/ElementBindPropertyTest.java @@ -0,0 +1,815 @@ +/* + * Copyright 2000-2025 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.dom; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.experimental.FeatureFlags; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.internal.CurrentInstance; +import com.vaadin.flow.internal.JacksonUtilsTest; +import com.vaadin.flow.internal.nodefeature.ElementListenerMap; +import com.vaadin.flow.internal.nodefeature.ElementListenersTest; +import com.vaadin.flow.server.ErrorEvent; +import com.vaadin.flow.server.MockVaadinServletService; +import com.vaadin.flow.server.MockVaadinSession; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.shared.JsonConstants; +import com.vaadin.signals.BindingActiveException; +import com.vaadin.signals.ValueSignal; +import com.vaadin.tests.util.MockUI; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class ElementBindPropertyTest { + + private static MockVaadinServletService service; + + private MockedStatic featureFlagStaticMock; + + private LinkedList events; + + @BeforeClass + public static void init() { + var featureFlagStaticMock = mockStatic(FeatureFlags.class); + featureFlagEnabled(featureFlagStaticMock); + service = new MockVaadinServletService(); + close(featureFlagStaticMock); + } + + @AfterClass + public static void clean() { + CurrentInstance.clearAll(); + service.destroy(); + } + + @Before + public void before() { + featureFlagStaticMock = mockStatic(FeatureFlags.class); + featureFlagEnabled(featureFlagStaticMock); + events = mockLockedSessionWithErrorHandler(); + } + + @After + public void after() { + close(featureFlagStaticMock); + events = null; + } + + private static void featureFlagEnabled( + MockedStatic featureFlagStaticMock) { + FeatureFlags flags = mock(FeatureFlags.class); + when(flags.isEnabled(FeatureFlags.FLOW_FULLSTACK_SIGNALS.getId())) + .thenReturn(true); + featureFlagStaticMock.when(() -> FeatureFlags.get(any())) + .thenReturn(flags); + } + + private static void close( + MockedStatic featureFlagStaticMock) { + CurrentInstance.clearAll(); + featureFlagStaticMock.close(); + } + + // common property signal binding tests + + @Test + public void bindProperty_nullProperty_throwException() { + Element element = new Element("foo"); + ValueSignal signal = new ValueSignal<>("bar"); + assertThrows(IllegalArgumentException.class, + () -> element.bindProperty(null, signal)); + } + + @Test + public void bindProperty_illegalProperty_throwException() { + Element element = new Element("foo"); + ValueSignal signal = new ValueSignal<>("bar"); + assertThrows(IllegalArgumentException.class, + () -> element.bindProperty("textContent", signal)); + assertThrows(IllegalArgumentException.class, + () -> element.bindProperty("classList", signal)); + assertThrows(IllegalArgumentException.class, + () -> element.bindProperty("className", signal)); + assertThrows(IllegalArgumentException.class, + () -> element.bindProperty("outerHTML", signal)); + } + + @Test + public void bindProperty_notComponent_doNotThrowException() { + Element element = new Element("foo"); + UI.getCurrent().getElement().appendChild(element); + + ValueSignal signal = new ValueSignal<>("bar"); + + element.bindProperty("foobar", signal); + Assert.assertTrue(events.isEmpty()); + } + + @Test + public void bindProperty_setPropertyWhileBindingIsActive_throwException() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>("bar"); + + component.getElement().bindProperty("foo", signal); + + assertThrows(BindingActiveException.class, + () -> component.getElement().setProperty("foo", "baz")); + Assert.assertTrue(events.isEmpty()); + } + + @Test + public void bindProperty_removePropertyWhileBindingIsActive_throwException() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>("bar"); + + component.getElement().bindProperty("foo", signal); + + assertThrows(BindingActiveException.class, + () -> component.getElement().removeProperty("foo")); + Assert.assertTrue(events.isEmpty()); + } + + @Test + public void bindProperty_addPropertyChangeListenerAttached_listenerReceivesValueChange() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>("bar"); + component.getElement().bindProperty("foo", signal); + + AtomicReference listenerValue = new AtomicReference<>(); + component.getElement().addPropertyChangeListener("foo", "event", + event -> listenerValue.set(event.getValue())); + + Assert.assertEquals("The property should be synchronized", + DisabledUpdateMode.ONLY_WHEN_ENABLED, + component.getElement().getNode() + .getFeature(ElementListenerMap.class) + .getPropertySynchronizationMode("foo")); + + ElementListenerMap listenerMap = component.getElement().getNode() + .getFeature(ElementListenerMap.class); + + Assert.assertEquals("A DOM event synchronization should be defined", + Collections.singleton( + JsonConstants.SYNCHRONIZE_PROPERTY_TOKEN + "foo"), + ElementListenersTest.getExpressions(listenerMap, "event")); + + signal.value("changedValue"); + Assert.assertEquals("changedValue", listenerValue.get()); + } + + @Test + public void bindProperty_addPropertyChangeListenerDetached_listenerReceivesValueChange() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>("bar"); + component.getElement().bindProperty("foo", signal); + + AtomicReference listenerValue = new AtomicReference<>(); + component.getElement().addPropertyChangeListener("foo", "event", + event -> listenerValue.set(event.getValue())); + + signal.value("changedValue"); + Assert.assertEquals("changedValue", listenerValue.get()); + + // When detached, signal change should not propagate to the property and + // the listener should not be triggered + component.removeFromParent(); + signal.value("secondChangedValue"); + Assert.assertEquals("changedValue", listenerValue.get()); + Assert.assertEquals("changedValue", + component.getElement().getProperty("foo")); + } + + // boolean property signal binding tests + + @Test + public void bindBooleanProperty_componentNotAttached_bindingIgnored() { + TestComponent component = new TestComponent(); + + ValueSignal signal = new ValueSignal<>(true); + + component.getElement().bindProperty("foo", signal); + + assertNull(component.getElement().getProperty("foo")); + } + + @Test + public void bindBooleanProperty_componentDetached_bindingIgnored() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>(true); + + component.getElement().bindProperty("foo", signal); + + component.removeFromParent(); + + signal.value(false); + + Assert.assertTrue(events.isEmpty()); + assertTrue(component.getElement().getProperty("foo", false)); + } + + @Test + public void bindBooleanProperty_componentAttached_bindingActive() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>(true); + + component.getElement().bindProperty("foo", signal); + + assertTrue(component.getElement().getProperty("foo", false)); + Assert.assertTrue(events.isEmpty()); + } + + @Test + public void bindBooleanProperty_componentReAttached_bindingSynced() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>(true); + + component.getElement().bindProperty("foo", signal); + assertTrue(component.getElement().getProperty("foo", false)); + + component.removeFromParent(); + signal.value(false); + + assertEquals(false, signal.peek()); + assertTrue(component.getElement().getProperty("foo", false)); + + UI.getCurrent().add(component); + assertFalse(component.getElement().getProperty("foo", true)); + + Assert.assertTrue(events.isEmpty()); + } + + // double property signal binding tests + + @Test + public void bindDoubleProperty_componentNotAttached_bindingIgnored() { + TestComponent component = new TestComponent(); + + ValueSignal signal = new ValueSignal<>(1.0d); + + component.getElement().bindProperty("foo", signal); + + assertNull(component.getElement().getProperty("foo")); + } + + @Test + public void bindDoubleProperty_componentDetached_bindingIgnored() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>(1.0d); + + component.getElement().bindProperty("foo", signal); + + component.removeFromParent(); + + signal.value(2.0d); + + assertTrue(events.isEmpty()); + assertEquals(1.0d, component.getElement().getProperty("foo", -1.0d), + 0.0d); + } + + @Test + public void bindDoubleProperty_componentAttached_bindingActive() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>(1.0d); + + component.getElement().bindProperty("foo", signal); + + assertEquals(1.0d, component.getElement().getProperty("foo", -1.0d), + 0.0d); + assertTrue(events.isEmpty()); + } + + @Test + public void bindDoubleProperty_componentReAttached_bindingSynced() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>(1.0d); + + component.getElement().bindProperty("foo", signal); + assertEquals(1.0d, component.getElement().getProperty("foo", -1.0d), + 0.0d); + + component.removeFromParent(); + signal.value(2.0d); + + assertEquals(2.0d, signal.peek(), 0.0d); + assertEquals(1.0d, component.getElement().getProperty("foo", -1.0d), + 0.0d); + + UI.getCurrent().add(component); + assertEquals(2.0d, component.getElement().getProperty("foo", -1.0d), + 0.0d); + + Assert.assertTrue(events.isEmpty()); + } + + // integer property signal binding tests + + @Test + public void bindIntegerProperty_componentNotAttached_bindingIgnored() { + TestComponent component = new TestComponent(); + + ValueSignal signal = new ValueSignal<>(1); + + component.getElement().bindProperty("foo", signal); + + assertNull(component.getElement().getProperty("foo")); + } + + @Test + public void bindIntegerProperty_componentDetached_bindingIgnored() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>(1); + + component.getElement().bindProperty("foo", signal); + + component.removeFromParent(); + + signal.value(2); + + assertTrue(events.isEmpty()); + assertEquals(1, component.getElement().getProperty("foo", -1)); + } + + @Test + public void bindIntegerProperty_componentAttached_bindingActive() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>(1); + + component.getElement().bindProperty("foo", signal); + + assertEquals(1, component.getElement().getProperty("foo", -1)); + assertTrue(events.isEmpty()); + } + + @Test + public void bindIntegerProperty_componentReAttached_bindingSynced() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>(1); + + component.getElement().bindProperty("foo", signal); + assertEquals(1, component.getElement().getProperty("foo", -1)); + + component.removeFromParent(); + signal.value(2); + + assertEquals(2, (long) signal.peek()); + assertEquals(1, component.getElement().getProperty("foo", -1)); + + UI.getCurrent().add(component); + assertEquals(2, component.getElement().getProperty("foo", -1)); + + Assert.assertTrue(events.isEmpty()); + } + + // string property signal binding tests + + @Test + public void bindStringProperty_componentNotAttached_bindingIgnored() { + TestComponent component = new TestComponent(); + + ValueSignal signal = new ValueSignal<>("bar"); + + component.getElement().bindProperty("foo", signal); + + assertNull(component.getElement().getProperty("foo", null)); + } + + @Test + public void bindStringProperty_componentDetached_bindingIgnored() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>("bar"); + + component.getElement().bindProperty("foo", signal); + + component.removeFromParent(); + + signal.value("baz"); + + Assert.assertTrue(events.isEmpty()); + assertEquals("bar", + component.getElement().getProperty("foo", "default")); + } + + @Test + public void bindStringProperty_componentAttached_bindingActive() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>("bar"); + + component.getElement().bindProperty("foo", signal); + + assertEquals("bar", + component.getElement().getProperty("foo", "default")); + Assert.assertTrue(events.isEmpty()); + } + + @Test + public void bindStringProperty_componentReAttached_bindingSynced() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal signal = new ValueSignal<>("bar"); + + component.getElement().bindProperty("foo", signal); + assertEquals("bar", + component.getElement().getProperty("foo", "default")); + + component.removeFromParent(); + signal.value("baz"); + + assertEquals("baz", signal.peek()); + assertEquals("bar", + component.getElement().getProperty("foo", "default")); + + UI.getCurrent().add(component); + assertEquals("baz", + component.getElement().getProperty("foo", "default")); + + Assert.assertTrue(events.isEmpty()); + } + + // bean property signal binding tests + + @Test + public void bindBeanProperty_componentNotAttached_bindingIgnored() { + TestComponent component = new TestComponent(); + + ValueSignal signal = new ValueSignal<>( + createJohn()); + component.getElement().bindProperty("foo", signal); + + assertNull(component.getElement().getProperty("foo", null)); + } + + @Test + public void bindBeanProperty_componentDetached_bindingIgnored() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + JacksonUtilsTest.Person john = createJohn(); + ValueSignal signal = new ValueSignal<>(john); + component.getElement().bindProperty("foo", signal); + + component.removeFromParent(); + + signal.value(createPerson("Jack", 52, false)); + + assertPersonEquals(john, + (JsonNode) component.getElement().getPropertyRaw("foo")); + Assert.assertTrue(events.isEmpty()); + + } + + @Test + public void bindBeanProperty_componentAttached_bindingActive() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + JacksonUtilsTest.Person john = createJohn(); + ValueSignal signal = new ValueSignal<>(john); + component.getElement().bindProperty("foo", signal); + + assertPersonEquals(john, + (JsonNode) component.getElement().getPropertyRaw("foo")); + Assert.assertTrue(events.isEmpty()); + } + + @Test + public void bindBeanProperty_componentReAttached_bindingSynced() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + JacksonUtilsTest.Person john = createJohn(); + ValueSignal signal = new ValueSignal<>(john); + component.getElement().bindProperty("foo", signal); + + assertPersonEquals(john, + (JsonNode) component.getElement().getPropertyRaw("foo")); + + component.removeFromParent(); + JacksonUtilsTest.Person jack = createJack(); + signal.value(jack); + + assertEquals(jack, signal.peek()); + assertPersonEquals(john, + (JsonNode) component.getElement().getPropertyRaw("foo")); + + UI.getCurrent().add(component); + + assertPersonEquals(jack, + (JsonNode) component.getElement().getPropertyRaw("foo")); + Assert.assertTrue(events.isEmpty()); + } + + // list property signal binding tests + + @Test + public void bindListProperty_componentNotAttached_bindingIgnored() { + TestComponent component = new TestComponent(); + + ValueSignal> signal = new ValueSignal<>( + Arrays.asList(createJohn(), createJack())); + component.getElement().bindProperty("foo", signal); + + assertNull(component.getElement().getProperty("foo", null)); + } + + @Test + public void bindListProperty_componentDetached_bindingIgnored() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal> signal = new ValueSignal<>( + Arrays.asList(createJohn(), createJack())); + component.getElement().bindProperty("foo", signal); + assertEquals("John", + getFromList(component, "foo", 0).get("name").asString()); + assertEquals("Jack", + getFromList(component, "foo", 1).get("name").asString()); + + component.removeFromParent(); + + signal.value(Arrays.asList(createJack(), createJohn())); + assertEquals("John", + getFromList(component, "foo", 0).get("name").asString()); + assertEquals("Jack", + getFromList(component, "foo", 1).get("name").asString()); + + Assert.assertTrue(events.isEmpty()); + } + + @Test + public void bindListProperty_componentAttached_bindingActive() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal> signal = new ValueSignal<>( + Arrays.asList(createJohn(), createJack())); + component.getElement().bindProperty("foo", signal); + + assertEquals("John", + getFromList(component, "foo", 0).get("name").asString()); + assertEquals("Jack", + getFromList(component, "foo", 1).get("name").asString()); + Assert.assertTrue(events.isEmpty()); + } + + @Test + public void bindListProperty_componentReAttached_bindingSynced() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal> signal = new ValueSignal<>( + Arrays.asList(createJohn(), createJack())); + component.getElement().bindProperty("foo", signal); + + // assert initial value + assertEquals("John", + getFromList(component, "foo", 0).get("name").asString()); + assertEquals("Jack", + getFromList(component, "foo", 1).get("name").asString()); + + component.removeFromParent(); + signal.value(Arrays.asList(createJack(), createJohn())); + + // assert signal value updated + assertEquals("Jack", + ((Map) signal.peek().getFirst()).get("name")); + assertEquals("John", ((Map) signal.peek().getLast()).get("name")); + // assert property value not updated + assertEquals("John", + getFromList(component, "foo", 0).get("name").asString()); + assertEquals("Jack", + getFromList(component, "foo", 1).get("name").asString()); + + UI.getCurrent().add(component); + + // assert property value updated + assertEquals("Jack", + getFromList(component, "foo", 0).get("name").asString()); + assertEquals("John", + getFromList(component, "foo", 1).get("name").asString()); + Assert.assertTrue(events.isEmpty()); + } + + // map property signal binding tests + + @Test + public void bindMapProperty_componentNotAttached_bindingIgnored() { + TestComponent component = new TestComponent(); + + ValueSignal> signal = new ValueSignal<>( + createPersonMap(createJohn(), createJack())); + component.getElement().bindProperty("foo", signal); + + assertNull(component.getElement().getProperty("foo", null)); + } + + @Test + public void bindMapProperty_componentDetached_bindingIgnored() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal> signal = new ValueSignal<>( + createPersonMap(createJohn(), createJack())); + component.getElement().bindProperty("foo", signal); + assertEquals("John", + getFromMap(component, "foo", "0").get("name").asString()); + assertEquals("Jack", + getFromMap(component, "foo", "1").get("name").asString()); + + component.removeFromParent(); + + signal.value(createPersonMap(createJack(), createJohn())); + assertEquals("John", + getFromMap(component, "foo", "0").get("name").asString()); + assertEquals("Jack", + getFromMap(component, "foo", "1").get("name").asString()); + + Assert.assertTrue(events.isEmpty()); + } + + @Test + public void bindMapProperty_componentAttached_bindingActive() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal> signal = new ValueSignal<>( + createPersonMap(createJohn(), createJack())); + component.getElement().bindProperty("foo", signal); + + assertEquals("John", + getFromMap(component, "foo", "0").get("name").asString()); + assertEquals("Jack", + getFromMap(component, "foo", "1").get("name").asString()); + Assert.assertTrue(events.isEmpty()); + } + + @Test + public void bindMapProperty_componentReAttached_bindingSynced() { + TestComponent component = new TestComponent(); + UI.getCurrent().add(component); + + ValueSignal> signal = new ValueSignal<>( + createPersonMap(createJohn(), createJack())); + component.getElement().bindProperty("foo", signal); + + // assert initial value + assertEquals("John", + getFromMap(component, "foo", "0").get("name").asString()); + assertEquals("Jack", + getFromMap(component, "foo", "1").get("name").asString()); + + component.removeFromParent(); + signal.value(createPersonMap(createJack(), createJohn())); + + // assert signal value updated + assertEquals("Jack", ((Map) signal.peek().get("0")).get("name")); + assertEquals("John", ((Map) signal.peek().get("1")).get("name")); + // assert property value not updated + assertEquals("John", + getFromMap(component, "foo", "0").get("name").asString()); + assertEquals("Jack", + getFromMap(component, "foo", "1").get("name").asString()); + + UI.getCurrent().add(component); + + // assert property value updated + assertEquals("Jack", + getFromMap(component, "foo", "0").get("name").asString()); + assertEquals("John", + getFromMap(component, "foo", "1").get("name").asString()); + Assert.assertTrue(events.isEmpty()); + } + + private void assertPersonEquals(JacksonUtilsTest.Person person, + JsonNode jsonNode) { + assertEquals(person.name(), jsonNode.get("name").asString()); + assertEquals(person.age(), jsonNode.get("age").asDouble(), 0); + assertEquals(person.canSwim(), jsonNode.get("canSwim").asBoolean()); + } + + private JacksonUtilsTest.Person createPerson(String name, double age, + boolean canSwim) { + return new JacksonUtilsTest.Person(name, age, canSwim); + } + + private JacksonUtilsTest.Person createJohn() { + return createPerson("John", 42, true); + } + + private JacksonUtilsTest.Person createJack() { + return createPerson("Jack", 52, false); + } + + private Map createPersonMap(JacksonUtilsTest.Person... persons) { + Map map = new HashMap<>(); + for (int i = 0; i < persons.length; i++) { + map.put(String.valueOf(i), persons[i]); + } + return map; + } + + private ObjectNode getFromList(Component component, String propertyName, + int index) { + ArrayNode arrayNode = (ArrayNode) component.getElement() + .getPropertyRaw(propertyName); + return (ObjectNode) arrayNode.get(index); + } + + private ObjectNode getFromMap(Component component, String propertyName, + String key) { + ObjectNode objectNode = (ObjectNode) component.getElement() + .getPropertyRaw(propertyName); + return (ObjectNode) objectNode.get(key); + } + + private LinkedList mockLockedSessionWithErrorHandler() { + VaadinService.setCurrent(service); + + var session = new MockVaadinSession(service); + session.lock(); + + var ui = new MockUI(session); + var events = new LinkedList(); + session.setErrorHandler(events::add); + + return events; + } + + @Tag("div") + private static class TestComponent extends Component { + + } +}