io.devbench.uibuilder.components.multivalue.UIBuilderMultiValue Maven / Gradle / Ivy
Show all versions of uibuilder-multi-value Show documentation
/*
*
* Copyright © 2018 Webvalto 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 io.devbench.uibuilder.components.multivalue;
import com.vaadin.flow.component.*;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.data.binder.HasItems;
import com.vaadin.flow.dom.DisabledUpdateMode;
import com.vaadin.flow.function.SerializableBiFunction;
import com.vaadin.flow.internal.StateNode;
import com.vaadin.flow.internal.nodefeature.ElementData;
import com.vaadin.flow.internal.nodefeature.VirtualChildrenList;
import elemental.json.Json;
import elemental.json.JsonArray;
import elemental.json.JsonObject;
import elemental.json.JsonValue;
import io.devbench.uibuilder.api.components.HasItemType;
import io.devbench.uibuilder.api.listeners.BackendAttachListener;
import io.devbench.uibuilder.api.utils.PropertyPath;
import io.devbench.uibuilder.components.crudpanel.UIBuilderCrudPanel;
import io.devbench.uibuilder.components.multivalue.exception.IllegalMultiValueItemInstantiationException;
import io.devbench.uibuilder.components.multivalue.exception.IllegalMultiValueItemsStateException;
import io.devbench.uibuilder.core.controllerbean.uiproperty.PropertyConverter;
import io.devbench.uibuilder.core.controllerbean.uiproperty.PropertyConverters;
import io.devbench.uibuilder.core.page.Page;
import io.devbench.uibuilder.core.utils.ElementCollector;
import io.devbench.uibuilder.core.utils.HtmlElementAwareComponent;
import io.devbench.uibuilder.core.utils.JsonSerializer;
import io.devbench.uibuilder.core.utils.reflection.ClassMetadata;
import io.devbench.uibuilder.core.utils.reflection.PropertyMetadata;
import io.devbench.uibuilder.data.collectionds.datasource.component.AbstractDataSourceComponent;
import lombok.AccessLevel;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import static io.devbench.uibuilder.components.multivalue.UIBuilderMultiValueParseInterceptor.*;
@Slf4j
@Tag("uibuilder-multi-value")
@JsModule("./uibuilder-multi-value/src/uibuilder-multi-value.js")
public class UIBuilderMultiValue extends AbstractSinglePropertyField, List> implements
HasItems, HasComponents, HasItemType, BackendAttachListener {
private static final String PROP_NAME_ITEM_SUPPLIER = "itemSupplier";
private static final String PROP_NAME_VALUE_ITEM_PATH = "valueItemPath";
private static final String EVENT_DETAIL_INDEX = "event.detail.index";
private static final String EVENT_DETAIL_TEMPLATE_HTML_CONTENT = "event.detail.templateHtmlContent";
private static final String EVENT_DETAIL_INNER_BINDINGS = "event.detail.innerBindings";
private static final String EVENT_DETAIL_INNER_BINDINGS_ELEMENT_ID_MAP = "event.detail.innerBindingsElementIdMap";
private static final String EVENT_DETAIL_PROPERTY_PATH = "event.detail.propertyPath";
private static final String EVENT_DETAIL_PROPERTY_VALUE = "event.detail.propertyValue";
private static final String EVENT_DETAIL_PROPERTY_ELEMENT_ID = "event.detail.propertyElementId";
private static final String EVENT_DETAIL_USER_ADDED = "event.detail.userAdded";
private static final com.vaadin.flow.component.PropertyDescriptor PROP_ITEM_SUPPLIER =
PropertyDescriptors.propertyWithDefault(PROP_NAME_ITEM_SUPPLIER, "");
private static final com.vaadin.flow.component.PropertyDescriptor PROP_VALUE_ITEM_PATH =
PropertyDescriptors.propertyWithDefault(PROP_NAME_VALUE_ITEM_PATH, DEFAULT_VALUE_ITEM_PATH);
@SuppressWarnings({"unchecked", "rawtypes"})
private static final PropertyConverter SUPPLIER_CONVERTER =
(PropertyConverter) PropertyConverters.getConverterByType(Supplier.class);
@Setter(AccessLevel.PACKAGE)
private UIBuilderMultiValueBindingContext bindingContext;
@Setter(AccessLevel.PACKAGE)
private String parentCrudElementId;
@Setter(AccessLevel.PACKAGE)
private String parentCrudElementBindingPropertyPath;
private List items;
private Class> itemType;
public UIBuilderMultiValue() {
super("value", new ArrayList<>(), JsonArray.class,
(SerializableBiFunction, JsonArray, List>) (component, presentation) ->
PropertyConverters.convertToObjectList(component.getValueType(), JsonSerializer.toObjects(String.class, presentation)),
(SerializableBiFunction, List, JsonArray>) (component, list) ->
JsonSerializer.toJson(PropertyConverters.convertToStringList(list)));
}
@Synchronize(property = PROP_NAME_VALUE_ITEM_PATH, value = {"value-changed"}, allowUpdates = DisabledUpdateMode.ALWAYS)
public String getValueItemPath() {
return get(PROP_VALUE_ITEM_PATH);
}
public void setValueItemPath(String valueItemPath) {
set(PROP_VALUE_ITEM_PATH, valueItemPath);
}
@SuppressWarnings("unchecked")
@Synchronize(property = PROP_NAME_ITEM_SUPPLIER, value = {"instance-added"}, allowUpdates = DisabledUpdateMode.ALWAYS)
public Supplier getItemSupplier() {
String supplierId = get(PROP_ITEM_SUPPLIER);
return StringUtils.isNotBlank(supplierId) ? SUPPLIER_CONVERTER.convertFrom(supplierId) : createDefaultItemSupplier();
}
public void setItemSupplier(Supplier supplier) {
set(PROP_ITEM_SUPPLIER, SUPPLIER_CONVERTER.convertTo(supplier != null ? supplier : createDefaultItemSupplier()));
}
private Supplier createDefaultItemSupplier() {
return () -> {
if (hasItemType()) {
try {
return this.getItemType().getDeclaredConstructor().newInstance();
} catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
throw new IllegalMultiValueItemInstantiationException("Could not create item instance", e);
}
} else {
return null;
}
};
}
public List getValues(Class valueType) {
return PropertyConverters.convertToObjectList(valueType,
getValue().stream().map(t -> t == null ? null : t.toString()).collect(Collectors.toList()));
}
@SuppressWarnings("unchecked")
public Class getItemType() {
if (itemType == null && parentCrudElementId != null && parentCrudElementBindingPropertyPath != null) {
Class> crudClassType = UIBuilderCrudPanel.findCrudClassType(parentCrudElementId);
if (crudClassType != null) {
ClassMetadata> classMetadata = ClassMetadata.ofClass(crudClassType);
Optional> foundProperty = classMetadata.property(parentCrudElementBindingPropertyPath);
foundProperty.flatMap(PropertyMetadata::getFirstParameterizedType).ifPresent(paramType -> itemType = paramType);
}
}
return (Class) itemType;
}
public void setItemType(Class> itemType) {
this.itemType = itemType;
}
@SuppressWarnings("unchecked")
Class getValueType() {
if (getItemType() != null) {
ClassMetadata itemTypeMetadata = ClassMetadata.ofClass(getItemType());
String valueItemPath = getValueItemPath();
Optional> foundValueItemPathProperty = itemTypeMetadata.property(valueItemPath);
if (foundValueItemPathProperty.isPresent()) {
return (Class) foundValueItemPathProperty.get().getType();
}
}
return (Class) String.class;
}
private boolean hasItemType() {
return getItemType() != null;
}
@Override
public void onAttached() {
attachInstanceAddedEvent();
attachInstanceRemovedEvent();
attachInstanceChangedEvent();
}
private void attachInstanceChangedEvent() {
getElement().addEventListener("instance-changed", event -> {
if (hasItemType()) {
JsonObject eventData = event.getEventData();
int index = (int) eventData.getNumber(EVENT_DETAIL_INDEX);
if (index >= getItems().size()) {
throw new IllegalMultiValueItemsStateException(index, getItems().size());
}
PropertyPath propertyPath = new PropertyPath(eventData.getString(EVENT_DETAIL_PROPERTY_PATH));
if (!propertyPath.isSingleLevel()) {
String propertyValue = JsonSerializer.toObject(String.class, eventData.get(EVENT_DETAIL_PROPERTY_VALUE));
String path = propertyPath.subPath(1).toString();
T item = getItems().get(index);
ClassMetadata itemMetadata = ClassMetadata.ofValue(item);
itemMetadata.getProperties().stream().filter(propertyMetadata -> propertyMetadata.getName().equals(path)).findFirst()
.ifPresent(propertyMetadata -> {
handleBackendComponentValueChange(eventData.getString(EVENT_DETAIL_PROPERTY_ELEMENT_ID),
propertyMetadata::setValue,
() -> {
try {
@SuppressWarnings("unchecked")
PropertyConverter, String> converterFor =
(PropertyConverter, String>) PropertyConverters.getConverterFor(propertyMetadata);
Object value = converterFor.convertFrom(propertyValue);
propertyMetadata.setValue(value);
} catch (Exception e) {
log.warn("Could not set illegal property ({}) value ({}) for item (index: {})", path, propertyValue, index, e);
}
});
});
}
}
}).addEventData(EVENT_DETAIL_INDEX)
.addEventData(EVENT_DETAIL_PROPERTY_PATH)
.addEventData(EVENT_DETAIL_PROPERTY_VALUE)
.addEventData(EVENT_DETAIL_PROPERTY_ELEMENT_ID);
}
private void handleBackendComponentValueChange(String elementId, Consumer backendValue, Runnable nonBackendValue) {
@SuppressWarnings("unchecked")
Optional> hasValue = Optional.ofNullable(elementId)
.flatMap(ElementCollector::getById)
.map(HtmlElementAwareComponent::getComponent)
.filter(HasValue.class::isInstance)
.map(HasValue.class::cast);
if (hasValue.isPresent()) {
backendValue.accept(hasValue.get().getValue());
} else {
nonBackendValue.run();
}
}
private void attachInstanceRemovedEvent() {
getElement().addEventListener("instance-removed", event -> {
if (hasItemType()) {
JsonObject eventData = event.getEventData();
int index = (int) eventData.getNumber(EVENT_DETAIL_INDEX);
List items = getItems();
if (items.size() > index) {
items.remove(index);
}
removeAttachedVirtualChildrenByIndex(index);
bindingContext.removeBackendComponentValueSuppliersAndConsumers(index);
}
}).addEventData(EVENT_DETAIL_INDEX);
}
private synchronized void removeAttachedVirtualChildrenByIndex(int index) {
String indexSuffix = "-index-" + index;
VirtualChildrenList virtualChildrenList = getElement().getNode().getFeature(VirtualChildrenList.class);
List stateNodeToRemove = new ArrayList<>();
virtualChildrenList.forEachChild(stateNode -> {
ElementData elementData = stateNode.getFeature(ElementData.class);
String id = getIdFromElementDataPayload(elementData);
if (id != null && id.endsWith(indexSuffix)) {
stateNodeToRemove.add(stateNode);
}
});
stateNodeToRemove.forEach(stateNode -> {
int stateNodeIndex = virtualChildrenList.indexOf(stateNode);
virtualChildrenList.remove(stateNodeIndex);
});
}
private String getIdFromElementDataPayload(@NotNull ElementData elementData) {
JsonValue payload = elementData.getPayload();
if (payload instanceof JsonObject) {
JsonObject payloadObject = (JsonObject) payload;
if (payloadObject.hasKey("type") && payloadObject.hasKey("payload") && payloadObject.getString("type").equals("@id")) {
return payloadObject.getString("payload");
}
}
return null;
}
private void attachInstanceAddedEvent() {
getElement().addEventListener("instance-added", event -> {
JsonObject eventData = event.getEventData();
int index = (int) eventData.getNumber(EVENT_DETAIL_INDEX);
boolean userAdded = eventData.getBoolean(EVENT_DETAIL_USER_ADDED);
String templateHtmlContent = eventData.getString(EVENT_DETAIL_TEMPLATE_HTML_CONTENT);
List innerBindings = JsonSerializer.toObjects(String.class, eventData.getArray(EVENT_DETAIL_INNER_BINDINGS));
Page.findComponentParentPage(this)
.ifPresent(parentPage -> parentPage.processFragment(templateHtmlContent, this, innerBindings));
T pushItem = null;
if (userAdded) {
T newItem = getItemSupplier().get();
if (hasItemType()) {
getItems().add(index, newItem);
}
pushItem = newItem;
} else {
if (hasItemType()) {
pushItem = getItems().get(index);
}
}
registerPossibleBackendComponentValueSuppliers(index, eventData.getObject(EVENT_DETAIL_INNER_BINDINGS_ELEMENT_ID_MAP));
if (pushItem != null) {
getElement().callJsFunction("_pushItemValue", index, itemToJson(index, pushItem));
}
}).addEventData(EVENT_DETAIL_USER_ADDED)
.addEventData(EVENT_DETAIL_INDEX)
.addEventData(EVENT_DETAIL_TEMPLATE_HTML_CONTENT)
.addEventData(EVENT_DETAIL_INNER_BINDINGS)
.addEventData(EVENT_DETAIL_INNER_BINDINGS_ELEMENT_ID_MAP);
}
@SuppressWarnings("unchecked")
private void registerPossibleBackendComponentValueSuppliers(int index, JsonObject innerBindingsElementIdMap) {
for (String propertyPath : innerBindingsElementIdMap.keys()) {
String elementId = innerBindingsElementIdMap.getString(propertyPath);
if (StringUtils.isNotBlank(elementId)) {
ElementCollector.getById(elementId)
.map(HtmlElementAwareComponent::getComponent)
.filter(HasValue.class::isInstance)
.map(HasValue.class::cast)
.ifPresent(hasValue -> {
Supplier> valueSupplier = hasValue instanceof AbstractDataSourceComponent
? createDataSourceComponentValueSupplier(hasValue)
: hasValue::getValue;
Consumer> valueInitializer = item -> {
ClassMetadata.ofValue(item)
.property(new PropertyPath(propertyPath).level(1).toString())
.ifPresent(propertyMetadata -> hasValue.setValue(propertyMetadata.getValue()));
};
bindingContext.registerBackendComponentValueSupplier(index, propertyPath, valueSupplier, valueInitializer);
});
}
}
}
@SuppressWarnings("unchecked")
private Supplier> createDataSourceComponentValueSupplier(HasValue, ?> hasValue) {
AbstractDataSourceComponent
component = (AbstractDataSourceComponent
) hasValue;
return () -> {
P value = (P) hasValue.getValue();
if (value == null) {
return null;
} else {
return component
.mapDataSource(
ds -> ds.getDataProcessor().getKeyMapper().getKey(ClassMetadata.ofValue(value)),
null)
.orElse(null);
}
};
}
public List getItems() {
if (items == null) {
items = new ArrayList<>();
}
return items;
}
/**
* Sets the items to handle by the component
*
* @param items is a collection of the items to handle. If items is a List, the list will be passed directly,
* and the original List instance will be handled. If the list is an other type of collection,
* for example a Set, the set will be wrapped in a list, and the original collection is untouched,
* the modifications will NOT be reflected to the original instance.
*/
public void setItems(Collection items) {
if (items instanceof List) {
this.items = (List) items;
} else if (items == null) {
this.items = new ArrayList<>();
} else {
this.items = new ArrayList<>(items);
}
getElement().callJsFunction("setItems", itemsToJson(this.items));
}
private JsonValue itemsToJson(List items) {
JsonArray itemsJson = Json.createArray();
for (int i = 0; i < items.size(); i++) {
itemsJson.set(i, itemToJson(i, items.get(i)));
}
return itemsJson;
}
private JsonObject itemToJson(int index, T item) {
if (item == null) {
JsonObject object = Json.createObject();
object.put(bindingContext.getTemplateBeanName(), Json.createObject());
return object;
} else {
return bindingContext.createJsonItem(index, item, getValueItemPath());
}
}
}