All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.vaadin.flow.component.checkbox.CheckboxGroup Maven / Gradle / Ivy

There is a newer version: 24.5.5
Show newest version
/*
 * Copyright 2000-2024 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.flow.component.checkbox;

import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import com.vaadin.flow.component.AbstractField;
import com.vaadin.flow.component.AbstractSinglePropertyField;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.component.HasAriaLabel;
import com.vaadin.flow.component.ItemLabelGenerator;
import com.vaadin.flow.component.Tag;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.checkbox.dataview.CheckboxGroupDataView;
import com.vaadin.flow.component.checkbox.dataview.CheckboxGroupListDataView;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.shared.ClientValidationUtil;
import com.vaadin.flow.component.shared.HasClientValidation;
import com.vaadin.flow.component.shared.HasThemeVariant;
import com.vaadin.flow.component.shared.HasValidationProperties;
import com.vaadin.flow.component.shared.InputField;
import com.vaadin.flow.component.shared.SelectionPreservationHandler;
import com.vaadin.flow.component.shared.SelectionPreservationMode;
import com.vaadin.flow.component.shared.ValidationUtil;
import com.vaadin.flow.data.binder.HasItemComponents;
import com.vaadin.flow.data.binder.HasValidator;
import com.vaadin.flow.data.provider.DataChangeEvent;
import com.vaadin.flow.data.provider.DataProvider;
import com.vaadin.flow.data.provider.DataProviderWrapper;
import com.vaadin.flow.data.provider.DataViewUtils;
import com.vaadin.flow.data.provider.HasDataView;
import com.vaadin.flow.data.provider.HasListDataView;
import com.vaadin.flow.data.provider.IdentifierProvider;
import com.vaadin.flow.data.provider.InMemoryDataProvider;
import com.vaadin.flow.data.provider.ItemCountChangeEvent;
import com.vaadin.flow.data.provider.KeyMapper;
import com.vaadin.flow.data.provider.ListDataProvider;
import com.vaadin.flow.data.provider.Query;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.data.selection.MultiSelect;
import com.vaadin.flow.data.selection.MultiSelectionEvent;
import com.vaadin.flow.data.selection.MultiSelectionListener;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.function.SerializableFunction;
import com.vaadin.flow.function.SerializablePredicate;
import com.vaadin.flow.shared.Registration;

import elemental.json.Json;
import elemental.json.JsonArray;

/**
 * CheckBoxGroup is a multi-selection component where items are displayed as
 * check boxes.
 * 

* Use CheckBoxGroup to group related items. Individual checkboxes should be * used for options that are not related to each other in any way. * * @author Vaadin Ltd */ @Tag("vaadin-checkbox-group") @NpmPackage(value = "@vaadin/polymer-legacy-adapter", version = "24.4.10") @JsModule("@vaadin/polymer-legacy-adapter/style-modules.js") @NpmPackage(value = "@vaadin/checkbox-group", version = "24.4.10") @JsModule("@vaadin/checkbox-group/src/vaadin-checkbox-group.js") public class CheckboxGroup extends AbstractSinglePropertyField, Set> implements HasAriaLabel, HasClientValidation, HasDataView>, HasItemComponents, InputField, Set>, Set>, HasListDataView>, HasThemeVariant, HasValidationProperties, HasValidator>, MultiSelect, T> { private static final String VALUE = "value"; private final KeyMapper keyMapper = new KeyMapper<>(this::getItemId); private final AtomicReference> dataProvider = new AtomicReference<>( DataProvider.ofItems()); private SerializablePredicate itemEnabledProvider = item -> isEnabled(); private ItemLabelGenerator itemLabelGenerator = String::valueOf; private ItemHelperGenerator itemHelperGenerator = item -> null; private ComponentRenderer itemRenderer; private Registration dataProviderListenerRegistration; private int lastNotifiedDataSize = -1; private volatile int lastFetchedDataSize = -1; private SerializableConsumer sizeRequest; private boolean manualValidationEnabled = false; private SelectionPreservationHandler selectionPreservationHandler; /** * Creates an empty checkbox group */ public CheckboxGroup() { super("value", Collections.emptySet(), JsonArray.class, CheckboxGroup::presentationToModel, CheckboxGroup::modelToPresentation); addValueChangeListener(e -> validate()); initSelectionPreservationHandler(); } /** * Creates an empty checkbox group with the defined label. * * @param label * the label describing the checkbox group * @see #setLabel(String) */ public CheckboxGroup(String label) { this(); setLabel(label); } /** * Creates a checkbox group with the defined label and populated with the * items in the collection. * * @param label * the label describing the checkbox group * @param items * the items to be shown in the list of the checkbox group * @see #setLabel(String) * @see #setItems(Collection) */ public CheckboxGroup(String label, Collection items) { this(); setLabel(label); setItems(items); } /** * Creates a checkbox group with the defined label and populated with the * items in the array. * * @param label * the label describing the checkbox group * @param items * the items to be shown in the list of the checkbox group * @see #setLabel(String) * @see #setItems(Object...) */ @SafeVarargs public CheckboxGroup(String label, T... items) { this(); setLabel(label); setItems(items); } /** * Constructs a checkbox group with a value change listener. * * @param listener * the value change listener to add * @see #addValueChangeListener(ValueChangeListener) */ public CheckboxGroup( ValueChangeListener, Set>> listener) { this(); addValueChangeListener(listener); } /** * Constructs a checkbox group with the defined label and a value change * listener. * * @param label * the label describing the checkbox group * @param listener * the value change listener to add * @see #setLabel(String) * @see #addValueChangeListener(ValueChangeListener) */ public CheckboxGroup(String label, ValueChangeListener, Set>> listener) { this(label); addValueChangeListener(listener); } /** * Constructs a checkbox group with the defined label, a value change * listener and populated with the items in the array. * * @param label * the label describing the checkbox group * @param listener * the value change listener to add * @param items * the items to be shown in the list of the checkbox group * @see #setLabel(String) * @see #addValueChangeListener(ValueChangeListener) * @see #setItems(Object...) */ @SafeVarargs public CheckboxGroup(String label, ValueChangeListener, Set>> listener, T... items) { this(label, listener); setItems(items); } @Override protected void onAttach(AttachEvent attachEvent) { super.onAttach(attachEvent); ClientValidationUtil.preventWebComponentFromModifyingInvalidState(this); } @Override public CheckboxGroupDataView setItems( DataProvider dataProvider) { setDataProvider(dataProvider); return getGenericDataView(); } @Override public CheckboxGroupDataView setItems( InMemoryDataProvider inMemoryDataProvider) { // We don't use DataProvider.withConvertedFilter() here because it's // implementation does not apply the filter converter if Query has a // null filter DataProvider convertedDataProvider = new DataProviderWrapper>( inMemoryDataProvider) { @Override protected SerializablePredicate getFilter(Query query) { // Just ignore the query filter (Void) and apply the // predicate only return Optional.ofNullable(inMemoryDataProvider.getFilter()) .orElse(item -> true); } }; return setItems(convertedDataProvider); } @Override public CheckboxGroupListDataView setItems( ListDataProvider dataProvider) { setDataProvider(dataProvider); return getListDataView(); } /** * Gets the list data view for the checkbox group. This data view should * only be used when the items are in-memory and set with: *

    *
  • {@link #setItems(Collection)}
  • *
  • {@link #setItems(Object[])}
  • *
  • {@link #setItems(ListDataProvider)}
  • *
* If the items are not in-memory an exception is thrown. * * @return the list data view that provides access to the data bound to the * checkbox group */ @Override public CheckboxGroupListDataView getListDataView() { return new CheckboxGroupListDataView<>(this::getDataProvider, this, this::identifierProviderChanged, (filter, sorting) -> rebuild()); } /** * Gets the generic data view for the checkbox group. This data view should * only be used when {@link #getListDataView()} is not applicable for the * underlying data provider. * * @return the generic DataView instance implementing * {@link CheckboxGroupDataView} */ @Override public CheckboxGroupDataView getGenericDataView() { return new CheckboxGroupDataView<>(this::getDataProvider, this, this::identifierProviderChanged); } private void initSelectionPreservationHandler() { selectionPreservationHandler = new SelectionPreservationHandler<>( SelectionPreservationMode.DISCARD) { @Override public void onPreserveAll(DataChangeEvent dataChangeEvent) { // NO-OP } @Override public void onPreserveExisting(DataChangeEvent dataChangeEvent) { Map deselectionCandidateIdsToItems = getSelectedItems() .stream().collect(Collectors .toMap(item -> getItemId(item), item -> item)); @SuppressWarnings("unchecked") Stream itemsStream = getDataProvider() .fetch(DataViewUtils.getQuery(CheckboxGroup.this)); Set existingItemIds = itemsStream .map(item -> getItemId(item)) .filter(deselectionCandidateIdsToItems::containsKey) .limit(deselectionCandidateIdsToItems.size()) .collect(Collectors.toSet()); existingItemIds.forEach(deselectionCandidateIdsToItems::remove); deselect(deselectionCandidateIdsToItems.values()); } @Override public void onDiscard(DataChangeEvent dataChangeEvent) { clear(); } }; } private void handleDataChange(DataChangeEvent dataChangeEvent) { if (dataChangeEvent instanceof DataChangeEvent.DataRefreshEvent) { T otherItem = ((DataChangeEvent.DataRefreshEvent) dataChangeEvent) .getItem(); Object otherItemId = getItemId(otherItem); getCheckboxItems().filter( item -> Objects.equals(getItemId(item.item), otherItemId)) .findFirst().ifPresent(this::updateCheckbox); } else { keyMapper.removeAll(); selectionPreservationHandler.handleDataChange(dataChangeEvent); rebuild(); } } private static class CheckBoxItem extends Checkbox implements HasItemComponents.ItemComponent { private final T item; private CheckBoxItem(String id, T item) { this.item = item; getElement().setProperty(VALUE, id); } @Override public T getItem() { return item; } } /** * Sets a generic data provider for the CheckboxGroup to use. *

* Use this method when none of the {@code setItems} methods are applicable, * e.g. when having a data provider with filter that cannot be transformed * to {@code DataProvider}. * * @param dataProvider * DataProvider instance to use, not null */ public void setDataProvider(DataProvider dataProvider) { this.dataProvider.set(dataProvider); DataViewUtils.removeComponentFilterAndSortComparator(this); keyMapper.removeAll(); clear(); rebuild(); if (dataProviderListenerRegistration != null) { dataProviderListenerRegistration.remove(); } dataProviderListenerRegistration = dataProvider .addDataProviderListener(this::handleDataChange); } @Override public void updateSelection(Set addedItems, Set removedItems) { Set value = new HashSet<>(getValue()); value.addAll(addedItems); value.removeAll(removedItems); setValue(value); } /** * Sets the value of this component. If the new value is not equal to the * previous value, fires a value change event. *

* The component doesn't accept {@code null} values. The value of a checkbox * group without any selected items is an empty set. You can use the * {@link #clear()} method to set the empty value. * * @param value * the new value to set, not {@code null} * @throws NullPointerException * if value is {@code null} */ @Override public void setValue(Set value) { Objects.requireNonNull(value, "Cannot set a null value to checkbox group. " + "Use the clear-method to reset the component's value to an empty set."); super.setValue(value); refreshCheckboxes(); } @Override public Set getSelectedItems() { return getValue(); } @Override public Registration addSelectionListener( MultiSelectionListener, T> listener) { return addValueChangeListener(event -> listener .selectionChange(new MultiSelectionEvent<>(this, this, event.getOldValue(), event.isFromClient()))); } /** * Gets the data provider used by this CheckboxGroup. * *

* To get information and control over the items in the CheckboxGroup, use * either {@link #getListDataView()} or {@link #getGenericDataView()} * instead. * * @return the data provider used by this CheckboxGroup */ public DataProvider getDataProvider() { // dataProvider reference won't have been initialized before // calling from CheckboxGroup constructor return Optional.ofNullable(dataProvider).map(AtomicReference::get) .orElse(null); } @Override public void onEnabledStateChanged(boolean enabled) { setDisabled(!enabled); getCheckboxItems().forEach(this::updateEnabled); } /** * Returns the item enabled predicate. * * @return the item enabled predicate * @see #setItemEnabledProvider */ public SerializablePredicate getItemEnabledProvider() { return itemEnabledProvider; } /** * Sets the item enabled predicate for this checkbox group. 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 true (all the items are enabled). * * @param itemEnabledProvider * the item enable predicate, not {@code null} */ public void setItemEnabledProvider( SerializablePredicate itemEnabledProvider) { this.itemEnabledProvider = Objects.requireNonNull(itemEnabledProvider); refreshCheckboxes(); } /** * Sets the item label generator that is used to produce the strings shown * in the checkbox group for each item. By default, * {@link String#valueOf(Object)} is used. *

* Setting an item label generator removes any previously set item renderer. * * @param itemLabelGenerator * the item label provider to use, not null */ public void setItemLabelGenerator( ItemLabelGenerator itemLabelGenerator) { Objects.requireNonNull(itemLabelGenerator, "The item label generator can not be null"); this.itemLabelGenerator = itemLabelGenerator; this.itemRenderer = null; refreshCheckboxes(); } /** * Gets the item label generator that is used to produce the strings shown * in the checkbox group for each item. * * @return the item label generator used, not null */ public ItemLabelGenerator getItemLabelGenerator() { return itemLabelGenerator; } /** * Sets the {@link ItemHelperGenerator} that is used for generating helper * text strings used by the checkbox group for each item. * * @since 24.4 * @see Checkbox#setHelperText(String) * @see #setItemLabelGenerator * @param itemHelperGenerator * the item helper generator to use, not null */ public void setItemHelperGenerator( ItemHelperGenerator itemHelperGenerator) { Objects.requireNonNull(itemHelperGenerator, "The item helper generator can not be null"); this.itemHelperGenerator = itemHelperGenerator; refreshCheckboxes(); } /** * Gets the {@link ItemHelperGenerator} function that is used for generating * helper text strings used by the checkbox group for each item. * * @since 24.4 * @see #getItemLabelGenerator() * @return the item helper generator, not null */ public ItemHelperGenerator getItemHelperGenerator() { return itemHelperGenerator; } /** * {@link ItemHelperGenerator} can be used to generate helper text strings * used by the checkbox group for each checkbox. * * @since 24.4 * @see Checkbox#setHelperText(String) * @param * item type */ @FunctionalInterface public interface ItemHelperGenerator extends SerializableFunction { /** * Gets a helper text string for the {@code item}. * * @param item * the item to get helper text for * @return the helper text string for the item, not {@code null} */ @Override String apply(T item); } /** * Sets the label for the checkbox group. * * @param label * value for the {@code label} property in the checkbox group */ public void setLabel(String label) { getElement().setProperty("label", label == null ? "" : label); } /** * Gets the label of the checkbox group. * * @return the {@code label} property of the checkbox group */ public String getLabel() { return getElement().getProperty("label"); } @Override public void setAriaLabel(String ariaLabel) { getElement().setProperty("accessibleName", ariaLabel); } @Override public Optional getAriaLabel() { return Optional.ofNullable(getElement().getProperty("accessibleName")); } @Override public void setAriaLabelledBy(String labelledBy) { getElement().setProperty("accessibleNameRef", labelledBy); } @Override public Optional getAriaLabelledBy() { return Optional .ofNullable(getElement().getProperty("accessibleNameRef")); } /** * Specifies that the user must fill in a value. * * @param required * the boolean value to set */ public void setRequired(boolean required) { getElement().setProperty("required", required); } /** * Determines whether the checkbox group is marked as input required. *

* This property is not synchronized automatically from the client side, so * the returned value may not be the same as in client side. * * @return {@code true} if the input is required, {@code false} otherwise */ public boolean isRequired() { return getElement().getProperty("required", false); } /** * If true, the user cannot interact with this element. * * @param disabled * the boolean value to set */ protected void setDisabled(boolean disabled) { getElement().setProperty("disabled", disabled); } /** * If true, the user cannot interact with this element. * * @return the {@code disabled} property from the webcomponent */ protected boolean isDisabledBoolean() { return getElement().getProperty("disabled", false); } @Override protected boolean valueEquals(Set value1, Set value2) { assert value1 != null && value2 != null; if (value1.size() != value2.size()) { return false; } if (getDataProvider() == null) { return super.valueEquals(value1, value2); } IdentifierProvider identifierProvider = getIdentifierProvider(); Set ids1 = value1.stream().map(identifierProvider) .collect(Collectors.toSet()); Set ids2 = value2.stream().map(identifierProvider) .collect(Collectors.toSet()); return ids1.equals(ids2); } @Override protected boolean hasValidValue() { // we need to compare old value with new value to see if any disabled // items changed their value Set value = presentationToModel(this, (JsonArray) getElement().getPropertyRaw(VALUE)); Set oldValue = getValue(); // disabled items cannot change their value return getCheckboxItems().filter(CheckBoxItem::isDisabledBoolean) .noneMatch(item -> oldValue.contains(item.getItem()) != value .contains(item.getItem())); } /** * Returns the item component renderer. * * @return the item renderer * @see #setRenderer(ComponentRenderer) * * @since 23.1 */ public ComponentRenderer getItemRenderer() { return itemRenderer; } /** * Sets the item renderer for this checkbox group. The renderer is applied * to each item to create a component which represents the item. *

* Note: Component acts as a label to the checkbox and clicks on it trigger * the checkbox. Hence interactive components like DatePicker or ComboBox * cannot be used. * * @param renderer * the item renderer, not {@code null} * * @since 23.1 */ public void setRenderer( ComponentRenderer renderer) { this.itemRenderer = Objects.requireNonNull(renderer); refreshCheckboxItems(); } /** * Sets the selection preservation mode. Determines what happens with the * selection when {@link DataProvider#refreshAll} is called. The selection * is discarded in any case when a new data provider is set. The default is * {@link SelectionPreservationMode#DISCARD}. * * @param selectionPreservationMode * the selection preservation mode to switch to, not {@code null} * * @see SelectionPreservationMode */ public void setSelectionPreservationMode( SelectionPreservationMode selectionPreservationMode) { selectionPreservationHandler .setSelectionPreservationMode(selectionPreservationMode); } /** * Gets the selection preservation mode. * * @return the selection preservation mode * * @see #setSelectionPreservationMode(SelectionPreservationMode) */ public SelectionPreservationMode getSelectionPreservationMode() { return selectionPreservationHandler.getSelectionPreservationMode(); } @SuppressWarnings("unchecked") private void rebuild() { synchronized (dataProvider) { // Cache helper component before removal Component helperComponent = getHelperComponent(); // Remove all known children (doesn't remove client-side-only // children such as the label) getChildren().forEach(this::remove); // reinsert helper component // see https://github.com/vaadin/vaadin-checkbox/issues/191 setHelperComponent(helperComponent); final AtomicInteger itemCounter = new AtomicInteger(0); getDataProvider().fetch(DataViewUtils.getQuery(this)) .map(item -> createCheckBox((T) item)) .forEach(component -> { add((Component) component); itemCounter.incrementAndGet(); }); lastFetchedDataSize = itemCounter.get(); // Ignore new size requests unless the last one has been executed // so as to avoid multiple beforeClientResponses. if (sizeRequest == null) { sizeRequest = ui -> { fireSizeEvent(); sizeRequest = null; }; // Size event is fired before client response so as to avoid // multiple size change events during server round trips runBeforeClientResponse(sizeRequest); } } } private void refreshCheckboxes() { getCheckboxItems().forEach(this::updateCheckbox); } @SuppressWarnings("unchecked") private Stream> getCheckboxItems() { return getChildren().filter(CheckBoxItem.class::isInstance) .map(child -> (CheckBoxItem) child); } private Checkbox createCheckBox(T item) { CheckBoxItem checkbox = new CheckBoxItem<>(keyMapper.key(item), item); updateCheckbox(checkbox); return checkbox; } private void refreshCheckboxItems() { getCheckboxItems().forEach(this::updateCheckbox); } private void updateCheckbox(CheckBoxItem checkbox) { if (itemRenderer == null) { checkbox.setLabel( getItemLabelGenerator().apply(checkbox.getItem())); } else { checkbox.setLabelComponent( getItemRenderer().createComponent(checkbox.item)); } String helper = itemHelperGenerator.apply(checkbox.item); if (helper != null) { checkbox.setHelperText(helper); } else if (checkbox.getHelperText() != null) { checkbox.setHelperText(null); } checkbox.setValue(getValue().stream().anyMatch( selectedItem -> Objects.equals(getItemId(selectedItem), getItemId(checkbox.getItem())))); updateEnabled(checkbox); } private void updateEnabled(CheckBoxItem checkbox) { boolean disabled = isDisabledBoolean() || !getItemEnabledProvider().test(checkbox.getItem()); checkbox.setDisabled(disabled); // When enabling a disabled checkbox group, individual checkbox Web // Components that should remain disabled (due to itemEnabledProvider), // may end up rendering as enabled. // Enforce the Web Component state using JS. checkbox.getElement().executeJs("this.disabled = $0", disabled); } private static Set presentationToModel(CheckboxGroup group, JsonArray presentation) { if (group.keyMapper == null) { return Collections.emptySet(); } JsonArray array = presentation; Set set = new HashSet<>(); for (int i = 0; i < array.length(); i++) { set.add(group.keyMapper.get(array.getString(i))); } return set; } private static JsonArray modelToPresentation(CheckboxGroup group, Set model) { JsonArray array = Json.createArray(); if (model.isEmpty()) { return array; } model.stream().map(group.keyMapper::key) .forEach(key -> array.set(array.length(), key)); return array; } private Object getItemId(T item) { return getIdentifierProvider().apply(item); } private void runBeforeClientResponse(SerializableConsumer command) { getElement().getNode().runWhenAttached(ui -> ui .beforeClientResponse(this, context -> command.accept(ui))); } private void fireSizeEvent() { final int newSize = lastFetchedDataSize; if (lastNotifiedDataSize != newSize) { lastNotifiedDataSize = newSize; fireEvent(new ItemCountChangeEvent<>(this, newSize, false)); } } @SuppressWarnings("unchecked") private IdentifierProvider getIdentifierProvider() { IdentifierProvider identifierProviderObject = ComponentUtil .getData(this, IdentifierProvider.class); if (identifierProviderObject == null) { DataProvider dataProvider = getDataProvider(); if (dataProvider != null) { return dataProvider::getId; } else { return IdentifierProvider.identity(); } } else { return identifierProviderObject; } } private void identifierProviderChanged( IdentifierProvider identifierProvider) { keyMapper.setIdentifierGetter(identifierProvider); } @Override public void setManualValidation(boolean enabled) { this.manualValidationEnabled = enabled; } protected void validate() { if (!this.manualValidationEnabled) { boolean isRequired = isRequiredIndicatorVisible(); boolean isInvalid = ValidationUtil .checkRequired(isRequired, getValue(), getEmptyValue()) .isError(); setInvalid(isInvalid); } } }