com.vaadin.collaborationengine.FormManager 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.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.type.TypeReference;
import com.vaadin.collaborationengine.HighlightHandler.HighlightContext;
import com.vaadin.collaborationengine.PropertyChangeHandler.PropertyChangeEvent;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.internal.UsageStatistics;
import com.vaadin.flow.shared.Registration;
/**
* Manager to handle form values and field highlight state. It allows to set a
* value for a property and toggle the highlight of a property for a user.
* Handlers can be set to react when a value changes or when the highlight
* status changes.
*
* @author Vaadin Ltd
*/
public class FormManager extends AbstractCollaborationManager
implements HasExpirationTimeout {
/**
* Maps the focused user to the index of the focused element inside the
* field. The index is needed for components such as radio button group,
* where the highlight should be displayed on an individual radio button
* inside the group.
*/
static final class FocusedEditor {
public final UserInfo user;
public final int fieldIndex;
public final String propertyName;
@JsonCreator
public FocusedEditor(@JsonProperty("user") UserInfo user,
@JsonProperty("fieldIndex") int fieldIndex,
@JsonProperty("propertyName") String propertyName) {
this.user = user;
this.fieldIndex = fieldIndex;
this.propertyName = propertyName;
}
@Override
public boolean equals(Object other) {
if (other instanceof FocusedEditor) {
FocusedEditor otherEditor = (FocusedEditor) other;
return Objects.equals(otherEditor.user.getId(), user.getId())
&& Objects.equals(otherEditor.propertyName,
propertyName);
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(user.getId(), propertyName);
}
}
private static class UserEntry {
private int count = 0;
private Registration registration;
}
static {
UsageStatistics.markAsUsed(
CollaborationEngine.COLLABORATION_ENGINE_NAME + "/FormManager",
CollaborationEngine.COLLABORATION_ENGINE_VERSION);
}
static final String COLLECTION_NAME = FormManager.class.getName();
private CollaborationMap map;
private CollaborationList list;
private final Map userEntries = new LinkedHashMap<>();
private PropertyChangeHandler propertyChangeHandler;
private HighlightHandler highlightHandler;
private Registration subscribeRegistration;
private Duration expirationTimeout;
/**
* Creates a new manager for the provided connection context.
*
* The provided user information is used to set the highlight for the local
* user with {@link #highlight(String,boolean)} or
* {@link #highlight(String,boolean,int)} (the default is {@code false}).
*
* @param context
* the context that manages connection status, not {@code null}
* @param localUser
* the information of the local user, not {@code null}
* @param topicId
* the id of the topic to connect to, not {@code null}
* @param collaborationEngine
* the collaboration engine instance to use, not {@code null}
*/
public FormManager(ConnectionContext context, UserInfo localUser,
String topicId, CollaborationEngine collaborationEngine) {
super(localUser, topicId, collaborationEngine);
openTopicConnection(context, this::onConnectionActivate);
}
/**
* Creates a new manager for the provided component.
*
* The provided user information is used to set the highlight for the local
* user with {@link #highlight(String,boolean)} or
* {@link #highlight(String,boolean,int)} (the default is {@code false}).
*
* @param component
* the component which holds UI access, not {@code null}
* @param localUser
* the information of the local user, not {@code null}
* @param topicId
* the id of the topic to connect to, not {@code null}
*/
public FormManager(Component component, UserInfo localUser,
String topicId) {
this(new ComponentConnectionContext(component), localUser, topicId,
CollaborationEngine.getInstance());
}
/**
* Sets the value for the provided property name.
*
* @param propertyName
* the name of the property that is being set, not {@code null}
* @param value
* the value to set
*/
public void setValue(String propertyName, Object value) {
Objects.requireNonNull(propertyName, "Property name can't be null.");
if (map != null) {
map.put(propertyName, value);
}
}
T getValue(String propertyName, Class type) {
if (map != null) {
return map.get(propertyName, type);
} else {
return null;
}
}
T getValue(String propertyName, TypeReference typeReference) {
if (map != null) {
return map.get(propertyName, typeReference);
} else {
return null;
}
}
/**
* Sets the highlight state of the provided property name for the local
* user. For properties that require a field index, this defaults to 0.
*
* @param propertyName
* the name of the property to highlight or remove highlight, not
* {@code null}
* @param highlight
* the highlight state to set
*/
public void highlight(String propertyName, boolean highlight) {
highlight(propertyName, highlight, getLocalUser());
}
/**
* Sets the highlight state at the provided field index of the provided
* property name for the local user.
*
* @param propertyName
* the name of the property to highlight or unhighlight, not
* {@code null}
* @param highlight
* the highlight state to set
* @param fieldIndex
* the field index to highlight or unhighlight
*/
public void highlight(String propertyName, boolean highlight,
int fieldIndex) {
highlight(propertyName, highlight, getLocalUser(), fieldIndex);
}
/**
* Sets the highlight state of the provided property name for the provided
* user. For properties that require a field index, this defaults to 0.
*
* @param propertyName
* the name of the property to highlight or unhighlight, not
* {@code null}
* @param highlight
* the highlight state to set
* @param user
* the user that the highlight corresponds to, not {@code null}
*/
void highlight(String propertyName, boolean highlight, UserInfo user) {
highlight(propertyName, highlight, user, 0);
}
/**
* Sets the highlight state at the provided field index of the provided
* property name for the provided user.
*
* @param propertyName
* the name of the property to highlight or unhighlight, not
* {@code null}
* @param highlight
* the highlight states to set
* @param user
* the user that the highlight corresponds to, not {@code null}
* @param fieldIndex
* the field index to highlight or unhighlight
*/
void highlight(String propertyName, boolean highlight, UserInfo user,
int fieldIndex) {
Objects.requireNonNull(propertyName, "Property name can't be null.");
Objects.requireNonNull(user, "User can't be null.");
if (list != null) {
if (highlight) {
ListOperation operation = ListOperation.insertLast(
new FocusedEditor(user, fieldIndex, propertyName))
.withScope(EntryScope.CONNECTION);
list.apply(operation);
} else {
list.getKeys().forEach(key -> {
FocusedEditor editor = list.getItem(key,
FocusedEditor.class);
if (editor.propertyName.equals(propertyName)
&& editor.user.equals(user)) {
ListOperation operation = ListOperation.set(key, null)
.withScope(EntryScope.CONNECTION);
list.apply(operation);
}
});
}
}
}
boolean isHighlight(String propertyName) {
if (list != null) {
for (FocusedEditor editor : list.getItems(FocusedEditor.class)) {
if (editor.propertyName.equals(propertyName)) {
return true;
}
}
}
return false;
}
/**
* Sets a handler which will be invoked when a property changes value.
*
* The handler accepts a {@link PropertyChangeEvent} instance as a
* parameter.
*
* @param handler
* the property change handler, or {@code null} to remove an
* existing handler
*/
public void setPropertyChangeHandler(PropertyChangeHandler handler) {
propertyChangeHandler = handler;
if (handler != null && map != null) {
map.subscribe(this::onMapChange);
}
}
/**
* Sets a handler which will be invoked when a highlight is set.
*
* The handler accepts a {@link HighlightContext} instance as a parameter
* and should return a {@link Registration} which will be removed when the
* highlight is toggled off.
*
* Replacing an existing handler will remove all registrations from the
* previous one.
*
* @param handler
* the highlight handler, or {@code null} to remove an existing
* handler
*/
public void setHighlightHandler(HighlightHandler handler) {
resetEntries();
highlightHandler = handler;
if (handler != null && list != null) {
subscribeRegistration = list.subscribe(this::onListChange);
}
}
/**
* Sets the expiration timeout of the field property data. If set, 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
*/
@Override
public void setExpirationTimeout(Duration expirationTimeout) {
this.expirationTimeout = expirationTimeout;
if (map != null) {
map.setExpirationTimeout(expirationTimeout);
}
}
/**
* Gets the optional expiration timeout of the field property data. An empty
* {@link Optional} is returned if no timeout it set, which means data is
* not cleared when there are no connected users to the related topic.
*
* @return the expiration timeout
*/
@Override
public Optional getExpirationTimeout() {
return Optional.ofNullable(expirationTimeout);
}
private Registration onConnectionActivate(TopicConnection topicConnection) {
map = topicConnection.getNamedMap(COLLECTION_NAME);
map.subscribe(this::onMapChange);
list = topicConnection.getNamedList(COLLECTION_NAME);
if (this.highlightHandler != null && subscribeRegistration == null) {
subscribeRegistration = list.subscribe(this::onListChange);
}
if (expirationTimeout != null) {
map.setExpirationTimeout(expirationTimeout);
}
return this::onConnectionDeactivate;
}
private void onConnectionDeactivate() {
map = null;
list = null;
resetEntries();
}
private void onMapChange(MapChangeEvent event) {
applyPropertyChangeHandler(event.getKey(),
event.getValue(Object.class));
}
private void onListChange(ListChangeEvent event) {
switch (event.getType()) {
case INSERT:
FocusedEditor editor = event.getValue(FocusedEditor.class);
handleNewHighlight(editor);
break;
case SET:
if (event.getValue(FocusedEditor.class) == null) {
FocusedEditor oldEditor = event
.getOldValue(FocusedEditor.class);
handleRemovedHighlight(oldEditor);
} else {
// Unexpected, but no problem in ignoring
}
break;
case MOVE:
// Unexpected, but no problem in ignoring
}
}
private void applyPropertyChangeHandler(String propertyName, Object value) {
if (propertyChangeHandler != null) {
PropertyChangeHandler.PropertyChangeEvent event = new DefaultPropertyChangeEvent(
propertyName, value);
propertyChangeHandler.handlePropertyChange(event);
}
}
private void handleNewHighlight(FocusedEditor editor) {
UserEntry userEntry = userEntries.computeIfAbsent(editor,
ignore -> new UserEntry());
if (userEntry.count++ == 0) {
if (highlightHandler != null) {
assert userEntry.registration == null;
userEntry.registration = highlightHandler.handleHighlight(
new DefaultHighlightContext(editor.user,
editor.propertyName, editor.fieldIndex));
}
}
}
private void handleRemovedHighlight(FocusedEditor editor) {
UserEntry userEntry = userEntries.get(editor);
assert userEntry != null;
if (--userEntry.count == 0) {
removeRegistration(userEntry);
userEntries.remove(editor);
}
}
private void removeRegistration(UserEntry entry) {
Registration registration = entry.registration;
if (registration != null) {
registration.remove();
entry.registration = null;
}
}
private void resetEntries() {
if (subscribeRegistration != null) {
subscribeRegistration.remove();
subscribeRegistration = null;
}
userEntries.values().forEach(this::removeRegistration);
userEntries.clear();
}
static class DefaultPropertyChangeEvent implements PropertyChangeEvent {
private final String propertyName;
private final Object value;
public DefaultPropertyChangeEvent(String propertyName, Object value) {
this.propertyName = propertyName;
this.value = value;
}
@Override
public String getPropertyName() {
return propertyName;
}
@Override
public Object getValue() {
return value;
}
}
static class DefaultHighlightContext implements HighlightContext {
private final UserInfo user;
private final String propertyName;
private final int fieldIndex;
public DefaultHighlightContext(UserInfo user, String propertyName,
int fieldIndex) {
this.user = user;
this.propertyName = propertyName;
this.fieldIndex = fieldIndex;
}
@Override
public UserInfo getUser() {
return user;
}
@Override
public String getPropertyName() {
return propertyName;
}
@Override
public int getFieldIndex() {
return fieldIndex;
}
}
}