com.vaadin.collaborationengine.CollaborationBinder Maven / Gradle / Ivy
/*
* Copyright 2020-2022 Vaadin Ltd.
*
* This program is available under Commercial Vaadin Runtime License 1.0
* (CVRLv1).
*
* For the full License, see http://vaadin.com/license/cvrl-1
*/
package com.vaadin.collaborationengine;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.vaadin.collaborationengine.HighlightHandler.HighlightContext;
import com.vaadin.collaborationengine.PropertyChangeHandler.PropertyChangeEvent;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.component.HasValue;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.BindingValidationStatusHandler;
import com.vaadin.flow.data.binder.PropertyDefinition;
import com.vaadin.flow.data.binder.Setter;
import com.vaadin.flow.data.converter.Converter;
import com.vaadin.flow.function.SerializableFunction;
import com.vaadin.flow.function.SerializableSupplier;
import com.vaadin.flow.function.ValueProvider;
import com.vaadin.flow.internal.UsageStatistics;
import com.vaadin.flow.shared.Registration;
/**
* Extension of {@link Binder} for creating collaborative forms with
* {@link CollaborationEngine}. In addition to Binder's data binding mechanism,
* CollaborationBinder synchronizes the field values between clients which are
* connected to the same topic via {@link TopicConnection}.
*
* @author Vaadin Ltd
* @since 1.0
*
* @param
* the bean type
*/
public class CollaborationBinder extends Binder
implements HasExpirationTimeout {
private static final List> SUPPORTED_CLASS_TYPES = Arrays.asList(
String.class, Boolean.class, Integer.class, Double.class,
BigDecimal.class, LocalDate.class, LocalTime.class,
LocalDateTime.class, Enum.class);
private static final List> SUPPORTED_COLLECTION_TYPES = Arrays
.asList(List.class, Set.class);
static class JsonHandler {
private final SerializableFunction serializer;
private final SerializableFunction deserializer;
private JsonHandler(SerializableFunction serializer,
SerializableFunction deserializer) {
this.serializer = serializer;
this.deserializer = deserializer;
}
private static JsonHandler forBasicType(Class fieldType) {
return new CollaborationBinder.JsonHandler<>(JsonUtil::toJsonNode,
jsonNode -> JsonUtil.toInstance(jsonNode, fieldType));
}
private void store(HasValue, ?> field) {
if (field instanceof Component) {
Component fieldAsComponent = (Component) field;
ComponentUtil.setData(fieldAsComponent, JsonHandler.class,
this);
} else {
throw new IllegalArgumentException(
"CollaborationBinder can only be used with component fields. The provided field is of type "
+ field.getClass().getName());
}
}
private static JsonHandler> getAndClear(HasValue, ?> field) {
if (field instanceof Component) {
Component fieldAsComponent = (Component) field;
JsonHandler> config = ComponentUtil.getData(fieldAsComponent,
JsonHandler.class);
ComponentUtil.setData(fieldAsComponent, JsonHandler.class,
null);
return config;
}
return null;
}
private JsonNode serialize(T value) {
return serializer.apply(value);
}
private T deserialize(JsonNode jsonNode) {
return deserializer.apply(jsonNode);
}
}
protected static class CollaborationBindingBuilderImpl
extends BindingBuilderImpl {
private String propertyName = null;
private boolean typeIsConverted = false;
private JsonHandler> explicitJsonHandler;
protected CollaborationBindingBuilderImpl(
CollaborationBinder binder, HasValue, FIELDVALUE> field,
Converter converterValidatorChain,
BindingValidationStatusHandler statusHandler) {
super(binder, field, converterValidatorChain, statusHandler);
explicitJsonHandler = JsonHandler.getAndClear(field);
}
@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
public Binding bind(ValueProvider getter,
Setter setter) {
// Capture current propertyName
final String propertyName = this.propertyName;
if (propertyName == null) {
throw new UnsupportedOperationException(
"A property name must always be provided when binding with the collaboration binder. "
+ "Use bind(String propertyName) instead.");
}
Binding binding = super.bind(getter, setter);
HasValue field = binding.getField();
CollaborationBinder binder = getBinder();
ComponentConnectionContext connectionContext = binder.connectionContext;
if (connectionContext != null) {
connectionContext.addComponent((Component) field);
}
List registrations = new ArrayList<>();
registrations.add(field.addValueChangeListener(
event -> binder.setMapValueFromField(propertyName, field)));
registrations.add(FieldHighlighter.setupForField(field,
propertyName, binder));
binder.bindingRegistrations.put(binding,
() -> registrations.forEach(Registration::remove));
binder.fieldToPropertyName.put(field, propertyName);
return binding;
}
@Override
public Binding bind(String propertyName) {
try {
this.propertyName = propertyName;
return super.bind(propertyName);
} finally {
this.propertyName = null;
}
}
@Override
protected CollaborationBinder getBinder() {
return (CollaborationBinder) super.getBinder();
}
@Override
protected BindingBuilder withConverter(
Converter converter,
boolean resetNullRepresentation) {
if (resetNullRepresentation) {
// Flag implies that this is a "real" converter
typeIsConverted = true;
}
return super.withConverter(converter, resetNullRepresentation);
}
@Override
public BindingBuilder withNullRepresentation(
TARGET nullRepresentation) {
/*
* The null representation is internally implemented as a converter,
* even though it doesn't change the type of the value. Because of
* this, we reset the flag to its previous value after it becomes
* set by the super call that sets a converter.
*/
boolean typeWasConverted = typeIsConverted;
try {
return super.withNullRepresentation(nullRepresentation);
} finally {
typeIsConverted = typeWasConverted;
}
}
}
private final UserInfo localUser;
private final CollaborationEngine ce;
private final FieldHighlighter fieldHighlighter;
private ComponentConnectionContext connectionContext;
private FormManager formManager;
private Duration expirationTimeout;
private final Map, Registration> bindingRegistrations = new HashMap<>();
private final Map, String> fieldToPropertyName = new HashMap<>();
private final Map> propertyJsonHandlers = new HashMap<>();
private final Map, JsonHandler>> typeConfigurations = new HashMap<>();
static {
UsageStatistics.markAsUsed(
CollaborationEngine.COLLABORATION_ENGINE_NAME
+ "/CollaborationBinder",
CollaborationEngine.COLLABORATION_ENGINE_VERSION);
}
/**
* Creates a new collaboration binder. It uses reflection based on the
* provided bean type to resolve bean properties.
*
* The provided user information is used in the field editing indicators.
* The name of the user will be displayed to other users when editing a
* field, and the user's color index will be used to set the field's
* highlight color.
*
* @param beanType
* the bean type to use, not null
* @param localUser
* the information of the local user, not null
* @since 1.0
*/
public CollaborationBinder(Class beanType, UserInfo localUser) {
this(beanType, localUser, CollaborationEngine.getInstance());
}
CollaborationBinder(Class beanType, UserInfo localUser,
CollaborationEngine ce) {
super(beanType);
this.localUser = Objects.requireNonNull(localUser,
"User cannot be null");
this.ce = ce;
this.fieldHighlighter = new FieldHighlighter(ce::getUserColorIndex);
}
@Override
protected BindingBuilder configureBinding(
BindingBuilder baseBinding,
PropertyDefinition definition) {
CollaborationBindingBuilderImpl, ?, ?> binding = (CollaborationBindingBuilderImpl, ?, ?>) baseBinding;
JsonHandler> handler = findJsonHandler(binding, definition);
propertyJsonHandlers.put(definition.getName(), handler);
return super.configureBinding(baseBinding, definition);
}
private static boolean isSupportedType(Type type) {
Objects.requireNonNull(type, "Type cannot be null");
if (type instanceof Class>) {
return isAssignableFromAny(SUPPORTED_CLASS_TYPES, (Class>) type);
} else if (type instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) type;
if (isAssignableFromAny(SUPPORTED_COLLECTION_TYPES,
(Class>) parameterizedType.getRawType())) {
for (Type typeArgument : parameterizedType
.getActualTypeArguments()) {
if (!isSupportedType(typeArgument)) {
return false;
}
}
return true;
}
}
return false;
}
private static String createTypeNotSupportedMessage(Type type) {
return "The type " + type.getTypeName() + " is not supported. "
+ "You must use setSerializer to define conversion logic for custom value types. "
+ "Supported types are: "
+ Stream.concat(SUPPORTED_CLASS_TYPES.stream(),
SUPPORTED_COLLECTION_TYPES.stream()).map(Class::getName)
.collect(Collectors.joining(", "))
+ ". "
+ "For collections, the element type must be among the supported types.";
}
private static boolean isAssignableFromAny(List> types,
Class> type) {
return types.stream()
.anyMatch(candidate -> candidate.isAssignableFrom(type));
}
private JsonHandler> findJsonHandler(
CollaborationBindingBuilderImpl, ?, ?> builder,
PropertyDefinition, ?> propertyDefinition) {
if (builder.explicitJsonHandler != null) {
// Use explicitly defined handler if available
return builder.explicitJsonHandler;
}
if (!builder.typeIsConverted) {
Class> propertyType = propertyDefinition.getType();
if (isAssignableFromAny(SUPPORTED_COLLECTION_TYPES, propertyType)) {
/*
* Property is a Collection but it did not have explicit
* collection and element types defined
*/
throw new IllegalStateException(
"Cannot configure JSON serializer for '"
+ builder.propertyName + "' with type '"
+ propertyType.getName() + "'. For collection "
+ "types, you have to specify the type of the "
+ "collection and the type of the elements in "
+ "the collection. Use "
+ "CollaborationBinder::forField(field, "
+ "collectionType, elementType) to specify "
+ "these. For example, if you are binding a "
+ "List of String, you should call "
+ "forField(field, List.class, String.class).");
}
/*
* Can use the property type as long as there is no converter. A
* converter would imply that the bean type is not the same as the
* field type.
*/
return getTypeConfiguration(propertyType)
.orElseThrow(() -> new IllegalStateException(
"Cannot configure JSON serializer for "
+ builder.propertyName + ". "
+ createTypeNotSupportedMessage(
propertyType)));
}
throw new IllegalStateException(
"Could not infer field type for property '"
+ builder.propertyName
+ "'. Configure the property using an overload of forField or forMemberField that allows explicitly defining the field type.");
}
private void handlePropertyChange(PropertyChangeEvent event) {
getBinding(event.getPropertyName()).map(Binding::getField)
.ifPresent(field -> {
String propertyName = event.getPropertyName();
JsonNode value = JsonUtil.toJsonNode(event.getValue());
setFieldValueFromFieldState(field, propertyName,
value == null ? NullNode.getInstance() : value);
});
}
private Registration handleHighlight(HighlightContext context) {
if (!context.getUser().equals(localUser)) {
getBinding(context.getPropertyName()).map(Binding::getField)
.ifPresent(field -> fieldHighlighter.addEditor(field,
context.getUser(), context.getFieldIndex()));
return () -> getBinding(context.getPropertyName())
.map(Binding::getField)
.ifPresent(field -> fieldHighlighter.removeEditor(field,
context.getUser(), context.getFieldIndex()));
}
return null;
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private void setFieldValueFromFieldState(HasValue field,
String propertyName, JsonNode stateValue) {
if (stateValue instanceof NullNode) {
field.clear();
} else {
JsonHandler handler = propertyJsonHandlers.get(propertyName);
field.setValue(handler.deserialize(stateValue));
}
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private void setMapValueFromField(String propertyName, HasValue field) {
if (formManager != null) {
Object value;
if (field.isEmpty()) {
value = null;
} else {
JsonHandler handler = propertyJsonHandlers.get(propertyName);
value = handler.serialize(field.getValue());
}
formManager.setValue(propertyName, value);
}
}
void addEditor(String propertyName, int fieldIndex) {
if (formManager != null) {
formManager.highlight(propertyName, true, fieldIndex);
}
}
void removeEditor(String propertyName) {
if (formManager != null) {
formManager.highlight(propertyName, false);
}
}
@Override
protected void removeBindingInternal(Binding binding) {
// Registration should be removed first, so it can e.g. remove editors
// in map.
// If the attached component is removed from the context first and the
// connection is deactivated, registration removal can't update map
// value.
Registration registration = bindingRegistrations.remove(binding);
if (registration != null) {
registration.remove();
}
String propertyName = fieldToPropertyName.remove(binding.getField());
propertyJsonHandlers.remove(propertyName);
if (connectionContext != null) {
connectionContext.removeComponent((Component) binding.getField());
}
super.removeBindingInternal(binding);
}
@Override
protected BindingBuilder doCreateBinding(
HasValue, FIELDVALUE> field,
Converter converter,
BindingValidationStatusHandler handler) {
return new CollaborationBindingBuilderImpl<>(this, field, converter,
handler);
}
/**
* Not supported by the collaboration binder! It requires a property name
* for binding, so the other overload
* {@link CollaborationBinder#bind(HasValue, String)} should be used
* instead.
*
* See {@link Binder#bind(HasValue, ValueProvider, Setter)} to learn how to
* use the method with the regular (non-collaboration) binder.
*
* @param
* the value type of the field
* @param field
* the field to bind, not null
* @param getter
* the function to get the value of the property to the field,
* not null
* @param setter
* the function to write the field value to the property or
* null
if read-only
* @return the newly created binding
* @throws UnsupportedOperationException
* as the method is not supported by the collaboration binder
* @deprecated The method does not work with the collaboration binder. Use
* {@link CollaborationBinder#bind(HasValue, String)} instead.
*/
@Override
@Deprecated
public Binding bind(
HasValue, FIELDVALUE> field,
ValueProvider getter,
Setter setter) {
return super.bind(field, getter, setter);
}
/**
* Binds the given field to the property with the given name, as described
* in {@link Binder#bind(HasValue, String)}.
*
* In addition, synchronizes the values with other collaboration binder
* instances which are connected to the same topic.
*
* @param
* the value type of the field to bind
* @param field
* the field to bind, not null
* @param propertyName
* the name of the property to bind, not null
* @return the newly created binding
* @throws IllegalArgumentException
* if the property name is invalid
* @throws IllegalArgumentException
* if the property has no accessible getter
*/
@Override
public Binding bind(
HasValue, FIELDVALUE> field, String propertyName) {
return super.bind(field, propertyName);
}
/**
* Binds the member fields found in the given object, as described in
* {@link Binder#bindInstanceFields(Object)}.
*
* In addition, synchronizes the values with other collaboration binder
* instances which are connected to the same topic.
*
* @param objectWithMemberFields
* the object that contains (Java) member fields to bind
* @throws IllegalStateException
* if there are incompatible HasValue<T> and property
* types
*/
@Override
public void bindInstanceFields(Object objectWithMemberFields) {
super.bindInstanceFields(objectWithMemberFields);
}
/**
* @deprecated This operation is not supported by the collaboration binder.
* You can instead provide the bean for populating the fields
* using {@link #setTopic}, and write the values back to the
* bean using {@link #writeBean}.
*/
@Override
@Deprecated
public void setBean(BEAN bean) {
throw new UnsupportedOperationException(
"This operation is not supported by the collaboration binder. "
+ "You can instead provide the bean for populating "
+ "the fields with the setTopic method, and write the "
+ "values back to the bean with the writeBean method.");
}
/**
* @deprecated This operation, along with {@link #setBean(Object)}, is not
* supported by the collaboration binder. Instead of
* {@link #setBean(Object)}, you can provide the bean for
* populating the fields using {@link #setTopic}, and write the
* values back to the bean using {@link #writeBean}.
*/
@Override
@Deprecated
public BEAN getBean() {
return super.getBean();
}
/**
* @deprecated This operation is not supported by the collaboration binder.
* You can instead provide the bean for populating the fields
* using {@link #setTopic} to avoid overriding currently edited
* values. If you explicitly want to reset the field values for
* every user currently editing the fields, you can use
* {@link #reset}.
*/
@Override
@Deprecated
public void readBean(BEAN bean) {
throw new UnsupportedOperationException(
"This operation is not supported by the collaboration binder. "
+ "You can instead provide the bean for populating the fields "
+ "with the setTopic method to avoid overriding currently edited values. "
+ "If you explicitly want to reset the field values for every user "
+ "currently editing the fields, you can use the reset method.");
}
/**
* Resets collaborative fields with values from the bound properties of the
* given bean. The values will be propagated to all collaborating users.
*
* @param bean
* the bean whose property values to read or {@code null} to
* clear bound fields
* @since 1.0
*/
public void reset(BEAN bean) {
super.readBean(bean);
}
UserInfo getLocalUser() {
return localUser;
}
/**
* Sets the topic to use with this binder and initializes the topic contents
* if not already set. Setting a topic removes the connection to the
* previous topic (if any) and resets all bindings based on values in the
* new topic. The bean supplier is used to provide initial values for
* bindings in case the topic doesn't yet contain any values.
*
*
* @param topicId
* the topic id to use, or null
to not use any topic
* @param initialBeanSupplier
* a supplier that is invoked to get a bean from which to read
* initial values. Only invoked if there are no property values
* in the topic, or if the topic id is null
.
* @since 1.0
*/
public void setTopic(String topicId,
SerializableSupplier initialBeanSupplier) {
if (formManager != null) {
formManager.close();
formManager = null;
fieldToPropertyName.keySet()
.forEach(fieldHighlighter::removeEditors);
connectionContext = null;
}
if (topicId == null) {
super.readBean(initialBeanSupplier.get());
} else {
super.readBean(null);
connectionContext = new ComponentConnectionContext();
fieldToPropertyName.keySet().forEach(
field -> connectionContext.addComponent((Component) field));
formManager = new FormManager(connectionContext, localUser, topicId,
ce);
formManager.setActivationHandler(() -> {
initializeBindingsWithoutFieldState(initialBeanSupplier);
return null;
});
formManager.onConnectionFailed(
e -> super.readBean(initialBeanSupplier.get()));
formManager.setPropertyChangeHandler(this::handlePropertyChange);
formManager.setHighlightHandler(this::handleHighlight);
if (expirationTimeout != null) {
formManager.setExpirationTimeout(expirationTimeout);
}
}
}
private void initializeBindingsWithoutFieldState(
SerializableSupplier initialBeanSupplier) {
List propertiesWithoutFieldState = fieldToPropertyName.values()
.stream().filter(propertyName -> formManager
.getValue(propertyName, JsonNode.class) == null)
.collect(Collectors.toList());
if (propertiesWithoutFieldState.isEmpty()) {
return;
}
BEAN initialBean = initialBeanSupplier.get();
propertiesWithoutFieldState.stream().map(this::getBinding)
.filter(Optional::isPresent).map(Optional::get)
.forEach(binding -> {
if (initialBean == null) {
binding.getField().clear();
} else {
binding.read(initialBean);
}
});
}
/**
* {@inheritDoc}
*
* The field value will be sent over the network to synchronize the value
* with other users also editing the same field. The value type to use for
* deserializing the value is automatically determined based on the bean
* property type. The type must be defined separately using another overload
* of this method in case a converter is used or if the property type is
* parameterized.
*
* @see #forField(HasValue, Class)
* @see #forField(HasValue, Class, Class)
*/
@Override
public BindingBuilder forField(
HasValue, FIELDVALUE> field) {
// Overridden only to supplement documentation
return super.forField(field);
}
/**
* Creates a new binding for the given field and type. The returned builder
* may be further configured before invoking
* {@link BindingBuilder#bind(String)} which completes the binding. Until
* {@code Binding.bind} is called, the binding has no effect.
*
* The field value will be sent over the network to synchronize the value
* with other users also editing the same field. This method allows
* explicitly defining the type to use. This is necessary when a converter
* is used since it's then not possible to derive the type from the bean
* property.
*
* @see #forField(HasValue)
* @see #forField(HasValue, Class, Class)
* @see #setSerializer(Class, SerializableFunction, SerializableFunction)
*
* @param
* the value type of the field
* @param field
* the field to be bound, not null
* @param fieldType
* the type of the field value, not null
* @return the new binding builder
*/
public BindingBuilder forField(
HasValue, FIELDVALUE> field, Class fieldType) {
getTypeConfigurationOrThrow(fieldType).store(field);
return forField(field);
}
/**
* Creates a new binding for the given (multi select) field whose value type
* is a collection. The returned builder may be further configured before
* invoking {@link BindingBuilder#bind(String)} which completes the binding.
* Until {@code Binding.bind} is called, the binding has no effect.
*
* The field value will be sent over the network to synchronize the value
* with other users also editing the same field. This method allows
* explicitly defining the collection type and element type to use.
*
* @see #forField(HasValue)
* @see #forField(HasValue, Class)
* @see #setSerializer(Class, SerializableFunction, SerializableFunction)
*
* @param
* the base type of the collection, e.g. {@code Set} for
* {@code CheckboxGroup}
* @param
* the type of the elements in the collection, e.g.
* {@code String} for {@code CheckboxGroup}
* @param field
* the field to be bound, not null
* @param collectionType
* the base type of the collection, e.g. {@code Set.class} for
* {@code CheckboxGroup}, not null
* @param elementType
* the type of the elements in the collection, e.g.
* {@code String.class} for {@code CheckboxGroup}, not
* null
* @return the new binding builder
*/
public , ELEMENT> BindingBuilder forField(
HasValue, FIELDVALUE> field,
Class super FIELDVALUE> collectionType,
Class elementType) {
getTypeConfiguration(collectionType, elementType).store(field);
return forField(field);
}
/**
* {@inheritDoc}
*
* The field value will be sent over the network to synchronize the value
* with other users also editing the same field. The value type to use for
* deserializing the value is automatically determined based on the bean
* property type. The type must be defined separately using another overload
* of this method in case a converter is used or if the property type is
* parameterized.
*
* @see #forMemberField(HasValue, Class)
* @see #forMemberField(HasValue, Class, Class)
*/
@Override
public BindingBuilder forMemberField(
HasValue, FIELDVALUE> field) {
// Overridden only to supplement documentation
return super.forMemberField(field);
}
/**
* Creates a new binding for the given field and type. The returned builder
* may be further configured before invoking
* {@link #bindInstanceFields(Object)}. Unlike with the
* {@link #forField(HasValue)} method, no explicit call to
* {@link BindingBuilder#bind(String)} is needed to complete this binding in
* the case that the name of the field matches a field name found in the
* bean.
*
* The field value will be sent over the network to synchronize the value
* with other users also editing the same field. This method allows
* explicitly defining the type to use. This is necessary when a converter
* is used since it's then not possible to derive the type from the bean
* property.
*
* @see #forMemberField(HasValue)
* @see #forMemberField(HasValue, Class, Class)
* @see #setSerializer(Class, SerializableFunction, SerializableFunction)
*
* @param
* the value type of the field
* @param field
* the field to be bound, not null
* @param fieldType
* @return the new binding builder
*
* @since 1.0
*/
public BindingBuilder forMemberField(
HasValue, FIELDVALUE> field, Class fieldType) {
getTypeConfigurationOrThrow(fieldType).store(field);
return forMemberField(field);
}
/**
* Creates a new binding for the given (multi select) field whose value type
* is a collection. The returned builder may be further configured before
* invoking {@link #bindInstanceFields(Object)}. Unlike with the
*
* {@link #forField(HasValue)} method, no explicit call to
* {@link BindingBuilder#bind(String)} is needed to complete this binding in
* the case that the name of the field matches a field name found in the
* bean.
*
* The field value will be sent over the network to synchronize the value
* with other users also editing the same field. This method allows
* explicitly defining the collection type and element type to use.
*
* @see #forMemberField(HasValue)
* @see #forMemberField(HasValue, Class)
* @see #setSerializer(Class, SerializableFunction, SerializableFunction)
*
* @param
* the base type of the collection, e.g. {@code Set} for
* {@code CheckboxGroup}
* @param
* the type of the elements in the collection, e.g.
* {@code String} for {@code CheckboxGroup}
* @param field
* the field to be bound, not null
* @param collectionType
* the base type of the collection, e.g. {@code Set.class} for
* {@code CheckboxGroup}, not null
* @param elementType
* the type of the elements in the collection, e.g.
* {@code String.class} for {@code CheckboxGroup}, not
* null
* @return the new binding builder
*
* @since 1.0
*/
public , ELEMENT> BindingBuilder forMemberField(
HasValue, FIELDVALUE> field,
Class super FIELDVALUE> collectionType,
Class elementType) {
getTypeConfiguration(collectionType, elementType).store(field);
return forMemberField(field);
}
/**
* Sets a custom serializer and deserializer to use for a specific value
* type. The serializer and deserializer will be used for all field bindings
* that implicitly or explicitly use that type either as the field type or
* as the collection element type in case of multi select fields. It is not
* allowed to reconfigure the serializer and deserializer for a previously
* configued type nor for any of the basic types that are supported without
* custom logic.
*
* Field values will be sent over the network to synchronize the value with
* other users also editing the same field. This method allows defining
* callbacks to convert between the field value and the value that is sent
* over the network. This is necessary when using complex objects that are
* not suitable to be sent as-is over the network.
*
* @param
* the type handled by the serializer
* @param type
* the type for which to set a serializer and deserializer, not
* null
* @param serializer
* a callback that receives a non-empty field value and returns
* the value to send over the network (not null
).
* The callback cannot be null
.
* @param deserializer
* a callback that receives a value produced by the serializer
* callback (not null
) and returns the field value
* to use. The callback cannot be null
.
* @since 1.0
*/
public void setSerializer(Class type,
SerializableFunction serializer,
SerializableFunction deserializer) {
Objects.requireNonNull(type, "Type cannot be null");
Objects.requireNonNull(serializer, "Serializer cannot be null");
Objects.requireNonNull(deserializer, "Deserializer cannot be null");
/*
* Cannot allow changing an existing serializer on the fly because we
* might then not be able to deserialize an existing value
*/
if (isAssignableFromAny(SUPPORTED_CLASS_TYPES, type)
|| isAssignableFromAny(SUPPORTED_COLLECTION_TYPES, type)) {
throw new IllegalArgumentException(
"Cannot set a custom serializer for a type that has built-in support.");
}
if (typeConfigurations.containsKey(type)) {
throw new IllegalStateException(
"Serializer has already been set for the type "
+ type.getName() + ".");
}
typeConfigurations.put(type,
new JsonHandler(
value -> new TextNode(serializer.apply(value)),
jsonNode -> deserializer.apply(jsonNode.asText())));
}
private Optional> getTypeConfiguration(
Class fieldType) {
@SuppressWarnings("unchecked")
JsonHandler configuration = (JsonHandler) typeConfigurations
.get(fieldType);
if (configuration != null) {
return Optional.of(configuration);
} else if (isSupportedType(fieldType)) {
return Optional.of(JsonHandler.forBasicType(fieldType));
} else {
return Optional.empty();
}
}
private JsonHandler getTypeConfigurationOrThrow(
Class fieldType) {
return getTypeConfiguration(fieldType)
.orElseThrow(() -> new IllegalStateException(
createTypeNotSupportedMessage(fieldType)));
}
private > JsonHandler getTypeConfiguration(
Class super FIELDVALUE> collectionType,
Class elementType) {
if (!isAssignableFromAny(SUPPORTED_COLLECTION_TYPES, collectionType)) {
throw new IllegalArgumentException(collectionType
+ " is not supported as a collection. Must use a type assignable to one of "
+ SUPPORTED_COLLECTION_TYPES);
}
JsonHandler elementConfiguration = getTypeConfigurationOrThrow(
elementType);
return new JsonHandler<>(collection -> {
ArrayNode arrayNode = JsonUtil.getObjectMapper().createArrayNode();
collection.forEach(element -> arrayNode
.add(elementConfiguration.serialize(element)));
return arrayNode;
}, json -> {
/*
* Deserialize an empty array of the expected type to reuse
* Jackson's logic for creating arbitrary collections.
*/
@SuppressWarnings("unchecked")
FIELDVALUE collection = (FIELDVALUE) JsonUtil.toInstance(
JsonUtil.getObjectMapper().createArrayNode(),
collectionType);
/*
* Then deserialize each element according to our json handler and
* add it to the collection
*/
((ArrayNode) json).forEach(elementJson -> collection
.add(elementConfiguration.deserialize(elementJson)));
return collection;
});
}
/**
* Gets the optional expiration timeout of this binder. An empty
* {@link Optional} is returned if no timeout is set, which means the binder
* is not cleared when there are no connected users to the related topic
* (this is the default).
*
* @return the expiration timeout
*
* @since 3.1
*/
@Override
public Optional getExpirationTimeout() {
return Optional.ofNullable(expirationTimeout);
}
/**
* Sets the expiration timeout of this binder. If set, this binder data is
* cleared when {@code expirationTimeout} has passed after the last
* connection to the related topic is closed. If set to {@code null}, the
* timeout is cancelled.
*
* @param expirationTimeout
* the expiration timeout
*
* @since 3.1
*/
@Override
public void setExpirationTimeout(Duration expirationTimeout) {
this.expirationTimeout = expirationTimeout;
if (formManager != null) {
formManager.setExpirationTimeout(expirationTimeout);
}
}
}