diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/pom.xml b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/pom.xml index 68ac366ae7a..5a77402eeab 100644 --- a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/pom.xml +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/pom.xml @@ -27,6 +27,11 @@ com.vaadin flow-html-components + + com.vaadin + vaadin-lumo-theme + ${project.version} + com.vaadin flow-data diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/main/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionPage.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/main/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionPage.java new file mode 100644 index 00000000000..acde304e609 --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/main/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionPage.java @@ -0,0 +1,120 @@ +/* + * 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.virtuallist.tests; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.virtuallist.VirtualList; +import com.vaadin.flow.component.virtuallist.VirtualList.SelectionMode; +import com.vaadin.flow.data.provider.DataProvider; +import com.vaadin.flow.data.renderer.LitRenderer; +import com.vaadin.flow.data.selection.SelectionListener; +import com.vaadin.flow.data.selection.SelectionModel; +import com.vaadin.flow.router.Route; + +/** + * Test view for {@link VirtualList} + * + * @author Vaadin Ltd. + */ +@Route("vaadin-virtual-list/selection") +public class VirtualListSelectionPage extends Div { + + public VirtualListSelectionPage() { + var list = new VirtualList(); + list.setHeight("200px"); + + var items = createItems(); + list.setDataProvider(DataProvider.ofCollection(items)); + + list.setRenderer(LitRenderer. of("
${item.name}
") + .withProperty("name", item -> item.name)); + + list.setItemAccessibleNameGenerator(item -> "Accessible " + item.name); + + add(list); + + var selectedIndexes = new Div(); + selectedIndexes.setHeight("30px"); + selectedIndexes.setId("selected-indexes"); + SelectionListener, Item> selectionListener = event -> { + selectedIndexes.setText(event.getAllSelectedItems().stream() + .map(item -> String.valueOf(items.indexOf(item))) + .collect(Collectors.joining(", "))); + }; + + add(new Div(new H2("Selected item indexes"), selectedIndexes)); + + var selectFirstButton = new NativeButton("Select first item", e -> { + list.select(items.get(0)); + }); + selectFirstButton.setId("select-first"); + + var deselectAllButton = new NativeButton("Deselect all", e -> { + list.deselectAll(); + }); + deselectAllButton.setId("deselect-all"); + + add(new Div(new H2("Actions"), selectFirstButton, deselectAllButton)); + + var noneSelectionModeButton = new NativeButton("None", e -> { + list.setSelectionMode(SelectionMode.NONE); + }); + noneSelectionModeButton.setId("none-selection-mode"); + + var singleSelectionModeButton = new NativeButton("Single", e -> { + list.setSelectionMode(SelectionMode.SINGLE); + list.addSelectionListener(selectionListener); + }); + singleSelectionModeButton.setId("single-selection-mode"); + + var singleSelectionModeDeselectionDisallowedButton = new NativeButton( + "Single (deselection disallowed)", e -> { + var model = list.setSelectionMode(SelectionMode.SINGLE); + ((SelectionModel.Single, Item>) model) + .setDeselectAllowed(false); + list.addSelectionListener(selectionListener); + }); + singleSelectionModeDeselectionDisallowedButton + .setId("single-selection-mode-deselection-disallowed"); + + var multiSelectionModeButton = new NativeButton("Multi", e -> { + list.setSelectionMode(SelectionMode.MULTI); + list.addSelectionListener(selectionListener); + }); + multiSelectionModeButton.setId("multi-selection-mode"); + + add(new Div(new H2("Selection mode"), noneSelectionModeButton, + singleSelectionModeButton, + singleSelectionModeDeselectionDisallowedButton, + multiSelectionModeButton)); + + } + + private List createItems() { + return IntStream.range(0, 1000).mapToObj(i -> new Item("Item " + i, i)) + .collect(Collectors.toList()); + } + + public static record Item(String name, int value) { + }; + +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionIT.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionIT.java new file mode 100644 index 00000000000..8fb42233d2b --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionIT.java @@ -0,0 +1,157 @@ +/* + * 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.virtuallist.tests; + +import java.util.Set; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.openqa.selenium.By; + +import com.vaadin.flow.component.virtuallist.testbench.VirtualListElement; +import com.vaadin.flow.testutil.TestPath; +import com.vaadin.testbench.TestBenchElement; +import com.vaadin.tests.AbstractComponentIT; + +@TestPath("vaadin-virtual-list/selection") +public class VirtualListSelectionIT extends AbstractComponentIT { + private VirtualListElement virtualList; + private TestBenchElement singleSelectionModeButton; + private TestBenchElement singleSelectionModeDeselectionDisallowedButton; + private TestBenchElement multiSelectionModeButton; + private TestBenchElement selectFirstButton; + private TestBenchElement deselectAllButton; + private TestBenchElement selectedIndexes; + + @Before + public void init() { + open(); + virtualList = $(VirtualListElement.class).waitForFirst(); + singleSelectionModeButton = $("button").id("single-selection-mode"); + singleSelectionModeDeselectionDisallowedButton = $("button") + .id("single-selection-mode-deselection-disallowed"); + multiSelectionModeButton = $("button").id("multi-selection-mode"); + selectFirstButton = $("button").id("select-first"); + deselectAllButton = $("button").id("deselect-all"); + selectedIndexes = $("div").id("selected-indexes"); + } + + @Test + public void select_shouldNotSelect() { + virtualList.select(0); + + Assert.assertFalse(virtualList.isRowSelected(0)); + } + + @Test + public void singleSelectionMode_select() { + singleSelectionModeButton.click(); + virtualList.select(0); + Assert.assertTrue(virtualList.isRowSelected(0)); + } + + @Test + public void singleSelectionMode_selectAnother() { + singleSelectionModeButton.click(); + virtualList.select(0); + virtualList.select(1); + Assert.assertFalse(virtualList.isRowSelected(0)); + Assert.assertTrue(virtualList.isRowSelected(1)); + } + + @Test + public void singleSelectionMode_deselect() { + singleSelectionModeButton.click(); + virtualList.select(0); + virtualList.deselect(0); + Assert.assertFalse(virtualList.isRowSelected(0)); + } + + @Test + public void singleSelectionModeDeselectionDisallowed_deselectionNotAllowed() { + singleSelectionModeDeselectionDisallowedButton.click(); + Assert.assertFalse(virtualList.isRowSelected(0)); + virtualList.select(0); + virtualList.deselect(0); + Assert.assertTrue(virtualList.isRowSelected(0)); + } + + @Test + public void multiSelectionMode_selectMultiple() { + multiSelectionModeButton.click(); + virtualList.select(0); + virtualList.select(2); + Assert.assertTrue(virtualList.isRowSelected(0)); + Assert.assertFalse(virtualList.isRowSelected(1)); + Assert.assertTrue(virtualList.isRowSelected(2)); + } + + @Test + public void multiSelectionMode_deselect() { + multiSelectionModeButton.click(); + virtualList.select(0); + virtualList.select(2); + virtualList.select(1); + virtualList.deselect(0); + Assert.assertFalse(virtualList.isRowSelected(0)); + Assert.assertTrue(virtualList.isRowSelected(1)); + Assert.assertTrue(virtualList.isRowSelected(2)); + } + + @Test + public void multiSelectionMode_serverSideSelection() { + multiSelectionModeButton.click(); + selectFirstButton.click(); + virtualList.select(3); + virtualList.select(80); + virtualList.deselect(0); + + var selectedIndexesSet = Set.of(selectedIndexes.getText().split(", ")); + Assert.assertEquals(Set.of("3", "80"), selectedIndexesSet); + } + + @Test + public void programmaticSelection() { + singleSelectionModeButton.click(); + selectFirstButton.click(); + Assert.assertTrue(virtualList.isRowSelected(0)); + + var selectedIndexesSet = Set.of(selectedIndexes.getText().split(", ")); + Assert.assertEquals(Set.of("0"), selectedIndexesSet); + + deselectAllButton.click(); + Assert.assertFalse(virtualList.isRowSelected(0)); + Assert.assertTrue(selectedIndexes.getText().isEmpty()); + } + + @Test + public void accessibleName() { + var firstChildElement = virtualList + .findElement(By.xpath("child::div[@aria-posinset='1']")); + Assert.assertEquals("Accessible Item 0", + firstChildElement.getAttribute("aria-label")); + } + + @Test + public void changeSelectionMode_resetSelection() { + singleSelectionModeButton.click(); + virtualList.select(0); + multiSelectionModeButton.click(); + Assert.assertFalse(virtualList.isRowSelected(0)); + } + +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/pom.xml b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/pom.xml index 1c5b7a9253d..37e6be9de79 100644 --- a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/pom.xml +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/pom.xml @@ -21,6 +21,11 @@ vaadin-renderer-flow ${project.version}
+ + org.mockito + mockito-core + test + com.vaadin flow-test-generic diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualList.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualList.java index a6f28ab6f42..2c9e945c761 100644 --- a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualList.java +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualList.java @@ -19,9 +19,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import com.vaadin.flow.component.ClientCallable; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.ComponentUtil; import com.vaadin.flow.component.Focusable; import com.vaadin.flow.component.HasSize; import com.vaadin.flow.component.HasStyle; @@ -30,6 +35,7 @@ import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.component.dependency.NpmPackage; import com.vaadin.flow.component.virtuallist.paging.PagelessDataCommunicator; +import com.vaadin.flow.data.binder.Binder; import com.vaadin.flow.data.binder.HasDataProvider; import com.vaadin.flow.data.provider.ArrayUpdater; import com.vaadin.flow.data.provider.ArrayUpdater.Update; @@ -39,13 +45,22 @@ import com.vaadin.flow.data.renderer.ComponentRenderer; import com.vaadin.flow.data.renderer.LitRenderer; import com.vaadin.flow.data.renderer.Renderer; +import com.vaadin.flow.data.selection.MultiSelect; +import com.vaadin.flow.data.selection.MultiSelectionEvent; +import com.vaadin.flow.data.selection.SelectionListener; +import com.vaadin.flow.data.selection.SelectionModel; +import com.vaadin.flow.data.selection.SelectionModel.Single; +import com.vaadin.flow.data.selection.SingleSelect; +import com.vaadin.flow.data.selection.SingleSelectionEvent; import com.vaadin.flow.dom.DisabledUpdateMode; +import com.vaadin.flow.function.SerializableFunction; import com.vaadin.flow.function.ValueProvider; import com.vaadin.flow.internal.JsonUtils; import com.vaadin.flow.server.Command; import com.vaadin.flow.shared.Registration; import elemental.json.Json; +import elemental.json.JsonArray; import elemental.json.JsonValue; /** @@ -119,6 +134,10 @@ public void initialize() { private Renderer renderer; + private SelectionMode selectionMode; + private SelectionModel, T> selectionModel; + private SerializableFunction itemAccessibleNameGenerator = item -> null; + private final CompositeDataGenerator dataGenerator = new CompositeDataGenerator<>(); private final List renderingRegistrations = new ArrayList<>(); private transient T placeholderItem; @@ -134,6 +153,11 @@ public void initialize() { public VirtualList() { setRenderer((ValueProvider) String::valueOf); addAttachListener((e) -> this.setPlaceholderItem(this.placeholderItem)); + + // Use NONE selection mode by default + setSelectionMode(SelectionMode.NONE); + + initSelection(); } private void initConnector() { @@ -144,6 +168,42 @@ private void initConnector() { getElement()); } + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void initSelection() { + // Generate "selected" property for selected items + dataGenerator.addDataGenerator((item, jsonObject) -> { + if (this.getSelectionModel().isSelected(item)) { + jsonObject.put("selected", true); + } + var accessibleName = this.itemAccessibleNameGenerator.apply(item); + if (accessibleName != null) { + jsonObject.put("accessibleName", accessibleName); + } + }); + + // Set up SingleSelectionEvent and MultiSelectionEvent listeners to + // refresh the items in data communicator when selection changes. This + // is to ensure the data generator labels selected items with "selected" + // property on selection change. + ComponentUtil.addListener(this, SingleSelectionEvent.class, + (ComponentEventListener) ((ComponentEventListener, T>>) event -> { + Stream.of(event.getValue(), event.getOldValue()) + .filter(Objects::nonNull) + .forEach(item -> getDataCommunicator() + .refresh((T) item)); + })); + + ComponentUtil.addListener(this, MultiSelectionEvent.class, + (ComponentEventListener) ((ComponentEventListener, T>>) event -> { + Stream.concat(event.getAddedSelection().stream(), + event.getRemovedSelection().stream()) + .filter(Objects::nonNull) + .forEach(item -> getDataCommunicator() + .refresh((T) item)); + })); + + } + @Override public void setDataProvider(DataProvider dataProvider) { Objects.requireNonNull(dataProvider, "The dataProvider cannot be null"); @@ -324,4 +384,289 @@ public void scrollToStart() { public void scrollToEnd() { scrollToIndex(Integer.MAX_VALUE); } + + /** + * Returns the selection mode for this virtual list. + * + * @return the selection mode, not null + */ + public SelectionMode getSelectionMode() { + assert selectionMode != null : "No selection mode set by " + + getClass().getName() + " constructor"; + return selectionMode; + } + + /** + * Sets the virtual list's selection mode. + * + * @param selectionMode + * the selection mode to switch to, not {@code null} + * @return the used selection model + * + * @see SelectionMode + * @see VirtualListSingleSelectionModel + * @see VirtualListMultiSelectionModel + * @see VirtualListNoneSelectionModel + */ + public SelectionModel, T> setSelectionMode( + SelectionMode selectionMode) { + Objects.requireNonNull(selectionMode, "Selection mode cannot be null."); + + if (selectionMode == SelectionMode.SINGLE) { + setSelectionModel(new VirtualListSingleSelectionModel<>(this), + selectionMode); + } else if (selectionMode == SelectionMode.MULTI) { + setSelectionModel(new VirtualListMultiSelectionModel<>(this), + selectionMode); + } else { + setSelectionModel(new VirtualListNoneSelectionModel<>(), + selectionMode); + } + return selectionModel; + } + + private Set getItemsFromKeys(JsonArray keys) { + return JsonUtils.stream(keys).map( + key -> getDataCommunicator().getKeyMapper().get(key.asString())) + .filter(Objects::nonNull).collect(Collectors.toSet()); + } + + @SuppressWarnings("unchecked") + @ClientCallable + private void updateSelection(JsonArray addedKeys, JsonArray removedKeys) { + var addedItems = getItemsFromKeys(addedKeys); + var removedItems = getItemsFromKeys(removedKeys); + + if (selectionModel instanceof VirtualListSingleSelectionModel model) { + model.setSelectedItem( + addedItems.isEmpty() ? null : addedItems.iterator().next()); + } else if (selectionModel instanceof VirtualListMultiSelectionModel model) { + model.updateSelection(addedItems, removedItems); + } + } + + /** + * Sets the selection model for the virtual list. + *

+ * The default selection model is {@link VirtualListNoneSelectionModel}. + * + * @param model + * the selection model to use, not {@code null} + * @param selectionMode + * the selection mode this selection model corresponds to, not + * {@code null} + * + * @see #setSelectionMode(SelectionMode) + */ + private void setSelectionModel(SelectionModel, T> model, + SelectionMode selectionMode) { + Objects.requireNonNull(model, "selection model cannot be null"); + Objects.requireNonNull(selectionMode, "selection mode cannot be null"); + + if (this.selectionModel != null) { + // Reset existing selections + this.selectionModel.deselectAll(); + } + + selectionModel = model; + this.selectionMode = selectionMode; + + getElement().removeProperty("__deselectionDisallowed"); + getElement().setProperty("selectionMode", selectionMode.name().toLowerCase()); + } + + /** + * Adds a selection listener to the current selection model. + *

+ * This is a shorthand for + * {@code virtualList.getSelectionModel().addSelectionListener()}. To get + * more detailed selection events, use {@link #getSelectionModel()} and + * either + * {@link VirtualListSingleSelectionModel#addSelectionListener(SelectionListener) + * or + * {@link VirtualListMultiSelectionModel#addSelectionListener(SelectionListener) + * depending on the used selection mode. + * + * @param listener + * the listener to add + * @return a registration handle to remove the listener + * @throws UnsupportedOperationException + * if {@link SelectionMode#NONE} is in use + */ + public Registration addSelectionListener( + SelectionListener, T> listener) { + return getSelectionModel().addSelectionListener(listener); + } + + /** + * Use this virtual list as a single select in {@link Binder}. + *

+ * Throws {@link IllegalStateException} if the virtual list is not using a + * {@link VirtualListSingleSelectionModel}. + * + * @return the single select wrapper that can be used in binder + * @throws IllegalStateException + * if not using a single selection model + */ + public SingleSelect, T> asSingleSelect() { + var model = getSelectionModel(); + if (!(model instanceof VirtualListSingleSelectionModel)) { + throw new IllegalStateException( + "VirtualList is not in single select mode, " + + "it needs to be explicitly set to such with " + + "setSelectionMode(SelectionMode.SINGLE) before " + + "being able to use single selection features."); + } + return ((VirtualListSingleSelectionModel) model).asSingleSelect(); + } + + /** + * Use this virtual list as a multiselect in {@link Binder}. + *

+ * Throws {@link IllegalStateException} if the virtual list is not using a + * {@link VirtualListMultiSelectionModel}. + * + * @return the multiselect wrapper that can be used in binder + * @throws IllegalStateException + * if not using a multiselection model + */ + public MultiSelect, T> asMultiSelect() { + var model = getSelectionModel(); + if (!(model instanceof VirtualListMultiSelectionModel)) { + throw new IllegalStateException( + "VirtualList is not in multi select mode, " + + "it needs to be explicitly set to such with " + + "setSelectionMode(SelectionMode.MULTI) before " + + "being able to use multi selection features."); + } + return ((VirtualListMultiSelectionModel) model).asMultiSelect(); + } + + /** + * This method is a shorthand that delegates to the currently set selection + * model. + * + * @see #getSelectionModel() + * @see VirtualListSingleSelectionModel#getSelectedItems() + * @see VirtualListMultiSelectionModel#getSelectedItems() + * + * @return a set with the selected items, never null + */ + public Set getSelectedItems() { + return getSelectionModel().getSelectedItems(); + } + + /** + * This method is a shorthand that delegates to the currently set selection + * model. + * + * @param item + * the item to select, not null + * + * @see #getSelectionModel() + * @see VirtualListSingleSelectionModel#select(Object) + * @see VirtualListMultiSelectionModel#select(Object) + */ + public void select(T item) { + getSelectionModel().select(item); + } + + /** + * This method is a shorthand that delegates to the currently set selection + * model. + * + * @param item + * the item to deselect, not null + * + * @see #getSelectionModel() + * @see VirtualListSingleSelectionModel#deselect(Object) + * @see VirtualListMultiSelectionModel#deselect(Object) + */ + public void deselect(T item) { + getSelectionModel().deselect(item); + } + + /** + * This method is a shorthand that delegates to the currently set selection + * model. + * + * @see #getSelectionModel() + * @see VirtualListSingleSelectionModel#deselectAll() + * @see VirtualListMultiSelectionModel#deselectAll() + */ + public void deselectAll() { + getSelectionModel().deselectAll(); + } + + /** + * Returns the selection model for this virtual list. + * + * @return the selection model, not null + */ + public SelectionModel, T> getSelectionModel() { + assert selectionModel != null : "No selection model set by " + + getClass().getName() + " constructor"; + return selectionModel; + } + + /** + * A function that generates accessible names for virtual list items. + * + * @param itemAccessibleNameGenerator + * the item accessible name generator to set, not {@code null} + * @throws NullPointerException + * if {@code itemAccessibleNameGenerator} is {@code null} + */ + public void setItemAccessibleNameGenerator( + SerializableFunction itemAccessibleNameGenerator) { + Objects.requireNonNull(itemAccessibleNameGenerator, + "Part name generator can not be null"); + this.itemAccessibleNameGenerator = itemAccessibleNameGenerator; + getDataCommunicator().reset(); + } + + /** + * Gets the function that generates accessible names for virtual list items. + * + * @return the item accessible name generator + */ + public SerializableFunction getItemAccessibleNameGenerator() { + return itemAccessibleNameGenerator; + } + + /** + * Selection mode representing the built-in selection models in virtual + * list. + *

+ * These enums can be used in + * {@link VirtualList#setSelectionMode(SelectionMode)} to easily switch + * between the built-in selection models. + * + * @see VirtualList#setSelectionMode(SelectionMode) + * @see VirtualList#setSelectionModel(SelectionModel, SelectionMode) + */ + public enum SelectionMode { + + /** + * Single selection mode that maps to built-in {@link Single}. + * + * @see VirtualListSingleSelectionModel + */ + SINGLE, + + /** + * Multiselection mode that maps to built-in + * {@link SelectionModel.Multi}. + * + * @see VirtualListMultiSelectionModelπ + */ + MULTI, + + /** + * Selection model that doesn't allow selection. + * + * @see VirtualListNoneSelectionModel + */ + NONE; + } } diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualListMultiSelectionModel.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualListMultiSelectionModel.java new file mode 100644 index 00000000000..3da51799f63 --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualListMultiSelectionModel.java @@ -0,0 +1,247 @@ +/* + * 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.virtuallist; + +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import com.vaadin.flow.component.AbstractField.ComponentValueChangeEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.ComponentUtil; +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.data.selection.SelectionEvent; +import com.vaadin.flow.data.selection.SelectionListener; +import com.vaadin.flow.data.selection.SelectionModel; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.shared.Registration; + +/** + * Implementation of a SelectionModel.Multi. + * + * @param + * the virtual list bean type + * @author Vaadin Ltd. + */ +public class VirtualListMultiSelectionModel + implements SelectionModel.Multi, T> { + + private final Map selected; + private VirtualList list; + + /** + * Constructor for passing a reference of the virtual list to this + * implementation. + * + * @param list + * reference to the virtual list for which this selection model + * is created + */ + public VirtualListMultiSelectionModel(VirtualList list) { + this.list = list; + selected = new LinkedHashMap<>(); + } + + @Override + public Set getSelectedItems() { + /* + * A new LinkedHashSet is created to avoid + * ConcurrentModificationExceptions when changing the selection during + * an iteration + */ + return Collections + .unmodifiableSet(new LinkedHashSet<>(selected.values())); + } + + /** + * Returns an unmodifiable view of the selected item ids. + *

+ * Exposed to be overridden within subclasses. + *

+ * The returned Set may be a direct view of the internal data structures of + * this class. A defensive copy should be made by callers when iterating + * over this Set and modifying the selection during iteration to avoid + * ConcurrentModificationExceptions. + * + * @return An unmodifiable view of the selected item ids. Updates in the + * selection may or may not be directly reflected in the Set. + */ + protected Set getSelectedItemIds() { + return Collections.unmodifiableSet(this.selected.keySet()); + } + + @Override + public Optional getFirstSelectedItem() { + return selected.values().stream().findFirst(); + } + + @Override + public void select(T item) { + Set selected = new HashSet<>(); + if (item != null) { + selected.add(item); + } + + doUpdateSelection(selected, Collections.emptySet(), false); + } + + @Override + public void deselect(T item) { + Set deselected = new HashSet<>(); + if (item != null) { + deselected.add(item); + } + doUpdateSelection(Collections.emptySet(), deselected, false); + } + + @Override + public void selectAll() { + updateSelection( + (Set) list.getDataCommunicator().getDataProvider() + .fetch(list.getDataCommunicator().buildQuery(0, + Integer.MAX_VALUE)) + .collect(Collectors.toSet()), + Collections.emptySet()); + } + + @Override + public void deselectAll() { + updateSelection(Collections.emptySet(), getSelectedItems()); + } + + @Override + public void updateSelection(Set addedItems, Set removedItems) { + Objects.requireNonNull(addedItems, "added items cannot be null"); + Objects.requireNonNull(removedItems, "removed items cannot be null"); + doUpdateSelection(addedItems, removedItems, false); + } + + private Map mapItemsById(Set items) { + return items.stream().collect(LinkedHashMap::new, + (map, item) -> map.put(this.getItemId(item), item), + Map::putAll); + } + + private void doUpdateSelection(Set addedItems, Set removedItems, + boolean userOriginated) { + Map addedItemsMap = mapItemsById(addedItems); + Map removedItemsMap = mapItemsById(removedItems); + addedItemsMap.keySet().stream().filter(removedItemsMap::containsKey) + .collect(Collectors.toList()).forEach(key -> { + addedItemsMap.remove(key); + removedItemsMap.remove(key); + }); + doUpdateSelection(addedItemsMap, removedItemsMap, userOriginated); + } + + private void doUpdateSelection(Map addedItems, + Map removedItems, boolean userOriginated) { + if (selected.keySet().containsAll(addedItems.keySet()) && Collections + .disjoint(selected.keySet(), removedItems.keySet())) { + return; + } + + Set oldSelection = getSelectedItems(); + removedItems.keySet().forEach(selected::remove); + selected.putAll(addedItems); + + ComponentUtil.fireEvent(list, new MultiSelectionEvent<>(list, + asMultiSelect(), oldSelection, userOriginated)); + } + + @Override + public boolean isSelected(T item) { + return selected.containsKey(getItemId(item)); + } + + public MultiSelect, T> asMultiSelect() { + return new MultiSelect, T>() { + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Override + public Registration addValueChangeListener( + ValueChangeListener, Set>> listener) { + Objects.requireNonNull(listener, "listener cannot be null"); + + ComponentEventListener componentEventListener = event -> listener + .valueChanged( + (ComponentValueChangeEvent, Set>) event); + + return ComponentUtil.addListener(list, + MultiSelectionEvent.class, componentEventListener); + } + + @Override + public Registration addSelectionListener( + MultiSelectionListener, T> listener) { + return addMultiSelectionListener(listener); + } + + @Override + public void deselectAll() { + VirtualListMultiSelectionModel.this.deselectAll(); + } + + @Override + public void updateSelection(Set addedItems, + Set removedItems) { + VirtualListMultiSelectionModel.this.updateSelection(addedItems, + removedItems); + } + + @Override + public Element getElement() { + return list.getElement(); + } + + @Override + public Set getSelectedItems() { + return VirtualListMultiSelectionModel.this.getSelectedItems(); + } + }; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Override + public Registration addSelectionListener( + SelectionListener, T> listener) { + Objects.requireNonNull(listener, "listener cannot be null"); + return ComponentUtil.addListener(list, MultiSelectionEvent.class, + (ComponentEventListener) (event -> listener + .selectionChange((SelectionEvent) event))); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public Registration addMultiSelectionListener( + MultiSelectionListener, T> listener) { + Objects.requireNonNull(listener, "listener cannot be null"); + return ComponentUtil.addListener(list, MultiSelectionEvent.class, + (ComponentEventListener) (event -> listener + .selectionChange((MultiSelectionEvent) event))); + } + + private Object getItemId(T item) { + return list.getDataCommunicator().getDataProvider().getId(item); + } +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualListNoneSelectionModel.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualListNoneSelectionModel.java new file mode 100644 index 00000000000..ee66ce63cc4 --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualListNoneSelectionModel.java @@ -0,0 +1,68 @@ +/* + * 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.virtuallist; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +import com.vaadin.flow.data.selection.SelectionListener; +import com.vaadin.flow.data.selection.SelectionModel; +import com.vaadin.flow.shared.Registration; + +/** + * Selection model implementation for disabling selection in VirtualList. + * + * @param + * the virtual list bean type + */ +public class VirtualListNoneSelectionModel + implements SelectionModel, T> { + + @Override + public Set getSelectedItems() { + return Collections.emptySet(); + } + + @Override + public Optional getFirstSelectedItem() { + return Optional.empty(); + } + + @Override + public void select(T item) { + // NO-OP + } + + @Override + public void deselect(T item) { + // NO-OP + } + + @Override + public void deselectAll() { + // NO-OP + } + + @Override + public Registration addSelectionListener( + SelectionListener, T> listener) { + throw new UnsupportedOperationException( + "This selection model doesn't allow selection, cannot add selection listeners to it. " + + "Please set suitable selection mode with virtualList.setSelectionMode"); + } + +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualListSingleSelectionModel.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualListSingleSelectionModel.java new file mode 100644 index 00000000000..74576248895 --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualListSingleSelectionModel.java @@ -0,0 +1,146 @@ +/* + * 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.virtuallist; + +import java.util.Objects; +import java.util.Optional; + +import com.vaadin.flow.component.AbstractField.ComponentValueChangeEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.ComponentUtil; +import com.vaadin.flow.data.selection.SelectionEvent; +import com.vaadin.flow.data.selection.SelectionListener; +import com.vaadin.flow.data.selection.SelectionModel; +import com.vaadin.flow.data.selection.SingleSelect; +import com.vaadin.flow.data.selection.SingleSelectionEvent; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.shared.Registration; + +/** + * Implementation of a SelectionModel.Single. + * + * @param + * the virtual list bean type + * @author Vaadin Ltd. + */ +public class VirtualListSingleSelectionModel + implements SelectionModel.Single, T> { + + private T selectedItem; + private VirtualList list; + + /** + * Constructor for passing a reference of the virtual list to this + * implementation. + * + * @param list + * reference to the virtual list for which this selection model + * is created + */ + public VirtualListSingleSelectionModel(VirtualList list) { + this.list = list; + } + + @Override + public void select(T item) { + if (isSelected(item)) { + return; + } + doSelect(item, false); + } + + @Override + public void deselect(T item) { + select(null); + } + + @Override + public boolean isSelected(T item) { + return Objects.equals(getItemId(item), getItemId(selectedItem)); + } + + @Override + public Optional getSelectedItem() { + return Optional.ofNullable(selectedItem); + } + + @Override + public void setDeselectAllowed(boolean deselectAllowed) { + list.getElement().setProperty("__deselectionDisallowed", + !deselectAllowed); + } + + @Override + public boolean isDeselectAllowed() { + return !list.getElement().getProperty("__deselectionDisallowed", false); + } + + public SingleSelect, T> asSingleSelect() { + return new SingleSelect, T>() { + + @Override + public void setValue(T value) { + setSelectedItem(value); + } + + @Override + public T getValue() { + return getSelectedItem().orElse(getEmptyValue()); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Override + public Registration addValueChangeListener( + ValueChangeListener, T>> listener) { + Objects.requireNonNull(listener, "listener cannot be null"); + ComponentEventListener componentEventListener = event -> listener + .valueChanged( + (ComponentValueChangeEvent, T>) event); + + return ComponentUtil.addListener(list, + SingleSelectionEvent.class, componentEventListener); + } + + @Override + public Element getElement() { + return list.getElement(); + } + }; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Override + public Registration addSelectionListener( + SelectionListener, T> listener) { + Objects.requireNonNull(listener, "listener cannot be null"); + + return ComponentUtil.addListener(list, SingleSelectionEvent.class, + (ComponentEventListener) (event -> listener + .selectionChange((SelectionEvent) event))); + } + + private void doSelect(T item, boolean userOriginated) { + T oldValue = selectedItem; + selectedItem = item; + ComponentUtil.fireEvent(list, new SingleSelectionEvent<>(list, + asSingleSelect(), oldValue, true)); + } + + private Object getItemId(T item) { + return item == null ? null + : list.getDataCommunicator().getDataProvider().getId(item); + } +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/resources/META-INF/resources/frontend/virtualListConnector.js b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/resources/META-INF/resources/frontend/virtualListConnector.js index 9089923d397..6f728471870 100644 --- a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/resources/META-INF/resources/frontend/virtualListConnector.js +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/resources/META-INF/resources/frontend/virtualListConnector.js @@ -15,6 +15,8 @@ window.Vaadin.Flow.virtualListConnector = { list.$connector = {}; list.$connector.placeholderItem = { __placeholder: true }; + list.itemAccessibleNameGenerator = (item) => item && item.accessibleName; + const updateRequestedItem = function () { /* * TODO virtual list seems to do a small index adjustment after scrolling @@ -105,7 +107,7 @@ window.Vaadin.Flow.virtualListConnector = { list.$connector.set = function (index, items) { list.items.splice(index, items.length, ...items); - list.items = [...list.items]; + list.$connector.updateItems([...list.items]); }; list.$connector.clear = function (index, length) { @@ -120,7 +122,7 @@ window.Vaadin.Flow.virtualListConnector = { return map; }, {}); - list.items = list.items.map((item) => { + const newItems = list.items.map((item) => { // Items can be undefined if they are outside the viewport if (!item) { return item; @@ -129,14 +131,15 @@ window.Vaadin.Flow.virtualListConnector = { // return existing item as fallback if it was not updated return updatedItemsMap[item.key] || item; }); + list.$connector.updateItems(newItems); }; list.$connector.updateSize = function (newSize) { const delta = newSize - list.items.length; if (delta > 0) { - list.items = [...list.items, ...Array(delta)]; + list.$connector.updateItems([...list.items, ...Array(delta)]); } else if (delta < 0) { - list.items = list.items.slice(0, newSize); + list.$connector.updateItems(list.items.slice(0, newSize)); } }; @@ -146,5 +149,58 @@ window.Vaadin.Flow.virtualListConnector = { const nodeId = Object.entries(placeholderItem).find(([key]) => key.endsWith('_nodeid')); list.$connector.placeholderElement = nodeId ? Vaadin.Flow.clients[appId].getByNodeId(nodeId[1]) : null; }; + + list.$connector.updateItems = function (items) { + // Update the virtual list's items + list.items = items; + + // Update the virtual list's selectedItems + list.$connector.__updatingSelectedItemsFromServer = true; + list.selectedItems = items.filter((item) => item && item.selected); + list.$connector.__updatingSelectedItemsFromServer = false; + }; + + let previousSelectedKeys = []; + + // This listener is used to prevent user from de-selecting the selected item when deselection is disallowed + list.addEventListener('selected-items-changed', function (event) { + if (list.$connector.__revertingSelection) { + // Reverting the (de)selection, stop the event and don't do anything + event.stopImmediatePropagation(); + return; + } + + if ( + list.selectionMode === 'single' && + list.__deselectionDisallowed && + previousSelectedKeys.length && + list.selectedItems.length === 0 && + !list.$connector.__updatingSelectedItemsFromServer + ) { + event.stopImmediatePropagation(); + list.$connector.__revertingSelection = true; + list.selectedItems = list.items.filter((item) => item && item.selected); + list.$connector.__revertingSelection = false; + } + }); + + list.addEventListener('selected-items-changed', function (event) { + const selectedKeys = event.detail.value.map((item) => item.key); + const addedKeys = selectedKeys.filter((key) => !previousSelectedKeys.includes(key)); + const removedKeys = previousSelectedKeys.filter((key) => !selectedKeys.includes(key)); + previousSelectedKeys = selectedKeys; + + if (list.$connector.__updatingSelectedItemsFromServer) { + // Items are being updated from the server, don't send the selection changes back + return; + } + + // If server sends partial updates while still making selections, other items might get temporarily + // de-selected / selected if their state is now yet synced from the server. + // Workaround the issue by updating the item selection state immediately on the client. + list.items.filter((item) => item).forEach((item) => (item.selected = selectedKeys.includes(item.key))); + + list.$server.updateSelection(addedKeys, removedKeys); + }); } }; diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListAsMultiSelectTest.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListAsMultiSelectTest.java new file mode 100644 index 00000000000..5a0ac38e936 --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListAsMultiSelectTest.java @@ -0,0 +1,226 @@ +/* + * 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.virtuallist.tests; + +import java.util.Collections; +import java.util.Set; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import com.vaadin.flow.component.virtuallist.VirtualList; +import com.vaadin.flow.component.virtuallist.VirtualList.SelectionMode; +import com.vaadin.flow.data.binder.Binder; +import com.vaadin.flow.data.selection.MultiSelect; +import com.vaadin.flow.data.selection.MultiSelectionListener; + +/** + * Tests using selection via VirtualList's MultiSelect API. + */ +public class VirtualListAsMultiSelectTest { + + private MultiSelect, String> multiSelect; + private MultiSelectionListener, String> selectionListenerSpy; + + @SuppressWarnings("unchecked") + @Before + public void setUp() { + var list = new VirtualList(); + list.setItems("1", "2", "3", "4", "5"); + list.setSelectionMode(SelectionMode.MULTI); + multiSelect = list.asMultiSelect(); + selectionListenerSpy = Mockito.mock(MultiSelectionListener.class); + multiSelect.addSelectionListener(selectionListenerSpy); + } + + @Test + public void isSelected() { + multiSelect.select("2", "3"); + + Assert.assertTrue(multiSelect.isSelected("2")); + Assert.assertTrue(multiSelect.isSelected("3")); + + Assert.assertFalse(multiSelect.isSelected("1")); + Assert.assertFalse(multiSelect.isSelected("4")); + Assert.assertFalse(multiSelect.isSelected("5")); + Assert.assertFalse(multiSelect.isSelected("99")); + } + + @Test + public void getSelectedItems() { + multiSelect.select("2", "3"); + + Assert.assertEquals(Set.of("2", "3"), multiSelect.getSelectedItems()); + } + + @Test + public void getElement() { + Assert.assertEquals("vaadin-virtual-list", + multiSelect.getElement().getTag()); + } + + @Test + public void setValue_updatesSelectionAndTriggersSelectionListener() { + multiSelect.setValue(Set.of("2", "3")); + + Assert.assertEquals(Set.of("2", "3"), multiSelect.getValue()); + Assert.assertEquals(Set.of("2", "3"), multiSelect.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void setValue_setExistingValue_noChanges() { + multiSelect.setValue(Set.of("2", "3")); + Mockito.reset(selectionListenerSpy); + multiSelect.setValue(Set.of("2", "3")); + + Assert.assertEquals(Set.of("2", "3"), multiSelect.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void setValue_setDifferentValue_selectionChanged() { + multiSelect.setValue(Set.of("2", "3")); + Mockito.reset(selectionListenerSpy); + multiSelect.setValue(Set.of("1", "2")); + + Assert.assertEquals(Set.of("1", "2"), multiSelect.getValue()); + Assert.assertEquals(Set.of("1", "2"), multiSelect.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void changeSelection_updatesSelectionAndValueAndTriggersSelectionListener() { + multiSelect.select("1", "2", "3"); + Assert.assertEquals(Set.of("1", "2", "3"), + multiSelect.getSelectedItems()); + Assert.assertEquals(Set.of("1", "2", "3"), multiSelect.getValue()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + Mockito.reset(selectionListenerSpy); + + multiSelect.deselect("2", "3"); + Assert.assertEquals(Set.of("1"), multiSelect.getSelectedItems()); + Assert.assertEquals(Set.of("1"), multiSelect.getValue()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + Mockito.reset(selectionListenerSpy); + + multiSelect.deselectAll(); + Assert.assertEquals(Set.of(), multiSelect.getSelectedItems()); + Assert.assertEquals(Set.of(), multiSelect.getValue()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + Mockito.reset(selectionListenerSpy); + } + + @SuppressWarnings("unchecked") + @Test + public void updateSelection_updatesSelectionAndValueAndTriggersSelectionListener() { + multiSelect.updateSelection(Set.of("1", "2", "3"), + Collections.emptySet()); + Assert.assertEquals(Set.of("1", "2", "3"), + multiSelect.getSelectedItems()); + Assert.assertEquals(Set.of("1", "2", "3"), multiSelect.getValue()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + Mockito.reset(selectionListenerSpy); + + multiSelect.updateSelection(Collections.emptySet(), Set.of("2", "3")); + Assert.assertEquals(Set.of("1"), multiSelect.getSelectedItems()); + Assert.assertEquals(Set.of("1"), multiSelect.getValue()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + Mockito.reset(selectionListenerSpy); + } + + @SuppressWarnings("unchecked") + @Test + public void selectExistingItems_noChanges() { + multiSelect.select("1", "2", "3"); + Mockito.reset(selectionListenerSpy); + + multiSelect.select(); + multiSelect.select("1"); + multiSelect.select("1", "2"); + multiSelect.select("1", "2", "3"); + Assert.assertEquals(Set.of("1", "2", "3"), + multiSelect.getSelectedItems()); + Assert.assertEquals(Set.of("1", "2", "3"), multiSelect.getValue()); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void deselectUnselectedItems_noChanges() { + multiSelect.select("1", "2", "3"); + Mockito.reset(selectionListenerSpy); + + multiSelect.deselect(); + multiSelect.deselect("4", "5"); + Assert.assertEquals(Set.of("1", "2", "3"), + multiSelect.getSelectedItems()); + Assert.assertEquals(Set.of("1", "2", "3"), multiSelect.getValue()); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .selectionChange(Mockito.any()); + } + + @Test + public void emptySelection_deselectAll_noChanges() { + multiSelect.deselectAll(); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .selectionChange(Mockito.any()); + } + + @Test + public void binderTest() { + var binder = new Binder(Person.class); + binder.bind(multiSelect, "values"); + + var person = new Person(Set.of("1")); + binder.setBean(person); + + multiSelect.select("2"); + + Assert.assertEquals(Set.of("1", "2"), multiSelect.getSelectedItems()); + Assert.assertEquals(Set.of("1", "2"), person.getValues()); + } + + public static class Person { + private Set values; + + public Person(Set values) { + this.values = values; + } + + public Set getValues() { + return values; + } + + public void setValues(Set values) { + this.values = values; + } + } +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListAsSingleSelectTest.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListAsSingleSelectTest.java new file mode 100644 index 00000000000..a0426c10601 --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListAsSingleSelectTest.java @@ -0,0 +1,142 @@ +/* + * 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.virtuallist.tests; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import com.vaadin.flow.component.HasValue.ValueChangeEvent; +import com.vaadin.flow.component.HasValue.ValueChangeListener; +import com.vaadin.flow.component.virtuallist.VirtualList; +import com.vaadin.flow.component.virtuallist.VirtualList.SelectionMode; +import com.vaadin.flow.data.binder.Binder; +import com.vaadin.flow.data.selection.SingleSelect; + +/** + * Tests using selection via VirtualList's SingleSelect API. + */ +public class VirtualListAsSingleSelectTest { + + private SingleSelect, String> singleSelect; + private ValueChangeListener> selectionListenerSpy; + + @SuppressWarnings("unchecked") + @Before + public void setUp() { + var list = new VirtualList(); + list.setItems("1", "2", "3", "4", "5"); + list.setSelectionMode(SelectionMode.SINGLE); + singleSelect = list.asSingleSelect(); + selectionListenerSpy = Mockito.mock(ValueChangeListener.class); + singleSelect.addValueChangeListener(selectionListenerSpy); + } + + @Test + public void getValue() { + singleSelect.setValue("2"); + + Assert.assertEquals("2", singleSelect.getValue()); + } + + @Test + public void getElement() { + Assert.assertEquals("vaadin-virtual-list", + singleSelect.getElement().getTag()); + } + + @Test + public void setValue_triggersSelectionListener() { + singleSelect.setValue("2"); + + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .valueChanged(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void setValue_setExistingValue_noChanges() { + singleSelect.setValue("2"); + Mockito.reset(selectionListenerSpy); + singleSelect.setValue("2"); + + Assert.assertEquals("2", singleSelect.getValue()); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .valueChanged(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void setValue_setDifferentValue_selectionChanged() { + singleSelect.setValue("2"); + Mockito.reset(selectionListenerSpy); + singleSelect.setValue("3"); + + Assert.assertEquals("3", singleSelect.getValue()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .valueChanged(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void clear_updatesSelectionAndValueAndTriggersSelectionListener() { + singleSelect.setValue("2"); + Mockito.reset(selectionListenerSpy); + + singleSelect.clear(); + Assert.assertEquals(null, singleSelect.getValue()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .valueChanged(Mockito.any()); + } + + @Test + public void emptySelection_clear_noChanges() { + singleSelect.clear(); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .valueChanged(Mockito.any()); + } + + @Test + public void binderTest() { + var binder = new Binder(Person.class); + binder.bind(singleSelect, "value"); + + var person = new Person("1"); + binder.setBean(person); + + singleSelect.setValue("2"); + + Assert.assertEquals("2", singleSelect.getValue()); + Assert.assertEquals("2", person.getValue()); + } + + public static class Person { + private String value; + + public Person(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListMultiSelectionTest.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListMultiSelectionTest.java new file mode 100644 index 00000000000..17e668a70c9 --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListMultiSelectionTest.java @@ -0,0 +1,249 @@ +/* + * 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.virtuallist.tests; + +import static com.vaadin.flow.component.virtuallist.tests.VirtualListTestHelpers.*; + +import java.util.Set; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import com.vaadin.flow.component.virtuallist.VirtualList; +import com.vaadin.flow.component.virtuallist.VirtualList.SelectionMode; +import com.vaadin.flow.data.provider.CompositeDataGenerator; +import com.vaadin.flow.data.provider.DataGenerator; +import com.vaadin.flow.data.selection.SelectionListener; +import com.vaadin.flow.data.selection.SelectionModel; +import com.vaadin.flow.data.selection.SelectionModel.Multi; + +/** + * Tests multi-selectable VirtualList + */ +public class VirtualListMultiSelectionTest { + + private VirtualList list; + private SelectionListener, String> selectionListenerSpy; + private CompositeDataGenerator dataGenerator; + private DataGenerator dataGeneratorSpy; + private SelectionModel.Multi, String> selectionModel; + + @SuppressWarnings("unchecked") + @Before + public void setUp() { + list = new VirtualList<>(); + list.setItems("1", "2", "3", "4", "5"); + selectionModel = (Multi, String>) list + .setSelectionMode(SelectionMode.MULTI); + + selectionListenerSpy = Mockito.mock(SelectionListener.class); + list.addSelectionListener(selectionListenerSpy); + + dataGenerator = getDataGenerator(list); + dataGeneratorSpy = Mockito.mock(DataGenerator.class); + dataGenerator.addDataGenerator(dataGeneratorSpy); + } + + @Test + public void setsWebComponentSelectionMode() { + Assert.assertEquals("multi", + list.getElement().getProperty("selectionMode")); + } + + @Test + public void getSelectionMode_returnsMode() { + Assert.assertEquals(SelectionMode.MULTI, list.getSelectionMode()); + } + + @Test + public void setSelectionMode_returnsModel() { + var model = list.setSelectionMode(SelectionMode.MULTI); + Assert.assertEquals(list.getSelectionModel(), model); + } + + @Test(expected = IllegalStateException.class) + public void asSingleSelect_throwsIfSelectionModeIsMulti() { + list.asSingleSelect(); + } + + @Test + public void getSelectedItems() { + list.select("2"); + list.select("3"); + + Assert.assertEquals(Set.of("2", "3"), list.getSelectedItems()); + } + + @Test + public void select_triggersSelectionListener() { + list.select("1"); + + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void select_selectExistingValue_noChanges() { + list.select("1"); + Mockito.reset(selectionListenerSpy); + list.select("1"); + + Assert.assertEquals(Set.of("1"), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void select_selectDifferentValue_selectionChanged() { + list.select("1"); + Mockito.reset(selectionListenerSpy); + list.select("2"); + + Assert.assertEquals(Set.of("1", "2"), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void select_deselect_selectionChanged() { + list.select("1"); + Mockito.reset(selectionListenerSpy); + list.deselect("1"); + + Assert.assertEquals(Set.of(), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void deselectNonSelectedValue_noChanges() { + list.select("1"); + Mockito.reset(selectionListenerSpy); + list.deselect("2"); + + Assert.assertEquals(Set.of("1"), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void selecti_deselectAll_selectionChanged() { + list.select("1"); + Mockito.reset(selectionListenerSpy); + list.deselectAll(); + Assert.assertEquals(Set.of(), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @Test + public void emptySelection_deselectAll_noChanges() { + list.deselectAll(); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .selectionChange(Mockito.any()); + } + + @Test + public void selectAll_selectionChanged() { + selectionModel.selectAll(); + Assert.assertEquals(Set.of("1", "2", "3", "4", "5"), + list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void selectAll_selectAll_selectionChanged() { + selectionModel.selectAll(); + Mockito.reset(selectionListenerSpy); + selectionModel.selectAll(); + Assert.assertEquals(Set.of("1", "2", "3", "4", "5"), + list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .selectionChange(Mockito.any()); + } + + @Test + public void select_generateItemSelected() { + list.select("1"); + Assert.assertTrue(generatesSelected(dataGenerator, "1")); + Assert.assertFalse(generatesSelected(dataGenerator, "2")); + } + + @Test + public void deselect_generateItemSelected() { + list.select("1"); + list.deselect("1"); + Assert.assertFalse(generatesSelected(dataGenerator, "1")); + } + + @Test + public void select_generateItemData() { + list.select("1"); + Mockito.verify(dataGeneratorSpy, Mockito.times(1)) + .refreshData(Mockito.eq("1")); + } + + @SuppressWarnings("unchecked") + @Test + public void deselect_generateItemData() { + list.select("1"); + Mockito.reset(dataGeneratorSpy); + list.deselect("1"); + Mockito.verify(dataGeneratorSpy, Mockito.times(1)) + .refreshData(Mockito.eq("1")); + } + + @Test + public void updateSelectionFromClient_itemsSelected() { + updateSelectionFromClient(list, Set.of("1", "2"), Set.of()); + Assert.assertEquals(Set.of("1", "2"), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void updateSelectionFromClient_itemsChanged() { + list.select("1"); + list.select("2"); + Mockito.reset(selectionListenerSpy); + updateSelectionFromClient(list, Set.of("3"), Set.of("1")); + Assert.assertEquals(Set.of("2", "3"), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @Test + public void getFirstSelectedItem() { + Assert.assertFalse(selectionModel.getFirstSelectedItem().isPresent()); + } + + @Test + public void select_getFirstSelectedItem() { + list.select("1"); + Assert.assertEquals("1", selectionModel.getFirstSelectedItem().get()); + } + +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListNoneSelectionTest.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListNoneSelectionTest.java new file mode 100644 index 00000000000..307180db295 --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListNoneSelectionTest.java @@ -0,0 +1,61 @@ +/* + * 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.virtuallist.tests; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import com.vaadin.flow.component.virtuallist.VirtualList; + +public class VirtualListNoneSelectionTest { + + private VirtualList list; + + @Before + public void setup() { + list = new VirtualList<>(); + list.setItems("foo", "bar", "baz"); + } + + @Test(expected = UnsupportedOperationException.class) + public void addSelectionListener_throws() { + list.addSelectionListener(e -> { + }); + } + + @Test(expected = IllegalStateException.class) + public void asSingleSelect_throws() { + list.asSingleSelect(); + } + + @Test(expected = IllegalStateException.class) + public void asMultiSelect_throws() { + list.asMultiSelect(); + } + + @Test + public void select_getSelectedItems_empty() { + list.select("foo"); + Assert.assertTrue(list.getSelectedItems().isEmpty()); + } + + @Test + public void clientSelectionMode() { + Assert.assertEquals("none", + list.getElement().getProperty("selectionMode")); + } +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionTest.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionTest.java new file mode 100644 index 00000000000..f93a37c6324 --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionTest.java @@ -0,0 +1,56 @@ +/* + * 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.virtuallist.tests; + +import java.util.Set; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import com.vaadin.flow.component.virtuallist.VirtualList; +import com.vaadin.flow.component.virtuallist.VirtualList.SelectionMode; +import com.vaadin.flow.data.selection.SelectionListener; + +/** + * Tests using selection via VirtualList's API. + */ +public class VirtualListSelectionTest { + + private VirtualList list; + private SelectionListener, String> selectionListenerSpy; + + @SuppressWarnings("unchecked") + @Before + public void setUp() { + list = new VirtualList<>(); + list.setItems("1", "2", "3", "4", "5"); + list.setSelectionMode(SelectionMode.MULTI); + selectionListenerSpy = Mockito.mock(SelectionListener.class); + list.addSelectionListener(selectionListenerSpy); + } + + @Test + public void select_updatesSelectionAndTriggersSelectionListener() { + list.select("2"); + + Assert.assertEquals(Set.of("2"), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSingleSelectionTest.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSingleSelectionTest.java new file mode 100644 index 00000000000..bda49d01343 --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSingleSelectionTest.java @@ -0,0 +1,248 @@ +/* + * 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.virtuallist.tests; + +import static com.vaadin.flow.component.virtuallist.tests.VirtualListTestHelpers.*; + +import java.util.Set; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import com.vaadin.flow.component.virtuallist.VirtualList; +import com.vaadin.flow.component.virtuallist.VirtualList.SelectionMode; +import com.vaadin.flow.component.virtuallist.VirtualListSingleSelectionModel; +import com.vaadin.flow.data.provider.CompositeDataGenerator; +import com.vaadin.flow.data.provider.DataGenerator; +import com.vaadin.flow.data.selection.SelectionListener; + +/** + * Tests single-selectable VirtualList + */ +public class VirtualListSingleSelectionTest { + + private VirtualList list; + private SelectionListener, String> selectionListenerSpy; + private CompositeDataGenerator dataGenerator; + private DataGenerator dataGeneratorSpy; + + @SuppressWarnings("unchecked") + @Before + public void setUp() { + list = new VirtualList<>(); + list.setItems("1", "2", "3", "4", "5"); + list.setSelectionMode(SelectionMode.SINGLE); + + selectionListenerSpy = Mockito.mock(SelectionListener.class); + list.addSelectionListener(selectionListenerSpy); + + dataGenerator = getDataGenerator(list); + dataGeneratorSpy = Mockito.mock(DataGenerator.class); + dataGenerator.addDataGenerator(dataGeneratorSpy); + } + + @Test + public void setsWebComponentSelectionMode() { + Assert.assertEquals("single", + list.getElement().getProperty("selectionMode")); + } + + @Test + public void getSelectionMode_returnsMode() { + Assert.assertEquals(SelectionMode.SINGLE, list.getSelectionMode()); + } + + @Test + public void setSelectionMode_returnsModel() { + var model = list.setSelectionMode(SelectionMode.SINGLE); + Assert.assertEquals(list.getSelectionModel(), model); + } + + @Test(expected = IllegalStateException.class) + public void asMultiSelect_throwsIfSelectionModeIsSingle() { + list.asMultiSelect(); + } + + @Test + public void getSelectedItems() { + list.select("2"); + list.select("3"); + + Assert.assertEquals(Set.of("3"), list.getSelectedItems()); + } + + @Test + public void select_triggersSelectionListener() { + list.select("1"); + + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void select_selectExistingValue_noChanges() { + list.select("1"); + Mockito.reset(selectionListenerSpy); + list.select("1"); + + Assert.assertEquals(Set.of("1"), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void select_selectDifferentValue_selectionChanged() { + list.select("1"); + Mockito.reset(selectionListenerSpy); + list.select("2"); + + Assert.assertEquals(Set.of("2"), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void select_deselect_selectionChanged() { + list.select("1"); + Mockito.reset(selectionListenerSpy); + list.deselect("1"); + + Assert.assertEquals(Set.of(), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void selecti_deselectAll_selectionChanged() { + list.select("1"); + Mockito.reset(selectionListenerSpy); + list.deselectAll(); + Assert.assertEquals(Set.of(), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @Test + public void emptySelection_deselectAll_noChanges() { + list.deselectAll(); + Mockito.verify(selectionListenerSpy, Mockito.times(0)) + .selectionChange(Mockito.any()); + } + + @Test + public void select_generateItemSelected() { + list.select("1"); + Assert.assertTrue(generatesSelected(dataGenerator, "1")); + Assert.assertFalse(generatesSelected(dataGenerator, "2")); + } + + @Test + public void deselect_generateItemSelected() { + list.select("1"); + list.deselect("1"); + Assert.assertFalse(generatesSelected(dataGenerator, "1")); + } + + @Test + public void select_generateItemData() { + list.select("1"); + Mockito.verify(dataGeneratorSpy, Mockito.times(1)) + .refreshData(Mockito.eq("1")); + } + + @SuppressWarnings("unchecked") + @Test + public void deselect_generateItemData() { + list.select("1"); + Mockito.reset(dataGeneratorSpy); + list.deselect("1"); + Mockito.verify(dataGeneratorSpy, Mockito.times(1)) + .refreshData(Mockito.eq("1")); + } + + @Test + public void updateSelectionFromClient_itemsSelected() { + updateSelectionFromClient(list, Set.of("1"), Set.of()); + Assert.assertEquals(Set.of("1"), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @SuppressWarnings("unchecked") + @Test + public void updateSelectionFromClient_itemsChanged() { + list.select("1"); + Mockito.reset(selectionListenerSpy); + updateSelectionFromClient(list, Set.of("3"), Set.of("1")); + Assert.assertEquals(Set.of("3"), list.getSelectedItems()); + Mockito.verify(selectionListenerSpy, Mockito.times(1)) + .selectionChange(Mockito.any()); + } + + @Test + public void deselectAllowed_defaultValue() { + var model = (VirtualListSingleSelectionModel) list + .getSelectionModel(); + Assert.assertTrue(model.isDeselectAllowed()); + } + + @Test + public void deselectAllowed_setDisallowed() { + var model = (VirtualListSingleSelectionModel) list + .getSelectionModel(); + model.setDeselectAllowed(false); + Assert.assertFalse(model.isDeselectAllowed()); + } + + @Test + public void deselectAllowed_changeMode() { + var model = (VirtualListSingleSelectionModel) list + .getSelectionModel(); + model.setDeselectAllowed(false); + list.setSelectionMode(SelectionMode.MULTI); + + model = (VirtualListSingleSelectionModel) list + .setSelectionMode(SelectionMode.SINGLE); + Assert.assertTrue(model.isDeselectAllowed()); + } + + @Test + public void deselectAllowed_setSameMode() { + var model = (VirtualListSingleSelectionModel) list + .getSelectionModel(); + model.setDeselectAllowed(false); + list.setSelectionMode(SelectionMode.SINGLE); + model = (VirtualListSingleSelectionModel) list + .setSelectionMode(SelectionMode.SINGLE); + Assert.assertTrue(model.isDeselectAllowed()); + } + + @Test + public void deselectDisallowed_allowProgrammaticDeselection() { + var model = (VirtualListSingleSelectionModel) list + .getSelectionModel(); + model.setDeselectAllowed(false); + list.select("1"); + list.deselect("1"); + Assert.assertEquals(Set.of(), list.getSelectedItems()); + } +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListTestHelpers.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListTestHelpers.java new file mode 100644 index 00000000000..0b3f288ee3e --- /dev/null +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListTestHelpers.java @@ -0,0 +1,71 @@ +/* + * 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.virtuallist.tests; + +import java.util.Set; + +import com.vaadin.flow.component.virtuallist.VirtualList; +import com.vaadin.flow.data.provider.CompositeDataGenerator; +import com.vaadin.flow.data.provider.DataGenerator; +import com.vaadin.flow.internal.JsonUtils; + +import elemental.json.Json; +import elemental.json.JsonArray; + +public class VirtualListTestHelpers { + + public static boolean generatesSelected(DataGenerator dataGenerator, + T item) { + var jsonObject = Json.createObject(); + dataGenerator.generateData(item, jsonObject); + return jsonObject.hasKey("selected"); + } + + @SuppressWarnings("unchecked") + public static CompositeDataGenerator getDataGenerator( + VirtualList list) { + try { + var dataGenerator = VirtualList.class + .getDeclaredField("dataGenerator"); + dataGenerator.setAccessible(true); + return (CompositeDataGenerator) dataGenerator.get(list); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + private static JsonArray getKeysFromItems(VirtualList list, + Set items) { + return JsonUtils.listToJson(items.stream().map( + item -> list.getDataCommunicator().getKeyMapper().key(item)) + .toList()); + } + + public static void updateSelectionFromClient(VirtualList list, + Set addedItems, Set removedItems) { + var addedKeys = getKeysFromItems(list, addedItems); + var removedKeys = getKeysFromItems(list, removedItems); + + try { + var updateSelection = VirtualList.class.getDeclaredMethod( + "updateSelection", JsonArray.class, JsonArray.class); + updateSelection.setAccessible(true); + updateSelection.invoke(list, addedKeys, removedKeys); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-testbench/src/main/java/com/vaadin/flow/component/virtuallist/testbench/VirtualListElement.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-testbench/src/main/java/com/vaadin/flow/component/virtuallist/testbench/VirtualListElement.java index fc15633da48..7cb88e5608a 100644 --- a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-testbench/src/main/java/com/vaadin/flow/component/virtuallist/testbench/VirtualListElement.java +++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-testbench/src/main/java/com/vaadin/flow/component/virtuallist/testbench/VirtualListElement.java @@ -15,6 +15,8 @@ */ package com.vaadin.flow.component.virtuallist.testbench; +import org.openqa.selenium.By; + import com.vaadin.testbench.TestBenchElement; import com.vaadin.testbench.elementsbase.Element; @@ -77,4 +79,70 @@ public int getRowCount() { return getPropertyInteger("items", "length"); } + /** + * Selects the row with the given index. + * + * @param rowIndex + * the row to select + */ + public void select(int rowIndex) { + var element = getRowElement(rowIndex); + if (!isSelected(element)) { + element.click(); + } + } + + /** + * Deselects the row with the given index. + * + * @param rowIndex + * the row to deselect + */ + public void deselect(int rowIndex) { + var element = getRowElement(rowIndex); + if (isSelected(element)) { + element.click(); + } + } + + /** + * Checks if the row at the specified index is selected. + * + * @param rowIndex + * the index of the row to check + * @return true if the row is selected, false otherwise + */ + public boolean isRowSelected(int rowIndex) { + return isSelected(getRowElement(rowIndex)); + } + + /** + * Checks if the given TestBenchElement is selected. + * + * @param element + * the TestBenchElement to check + * @return true if the element has the "selected" attribute, false otherwise + */ + private boolean isSelected(TestBenchElement element) { + return element.hasAttribute("selected"); + + } + + /** + * Retrieves the row element at the specified index. If the row is not + * currently in view, it will scroll to the row first. + * + * @param rowIndex + * the index of the row to retrieve + * @return the TestBenchElement representing the row at the specified index + */ + private TestBenchElement getRowElement(int rowIndex) { + if (!isRowInView(rowIndex)) { + scrollToRow(rowIndex); + waitUntil(e -> isRowInView(rowIndex)); + } + return this.findElement(By + .xpath("child::div[@aria-posinset='" + (rowIndex + 1) + "']")); + } + }