Skip to content

Commit bd2d668

Browse files
authored
feat: signal binding for element property (#22408) (#22683)
feat: adds Element::bindProperty(String, Signal) method that establishes a binding between a signal instance and an element: element's property is updated on UI automatically whenever the signal value changes and signal value is updated whenever the attribute is changed on the client side. Fixes #22408
1 parent feb0fe9 commit bd2d668

File tree

15 files changed

+1299
-11
lines changed

15 files changed

+1299
-11
lines changed

flow-server/src/main/java/com/vaadin/flow/dom/Element.java

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,52 @@ public Element setPropertyMap(String name, Map<String, ?> value) {
848848
return setPropertyJson(name, JacksonUtils.mapToJson(value));
849849
}
850850

851+
/**
852+
* Binds a {@link Signal}'s value to the given property and keeps the
853+
* property value synchronized with the signal value while the element is in
854+
* attached state. When the element is in detached state, signal value
855+
* changes have no effect. <code>null</code> signal unbinds existing
856+
* binding.
857+
* <p>
858+
* Same rules apply for the property name and value from the bound Signal as
859+
* in {@link #setProperty(String, String)}.
860+
* <p>
861+
* While a Signal is bound to a property, any attempt to set the property
862+
* value manually throws {@link BindingActiveException}. Same happens when
863+
* trying to bind a new Signal while one is already bound.
864+
* <p>
865+
* Supported data types for the signal are the same as for the various
866+
* {@code setProperty} methods in this class: {@link String},
867+
* {@link Boolean}, {@link Double}, {@link BaseJsonNode}, {@link Object}
868+
* (bean), {@link List} and {@link Map}. Typed Lists and Maps are not
869+
* supported, i.e. the signal must be of type {@code Signal<List<?>>} or
870+
* {@code Signal<Map<?,?>}.
871+
* <p>
872+
* Example of usage:
873+
*
874+
* <pre>
875+
* ValueSignal&lt;String&gt; signal = new ValueSignal&lt;&gt;("");
876+
* Element element = new Element("span");
877+
* getElement().appendChild(element);
878+
* element.bindProperty("mol", signal);
879+
* signal.value("42"); // The element now has property mol="42"
880+
* </pre>
881+
*
882+
* @param name
883+
* the name of the property
884+
* @param signal
885+
* the signal to bind or <code>null</code> to unbind any existing
886+
* binding
887+
* @throws com.vaadin.signals.BindingActiveException
888+
* thrown when there is already an existing binding
889+
* @see #setProperty(String, String)
890+
*/
891+
public void bindProperty(String name, Signal<?> signal) {
892+
verifySetPropertyName(name);
893+
894+
getStateProvider().bindPropertySignal(this, name, signal);
895+
}
896+
851897
/**
852898
* Adds a property change listener which is triggered when the property's
853899
* value is updated on the server side.
@@ -947,10 +993,6 @@ public String getProperty(String name, String defaultValue) {
947993
Object value = getPropertyRaw(name);
948994
if (value == null || value instanceof NullNode) {
949995
return defaultValue;
950-
} else if (value instanceof JsonNode) {
951-
return ((JsonNode) value).toString();
952-
} else if (value instanceof NullNode) {
953-
return defaultValue;
954996
} else if (value instanceof Number) {
955997
double doubleValue = ((Number) value).doubleValue();
956998
int intValue = (int) doubleValue;

flow-server/src/main/java/com/vaadin/flow/dom/ElementStateProvider.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ DomListenerRegistration addEventListener(StateNode node, String eventType,
244244
* the property value
245245
* @param emitChange
246246
* true to create a change event for the client side
247+
* @throws com.vaadin.signals.BindingActiveException
248+
* thrown when there is an existing binding for the given
249+
* property
247250
*/
248251
void setProperty(StateNode node, String name, Serializable value,
249252
boolean emitChange);
@@ -255,9 +258,30 @@ void setProperty(StateNode node, String name, Serializable value,
255258
* the node containing the data
256259
* @param name
257260
* the property name, not <code>null</code>
261+
* @throws com.vaadin.signals.BindingActiveException
262+
* thrown when there is an existing binding for the given
263+
* property
258264
*/
259265
void removeProperty(StateNode node, String name);
260266

267+
/**
268+
* Binds the given signal to the given property. <code>null</code> signal
269+
* unbinds existing binding.
270+
*
271+
* @param owner
272+
* the owner element for which the signal is bound, not
273+
* <code>null</code>
274+
* @param name
275+
* the property name, not <code>null</code>
276+
* @param signal
277+
* the signal to bind or <code>null</code> to unbind any existing
278+
* binding
279+
* @throws com.vaadin.signals.BindingActiveException
280+
* thrown when there is already an existing binding for the
281+
* given property
282+
*/
283+
void bindPropertySignal(Element owner, String name, Signal<?> signal);
284+
261285
/**
262286
* Checks if the given property has been set.
263287
*

flow-server/src/main/java/com/vaadin/flow/dom/impl/AbstractTextElementStateProvider.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ public void setProperty(StateNode node, String name, Serializable value,
134134
throw new UnsupportedOperationException();
135135
}
136136

137+
@Override
138+
public void bindPropertySignal(Element owner, String name,
139+
Signal<?> signal) {
140+
throw new UnsupportedOperationException();
141+
}
142+
137143
@Override
138144
public void removeProperty(StateNode node, String name) {
139145
throw new UnsupportedOperationException();

flow-server/src/main/java/com/vaadin/flow/dom/impl/BasicElementStateProvider.java

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
import com.vaadin.flow.internal.nodefeature.VirtualChildrenList;
5858
import com.vaadin.flow.server.AbstractStreamResource;
5959
import com.vaadin.flow.shared.Registration;
60+
import com.vaadin.signals.BindingActiveException;
6061
import com.vaadin.signals.Signal;
6162

6263
/**
@@ -281,16 +282,39 @@ public void setProperty(StateNode node, String name, Serializable value,
281282
assert node != null;
282283
assert name != null;
283284

284-
getPropertyFeature(node).setProperty(name, value, emitChange);
285+
ElementPropertyMap propertyFeature = getPropertyFeature(node);
286+
287+
if (propertyFeature.hasSignal(name)) {
288+
throw new BindingActiveException(String.format(
289+
"setProperty is not allowed while a binding for the property '%s' exists.",
290+
name));
291+
}
292+
293+
propertyFeature.setProperty(name, value, emitChange);
294+
}
295+
296+
@Override
297+
public void bindPropertySignal(Element owner, String name,
298+
Signal<?> signal) {
299+
assert owner != null;
300+
assert name != null;
301+
302+
getPropertyFeature(owner.getNode()).bindSignal(owner, name, signal);
285303
}
286304

287305
@Override
288306
public void removeProperty(StateNode node, String name) {
289307
assert node != null;
290308
assert name != null;
291309

292-
getPropertyFeatureIfInitialized(node)
293-
.ifPresent(feature -> feature.removeProperty(name));
310+
getPropertyFeatureIfInitialized(node).ifPresent(propertyMap -> {
311+
if (propertyMap.hasSignal(name)) {
312+
throw new BindingActiveException(String.format(
313+
"removeProperty is not allowed while a binding for the property '%s' exists.",
314+
name));
315+
}
316+
propertyMap.removeProperty(name);
317+
});
294318
}
295319

296320
@Override

flow-server/src/main/java/com/vaadin/flow/dom/impl/ShadowRootStateProvider.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ public void setProperty(StateNode node, String name, Serializable value,
146146
throw new UnsupportedOperationException();
147147
}
148148

149+
@Override
150+
public void bindPropertySignal(Element owner, String name,
151+
Signal<?> signal) {
152+
throw new UnsupportedOperationException();
153+
}
154+
149155
@Override
150156
public void removeProperty(StateNode node, String name) {
151157
throw new UnsupportedOperationException();

flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/AbstractPropertyMap.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,13 @@ public void setProperty(String name, Serializable value,
5959
assert name != null;
6060
assert isValidValueType(value);
6161

62-
put(name, value, emitChange);
62+
if (hasSignal(name)) {
63+
SignalBinding b = (SignalBinding) super.get(name);
64+
put(name, new SignalBinding(b.signal(), b.registration(), value),
65+
emitChange);
66+
} else {
67+
put(name, value, emitChange);
68+
}
6369
}
6470

6571
/**
@@ -130,4 +136,19 @@ public static boolean isValidValueType(Serializable value) {
130136
|| StateNode.class.isAssignableFrom(type);
131137
}
132138

139+
public boolean hasSignal(String key) {
140+
return super.get(key) instanceof SignalBinding binding
141+
&& binding.signal() != null && binding.registration() != null;
142+
}
143+
144+
@Override
145+
public void updateFromClient(String key, Serializable value) {
146+
if (hasSignal(key)) {
147+
SignalBinding b = (SignalBinding) super.get(key);
148+
super.updateFromClient(key,
149+
new SignalBinding(b.signal(), b.registration(), value));
150+
} else {
151+
super.updateFromClient(key, value);
152+
}
153+
}
133154
}

flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/ElementPropertyMap.java

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,19 @@
2929

3030
import org.slf4j.Logger;
3131
import org.slf4j.LoggerFactory;
32+
import tools.jackson.databind.node.BaseJsonNode;
3233

3334
import com.vaadin.flow.dom.Element;
35+
import com.vaadin.flow.dom.ElementEffect;
3436
import com.vaadin.flow.dom.PropertyChangeEvent;
3537
import com.vaadin.flow.dom.PropertyChangeListener;
3638
import com.vaadin.flow.function.SerializablePredicate;
39+
import com.vaadin.flow.internal.JacksonUtils;
3740
import com.vaadin.flow.internal.StateNode;
3841
import com.vaadin.flow.shared.Registration;
42+
import com.vaadin.signals.BindingActiveException;
43+
import com.vaadin.signals.Signal;
44+
import com.vaadin.signals.ValueSignal;
3945

4046
/**
4147
* Map for element property values.
@@ -82,7 +88,9 @@ public ElementPropertyMap(StateNode node) {
8288
* the value to store
8389
* @return a runnable for firing the deferred change event
8490
* @exception PropertyChangeDeniedException
85-
* if the property change is disallowed
91+
* if the property change is disallowed due to the property
92+
* not being set as synchronized, or the signal bound to the
93+
* property is not a ValueSignal and can not thus be updated
8694
*/
8795
public Runnable deferredUpdateFromClient(String key, Serializable value)
8896
throws PropertyChangeDeniedException {
@@ -112,6 +120,81 @@ public void setProperty(String name, Serializable value) {
112120
setProperty(name, value, true);
113121
}
114122

123+
/**
124+
* Binds the given signal to the given property. <code>null</code> signal
125+
* unbinds existing binding.
126+
*
127+
* @param owner
128+
* the element owning the property, not <code>null</code>
129+
* @param name
130+
* the name of the property
131+
* @param signal
132+
* the signal to bind or <code>null</code> to unbind any existing
133+
* binding
134+
* @throws com.vaadin.signals.BindingActiveException
135+
* thrown when there is already an existing binding for the
136+
* given property
137+
*/
138+
public void bindSignal(Element owner, String name, Signal<?> signal) {
139+
SignalBinding previousSignalBinding;
140+
if (super.get(name) instanceof SignalBinding binding) {
141+
previousSignalBinding = binding;
142+
} else {
143+
previousSignalBinding = null;
144+
}
145+
if (signal != null && hasSignal(name)) {
146+
throw new BindingActiveException();
147+
}
148+
Registration registration = signal != null
149+
? ElementEffect.bind(owner, signal,
150+
(element, value) -> setPropertyFromSignal(name, value))
151+
: null;
152+
if (signal == null && previousSignalBinding != null) {
153+
if (previousSignalBinding.registration() != null) {
154+
previousSignalBinding.registration().remove();
155+
}
156+
put(name, get(name), false);
157+
} else {
158+
put(name, new SignalBinding(signal, registration, get(name)),
159+
false);
160+
}
161+
}
162+
163+
@Override
164+
protected Serializable get(String key) {
165+
Serializable value = super.get(key);
166+
if (value instanceof SignalBinding) {
167+
return ((SignalBinding) value).value();
168+
} else {
169+
return value;
170+
}
171+
}
172+
173+
public void setPropertyFromSignal(String name, Object value) {
174+
assert !forbiddenProperties.contains(name)
175+
: "Forbidden property name: " + name;
176+
177+
Serializable valueToSet;
178+
if (value == null) {
179+
valueToSet = JacksonUtils.nullNode();
180+
} else if (value instanceof String || value instanceof Number
181+
|| value instanceof Boolean || value instanceof BaseJsonNode) {
182+
valueToSet = (Serializable) value;
183+
} else if (value instanceof List) {
184+
// List type conversion (return type ArrayNode)
185+
valueToSet = JacksonUtils.listToJson((List<?>) value);
186+
} else {
187+
// Map and Bean/Object types conversion (return type ObjectNode)
188+
valueToSet = JacksonUtils.beanToJson(value);
189+
}
190+
191+
Serializable oldValue = get(name);
192+
boolean valueChanged = !Objects.equals(oldValue, valueToSet);
193+
if (valueChanged) {
194+
setProperty(name, valueToSet, true);
195+
}
196+
}
197+
115198
/**
116199
* Adds a property change listener.
117200
*
@@ -567,6 +650,23 @@ private Runnable doDeferredUpdateFromClient(String key, Serializable value)
567650

568651
}
569652

570-
return putWithDeferredChangeEvent(key, value, false);
653+
PutResult putResult = null;
654+
if (hasSignal(key)) {
655+
SignalBinding binding = (SignalBinding) super.get(key);
656+
putResult = putWithDeferredChangeEvent(key, new SignalBinding(
657+
binding.signal(), binding.registration(), value), false);
658+
if (binding.signal() instanceof ValueSignal valueSignal) {
659+
valueSignal.value(value);
660+
} else {
661+
throw new PropertyChangeDeniedException(String.format(
662+
"Signal bound to property '%s' is not a ValueSignal, "
663+
+ "cannot update its value from the client.",
664+
key));
665+
}
666+
} else {
667+
putResult = putWithDeferredChangeEvent(key, value, false);
668+
}
669+
670+
return putResult;
571671
}
572672
}

flow-server/src/main/java/com/vaadin/flow/internal/nodefeature/NodeMap.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ public Stream<Serializable> streamValues() {
147147
}
148148
}
149149

150-
record SignalBinding(Signal<String> signal, Registration registration,
150+
public record SignalBinding(Signal<?> signal, Registration registration,
151151
Serializable value) implements Serializable {
152152
}
153153

0 commit comments

Comments
 (0)