From c7ad6ff733aa38ac23f73679d162c88d86632209 Mon Sep 17 00:00:00 2001 From: miki Date: Fri, 1 Dec 2023 14:16:19 +0200 Subject: [PATCH 1/2] #497 work started, reworked ComponentSelect to allow ComponentMultiSelect --- .../AbstractComponentSelect.java | 292 ++++++++++++++++++ .../componentselect/ComponentMultiSelect.java | 80 +++++ .../componentselect/ComponentSelect.java | 266 ++-------------- .../frontend/component-multi-select.js | 9 + .../componentselect/ComponentSelectTest.java | 46 ++- 5 files changed, 457 insertions(+), 236 deletions(-) create mode 100644 superfields/src/main/java/org/vaadin/miki/superfields/componentselect/AbstractComponentSelect.java create mode 100644 superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelect.java create mode 100644 superfields/src/main/resources/META-INF/resources/frontend/component-multi-select.js diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/AbstractComponentSelect.java b/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/AbstractComponentSelect.java new file mode 100644 index 0000000..e7dd221 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/AbstractComponentSelect.java @@ -0,0 +1,292 @@ +package org.vaadin.miki.superfields.componentselect; + +import com.vaadin.flow.component.AbstractField; +import com.vaadin.flow.component.ClickEvent; +import com.vaadin.flow.component.ClickNotifier; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Focusable; +import com.vaadin.flow.component.HasComponents; +import com.vaadin.flow.component.HasStyle; +import com.vaadin.flow.component.customfield.CustomField; +import com.vaadin.flow.function.SerializableBiConsumer; +import com.vaadin.flow.function.SerializableBiFunction; +import org.vaadin.miki.markers.WithHelperMixin; +import org.vaadin.miki.markers.WithHelperPositionableMixin; +import org.vaadin.miki.markers.WithIdMixin; +import org.vaadin.miki.markers.WithItemsMixin; +import org.vaadin.miki.markers.WithLabelMixin; +import org.vaadin.miki.markers.WithLabelPositionableMixin; +import org.vaadin.miki.markers.WithValueMixin; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Base class for Component(Multi)Select. + * @author miki + * @since 2023-11-28 + */ +@SuppressWarnings("squid:S119") // SELF is a fine generic name that is more descriptive than S +public abstract class AbstractComponentSelect, T, SELF extends AbstractComponentSelect> extends CustomField + implements HasStyle, WithItemsMixin, WithIdMixin, + WithLabelMixin, WithLabelPositionableMixin, + WithHelperMixin>, WithHelperPositionableMixin, + WithValueMixin, T>, T, SELF> { + + /** + * Index for no selection. + */ + public static final int NO_SELECTION = -1; + + /** + * A no-op, default do-nothing operation for selection/deselection. + * @return A bi-consumer that does nothing. + * @param First argument type. + * @param Second argument type. + */ + protected static SerializableBiConsumer noOp() {return (x, y) -> {};} + + private final List options = new ArrayList<>(); + private final List components = new ArrayList<>(); + private final HasComponents layout; + + private SerializableBiFunction factory; + private SerializableBiConsumer whenSelected = noOp(); + private SerializableBiConsumer whenDeselected = noOp(); + + /** + * Creates the select with given options. + * @param layoutSupplier Provides layout for the component. + * @param componentFactory A function that creates components for the {@code options}. + * @param selectionModifier Action to perform on a component when it is selected. + * @param deselectionModifier Action to perform on a component when it is deselected. + * @param options Items to select from. + * @param Layout type. + */ + @SafeVarargs + protected AbstractComponentSelect(Supplier layoutSupplier, SerializableBiFunction componentFactory, SerializableBiConsumer selectionModifier, SerializableBiConsumer deselectionModifier, T... options) { + this.layout = layoutSupplier.get(); + this.setComponentFactory(componentFactory); + this.setComponentSelectedAction(selectionModifier); + this.setComponentDeselectedAction(deselectionModifier); + this.add((Component) this.layout); + this.setItems(Arrays.asList(options)); + } + + /** + * Checks if the item at the given index is currently selected. + * @param index Index of an item. + * @return {@code true} when the item is selected, {@code false} otherwise. + */ + protected abstract boolean isSelected(int index); + + /** + * Clicks an item at the given index. + * @param index Index of an item to be clicked. + * @return Whether value should be updated. + */ + protected abstract boolean itemClicked(int index); + + private void onComponentClicked(ClickEvent event) { + if(this.itemClicked(this.getComponentIndex(event.getSource()))) + this.updateValue(); + } + + /** + * Deselects component at the given index. + * @param index Index of a component to deselect. + */ + protected void deselect(int index) { + this.ensureValidIndex(index); + if(index != NO_SELECTION) + this.whenDeselected.accept(index, this.components.get(index)); + } + + /** + * Selects component at the given index. + * @param index Index of a component to select. + */ + protected void select(int index) { + this.ensureValidIndex(index); + if(index != NO_SELECTION) + this.whenSelected.accept(index, this.components.get(index)); + } + + /** + * Rebuilds the components - removes the existing ones then calls the factory to produce new ones. + * Selected/deselected actions will be called on the newly created components, depending on their state. + */ + protected final void rebuildComponents() { + this.layout.removeAll(); + this.components.clear(); + int index = NO_SELECTION; + for(T option: this.options) { + index++; + final C component = this.getComponentFactory().apply(index, option); + component.addClickListener(this::onComponentClicked); + if(this.isSelected(index)) + this.getComponentSelectedAction().accept(index, component); + else this.getComponentDeselectedAction().accept(index, component); + this.components.add(component); + this.layout.add(component); + } + } + + /** + * Returns the current list of options. Modifying this list will modify the options available to this component, but will not rebuild components. + * @return List of options. Never {@code null}, but possibly empty. + */ + protected List getOptions() { + return options; + } + + /** + * Returns the index of the given component. + * @param component Component to find. + * @return The index of the component, if found, otherwise {@link #NO_SELECTION}. + */ + protected final int getComponentIndex(C component) { + int result = NO_SELECTION; + for(int zmp1 = 0; zmp1= this.options.size()) + throw new IllegalArgumentException("incorrect index, expected 0 <= index < " + this.options.size()); + } + + /** + * Sets the factory that will be used to create new components. + * @param factory Factory. Must not be {@code null}. + */ + public final void setComponentFactory(SerializableBiFunction factory) { + this.factory = Objects.requireNonNull(factory); + this.rebuildComponents(); + } + + /** + * Returns the current component factory. + * @return The factory. Never {@code null}. + */ + public SerializableBiFunction getComponentFactory() { + return this.factory; + } + + /** + * Chains {@link #setComponentFactory(SerializableBiFunction)} and returns itself. + * @param factory Factory. Must not be {@code null}. + * @return This. + * @see #setComponentFactory(SerializableBiFunction) + */ + @SuppressWarnings("unchecked") // should be fine + public final SELF withComponentFactory(SerializableBiFunction factory) { + this.setComponentFactory(factory); + return (SELF) this; + } + + /** + * Sets the action to be performed on a selected component. + * @param action Action to use. If {@code null} is passed, result of {@link #noOp()} will be used instead. + */ + public final void setComponentSelectedAction(SerializableBiConsumer action) { + this.whenSelected = Objects.requireNonNullElseGet(action, ComponentSelect::noOp); + this.rebuildComponents(); + } + + /** + * Returns the action that is currently performed when a component gets selected. + * @return An action. Never {@code null}. + */ + public SerializableBiConsumer getComponentSelectedAction() { + return this.whenSelected; + } + + /** + * Chains {@link #setComponentSelectedAction(SerializableBiConsumer)} and returns itself. + * @param action Action. + * @return This. + * @see #setComponentSelectedAction(SerializableBiConsumer) + */ + @SuppressWarnings("unchecked") // should be fine + public final SELF withComponentSelectedAction(SerializableBiConsumer action) { + this.setComponentSelectedAction(action); + return (SELF) this; + } + + /** + * Sets the action to be performed on a deselected component. + * @param action Action to use. If {@code null} is passed, result of {@link #noOp()} will be used instead. + */ + public final void setComponentDeselectedAction(SerializableBiConsumer action) { + this.whenDeselected = Objects.requireNonNullElseGet(action, ComponentSelect::noOp); + this.rebuildComponents(); + } + + /** + * Returns the action that is currently performed when a component gets deselected. + * @return An action. Never {@code null}. + */ + public SerializableBiConsumer getComponentDeselectedAction() { + return this.whenDeselected; + } + + /** + * Chains {@link #setComponentDeselectedAction(SerializableBiConsumer)} and returns itself. + * @param action Action. + * @return This. + */ + @SuppressWarnings("unchecked") // should be fine + public final SELF withComponentDeselectedAction(SerializableBiConsumer action) { + this.setComponentDeselectedAction(action); + return (SELF) this; + } + + @Override + public void setItems(Collection items) { + this.options.clear(); + this.options.addAll(items); + this.rebuildComponents(); + } + + @Override + public void focus() { + if(!this.components.isEmpty() && this.components.get(0) instanceof Focusable first) + first.focus(); + else super.focus(); + } + + /** + * Returns the component at the given index. + * Used in tests only. + * + * @param index Index. + * @return The component. + */ + C getComponent(int index) { + this.ensureValidIndex(index); + return this.components.get(index); + } + + /** + * Returns the number of components currently available. + * Used in tests only. + * + * @return The number of components. + */ + int getComponentCount() { + return this.components.size(); + } + +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelect.java b/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelect.java new file mode 100644 index 0000000..c4e3346 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelect.java @@ -0,0 +1,80 @@ +package org.vaadin.miki.superfields.componentselect; + +import com.vaadin.flow.component.ClickNotifier; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.HasComponents; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.function.SerializableBiConsumer; +import com.vaadin.flow.function.SerializableBiFunction; +import org.vaadin.miki.markers.WithMaximumSelectionSizeMixin; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.function.Supplier; + +/** + * @author miki + * @since 2023-12-01 + */ +@Tag("component-multi-select") +@JsModule("./component-multi-select.js") +public class ComponentMultiSelect, T> + extends AbstractComponentSelect, ComponentMultiSelect> + implements WithMaximumSelectionSizeMixin> { + + private int maxSelectionSize = UNLIMITED; + private final Set selection = new LinkedHashSet<>(); + + /** + * Creates the select with given options. + * + * @param layoutSupplier Provides layout for the component. + * @param componentFactory A function that creates components for the {@code options}. + * @param selectionModifier Action to perform on a component when it is selected. + * @param deselectionModifier Action to perform on a component when it is deselected. + * @param options Items to select from. + */ + public ComponentMultiSelect(Supplier layoutSupplier, SerializableBiFunction, C> componentFactory, SerializableBiConsumer selectionModifier, SerializableBiConsumer deselectionModifier, Set... options) { + super(layoutSupplier, componentFactory, selectionModifier, deselectionModifier, options); + } + + public void selectAll() { + + } + + public void selectNone() { + + } + + @Override + protected boolean isSelected(int index) { + return false; + } + + @Override + protected boolean itemClicked(int index) { + return false; + } + + @Override + protected Set generateModelValue() { + return Collections.emptySet(); + } + + @Override + protected void setPresentationValue(Set newPresentationValue) { + + } + + @Override + public void setMaximumSelectionSize(int maximumSelectionSize) { + + } + + @Override + public int getMaximumSelectionSize() { + return this.maxSelectionSize; + } +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentSelect.java b/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentSelect.java index 5ae34ab..4aaa611 100644 --- a/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentSelect.java +++ b/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentSelect.java @@ -1,30 +1,16 @@ package org.vaadin.miki.superfields.componentselect; import com.vaadin.flow.component.AbstractField; -import com.vaadin.flow.component.ClickEvent; import com.vaadin.flow.component.ClickNotifier; import com.vaadin.flow.component.Component; -import com.vaadin.flow.component.Focusable; import com.vaadin.flow.component.HasComponents; -import com.vaadin.flow.component.HasStyle; import com.vaadin.flow.component.Tag; import com.vaadin.flow.component.customfield.CustomField; import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.function.SerializableBiConsumer; import com.vaadin.flow.function.SerializableBiFunction; -import org.vaadin.miki.markers.WithHelperMixin; -import org.vaadin.miki.markers.WithHelperPositionableMixin; -import org.vaadin.miki.markers.WithIdMixin; -import org.vaadin.miki.markers.WithItemsMixin; -import org.vaadin.miki.markers.WithLabelMixin; -import org.vaadin.miki.markers.WithLabelPositionableMixin; import org.vaadin.miki.markers.WithNullValueOptionallyAllowedMixin; -import org.vaadin.miki.markers.WithValueMixin; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; import java.util.Objects; import java.util.function.Supplier; @@ -39,25 +25,12 @@ */ @Tag("component-select") @JsModule("./component-select.js") -public class ComponentSelect, T> extends CustomField - implements WithItemsMixin>, HasStyle, WithIdMixin>, - WithLabelMixin>, WithLabelPositionableMixin>, - WithHelperMixin>, WithHelperPositionableMixin>, - WithValueMixin, T>, T, ComponentSelect>, - WithNullValueOptionallyAllowedMixin, AbstractField.ComponentValueChangeEvent, T>, T> { - public static final int NO_SELECTION = -1; +public class ComponentSelect, T> + extends AbstractComponentSelect> + implements WithNullValueOptionallyAllowedMixin, AbstractField.ComponentValueChangeEvent, T>, T> { - private static SerializableBiConsumer noOp() {return (x, y) -> {};} - - private final List options = new ArrayList<>(); - private final List components = new ArrayList<>(); - private final HasComponents layout; - - private SerializableBiFunction factory; - private SerializableBiConsumer whenSelected = noOp(); - private SerializableBiConsumer whenDeselected = noOp(); - private int selected = NO_SELECTION; private boolean nullValueAllowed = true; + private int selected = NO_SELECTION; /** * Creates the select with given options, but without any action to perform on selection and deselection. @@ -82,55 +55,7 @@ public ComponentSelect(Supplier layoutS */ @SafeVarargs public ComponentSelect(Supplier layoutSupplier, SerializableBiFunction componentFactory, SerializableBiConsumer selectionModifier, SerializableBiConsumer deselectionModifier, T... options) { - this.layout = layoutSupplier.get(); - this.setComponentFactory(componentFactory); - this.setComponentSelectedAction(selectionModifier); - this.setComponentDeselectedAction(deselectionModifier); - this.add((Component) this.layout); - this.setItems(Arrays.asList(options)); - } - - private void rebuildComponents(Collection items) { - this.options.clear(); - this.options.addAll(items); - this.rebuildComponents(); - } - - /** - * Rebuilds the components - removes the existing ones then calls the factory to produce new ones. - * Selected/deselected actions will be called on the newly created components, depending on their state. - */ - protected final void rebuildComponents() { - this.layout.removeAll(); - this.components.clear(); - int index = NO_SELECTION; - for(T option: this.options) { - index++; - final C component = this.getComponentFactory().apply(index, option); - component.addClickListener(this::onComponentClicked); - if(index == this.getSelectedIndex()) - this.getComponentSelectedAction().accept(index, component); - else this.getComponentDeselectedAction().accept(index, component); - this.components.add(component); - this.layout.add(component); - } - } - - /** - * Returns the index of the given component. - * @param component Component to find. - * @return The index of the component, if found, otherwise {@link #NO_SELECTION}. - */ - protected final int getComponentIndex(C component) { - int result = NO_SELECTION; - for(int zmp1 = 0; zmp1 event) { - this.setSelectedIndex(this.getComponentIndex(event.getSource())); + super(layoutSupplier, componentFactory, selectionModifier, deselectionModifier, options); } private void deselectCurrent() { @@ -146,27 +71,29 @@ public boolean isSelected() { return this.getSelectedIndex() != NO_SELECTION; } - /** - * Deselects component at the given index. - * @param index Index of a component to deselect. - */ - protected void deselect(int index) { - this.ensureValidIndex(index); - if(index != NO_SELECTION) - this.whenDeselected.accept(index, this.components.get(index)); - - this.selected = NO_SELECTION; + @Override + protected boolean isSelected(int index) { + return this.getSelectedIndex() == index; } - /** - * Selects component at the given index. - * @param index Index of a component to select. - */ - protected void select(int index) { - this.ensureValidIndex(index); - if(index != NO_SELECTION) - this.whenSelected.accept(index, this.components.get(index)); - this.selected = index; + @Override + protected boolean itemClicked(int index) { + final boolean isAlreadySelected = this.isSelected(index); + // if clicked item is currently selected AND null value is allowed, just deselect + if (isAlreadySelected && this.isNullValueAllowed()) { + this.deselect(index); + this.selected = NO_SELECTION; + return true; + } + // if the clicked item is not selected, deselect current selection and select the new thing instead + else if (!isAlreadySelected) { + this.deselectCurrent(); + this.select(index); + this.selected = index; + return true; + } + // otherwise (clicked selected item, but null value not allowed) - no changes + else return false; } /** @@ -176,16 +103,7 @@ protected void select(int index) { */ public void setSelectedIndex(int index) { this.ensureValidIndex(index); - // change selection - if(index != this.getSelectedIndex()) { - this.deselectCurrent(); - if (index != NO_SELECTION) - this.select(index); - } - // deselect selection only if null value is explicitly allowed - else if(this.isNullValueAllowed() && index != NO_SELECTION) - this.deselectCurrent(); - + this.itemClicked(index > NO_SELECTION ? index : this.getSelectedIndex()); this.updateValue(); } @@ -197,115 +115,20 @@ public int getSelectedIndex() { return this.selected; } - private void ensureValidIndex(int index) { - if(index < NO_SELECTION || index >= this.options.size()) - throw new IllegalArgumentException("incorrect index, expected 0 <= index < " + this.options.size()); - } - - /** - * Sets the factory that will be used to create new components. - * @param factory Factory. Must not be {@code null}. - */ - public final void setComponentFactory(SerializableBiFunction factory) { - this.factory = Objects.requireNonNull(factory); - this.rebuildComponents(); - } - - /** - * Returns the current component factory. - * @return The factory. Never {@code null}. - */ - public SerializableBiFunction getComponentFactory() { - return this.factory; - } - - /** - * Chains {@link #setComponentFactory(SerializableBiFunction)} and returns itself. - * @param factory Factory. Must not be {@code null}. - * @return This. - * @see #setComponentFactory(SerializableBiFunction) - */ - public final ComponentSelect withComponentFactory(SerializableBiFunction factory) { - this.setComponentFactory(factory); - return this; - } - - /** - * Sets the action to be performed on a selected component. - * @param action Action to use. If {@code null} is passed, result of {@link #noOp()} will be used instead. - */ - public final void setComponentSelectedAction(SerializableBiConsumer action) { - this.whenSelected = Objects.requireNonNullElseGet(action, ComponentSelect::noOp); - this.rebuildComponents(); - } - - /** - * Returns the action that is currently performed when a component gets selected. - * @return An action. Never {@code null}. - */ - public SerializableBiConsumer getComponentSelectedAction() { - return this.whenSelected; - } - - /** - * Chains {@link #setComponentSelectedAction(SerializableBiConsumer)} and returns itself. - * @param action Action. - * @return This. - * @see #setComponentSelectedAction(SerializableBiConsumer) - */ - public final ComponentSelect withComponentSelectedAction(SerializableBiConsumer action) { - this.setComponentSelectedAction(action); - return this; - } - - /** - * Sets the action to be performed on a deselected component. - * @param action Action to use. If {@code null} is passed, result of {@link #noOp()} will be used instead. - */ - public final void setComponentDeselectedAction(SerializableBiConsumer action) { - this.whenDeselected = Objects.requireNonNullElseGet(action, ComponentSelect::noOp); - this.rebuildComponents(); - } - - /** - * Returns the action that is currently performed when a component gets deselected. - * @return An action. Never {@code null}. - */ - public SerializableBiConsumer getComponentDeselectedAction() { - return this.whenDeselected; - } - - /** - * Chains {@link #setComponentDeselectedAction(SerializableBiConsumer)} and returns itself. - * @param action Action. - * @return This. - */ - public final ComponentSelect withComponentDeselectedAction(SerializableBiConsumer action) { - this.setComponentDeselectedAction(action); - return this; - } - @Override protected T generateModelValue() { - return this.getSelectedIndex() == NO_SELECTION ? null : this.options.get(this.getSelectedIndex()); + return this.getSelectedIndex() == NO_SELECTION ? null : this.getOptions().get(this.getSelectedIndex()); } @Override protected void setPresentationValue(T t) { int selection = NO_SELECTION; if(t != null) - for (int zmp1 = 0; zmp1 < this.options.size() && selection == NO_SELECTION; zmp1++) - if (Objects.equals(t, this.options.get(zmp1))) + for (int zmp1 = 0; zmp1 < this.getOptions().size() && selection == NO_SELECTION; zmp1++) + if (Objects.equals(t, this.getOptions().get(zmp1))) selection = zmp1; - if(selection != NO_SELECTION || this.isNullValueAllowed()) - this.setSelectedIndex(selection); - else this.updateValue(); - } - - @Override - public void setItems(Collection collection) { - this.rebuildComponents(collection); + this.setSelectedIndex(selection); } @Override @@ -318,31 +141,4 @@ public void setNullValueAllowed(boolean allowingNullValue) { this.nullValueAllowed = allowingNullValue; } - @Override - public void focus() { - if(!this.components.isEmpty() && this.components.get(0) instanceof Focusable first) - first.focus(); - else super.focus(); - } - - /** - * Returns the component at the given index. - * Used in tests only. - * - * @param index Index. - * @return The component. - */ - C getComponent(int index) { - this.ensureValidIndex(index); - return this.components.get(index); - } - - /** - * Returns the number of components currently available. - * Used in tests only. - * @return The number of components. - */ - int getComponentCount() { - return this.components.size(); - } } diff --git a/superfields/src/main/resources/META-INF/resources/frontend/component-multi-select.js b/superfields/src/main/resources/META-INF/resources/frontend/component-multi-select.js new file mode 100644 index 0000000..ed10c79 --- /dev/null +++ b/superfields/src/main/resources/META-INF/resources/frontend/component-multi-select.js @@ -0,0 +1,9 @@ +import {CustomField} from '@vaadin/custom-field'; + +class ComponentMultiSelect extends CustomField { + + static get is() {return 'component-multi-select'} + +} + +customElements.define(ComponentMultiSelect.is, ComponentMultiSelect); diff --git a/superfields/src/test/java/org/vaadin/miki/superfields/componentselect/ComponentSelectTest.java b/superfields/src/test/java/org/vaadin/miki/superfields/componentselect/ComponentSelectTest.java index d3ede2f..202ae94 100644 --- a/superfields/src/test/java/org/vaadin/miki/superfields/componentselect/ComponentSelectTest.java +++ b/superfields/src/test/java/org/vaadin/miki/superfields/componentselect/ComponentSelectTest.java @@ -37,7 +37,8 @@ public void setup() { ComponentSelectHelpers.simpleComponentFactory(Button::new), OPTIONS) .withComponentSelectedAction((index, button) -> this.mostRecentlySelectedButton = index); - this.select.addValueChangeListener(this::valueChanged); } + this.select.addValueChangeListener(this::valueChanged); + } @After public void tearDown() { @@ -82,10 +83,29 @@ public void testNullValueDisallowed() { this.select.setValue(OPTIONS[2]); this.select.setValue(null); // null value disallowed, no value change should happen + Assert.assertEquals(OPTIONS[2], this.select.getValue()); Assert.assertEquals(2, this.select.getSelectedIndex()); Assert.assertEquals(1, this.eventCounter); Assert.assertEquals(2, this.mostRecentlySelectedButton); + } + + @Test + public void testButtonClickSelectsDeselects() { + this.select.setValue(OPTIONS[2]); + this.select.getComponent(2).click(); + // null selection is allowed by default + Assert.assertNull(this.select.getValue()); + Assert.assertEquals(2, this.eventCounter); + } + + @Test + public void testButtonClickNullDisallowed() { + this.select.setNullValueAllowed(false); + this.select.setValue(OPTIONS[2]); + this.select.getComponent(2).click(); + // value should not be changed, it is disallowed Assert.assertEquals(OPTIONS[2], this.select.getValue()); + Assert.assertEquals(1, this.eventCounter); } @Test @@ -106,6 +126,30 @@ public void testVariantChanges() { Assert.assertEquals(zmp1 == selection, this.select.getComponent(zmp1).getThemeNames().contains(ButtonVariant.LUMO_PRIMARY.getVariantName())); } + // select items by clicking them + // select each option in turn, there should be only one selected button at a time + for(int selection = 0; selection < OPTIONS.length; selection++) { + // alternative way of setting value + this.select.getComponent(selection).click(); + Assert.assertEquals(OPTIONS[selection], this.select.getValue()); + for (int zmp1 = 0; zmp1 < OPTIONS.length; zmp1++) + Assert.assertEquals(zmp1 == selection, this.select.getComponent(zmp1).getThemeNames().contains(ButtonVariant.LUMO_PRIMARY.getVariantName())); + } + + // now do the same, but disallow null value + this.select.setNullValueAllowed(false); + // select items by clicking them + // select each option in turn, there should be only one selected button at a time + for(int selection = 0; selection < OPTIONS.length; selection++) { + // alternative way of setting value + this.select.getComponent(selection).click(); + Assert.assertEquals(OPTIONS[selection], this.select.getValue()); + for (int zmp1 = 0; zmp1 < OPTIONS.length; zmp1++) + Assert.assertEquals(zmp1 == selection, this.select.getComponent(zmp1).getThemeNames().contains(ButtonVariant.LUMO_PRIMARY.getVariantName())); + } + + this.select.setNullValueAllowed(true); + // deselect this.select.setValue(null); for(int zmp1=0; zmp1 Date: Fri, 8 Dec 2023 13:55:39 +0200 Subject: [PATCH 2/2] #497 most likely done --- .../ComponentMultiSelectProvider.java | 45 ++++++++++ .../providers/ComponentSelectProvider.java | 2 +- .../main/resources/ComponentMultiSelect.md | 1 + superfields/README.md | 8 +- .../buttons/ButtonMultiSelect.java | 72 ++++++++++++++++ .../superfields/buttons/ButtonSelect.java | 1 + .../AbstractComponentSelect.java | 56 +++++++----- .../componentselect/ComponentMultiSelect.java | 72 ++++++++++++---- .../componentselect/ComponentSelect.java | 4 +- .../resources/frontend/button-multi-select.js | 9 ++ .../ComponentMultiSelectTest.java | 86 +++++++++++++++++++ 11 files changed, 315 insertions(+), 41 deletions(-) create mode 100644 demo-v24/src/main/java/org/vaadin/miki/demo/providers/ComponentMultiSelectProvider.java create mode 100644 demo-v24/src/main/resources/ComponentMultiSelect.md create mode 100644 superfields/src/main/java/org/vaadin/miki/superfields/buttons/ButtonMultiSelect.java create mode 100644 superfields/src/main/resources/META-INF/resources/frontend/button-multi-select.js create mode 100644 superfields/src/test/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelectTest.java diff --git a/demo-v24/src/main/java/org/vaadin/miki/demo/providers/ComponentMultiSelectProvider.java b/demo-v24/src/main/java/org/vaadin/miki/demo/providers/ComponentMultiSelectProvider.java new file mode 100644 index 0000000..2930052 --- /dev/null +++ b/demo-v24/src/main/java/org/vaadin/miki/demo/providers/ComponentMultiSelectProvider.java @@ -0,0 +1,45 @@ +package org.vaadin.miki.demo.providers; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.data.binder.ValidationResult; +import com.vaadin.flow.data.binder.Validator; +import com.vaadin.flow.data.binder.ValueContext; +import org.vaadin.miki.demo.ComponentProvider; +import org.vaadin.miki.demo.Order; +import org.vaadin.miki.superfields.componentselect.ComponentMultiSelect; +import org.vaadin.miki.superfields.componentselect.ComponentSelectHelpers; +import org.vaadin.miki.superfields.layouts.FlexLayoutHelpers; + +import java.util.Set; + +/** + * Provides a {@link ComponentMultiSelect} of {@link Button}s and {@link String}s. + * + * @author miki + * @since 2023-12-08 + */ +@Order(92) +public class ComponentMultiSelectProvider implements ComponentProvider>, Validator> { + + private static final Set ANSWER = Set.of("Athens", "Berlin", "Rome", "Tallinn", "Warsaw"); + + @Override + public ComponentMultiSelect getComponent() { + return new ComponentMultiSelect( + FlexLayoutHelpers::row, + ComponentSelectHelpers.simpleComponentFactory(Button::new), + ComponentSelectHelpers.addVariant(ButtonVariant.LUMO_ERROR), + ComponentSelectHelpers.removeVariant(ButtonVariant.LUMO_ERROR), + "Athens", "Belgrade", "Berlin", "London", "Rome", "Tallinn", "Warsaw" + ) + .withHelperText("(EU as of the end of 2023)") + .withLabel("Select the capital cities of EU countries:") + ; + } + + @Override + public ValidationResult apply(Set strings, ValueContext valueContext) { + return ANSWER.equals(strings) ? ValidationResult.ok() : ValidationResult.error("your answer is not correct!"); + } +} diff --git a/demo-v24/src/main/java/org/vaadin/miki/demo/providers/ComponentSelectProvider.java b/demo-v24/src/main/java/org/vaadin/miki/demo/providers/ComponentSelectProvider.java index 67b1082..ecb6ab3 100644 --- a/demo-v24/src/main/java/org/vaadin/miki/demo/providers/ComponentSelectProvider.java +++ b/demo-v24/src/main/java/org/vaadin/miki/demo/providers/ComponentSelectProvider.java @@ -17,7 +17,7 @@ * @author miki * @since 2023-11-17 */ -@Order(96) +@Order(91) public class ComponentSelectProvider implements ComponentProvider> { @Override public ComponentSelect getComponent() { diff --git a/demo-v24/src/main/resources/ComponentMultiSelect.md b/demo-v24/src/main/resources/ComponentMultiSelect.md new file mode 100644 index 0000000..c941a61 --- /dev/null +++ b/demo-v24/src/main/resources/ComponentMultiSelect.md @@ -0,0 +1 @@ +A multi-select version of `ComponentSelect`. \ No newline at end of file diff --git a/superfields/README.md b/superfields/README.md index e33fc5d..15579fb 100644 --- a/superfields/README.md +++ b/superfields/README.md @@ -140,9 +140,13 @@ A single- and multi-selection `Grid`s that are value components, meaning they br `GridMultiSelect` operates on `Set` and has an option to limit the size of the selection. -### `ComponentSelect` (and `ButtonSelect`) +### `Component(Multi)Select` (and `Button(Multi)Select`) -A single-selection component that shows each option as an individual component that is a `ClickNotifier`, for example a button. +Single- and multi-selection components that show each option as an individual component that is a `ClickNotifier`, for example a button. `Button(Multi)Select` uses `Button`s and constructors that allow usage of styles or variants to show if a button is selected. + +`ComponentMultiSelect` and `ButtonMultiSelect` operate on `Set` and have an option to limit the size of the selection. + +`ComponentSelect` and `ButtonSelect` can optionally allow `null` value. Multi-selection versions do not accept `null` and use an empty set instead. ### `SuperTabs` diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/buttons/ButtonMultiSelect.java b/superfields/src/main/java/org/vaadin/miki/superfields/buttons/ButtonMultiSelect.java new file mode 100644 index 0000000..b162a65 --- /dev/null +++ b/superfields/src/main/java/org/vaadin/miki/superfields/buttons/ButtonMultiSelect.java @@ -0,0 +1,72 @@ +package org.vaadin.miki.superfields.buttons; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.HasComponents; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.function.SerializableBiConsumer; +import com.vaadin.flow.function.SerializableBiFunction; +import org.vaadin.miki.superfields.componentselect.ComponentMultiSelect; +import org.vaadin.miki.superfields.componentselect.ComponentSelectHelpers; + +import java.util.function.Supplier; + +/** + * The simples possible extension of {@link ComponentMultiSelect} that uses {@link Button}s. + * + * @author miki + * @since 2023-12-08 + */ +@Tag("button-multi-select") +@JsModule("./button-multi-select.js") +@SuppressWarnings("squid:S110") // more than 5 superclasses, but that is ok +public class ButtonMultiSelect extends ComponentMultiSelect { + + /** + * Creates a {@link ButtonMultiSelect} that uses style names to visually distinguish the selected button. + * @param layoutProvider Provides the root layout of the component. + * @param selectedClassName Style name used when a button is selected. + * @param deselectedClassName Style name used when a button is deselected. + * @param items Items. + * @param Layout type. + */ + @SafeVarargs + public ButtonMultiSelect(Supplier layoutProvider, String selectedClassName, String deselectedClassName, T... items) { + this(layoutProvider, ComponentSelectHelpers.simpleComponentFactory(Button::new), + ComponentSelectHelpers.changeStyle(deselectedClassName, selectedClassName), + ComponentSelectHelpers.changeStyle(selectedClassName, deselectedClassName), + items); + } + + /** + * Creates a {@link ButtonMultiSelect} that uses {@link ButtonVariant} to visually distinguish the selected button. + * @param layoutProvider Provides the root layout of the component. + * @param selectedVariant Variant to use for the selected button. The lack of this variant indicates a non-selected button. + * @param items Items. + * @param Layout type. + */ + @SafeVarargs + public ButtonMultiSelect(Supplier layoutProvider, ButtonVariant selectedVariant, T... items) { + this(layoutProvider, ComponentSelectHelpers.simpleComponentFactory(Button::new, Object::toString), + ComponentSelectHelpers.addVariant(selectedVariant), + ComponentSelectHelpers.removeVariant(selectedVariant), + items); + } + + /** + * Creates a {@link ButtonMultiSelect}. + * @param layoutSupplier Provides the root layout of the component. + * @param componentFactory A factory to create {@link Button}s for each option. + * @param selectionModifier Action to perform when a button is selected. + * @param deselectionModifier Action to perform when a button is deselected. + * @param options Items. + * @param Layout type. + */ + @SafeVarargs + public ButtonMultiSelect(Supplier layoutSupplier, SerializableBiFunction componentFactory, SerializableBiConsumer selectionModifier, SerializableBiConsumer deselectionModifier, T... options) { + super(layoutSupplier, componentFactory, selectionModifier, deselectionModifier, options); + } + +} diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/buttons/ButtonSelect.java b/superfields/src/main/java/org/vaadin/miki/superfields/buttons/ButtonSelect.java index 658866e..86dfdf7 100644 --- a/superfields/src/main/java/org/vaadin/miki/superfields/buttons/ButtonSelect.java +++ b/superfields/src/main/java/org/vaadin/miki/superfields/buttons/ButtonSelect.java @@ -21,6 +21,7 @@ */ @Tag("button-select") @JsModule("./button-select.js") +@SuppressWarnings("squid:S110") // there are more than 5 superclasses, but that is ok public class ButtonSelect extends ComponentSelect { /** diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/AbstractComponentSelect.java b/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/AbstractComponentSelect.java index e7dd221..383c596 100644 --- a/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/AbstractComponentSelect.java +++ b/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/AbstractComponentSelect.java @@ -27,14 +27,20 @@ /** * Base class for Component(Multi)Select. + * + * @param Component to display for each option. + * @param Type of the field. + * @param Type of item. + * @param Self type. + * * @author miki * @since 2023-11-28 */ @SuppressWarnings("squid:S119") // SELF is a fine generic name that is more descriptive than S -public abstract class AbstractComponentSelect, T, SELF extends AbstractComponentSelect> extends CustomField - implements HasStyle, WithItemsMixin, WithIdMixin, +public abstract class AbstractComponentSelect, T, I, SELF extends AbstractComponentSelect> extends CustomField + implements HasStyle, WithItemsMixin, WithIdMixin, WithLabelMixin, WithLabelPositionableMixin, - WithHelperMixin>, WithHelperPositionableMixin, + WithHelperMixin, WithHelperPositionableMixin, WithValueMixin, T>, T, SELF> { /** @@ -50,11 +56,11 @@ public abstract class AbstractComponentSelect SerializableBiConsumer noOp() {return (x, y) -> {};} - private final List options = new ArrayList<>(); + private final List options = new ArrayList<>(); private final List components = new ArrayList<>(); private final HasComponents layout; - private SerializableBiFunction factory; + private SerializableBiFunction factory; private SerializableBiConsumer whenSelected = noOp(); private SerializableBiConsumer whenDeselected = noOp(); @@ -68,13 +74,15 @@ public abstract class AbstractComponentSelect Layout type. */ @SafeVarargs - protected AbstractComponentSelect(Supplier layoutSupplier, SerializableBiFunction componentFactory, SerializableBiConsumer selectionModifier, SerializableBiConsumer deselectionModifier, T... options) { + protected AbstractComponentSelect(T defaultValue, Supplier layoutSupplier, SerializableBiFunction componentFactory, SerializableBiConsumer selectionModifier, SerializableBiConsumer deselectionModifier, I... options) { + super(defaultValue); this.layout = layoutSupplier.get(); this.setComponentFactory(componentFactory); this.setComponentSelectedAction(selectionModifier); this.setComponentDeselectedAction(deselectionModifier); this.add((Component) this.layout); - this.setItems(Arrays.asList(options)); + this.options.addAll(Arrays.asList(options)); + this.rebuildComponents(true); // nothing is selected at start anyway } /** @@ -116,31 +124,37 @@ protected void select(int index) { this.whenSelected.accept(index, this.components.get(index)); } - /** - * Rebuilds the components - removes the existing ones then calls the factory to produce new ones. - * Selected/deselected actions will be called on the newly created components, depending on their state. - */ - protected final void rebuildComponents() { + private void rebuildComponents(boolean ignoreActions) { this.layout.removeAll(); this.components.clear(); int index = NO_SELECTION; - for(T option: this.options) { + for(I option: this.options) { index++; final C component = this.getComponentFactory().apply(index, option); component.addClickListener(this::onComponentClicked); - if(this.isSelected(index)) - this.getComponentSelectedAction().accept(index, component); - else this.getComponentDeselectedAction().accept(index, component); + if(!ignoreActions) { + if (this.isSelected(index)) + this.getComponentSelectedAction().accept(index, component); + else this.getComponentDeselectedAction().accept(index, component); + } this.components.add(component); this.layout.add(component); } } + /** + * Rebuilds the components - removes the existing ones then calls the factory to produce new ones. + * Selected/deselected actions will be called on the newly created components, depending on their state. + */ + protected final void rebuildComponents() { + this.rebuildComponents(false); + } + /** * Returns the current list of options. Modifying this list will modify the options available to this component, but will not rebuild components. * @return List of options. Never {@code null}, but possibly empty. */ - protected List getOptions() { + protected List getOptions() { return options; } @@ -171,7 +185,7 @@ protected void ensureValidIndex(int index) { * Sets the factory that will be used to create new components. * @param factory Factory. Must not be {@code null}. */ - public final void setComponentFactory(SerializableBiFunction factory) { + public final void setComponentFactory(SerializableBiFunction factory) { this.factory = Objects.requireNonNull(factory); this.rebuildComponents(); } @@ -180,7 +194,7 @@ public final void setComponentFactory(SerializableBiFunction fact * Returns the current component factory. * @return The factory. Never {@code null}. */ - public SerializableBiFunction getComponentFactory() { + public SerializableBiFunction getComponentFactory() { return this.factory; } @@ -191,7 +205,7 @@ public SerializableBiFunction getComponentFactory() { * @see #setComponentFactory(SerializableBiFunction) */ @SuppressWarnings("unchecked") // should be fine - public final SELF withComponentFactory(SerializableBiFunction factory) { + public final SELF withComponentFactory(SerializableBiFunction factory) { this.setComponentFactory(factory); return (SELF) this; } @@ -254,7 +268,7 @@ public final SELF withComponentDeselectedAction(SerializableBiConsumer items) { + public void setItems(Collection items) { this.options.clear(); this.options.addAll(items); this.rebuildComponents(); diff --git a/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelect.java b/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelect.java index c4e3346..e5cc33a 100644 --- a/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelect.java +++ b/superfields/src/main/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelect.java @@ -11,17 +11,21 @@ import java.util.Collections; import java.util.LinkedHashSet; +import java.util.Objects; import java.util.Set; import java.util.function.Supplier; +import java.util.stream.Collectors; /** + * A multi-select that displays each option as a {@link ClickNotifier}s and then selects/deselects it on click. + * * @author miki * @since 2023-12-01 */ @Tag("component-multi-select") @JsModule("./component-multi-select.js") public class ComponentMultiSelect, T> - extends AbstractComponentSelect, ComponentMultiSelect> + extends AbstractComponentSelect, T, ComponentMultiSelect> implements WithMaximumSelectionSizeMixin> { private int maxSelectionSize = UNLIMITED; @@ -32,45 +36,83 @@ public class ComponentMultiSelect, T> * * @param layoutSupplier Provides layout for the component. * @param componentFactory A function that creates components for the {@code options}. - * @param selectionModifier Action to perform on a component when it is selected. - * @param deselectionModifier Action to perform on a component when it is deselected. * @param options Items to select from. */ - public ComponentMultiSelect(Supplier layoutSupplier, SerializableBiFunction, C> componentFactory, SerializableBiConsumer selectionModifier, SerializableBiConsumer deselectionModifier, Set... options) { - super(layoutSupplier, componentFactory, selectionModifier, deselectionModifier, options); - } - - public void selectAll() { - + @SafeVarargs + public ComponentMultiSelect(Supplier layoutSupplier, SerializableBiFunction componentFactory, T... options) { + this(layoutSupplier, componentFactory, noOp(), noOp(), options); } - public void selectNone() { - + /** + * Creates the select with given options. + * + * @param layoutSupplier Provides layout for the component. + * @param componentFactory A function that creates components for the {@code options}. + * @param selectionModifier Action to perform on a component when it is selected. + * @param deselectionModifier Action to perform on a component when it is deselected. + * @param options Items to select from. + */ + @SafeVarargs + public ComponentMultiSelect(Supplier layoutSupplier, SerializableBiFunction componentFactory, SerializableBiConsumer selectionModifier, SerializableBiConsumer deselectionModifier, T... options) { + super(Collections.emptySet(), layoutSupplier, componentFactory, selectionModifier, deselectionModifier, options); } @Override protected boolean isSelected(int index) { - return false; + return this.selection.contains(index); } @Override protected boolean itemClicked(int index) { - return false; + final boolean isAlreadySelected = this.isSelected(index); + // if clicked item is currently selected AND null value is allowed, just deselect + if (isAlreadySelected) { + this.deselect(index); + this.selection.remove(index); + return true; + } + // selection may have its cap, so make sure there is space for it + else if(this.getMaximumSelectionSize() == UNLIMITED || this.selection.size() < this.getMaximumSelectionSize()) { + this.select(index); + this.selection.add(index); + return true; + } + else return false; } @Override protected Set generateModelValue() { - return Collections.emptySet(); + return this.selection.stream().map(this.getOptions()::get).collect(Collectors.toSet()); } @Override protected void setPresentationValue(Set newPresentationValue) { + newPresentationValue = Objects.requireNonNullElseGet(newPresentationValue, Collections::emptySet); + this.selection.clear(); + + for(int zmp1=0; zmp1, T> - extends AbstractComponentSelect> + extends AbstractComponentSelect> implements WithNullValueOptionallyAllowedMixin, AbstractField.ComponentValueChangeEvent, T>, T> { private boolean nullValueAllowed = true; @@ -55,7 +55,7 @@ public ComponentSelect(Supplier layoutS */ @SafeVarargs public ComponentSelect(Supplier layoutSupplier, SerializableBiFunction componentFactory, SerializableBiConsumer selectionModifier, SerializableBiConsumer deselectionModifier, T... options) { - super(layoutSupplier, componentFactory, selectionModifier, deselectionModifier, options); + super(null, layoutSupplier, componentFactory, selectionModifier, deselectionModifier, options); } private void deselectCurrent() { diff --git a/superfields/src/main/resources/META-INF/resources/frontend/button-multi-select.js b/superfields/src/main/resources/META-INF/resources/frontend/button-multi-select.js new file mode 100644 index 0000000..fd460f1 --- /dev/null +++ b/superfields/src/main/resources/META-INF/resources/frontend/button-multi-select.js @@ -0,0 +1,9 @@ +import {CustomField} from '@vaadin/custom-field'; + +class ButtonMultiSelect extends CustomField { + + static get is() {return 'button-multi-select'} + +} + +customElements.define(ButtonMultiSelect.is, ButtonMultiSelect); diff --git a/superfields/src/test/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelectTest.java b/superfields/src/test/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelectTest.java new file mode 100644 index 0000000..c761938 --- /dev/null +++ b/superfields/src/test/java/org/vaadin/miki/superfields/componentselect/ComponentMultiSelectTest.java @@ -0,0 +1,86 @@ +package org.vaadin.miki.superfields.componentselect; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.vaadin.miki.superfields.layouts.FlexLayoutHelpers; + +import java.util.Set; + +public class ComponentMultiSelectTest { + + public enum Option {THESE, ARE, THE, OPTIONS, FOR, MULTISELECT} + + private ComponentMultiSelect select; + private int eventCounter = 0; + + @Before + public void setup() { + this.eventCounter = 0; + this.select = new ComponentMultiSelect(FlexLayoutHelpers::row, ComponentSelectHelpers.simpleComponentFactory(Button::new), Option.values()); + this.select.addValueChangeListener(event -> eventCounter++); + } + + @Test + public void testEmptyAtStartAndAssignValues() { + Assert.assertTrue(this.select.getValue().isEmpty()); + final Set