/* * Copyright 2000-2022 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.ui; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import org.jsoup.nodes.Element; import com.vaadin.data.HasValue; import com.vaadin.data.SelectionModel; import com.vaadin.data.SelectionModel.Multi; import com.vaadin.data.provider.DataGenerator; import com.vaadin.data.provider.DataProvider; import com.vaadin.event.selection.MultiSelectionEvent; import com.vaadin.event.selection.MultiSelectionListener; import com.vaadin.server.Resource; import com.vaadin.server.ResourceReference; import com.vaadin.server.SerializableConsumer; import com.vaadin.server.SerializablePredicate; import com.vaadin.shared.Registration; import com.vaadin.shared.data.selection.MultiSelectServerRpc; import com.vaadin.shared.ui.ListingJsonConstants; import com.vaadin.shared.ui.abstractmultiselect.AbstractMultiSelectState; import com.vaadin.ui.declarative.DesignContext; import com.vaadin.ui.declarative.DesignException; import elemental.json.JsonObject; /** * Base class for listing components that allow selecting multiple items. *

* Sends selection information individually for each item. * * @param * item type * @author Vaadin Ltd * @since 8.0 */ public abstract class AbstractMultiSelect extends AbstractListing implements MultiSelect { private List selection = new ArrayList<>(); private class MultiSelectServerRpcImpl implements MultiSelectServerRpc { @Override public void updateSelection(Set selectedItemKeys, Set deselectedItemKeys) { AbstractMultiSelect.this.updateSelection( getItemsForSelectionChange(selectedItemKeys), getItemsForSelectionChange(deselectedItemKeys), true); } private Set getItemsForSelectionChange(Set keys) { return keys.stream().map(key -> getItemForSelectionChange(key)) .filter(Optional::isPresent).map(Optional::get) .collect(Collectors.toSet()); } private Optional getItemForSelectionChange(String key) { T item = getDataCommunicator().getKeyMapper().get(key); if (item == null || !getItemEnabledProvider().test(item)) { return Optional.empty(); } return Optional.of(item); } } private final class MultiSelectDataGenerator implements DataGenerator { @Override public void generateData(T data, JsonObject jsonObject) { String caption = getItemCaptionGenerator().apply(data); if (caption != null) { jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_VALUE, caption); } else { jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_VALUE, ""); } Resource icon = getItemIconGenerator().apply(data); if (icon != null) { String iconUrl = ResourceReference .create(icon, AbstractMultiSelect.this, null).getURL(); jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_ICON, iconUrl); } if (!getItemEnabledProvider().test(data)) { jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_DISABLED, true); } if (isSelected(data)) { jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_SELECTED, true); } } @Override public void destroyData(T data) { } @Override public void destroyAllData() { AbstractMultiSelect.this.deselectAll(); } @Override public void refreshData(T item) { refreshSelectedItem(item); } } /** * The item enabled status provider. It is up to the implementing class to * support this or not. */ private SerializablePredicate itemEnabledProvider = item -> true; /** * Creates a new multi select with an empty data provider. */ protected AbstractMultiSelect() { registerRpc(new MultiSelectServerRpcImpl()); // #FIXME it should be the responsibility of the SelectionModel // (AbstractSelectionModel) to add selection data for item addDataGenerator(new MultiSelectDataGenerator()); } /** * Adds a selection listener that will be called when the selection is * changed either by the user or programmatically. * * @param listener * the value change listener, not {@code null} * @return a registration for the listener */ @Override public Registration addSelectionListener( MultiSelectionListener listener) { return addListener(MultiSelectionEvent.class, listener, MultiSelectionListener.SELECTION_CHANGE_METHOD); } @Override public ItemCaptionGenerator getItemCaptionGenerator() { return super.getItemCaptionGenerator(); } @Override public void setItemCaptionGenerator( ItemCaptionGenerator itemCaptionGenerator) { super.setItemCaptionGenerator(itemCaptionGenerator); } /** * Returns the current value of this object which is an immutable set of the * currently selected items. *

* The call is delegated to {@link #getSelectedItems()} * * @return the current selection * * @see #getSelectedItems() * @see SelectionModel#getSelectedItems */ @Override public Set getValue() { return getSelectedItems(); } /** * Sets the value of this object which is a set of items to select. If the * new value is not equal to {@code getValue()}, fires a value change event. * May throw {@code IllegalArgumentException} if the value is not * acceptable. *

* The method effectively selects the given items and deselects previously * selected. The call is delegated to * {@link Multi#updateSelection(Set, Set)}. * * @see Multi#updateSelection(Set, Set) * * @param value * the items to select, not {@code null} * @throws NullPointerException * if the value is invalid */ @Override public void setValue(Set value) { Objects.requireNonNull(value); Set copy = value.stream().map(Objects::requireNonNull) .collect(Collectors.toCollection(LinkedHashSet::new)); updateSelection(copy, new LinkedHashSet<>(getSelectedItems())); } /** * Adds a value change listener. The listener is called when the selection * set of this multi select is changed either by the user or * programmatically. * * @see #addSelectionListener(MultiSelectionListener) * * @param listener * the value change listener, not null * @return a registration for the listener */ @Override public Registration addValueChangeListener( HasValue.ValueChangeListener> listener) { return addSelectionListener( event -> listener.valueChange(new ValueChangeEvent<>(this, event.getOldValue(), event.isUserOriginated()))); } /** * Returns the item enabled provider for this multiselect. *

* Implementation note: Override this method and * {@link #setItemEnabledProvider(SerializablePredicate)} as {@code public} * and invoke {@code super} methods to support this feature in the * multiselect component. * * @return the item enabled provider, not {@code null} * @see #setItemEnabledProvider(SerializablePredicate) */ protected SerializablePredicate getItemEnabledProvider() { return itemEnabledProvider; } /** * Sets the item enabled predicate for this multiselect. The predicate is * applied to each item to determine whether the item should be enabled ( * {@code true}) or disabled ({@code false}). Disabled items are displayed * as grayed out and the user cannot select them. The default predicate * always returns {@code true} (all the items are enabled). *

* Implementation note: Override this method and * {@link #getItemEnabledProvider()} as {@code public} and invoke * {@code super} methods to support this feature in the multiselect * component. * * @param itemEnabledProvider * the item enabled provider to set, not {@code null} */ protected void setItemEnabledProvider( SerializablePredicate itemEnabledProvider) { Objects.requireNonNull(itemEnabledProvider); this.itemEnabledProvider = itemEnabledProvider; } @Override public void setRequiredIndicatorVisible(boolean visible) { super.setRequiredIndicatorVisible(visible); } @Override public boolean isRequiredIndicatorVisible() { return super.isRequiredIndicatorVisible(); } @Override protected AbstractMultiSelectState getState() { return (AbstractMultiSelectState) super.getState(); } @Override protected AbstractMultiSelectState getState(boolean markAsDirty) { return (AbstractMultiSelectState) super.getState(markAsDirty); } @Override public void setReadOnly(boolean readOnly) { super.setReadOnly(readOnly); } @Override public boolean isReadOnly() { return super.isReadOnly(); } @Override public void updateSelection(Set addedItems, Set removedItems) { updateSelection(addedItems, removedItems, false); } /** * Updates the selection by adding and removing the given items. * * @param addedItems * the items added to selection, not {@code} null * @param removedItems * the items removed from selection, not {@code} null * @param userOriginated * {@code true} if this was used originated, {@code false} if not */ protected void updateSelection(Set addedItems, Set removedItems, boolean userOriginated) { Objects.requireNonNull(addedItems); Objects.requireNonNull(removedItems); // if there are duplicates, some item is both added & removed, just // discard that and leave things as was before DataProvider dataProvider = internalGetDataProvider(); addedItems.removeIf(item -> { Object addedId = dataProvider.getId(item); return removedItems.stream().map(dataProvider::getId).anyMatch( addedId::equals) ? removedItems.remove(item) : false; }); if (isAllSelected(addedItems) && isNoneSelected(removedItems)) { return; } updateSelection(set -> { // order of add / remove does not matter since no duplicates set.removeIf(item -> { Object itemId = dataProvider.getId(item); return removedItems.stream().map(dataProvider::getId) .anyMatch(itemId::equals); }); set.addAll(addedItems); }, userOriginated); } @Override public Set getSelectedItems() { return Collections.unmodifiableSet(new LinkedHashSet<>(selection)); } @Override public void deselectAll() { if (selection.isEmpty()) { return; } updateSelection(Collection::clear, false); } @Override public boolean isSelected(T item) { DataProvider dataProvider = internalGetDataProvider(); Object id = dataProvider.getId(item); return selection.stream().map(dataProvider::getId).anyMatch(id::equals); } private boolean isAllSelected(Collection items) { for (T item : items) { if (!isSelected(item)) { return false; } } return true; } private boolean isNoneSelected(Collection items) { for (T item : items) { if (isSelected(item)) { return false; } } return true; } /** * Deselects the given item. If the item is not currently selected, does * nothing. * * @param item * the item to deselect, not null * @param userOriginated * {@code true} if this was used originated, {@code false} if not */ protected void deselect(T item, boolean userOriginated) { if (!selection.contains(item)) { return; } updateSelection(set -> set.remove(item), userOriginated); } /** * Removes the given items. Any item that is not currently selected, is * ignored. If none of the items are selected, does nothing. * * @param items * the items to deselect, not {@code null} * @param userOriginated * {@code true} if this was used originated, {@code false} if not */ protected void deselect(Set items, boolean userOriginated) { Objects.requireNonNull(items); if (items.stream().noneMatch(i -> isSelected(i))) { return; } updateSelection(set -> set.removeAll(items), userOriginated); } /** * Selects the given item. Depending on the implementation, may cause other * items to be deselected. If the item is already selected, does nothing. * * @param item * the item to select, not null * @param userOriginated * {@code true} if this was used originated, {@code false} if not */ protected void select(T item, boolean userOriginated) { if (selection.contains(item)) { return; } updateSelection(set -> set.add(item), userOriginated); } @Override protected Collection getCustomAttributes() { Collection attributes = super.getCustomAttributes(); // "value" is not an attribute for the component. "selected" attribute // is used in "option"'s tag to mark selection which implies value for // multiselect component attributes.add("value"); return attributes; } @Override protected Element writeItem(Element design, T item, DesignContext context) { Element element = super.writeItem(design, item, context); if (isSelected(item)) { element.attr("selected", true); } return element; } @Override protected void readItems(Element design, DesignContext context) { Set selected = new HashSet<>(); List items = design.children().stream() .map(child -> readItem(child, selected, context)) .collect(Collectors.toList()); deselectAll(); if (!items.isEmpty()) { setItems(items); } selected.forEach(this::select); } /** * Reads an Item from a design and inserts it into the data source. * Hierarchical select components should override this method to recursively * recursively read any child items as well. * * @param child * a child element representing the item * @param selected * A set accumulating selected items. If the item that is read is * marked as selected, its item id should be added to this set. * @param context * the DesignContext instance used in parsing * @return the item id of the new item * * @throws DesignException * if the tag name of the {@code child} element is not * {@code option}. */ protected T readItem(Element child, Set selected, DesignContext context) { T item = readItem(child, context); if (child.hasAttr("selected")) { selected.add(item); } return item; } private void updateSelection(SerializableConsumer> handler, boolean userOriginated) { LinkedHashSet oldSelection = new LinkedHashSet<>(selection); handler.accept(selection); fireEvent(new MultiSelectionEvent<>(AbstractMultiSelect.this, oldSelection, userOriginated)); getDataCommunicator().reset(); } private final void refreshSelectedItem(T item) { DataProvider dataProvider = internalGetDataProvider(); Object id = dataProvider.getId(item); for (int i = 0; i < selection.size(); ++i) { if (id.equals(dataProvider.getId(selection.get(i)))) { selection.set(i, item); return; } } } }