]> source.dussan.org Git - vaadin-framework.git/commitdiff
TwinColSelect with new databinding API
authorPekka Hyvönen <pekka@vaadin.com>
Thu, 22 Sep 2016 11:19:34 +0000 (14:19 +0300)
committerVaadin Code Review <review@vaadin.com>
Tue, 27 Sep 2016 07:23:36 +0000 (07:23 +0000)
Removes feature for adding new items.
Introduces a AbstractMultiSelect-abstraction layer,
which is used in server side by TwinColSelect & CheckBoxGroup and
on client side only TwinColSelect for now. Plan is to use it for
ListSelect too.

Further improvement would be to make AbstractMultiSelect use
SelectionModel that extends AbstractSelectionModel and is thus used
as an extension both as client & server side.

Updates to JUnit 4.12 for easier use of @Parameterized test..

Change-Id: I64258c2229b9514d382693748e2ca562a1e448d4

28 files changed:
client/src/main/java/com/vaadin/client/connectors/AbstractMultiSelectConnector.java [new file with mode: 0644]
client/src/main/java/com/vaadin/client/ui/VCheckBoxGroup.java
client/src/main/java/com/vaadin/client/ui/VRadioButtonGroup.java
client/src/main/java/com/vaadin/client/ui/VTwinColSelect.java [new file with mode: 0644]
client/src/main/java/com/vaadin/client/ui/optiongroup/CheckBoxGroupConnector.java
client/src/main/java/com/vaadin/client/ui/twincolselect/TwinColSelectConnector.java [new file with mode: 0644]
pom.xml
server/src/main/java/com/vaadin/ui/AbstractMultiSelect.java
server/src/main/java/com/vaadin/ui/CheckBoxGroup.java
server/src/main/java/com/vaadin/ui/RadioButtonGroup.java
server/src/main/java/com/vaadin/ui/TwinColSelect.java [new file with mode: 0644]
server/src/test/java/com/vaadin/tests/server/component/twincolselect/TwinColSelectDeclarativeTest.java [new file with mode: 0644]
server/src/test/java/com/vaadin/ui/AbstractMultiSelectTest.java [new file with mode: 0644]
server/src/test/java/com/vaadin/ui/CheckBoxGroupBoVTest.java
server/src/test/java/com/vaadin/ui/CheckBoxGroupTest.java [deleted file]
shared/src/main/java/com/vaadin/shared/data/selection/MultiSelectServerRpc.java [new file with mode: 0644]
shared/src/main/java/com/vaadin/shared/data/selection/SelectionModel.java
shared/src/main/java/com/vaadin/shared/ui/ListingJsonConstants.java [new file with mode: 0644]
shared/src/main/java/com/vaadin/shared/ui/optiongroup/CheckBoxGroupConstants.java [deleted file]
shared/src/main/java/com/vaadin/shared/ui/optiongroup/RadioButtonGroupConstants.java [deleted file]
shared/src/main/java/com/vaadin/shared/ui/twincolselect/TwinColSelectState.java [new file with mode: 0644]
uitest/src/main/java/com/vaadin/tests/components/abstractlisting/AbstractMultiSelectTestUI.java [new file with mode: 0644]
uitest/src/main/java/com/vaadin/tests/components/checkbox/CheckBoxGroupTestUI.java
uitest/src/main/java/com/vaadin/tests/components/select/TwinColSelectCaptionStyles.java
uitest/src/main/java/com/vaadin/tests/components/select/TwinColSelects.java
uitest/src/main/java/com/vaadin/tests/components/twincolselect/TwinColSelectTestUI.java [new file with mode: 0644]
uitest/src/test/java/com/vaadin/tests/components/checkboxgroup/CheckBoxGroupTest.java
uitest/src/test/java/com/vaadin/tests/components/twincolselect/TwinColSelectTest.java [new file with mode: 0644]

diff --git a/client/src/main/java/com/vaadin/client/connectors/AbstractMultiSelectConnector.java b/client/src/main/java/com/vaadin/client/connectors/AbstractMultiSelectConnector.java
new file mode 100644 (file)
index 0000000..86dbe67
--- /dev/null
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2000-2016 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.client.connectors;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.BiConsumer;
+
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.IsWidget;
+import com.google.gwt.user.client.ui.Widget;
+import com.vaadin.client.data.DataSource;
+import com.vaadin.shared.Range;
+import com.vaadin.shared.Registration;
+import com.vaadin.shared.data.selection.MultiSelectServerRpc;
+import com.vaadin.shared.data.selection.SelectionModel;
+import com.vaadin.shared.ui.ListingJsonConstants;
+
+import elemental.json.JsonObject;
+
+/**
+ * A base connector class for multiselects.
+ * <p>
+ * Does not care about the framework provided selection model for now, instead
+ * just passes selection information per item.
+ *
+ * @author Vaadin Ltd.
+ *
+ * @since 8.0
+ */
+public abstract class AbstractMultiSelectConnector
+        extends AbstractListingConnector<SelectionModel.Multi<?>> {
+
+    /**
+     * Abstraction layer to help populate different multiselect widgets based on
+     * same JSON data.
+     */
+    public interface MultiSelectWidget {
+
+        /**
+         * Sets the given items to the select.
+         *
+         * @param items
+         *            the items for the select
+         */
+        void setItems(List<JsonObject> items);
+
+        /**
+         * Adds a selection change listener the select.
+         *
+         * @param selectionChangeListener
+         *            the listener to add, not {@code null}
+         * @return a registration handle to remove the listener
+         */
+        Registration addSelectionChangeListener(
+                BiConsumer<Set<String>, Set<String>> selectionChangeListener);
+
+        /**
+         * Returns the caption for the given item.
+         *
+         * @param item
+         *            the item, not {@code null}
+         * @return caption of the item
+         */
+        static String getCaption(JsonObject item) {
+            return item.getString(ListingJsonConstants.JSONKEY_ITEM_VALUE);
+        }
+
+        /**
+         * Returns the key for the given item.
+         *
+         * @param item
+         *            the item, not {@code null}
+         * @return key of the item
+         */
+        static String getKey(JsonObject item) {
+            return getRowKey(item);
+        }
+
+        /**
+         * Returns whether the given item is enabled or not.
+         * <p>
+         * Disabling items is not supported by all multiselects.
+         *
+         * @param item
+         *            the item, not {@code null}
+         * @return {@code true} enabled, {@code false} if not
+         */
+        static boolean isEnabled(JsonObject item) {
+            return !(item.hasKey(ListingJsonConstants.JSONKEY_ITEM_DISABLED)
+                    && item.getBoolean(
+                            ListingJsonConstants.JSONKEY_ITEM_DISABLED));
+        }
+
+        /**
+         * Returns whether this item is selected or not.
+         *
+         * @param item
+         *            the item, not {@code null}
+         * @return {@code true} is selected, {@code false} if not
+         */
+        static boolean isSelected(JsonObject item) {
+            return item.getBoolean(ListingJsonConstants.JSONKEY_ITEM_SELECTED);
+        }
+
+        /**
+         * Returns the optional icon URL for the given item.
+         * <p>
+         * Item icons are not supported by all multiselects.
+         *
+         * @param item
+         *            the item
+         * @return the optional icon URL, or an empty optional if none specified
+         */
+        static Optional<String> getIconUrl(JsonObject item) {
+            return Optional.ofNullable(
+                    item.getString(ListingJsonConstants.JSONKEY_ITEM_ICON));
+        }
+    }
+
+    /**
+     * Returns the multiselect widget for this connector.
+     * <p>
+     * This is used because {@link #getWidget()} returns a class
+     * ({@link Widget}) instead of an interface ({@link IsWidget}), and most
+     * multiselects extends {@link Composite}.
+     *
+     * @return the multiselect widget
+     */
+    public abstract MultiSelectWidget getMultiSelectWidget();
+
+    @Override
+    protected void init() {
+        super.init();
+
+        MultiSelectServerRpc rpcProxy = getRpcProxy(
+                MultiSelectServerRpc.class);
+        getMultiSelectWidget().addSelectionChangeListener(
+                (addedItems, removedItems) -> rpcProxy
+                        .updateSelection(addedItems, removedItems));
+    }
+
+    @Override
+    public void setDataSource(DataSource<JsonObject> dataSource) {
+        dataSource.addDataChangeHandler(this::onDataChange);
+        super.setDataSource(dataSource);
+    }
+
+    /**
+     * This method handles the parsing of the new JSON data containing the items
+     * and the selection information.
+     *
+     * @param range
+     *            the updated range, never {@code null}
+     */
+    protected void onDataChange(Range range) {
+        assert range.getStart() == 0
+                && range.getEnd() == getDataSource().size() : getClass()
+                        .getSimpleName()
+                        + " only supports full updates, but got range " + range;
+        List<JsonObject> items = new ArrayList<>(range.length());
+        for (int i = 0; i < range.getEnd(); i++) {
+            items.add(getDataSource().getRow(i));
+        }
+        getMultiSelectWidget().setItems(items);
+    }
+}
index c51cb32a2faabc06a13c2b3317fa5adc78f47353..e47598426b92e27f44409acecd086fa2b9200d08 100644 (file)
@@ -16,8 +16,6 @@
 
 package com.vaadin.client.ui;
 
-import static com.vaadin.shared.ui.optiongroup.CheckBoxGroupConstants.JSONKEY_ITEM_DISABLED;
-
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Iterator;
@@ -38,7 +36,7 @@ import com.google.gwt.user.client.ui.Widget;
 import com.vaadin.client.ApplicationConnection;
 import com.vaadin.client.WidgetUtil;
 import com.vaadin.shared.Registration;
-import com.vaadin.shared.ui.optiongroup.CheckBoxGroupConstants;
+import com.vaadin.shared.ui.ListingJsonConstants;
 
 import elemental.json.JsonObject;
 
@@ -77,7 +75,7 @@ public class VCheckBoxGroup extends Composite implements Field, ClickHandler,
 
     public VCheckBoxGroup() {
         optionsContainer = new FlowPanel();
-        initWidget(this.optionsContainer);
+        initWidget(optionsContainer);
         optionsContainer.setStyleName(CLASSNAME);
         optionsToItems = new HashMap<>();
         selectionChangeListeners = new ArrayList<>();
@@ -98,14 +96,14 @@ public class VCheckBoxGroup extends Composite implements Field, ClickHandler,
         optionsContainer.clear();
         for (JsonObject item : items) {
             String itemHtml = item
-                    .getString(CheckBoxGroupConstants.JSONKEY_ITEM_VALUE);
+                    .getString(ListingJsonConstants.JSONKEY_ITEM_VALUE);
             if (!isHtmlContentAllowed()) {
                 itemHtml = WidgetUtil.escapeHTML(itemHtml);
             }
             VCheckBox checkBox = new VCheckBox();
 
             String iconUrl = item
-                    .getString(CheckBoxGroupConstants.JSONKEY_ITEM_ICON);
+                    .getString(ListingJsonConstants.JSONKEY_ITEM_ICON);
             if (iconUrl != null && iconUrl.length() != 0) {
                 Icon icon = client.getIcon(iconUrl);
                 itemHtml = icon.getElement().getString() + itemHtml;
@@ -115,10 +113,8 @@ public class VCheckBoxGroup extends Composite implements Field, ClickHandler,
             checkBox.addClickHandler(this);
             checkBox.setHTML(itemHtml);
             checkBox.setValue(item
-                    .getBoolean(CheckBoxGroupConstants.JSONKEY_ITEM_SELECTED));
-            boolean optionEnabled = !item.getBoolean(JSONKEY_ITEM_DISABLED);
-            boolean enabled = optionEnabled && !isReadonly() && isEnabled();
-            checkBox.setEnabled(enabled);
+                    .getBoolean(ListingJsonConstants.JSONKEY_ITEM_SELECTED));
+            setOptionEnabled(checkBox, item);
 
             optionsContainer.add(checkBox);
             optionsToItems.put(checkBox, item);
@@ -152,18 +148,20 @@ public class VCheckBoxGroup extends Composite implements Field, ClickHandler,
         }
     }
 
-    protected void updateEnabledState() {
-        boolean optionGroupEnabled = isEnabled() && !isReadonly();
-        // sets options enabled according to the widget's enabled,
-        // readonly and each options own enabled
-        for (Map.Entry<VCheckBox, JsonObject> entry : optionsToItems
-                .entrySet()) {
-            VCheckBox checkBox = entry.getKey();
-            JsonObject value = entry.getValue();
-            Boolean isOptionEnabled = !value
-                    .getBoolean(CheckBoxGroupConstants.JSONKEY_ITEM_DISABLED);
-            checkBox.setEnabled(optionGroupEnabled && isOptionEnabled);
-        }
+    /**
+     * Updates the checkbox's enabled state according to the widget's enabled,
+     * read only and the item's enabled.
+     *
+     * @param checkBox
+     *            the checkbox to update
+     * @param item
+     *            the item for the checkbox
+     */
+    protected void setOptionEnabled(VCheckBox checkBox, JsonObject item) {
+        boolean optionEnabled = !item
+                .getBoolean(ListingJsonConstants.JSONKEY_ITEM_DISABLED);
+        boolean enabled = optionEnabled && !isReadonly() && isEnabled();
+        checkBox.setEnabled(enabled);
     }
 
     @Override
@@ -194,7 +192,7 @@ public class VCheckBoxGroup extends Composite implements Field, ClickHandler,
     public void setReadonly(boolean readonly) {
         if (this.readonly != readonly) {
             this.readonly = readonly;
-            updateEnabledState();
+            optionsToItems.forEach(this::setOptionEnabled);
         }
     }
 
@@ -202,7 +200,7 @@ public class VCheckBoxGroup extends Composite implements Field, ClickHandler,
     public void setEnabled(boolean enabled) {
         if (this.enabled != enabled) {
             this.enabled = enabled;
-            updateEnabledState();
+            optionsToItems.forEach(this::setOptionEnabled);
         }
     }
 
index 893935d562eba8c1dc3b155d343d4dcccaf8cb30..bb81b2872e760a2e582479f6c8b8deb7b7c7364c 100644 (file)
 
 package com.vaadin.client.ui;
 
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
 import com.google.gwt.aria.client.Roles;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
@@ -33,17 +40,9 @@ import com.vaadin.client.BrowserInfo;
 import com.vaadin.client.WidgetUtil;
 import com.vaadin.shared.Registration;
 import com.vaadin.shared.data.DataCommunicatorConstants;
-import com.vaadin.shared.ui.optiongroup.RadioButtonGroupConstants;
-import elemental.json.JsonObject;
+import com.vaadin.shared.ui.ListingJsonConstants;
 
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Consumer;
-
-import static com.vaadin.shared.ui.optiongroup.RadioButtonGroupConstants.JSONKEY_ITEM_DISABLED;
+import elemental.json.JsonObject;
 
 /**
  * The client-side widget for the {@code RadioButtonGroup} component.
@@ -83,7 +82,7 @@ public class VRadioButtonGroup extends Composite implements Field, ClickHandler,
     public VRadioButtonGroup() {
         groupId = DOM.createUniqueId();
         optionsContainer = new FlowPanel();
-        initWidget(this.optionsContainer);
+        initWidget(optionsContainer);
         optionsContainer.setStyleName(CLASSNAME);
         optionsToItems = new HashMap<>();
         keyToOptions = new HashMap<>();
@@ -107,14 +106,14 @@ public class VRadioButtonGroup extends Composite implements Field, ClickHandler,
         keyToOptions.clear();
         for (JsonObject item : items) {
             String itemHtml = item
-                    .getString(RadioButtonGroupConstants.JSONKEY_ITEM_VALUE);
+                    .getString(ListingJsonConstants.JSONKEY_ITEM_VALUE);
             if (!isHtmlContentAllowed()) {
                 itemHtml = WidgetUtil.escapeHTML(itemHtml);
             }
             RadioButton radioButton = new RadioButton(groupId);
 
             String iconUrl = item
-                    .getString(RadioButtonGroupConstants.JSONKEY_ITEM_ICON);
+                    .getString(ListingJsonConstants.JSONKEY_ITEM_ICON);
             if (iconUrl != null && iconUrl.length() != 0) {
                 Icon icon = client.getIcon(iconUrl);
                 itemHtml = icon.getElement().getString() + itemHtml;
@@ -124,8 +123,9 @@ public class VRadioButtonGroup extends Composite implements Field, ClickHandler,
             radioButton.addClickHandler(this);
             radioButton.setHTML(itemHtml);
             radioButton.setValue(item
-                    .getBoolean(RadioButtonGroupConstants.JSONKEY_ITEM_SELECTED));
-            boolean optionEnabled = !item.getBoolean(JSONKEY_ITEM_DISABLED);
+                    .getBoolean(ListingJsonConstants.JSONKEY_ITEM_SELECTED));
+            boolean optionEnabled = !item
+                    .getBoolean(ListingJsonConstants.JSONKEY_ITEM_DISABLED);
             boolean enabled = optionEnabled && !isReadonly() && isEnabled();
             radioButton.setEnabled(enabled);
             String key = item.getString(DataCommunicatorConstants.KEY);
@@ -175,7 +175,7 @@ public class VRadioButtonGroup extends Composite implements Field, ClickHandler,
             RadioButton radioButton = entry.getKey();
             JsonObject value = entry.getValue();
             Boolean isOptionEnabled = !value
-                    .getBoolean(RadioButtonGroupConstants.JSONKEY_ITEM_DISABLED);
+                    .getBoolean(ListingJsonConstants.JSONKEY_ITEM_DISABLED);
             radioButton.setEnabled(radioButtonEnabled && isOptionEnabled);
         }
     }
@@ -229,7 +229,7 @@ public class VRadioButtonGroup extends Composite implements Field, ClickHandler,
 
     public void selectItemKey(String selectedItemKey) {
         RadioButton radioButton = keyToOptions.get(selectedItemKey);
-        assert radioButton!=null;
+        assert radioButton != null;
         radioButton.setValue(true);
     }
 }
diff --git a/client/src/main/java/com/vaadin/client/ui/VTwinColSelect.java b/client/src/main/java/com/vaadin/client/ui/VTwinColSelect.java
new file mode 100644 (file)
index 0000000..d744e28
--- /dev/null
@@ -0,0 +1,716 @@
+/*
+ * Copyright 2000-2016 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.client.ui;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.stream.Collectors;
+
+import com.google.gwt.dom.client.Style.Overflow;
+import com.google.gwt.dom.client.Style.Position;
+import com.google.gwt.event.dom.client.ClickEvent;
+import com.google.gwt.event.dom.client.ClickHandler;
+import com.google.gwt.event.dom.client.DoubleClickEvent;
+import com.google.gwt.event.dom.client.DoubleClickHandler;
+import com.google.gwt.event.dom.client.HasDoubleClickHandlers;
+import com.google.gwt.event.dom.client.KeyCodes;
+import com.google.gwt.event.dom.client.KeyDownEvent;
+import com.google.gwt.event.dom.client.KeyDownHandler;
+import com.google.gwt.event.dom.client.MouseDownEvent;
+import com.google.gwt.event.dom.client.MouseDownHandler;
+import com.google.gwt.event.shared.HandlerRegistration;
+import com.google.gwt.user.client.ui.Composite;
+import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.HTML;
+import com.google.gwt.user.client.ui.HasEnabled;
+import com.google.gwt.user.client.ui.ListBox;
+import com.google.gwt.user.client.ui.Panel;
+import com.google.gwt.user.client.ui.Widget;
+import com.vaadin.client.Focusable;
+import com.vaadin.client.StyleConstants;
+import com.vaadin.client.WidgetUtil;
+import com.vaadin.client.connectors.AbstractMultiSelectConnector.MultiSelectWidget;
+import com.vaadin.shared.Registration;
+
+import elemental.json.JsonObject;
+
+/**
+ * A list builder widget that has two selects; one for selectable options,
+ * another for selected options, and buttons for selecting and deselecting the
+ * items.
+ *
+ * @author Vaadin Ltd
+ */
+public class VTwinColSelect extends Composite implements MultiSelectWidget,
+        Field, ClickHandler, Focusable, HasEnabled, KeyDownHandler,
+        MouseDownHandler, DoubleClickHandler, SubPartAware {
+
+    private static final String SUBPART_OPTION_SELECT = "leftSelect";
+    private static final String SUBPART_OPTION_SELECT_ITEM = SUBPART_OPTION_SELECT
+            + "-item";
+    private static final String SUBPART_SELECTION_SELECT = "rightSelect";
+    private static final String SUBPART_SELECTION_SELECT_ITEM = SUBPART_SELECTION_SELECT
+            + "-item";
+    private static final String SUBPART_LEFT_CAPTION = "leftCaption";
+    private static final String SUBPART_RIGHT_CAPTION = "rightCaption";
+    private static final String SUBPART_ADD_BUTTON = "add";
+    private static final String SUBPART_REMOVE_BUTTON = "remove";
+
+    /** Primary style name for twin col select. */
+    public static final String CLASSNAME = "v-select-twincol";
+
+    private static final int VISIBLE_COUNT = 10;
+
+    private static final int DEFAULT_COLUMN_COUNT = 10;
+
+    private final DoubleClickListBox optionsListBox;
+
+    private final DoubleClickListBox selectionsListBox;
+
+    private final FlowPanel optionsContainer;
+
+    private final FlowPanel captionWrapper;
+
+    private final VButton addItemsLeftToRightButton;
+
+    private final VButton removeItemsRightToLeftButton;
+
+    private final FlowPanel buttons;
+
+    private final Panel panel;
+
+    private HTML optionsCaption = null;
+
+    private HTML selectionsCaption = null;
+
+    private List<BiConsumer<Set<String>, Set<String>>> selectionChangeListeners;
+
+    private boolean enabled;
+    private boolean readOnly;
+
+    private int rows = 0;
+
+    /**
+     * A multiselect ListBox which catches double clicks.
+     */
+    public class DoubleClickListBox extends ListBox
+            implements HasDoubleClickHandlers {
+        /**
+         * Constructs a new DoubleClickListBox.
+         */
+        public DoubleClickListBox() {
+            setMultipleSelect(true);
+        }
+
+        @Override
+        public HandlerRegistration addDoubleClickHandler(
+                DoubleClickHandler handler) {
+            return addDomHandler(handler, DoubleClickEvent.getType());
+        }
+    }
+
+    /**
+     * Constructs a new VTwinColSelect.
+     */
+    public VTwinColSelect() {
+        selectionChangeListeners = new ArrayList<>();
+
+        optionsContainer = new FlowPanel();
+        initWidget(optionsContainer);
+        optionsContainer.setStyleName(CLASSNAME);
+
+        captionWrapper = new FlowPanel();
+
+        optionsListBox = new DoubleClickListBox();
+        optionsListBox.addClickHandler(this);
+        optionsListBox.addDoubleClickHandler(this);
+        optionsListBox.setVisibleItemCount(VISIBLE_COUNT);
+        optionsListBox.setStyleName(CLASSNAME + "-options");
+
+        selectionsListBox = new DoubleClickListBox();
+        selectionsListBox.addClickHandler(this);
+        selectionsListBox.addDoubleClickHandler(this);
+        selectionsListBox.setVisibleItemCount(VISIBLE_COUNT);
+        selectionsListBox.setStyleName(CLASSNAME + "-selections");
+
+        buttons = new FlowPanel();
+        buttons.setStyleName(CLASSNAME + "-buttons");
+        addItemsLeftToRightButton = new VButton();
+        addItemsLeftToRightButton.setText(">>");
+        addItemsLeftToRightButton.addClickHandler(this);
+        removeItemsRightToLeftButton = new VButton();
+        removeItemsRightToLeftButton.setText("<<");
+        removeItemsRightToLeftButton.addClickHandler(this);
+
+        panel = optionsContainer;
+
+        panel.add(captionWrapper);
+        captionWrapper.getElement().getStyle().setOverflow(Overflow.HIDDEN);
+        // Hide until there actually is a caption to prevent IE from rendering
+        // extra empty space
+        captionWrapper.setVisible(false);
+
+        panel.add(optionsListBox);
+        buttons.add(addItemsLeftToRightButton);
+        final HTML br = new HTML("<span/>");
+        br.setStyleName(CLASSNAME + "-deco");
+        buttons.add(br);
+        buttons.add(removeItemsRightToLeftButton);
+        panel.add(buttons);
+        panel.add(selectionsListBox);
+
+        optionsListBox.addKeyDownHandler(this);
+        optionsListBox.addMouseDownHandler(this);
+
+        selectionsListBox.addMouseDownHandler(this);
+        selectionsListBox.addKeyDownHandler(this);
+
+        updateEnabledState();
+    }
+
+    /**
+     * Gets the options caption HTML Widget.
+     *
+     * @return the options caption widget
+     */
+    protected HTML getOptionsCaption() {
+        if (optionsCaption == null) {
+            optionsCaption = new HTML();
+            optionsCaption.setStyleName(CLASSNAME + "-caption-left");
+            optionsCaption.getElement().getStyle()
+                    .setFloat(com.google.gwt.dom.client.Style.Float.LEFT);
+            captionWrapper.add(optionsCaption);
+        }
+
+        return optionsCaption;
+    }
+
+    /**
+     * Gets the selections caption HTML widget.
+     *
+     * @return the selections caption widget
+     */
+    protected HTML getSelectionsCaption() {
+        if (selectionsCaption == null) {
+            selectionsCaption = new HTML();
+            selectionsCaption.setStyleName(CLASSNAME + "-caption-right");
+            selectionsCaption.getElement().getStyle()
+                    .setFloat(com.google.gwt.dom.client.Style.Float.RIGHT);
+            captionWrapper.add(selectionsCaption);
+        }
+
+        return selectionsCaption;
+    }
+
+    /**
+     * For internal use only. May be removed or replaced in the future.
+     *
+     * @return the caption wrapper widget
+     */
+    public Widget getCaptionWrapper() {
+        return captionWrapper;
+    }
+
+    /**
+     * Sets the number of visible items for the list boxes.
+     *
+     * @param rows
+     *            the number of items to show
+     * @see ListBox#setVisibleItemCount(int)
+     */
+    public void setRows(int rows) {
+        if (this.rows != rows) {
+            this.rows = rows;
+            optionsListBox.setVisibleItemCount(rows);
+            selectionsListBox.setVisibleItemCount(rows);
+        }
+    }
+
+    /**
+     * Returns the number of visible items for the list boxes.
+     *
+     * @return the number of items to show
+     * @see ListBox#setVisibleItemCount(int)
+     */
+    public int getRows() {
+        return rows;
+    }
+
+    /**
+     * Updates the captions above the left (options) and right (selections)
+     * columns. {code null} value clear the caption.
+     *
+     * @param leftCaption
+     *            the left caption to set, or {@code null} to clear
+     * @param rightCaption
+     *            the right caption to set, or {@code null} to clear
+     */
+    public void updateCaptions(String leftCaption, String rightCaption) {
+        boolean hasCaptions = leftCaption != null || rightCaption != null;
+
+        if (leftCaption == null) {
+            removeOptionsCaption();
+        } else {
+            getOptionsCaption().setText(leftCaption);
+
+        }
+
+        if (rightCaption == null) {
+            removeSelectionsCaption();
+        } else {
+            getSelectionsCaption().setText(rightCaption);
+        }
+
+        captionWrapper.setVisible(hasCaptions);
+    }
+
+    private void removeOptionsCaption() {
+        if (optionsCaption == null) {
+            return;
+        }
+
+        if (optionsCaption.getParent() != null) {
+            captionWrapper.remove(optionsCaption);
+        }
+
+        optionsCaption = null;
+    }
+
+    private void removeSelectionsCaption() {
+        if (selectionsCaption == null) {
+            return;
+        }
+
+        if (selectionsCaption.getParent() != null) {
+            captionWrapper.remove(selectionsCaption);
+        }
+
+        selectionsCaption = null;
+    }
+
+    @Override
+    public Registration addSelectionChangeListener(
+            BiConsumer<Set<String>, Set<String>> listener) {
+        Objects.nonNull(listener);
+        selectionChangeListeners.add(listener);
+        return (Registration) () -> selectionChangeListeners.remove(listener);
+    }
+
+    @Override
+    public void setItems(List<JsonObject> items) {
+        // filter selected items
+        List<JsonObject> selection = items.stream()
+                .filter(item -> MultiSelectWidget.isSelected(item))
+                .collect(Collectors.toList());
+        items.removeAll(selection);
+
+        updateListBox(optionsListBox, items);
+        updateListBox(selectionsListBox, selection);
+    }
+
+    private static void updateListBox(ListBox listBox,
+            List<JsonObject> options) {
+        for (int i = 0; i < options.size(); i++) {
+            final JsonObject item = options.get(i);
+            // reuse existing option if possible
+            if (i < listBox.getItemCount()) {
+                listBox.setItemText(i, MultiSelectWidget.getCaption(item));
+                listBox.setValue(i, MultiSelectWidget.getKey(item));
+            } else {
+                listBox.addItem(MultiSelectWidget.getCaption(item),
+                        MultiSelectWidget.getKey(item));
+            }
+        }
+        // remove extra
+        for (int i = listBox.getItemCount() - 1; i >= options.size(); i--) {
+            listBox.removeItem(i);
+        }
+    }
+
+    private static boolean[] getSelectionBitmap(ListBox listBox) {
+        final boolean[] selectedIndexes = new boolean[listBox.getItemCount()];
+        for (int i = 0; i < listBox.getItemCount(); i++) {
+            if (listBox.isItemSelected(i)) {
+                selectedIndexes[i] = true;
+            } else {
+                selectedIndexes[i] = false;
+            }
+        }
+        return selectedIndexes;
+    }
+
+    private void moveSelectedItemsLeftToRight() {
+        Set<String> movedItems = moveSelectedItems(optionsListBox,
+                selectionsListBox);
+        selectionChangeListeners
+                .forEach(e -> e.accept(movedItems, Collections.emptySet()));
+    }
+
+    private void moveSelectedItemsRightToLeft() {
+        Set<String> movedItems = moveSelectedItems(selectionsListBox,
+                optionsListBox);
+        selectionChangeListeners
+                .forEach(e -> e.accept(Collections.emptySet(), movedItems));
+    }
+
+    private static Set<String> moveSelectedItems(ListBox source,
+            ListBox target) {
+        final boolean[] sel = getSelectionBitmap(source);
+        final Set<String> movedItems = new HashSet<>();
+        for (int i = 0; i < sel.length; i++) {
+            if (sel[i]) {
+                final int optionIndex = i
+                        - (sel.length - source.getItemCount());
+                movedItems.add(source.getValue(optionIndex));
+
+                // Move selection to another column
+                final String text = source.getItemText(optionIndex);
+                final String value = source.getValue(optionIndex);
+                target.addItem(text, value);
+                target.setItemSelected(target.getItemCount() - 1, true);
+                source.removeItem(optionIndex);
+            }
+        }
+
+        // If no items are left move the focus to the selections
+        if (source.getItemCount() == 0) {
+            target.setFocus(true);
+        } else {
+            source.setFocus(true);
+        }
+
+        return movedItems;
+    }
+
+    @Override
+    public void onClick(ClickEvent event) {
+        if (event.getSource() == addItemsLeftToRightButton) {
+            moveSelectedItemsLeftToRight();
+        } else if (event.getSource() == removeItemsRightToLeftButton) {
+            moveSelectedItemsRightToLeft();
+        } else if (event.getSource() == optionsListBox) {
+            // unselect all in other list, to avoid mistakes (i.e wrong button)
+            final int count = selectionsListBox.getItemCount();
+            for (int i = 0; i < count; i++) {
+                selectionsListBox.setItemSelected(i, false);
+            }
+        } else if (event.getSource() == selectionsListBox) {
+            // unselect all in other list, to avoid mistakes (i.e wrong button)
+            final int count = optionsListBox.getItemCount();
+            for (int i = 0; i < count; i++) {
+                optionsListBox.setItemSelected(i, false);
+            }
+        }
+    }
+
+    /** For internal use only. May be removed or replaced in the future. */
+    public void clearInternalHeights() {
+        selectionsListBox.setHeight("");
+        optionsListBox.setHeight("");
+    }
+
+    /** For internal use only. May be removed or replaced in the future. */
+    public void setInternalHeights() {
+        int captionHeight = WidgetUtil.getRequiredHeight(captionWrapper);
+        int totalHeight = getOffsetHeight();
+
+        String selectHeight = totalHeight - captionHeight + "px";
+
+        selectionsListBox.setHeight(selectHeight);
+        optionsListBox.setHeight(selectHeight);
+    }
+
+    /** For internal use only. May be removed or replaced in the future. */
+    public void clearInternalWidths() {
+        String colWidth = DEFAULT_COLUMN_COUNT + "em";
+        String containerWidth = 2 * DEFAULT_COLUMN_COUNT + 4 + "em";
+        // Caption wrapper width == optionsSelect + buttons +
+        // selectionsSelect
+        String captionWrapperWidth = 2 * DEFAULT_COLUMN_COUNT + 4 - 0.5 + "em";
+
+        optionsListBox.setWidth(colWidth);
+        if (optionsCaption != null) {
+            optionsCaption.setWidth(colWidth);
+        }
+        selectionsListBox.setWidth(colWidth);
+        if (selectionsCaption != null) {
+            selectionsCaption.setWidth(colWidth);
+        }
+        buttons.setWidth("3.5em");
+        optionsContainer.setWidth(containerWidth);
+        captionWrapper.setWidth(captionWrapperWidth);
+    }
+
+    /** For internal use only. May be removed or replaced in the future. */
+    public void setInternalWidths() {
+        getElement().getStyle().setPosition(Position.RELATIVE);
+        int bordersAndPaddings = WidgetUtil
+                .measureHorizontalPaddingAndBorder(buttons.getElement(), 0);
+
+        int buttonWidth = WidgetUtil.getRequiredWidth(buttons);
+        int totalWidth = getOffsetWidth();
+
+        int spaceForSelect = (totalWidth - buttonWidth - bordersAndPaddings)
+                / 2;
+
+        optionsListBox.setWidth(spaceForSelect + "px");
+        if (optionsCaption != null) {
+            optionsCaption.setWidth(spaceForSelect + "px");
+        }
+
+        selectionsListBox.setWidth(spaceForSelect + "px");
+        if (selectionsCaption != null) {
+            selectionsCaption.setWidth(spaceForSelect + "px");
+        }
+        captionWrapper.setWidth("100%");
+    }
+
+    /**
+     * Sets the tab index.
+     *
+     * @param tabIndex
+     *            the tab index to set
+     */
+    public void setTabIndex(int tabIndex) {
+        optionsListBox.setTabIndex(tabIndex);
+        selectionsListBox.setTabIndex(tabIndex);
+        addItemsLeftToRightButton.setTabIndex(tabIndex);
+        removeItemsRightToLeftButton.setTabIndex(tabIndex);
+    }
+
+    /**
+     * Sets this twin column select as read only, meaning selection cannot be
+     * changed.
+     *
+     * @param readOnly
+     *            {@code true} for read only, {@code false} for not read only
+     */
+    public void setReadOnly(boolean readOnly) {
+        if (this.readOnly != readOnly) {
+            this.readOnly = readOnly;
+            updateEnabledState();
+        }
+    }
+
+    /**
+     * Returns {@code true} if this twin column select is in read only mode,
+     * {@code false} if not.
+     *
+     * @return {@code true} for read only, {@code false} for not read only
+     */
+    public boolean isReadOnly() {
+        return readOnly;
+    }
+
+    @Override
+    public void setEnabled(boolean enabled) {
+        if (this.enabled != enabled) {
+            this.enabled = enabled;
+            updateEnabledState();
+        }
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    private void updateEnabledState() {
+        boolean enabled = isEnabled() && !isReadOnly();
+        optionsListBox.setEnabled(enabled);
+        selectionsListBox.setEnabled(enabled);
+        addItemsLeftToRightButton.setEnabled(enabled);
+        removeItemsRightToLeftButton.setEnabled(enabled);
+        addItemsLeftToRightButton.setStyleName(StyleConstants.DISABLED,
+                !enabled);
+        removeItemsRightToLeftButton.setStyleName(StyleConstants.DISABLED,
+                !enabled);
+    }
+
+    @Override
+    public void focus() {
+        optionsListBox.setFocus(true);
+    }
+
+    /**
+     * Get the key that selects an item in the table. By default it is the Enter
+     * key but by overriding this you can change the key to whatever you want.
+     *
+     * @return the key that selects an item
+     */
+    protected int getNavigationSelectKey() {
+        return KeyCodes.KEY_ENTER;
+    }
+
+    @Override
+    public void onKeyDown(KeyDownEvent event) {
+        int keycode = event.getNativeKeyCode();
+
+        // Catch tab and move between select:s
+        if (keycode == KeyCodes.KEY_TAB
+                && event.getSource() == optionsListBox) {
+            // Prevent default behavior
+            event.preventDefault();
+
+            // Remove current selections
+            for (int i = 0; i < optionsListBox.getItemCount(); i++) {
+                optionsListBox.setItemSelected(i, false);
+            }
+
+            // Focus selections
+            selectionsListBox.setFocus(true);
+        }
+
+        if (keycode == KeyCodes.KEY_TAB && event.isShiftKeyDown()
+                && event.getSource() == selectionsListBox) {
+            // Prevent default behavior
+            event.preventDefault();
+
+            // Remove current selections
+            for (int i = 0; i < selectionsListBox.getItemCount(); i++) {
+                selectionsListBox.setItemSelected(i, false);
+            }
+
+            // Focus options
+            optionsListBox.setFocus(true);
+        }
+
+        if (keycode == getNavigationSelectKey()) {
+            // Prevent default behavior
+            event.preventDefault();
+
+            // Decide which select the selection was made in
+            if (event.getSource() == optionsListBox) {
+                // Prevents the selection to become a single selection when
+                // using Enter key
+                // as the selection key (default)
+                optionsListBox.setFocus(false);
+
+                moveSelectedItemsLeftToRight();
+
+            } else if (event.getSource() == selectionsListBox) {
+                // Prevents the selection to become a single selection when
+                // using Enter key
+                // as the selection key (default)
+                selectionsListBox.setFocus(false);
+
+                moveSelectedItemsRightToLeft();
+            }
+        }
+
+    }
+
+    @Override
+    public void onMouseDown(MouseDownEvent event) {
+        // Ensure that items are deselected when selecting
+        // from a different source. See #3699 for details.
+        if (event.getSource() == optionsListBox) {
+            for (int i = 0; i < selectionsListBox.getItemCount(); i++) {
+                selectionsListBox.setItemSelected(i, false);
+            }
+        } else if (event.getSource() == selectionsListBox) {
+            for (int i = 0; i < optionsListBox.getItemCount(); i++) {
+                optionsListBox.setItemSelected(i, false);
+            }
+        }
+
+    }
+
+    @Override
+    public void onDoubleClick(DoubleClickEvent event) {
+        if (event.getSource() == optionsListBox) {
+            moveSelectedItemsLeftToRight();
+            optionsListBox.setSelectedIndex(-1);
+            optionsListBox.setFocus(false);
+        } else if (event.getSource() == selectionsListBox) {
+            moveSelectedItemsRightToLeft();
+            selectionsListBox.setSelectedIndex(-1);
+            selectionsListBox.setFocus(false);
+        }
+
+    }
+
+    @Override
+    public com.google.gwt.user.client.Element getSubPartElement(
+            String subPart) {
+        if (SUBPART_OPTION_SELECT.equals(subPart)) {
+            return optionsListBox.getElement();
+        } else if (subPart.startsWith(SUBPART_OPTION_SELECT_ITEM)) {
+            String idx = subPart.substring(SUBPART_OPTION_SELECT_ITEM.length());
+            return (com.google.gwt.user.client.Element) optionsListBox
+                    .getElement().getChild(Integer.parseInt(idx));
+        } else if (SUBPART_SELECTION_SELECT.equals(subPart)) {
+            return selectionsListBox.getElement();
+        } else if (subPart.startsWith(SUBPART_SELECTION_SELECT_ITEM)) {
+            String idx = subPart
+                    .substring(SUBPART_SELECTION_SELECT_ITEM.length());
+            return (com.google.gwt.user.client.Element) selectionsListBox
+                    .getElement().getChild(Integer.parseInt(idx));
+        } else if (optionsCaption != null
+                && SUBPART_LEFT_CAPTION.equals(subPart)) {
+            return optionsCaption.getElement();
+        } else if (selectionsCaption != null
+                && SUBPART_RIGHT_CAPTION.equals(subPart)) {
+            return selectionsCaption.getElement();
+        } else if (SUBPART_ADD_BUTTON.equals(subPart)) {
+            return addItemsLeftToRightButton.getElement();
+        } else if (SUBPART_REMOVE_BUTTON.equals(subPart)) {
+            return removeItemsRightToLeftButton.getElement();
+        }
+
+        return null;
+    }
+
+    @Override
+    public String getSubPartName(
+            com.google.gwt.user.client.Element subElement) {
+        if (optionsCaption != null
+                && optionsCaption.getElement().isOrHasChild(subElement)) {
+            return SUBPART_LEFT_CAPTION;
+        } else if (selectionsCaption != null
+                && selectionsCaption.getElement().isOrHasChild(subElement)) {
+            return SUBPART_RIGHT_CAPTION;
+        } else if (optionsListBox.getElement().isOrHasChild(subElement)) {
+            if (optionsListBox.getElement() == subElement) {
+                return SUBPART_OPTION_SELECT;
+            } else {
+                int idx = WidgetUtil.getChildElementIndex(subElement);
+                return SUBPART_OPTION_SELECT_ITEM + idx;
+            }
+        } else if (selectionsListBox.getElement().isOrHasChild(subElement)) {
+            if (selectionsListBox.getElement() == subElement) {
+                return SUBPART_SELECTION_SELECT;
+            } else {
+                int idx = WidgetUtil.getChildElementIndex(subElement);
+                return SUBPART_SELECTION_SELECT_ITEM + idx;
+            }
+        } else if (addItemsLeftToRightButton.getElement()
+                .isOrHasChild(subElement)) {
+            return SUBPART_ADD_BUTTON;
+        } else if (removeItemsRightToLeftButton.getElement()
+                .isOrHasChild(subElement)) {
+            return SUBPART_REMOVE_BUTTON;
+        }
+
+        return null;
+    }
+}
index a60a1b111f836f56b63c4395c3292d19af2e3ea4..254ae984d2e922fa1f875b252d0629e6cd79f969 100644 (file)
 package com.vaadin.client.ui.optiongroup;
 
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 
 import com.vaadin.client.communication.StateChangeEvent;
 import com.vaadin.client.connectors.AbstractListingConnector;
 import com.vaadin.client.data.DataSource;
 import com.vaadin.client.ui.VCheckBoxGroup;
+import com.vaadin.shared.data.selection.MultiSelectServerRpc;
 import com.vaadin.shared.data.selection.SelectionModel;
-import com.vaadin.shared.data.selection.SelectionServerRpc;
 import com.vaadin.shared.ui.Connect;
 import com.vaadin.shared.ui.optiongroup.CheckBoxGroupState;
 import com.vaadin.ui.CheckBoxGroup;
@@ -43,13 +45,14 @@ public class CheckBoxGroupConnector
     }
 
     private void selectionChanged(JsonObject changedItem, Boolean selected) {
-        SelectionServerRpc rpc = getRpcProxy(SelectionServerRpc.class);
+        MultiSelectServerRpc rpc = getRpcProxy(MultiSelectServerRpc.class);
         String key = getRowKey(changedItem);
-
+        HashSet<String> change = new HashSet<>();
+        change.add(key);
         if (Boolean.TRUE.equals(selected)) {
-            rpc.select(key);
+            rpc.updateSelection(change, Collections.emptySet());
         } else {
-            rpc.deselect(key);
+            rpc.updateSelection(Collections.emptySet(), change);
         }
     }
 
diff --git a/client/src/main/java/com/vaadin/client/ui/twincolselect/TwinColSelectConnector.java b/client/src/main/java/com/vaadin/client/ui/twincolselect/TwinColSelectConnector.java
new file mode 100644 (file)
index 0000000..304f5dc
--- /dev/null
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2000-2016 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.client.ui.twincolselect;
+
+import com.vaadin.client.DirectionalManagedLayout;
+import com.vaadin.client.annotations.OnStateChange;
+import com.vaadin.client.connectors.AbstractMultiSelectConnector;
+import com.vaadin.client.ui.VTwinColSelect;
+import com.vaadin.shared.ui.Connect;
+import com.vaadin.shared.ui.twincolselect.TwinColSelectState;
+import com.vaadin.ui.TwinColSelect;
+
+/**
+ * Client side connector for {@link TwinColSelect} component.
+ *
+ * @author Vaadin Ltd
+ *
+ */
+@Connect(TwinColSelect.class)
+public class TwinColSelectConnector extends AbstractMultiSelectConnector
+        implements DirectionalManagedLayout {
+
+    @Override
+    protected void init() {
+        super.init();
+        getLayoutManager().registerDependency(this,
+                getWidget().getCaptionWrapper().getElement());
+    }
+
+    @Override
+    public void onUnregister() {
+        getLayoutManager().unregisterDependency(this,
+                getWidget().getCaptionWrapper().getElement());
+    }
+
+    @Override
+    public VTwinColSelect getWidget() {
+        return (VTwinColSelect) super.getWidget();
+    }
+
+    @Override
+    public TwinColSelectState getState() {
+        return (TwinColSelectState) super.getState();
+    }
+
+    @OnStateChange(value = { "leftColumnCaption", "rightColumnCaption",
+            "caption" })
+    void updateCaptions() {
+        getWidget().updateCaptions(getState().leftColumnCaption,
+                getState().rightColumnCaption);
+
+        getLayoutManager().setNeedsHorizontalLayout(this);
+    }
+
+    @OnStateChange("readOnly")
+    void updateReadOnly() {
+        getWidget().setReadOnly(isReadOnly());
+    }
+
+    @Override
+    public void layoutVertically() {
+        if (isUndefinedHeight()) {
+            getWidget().clearInternalHeights();
+        } else {
+            getWidget().setInternalHeights();
+        }
+    }
+
+    @Override
+    public void layoutHorizontally() {
+        if (isUndefinedWidth()) {
+            getWidget().clearInternalWidths();
+        } else {
+            getWidget().setInternalWidths();
+        }
+    }
+
+    @Override
+    public MultiSelectWidget getMultiSelectWidget() {
+        return getWidget();
+    }
+}
diff --git a/pom.xml b/pom.xml
index eaeaf01c99abdf16dce98d0e7d6a6d7f758dc7f2..25c553bd329376c29ef7ebf1d81d8dd972da5957 100644 (file)
--- a/pom.xml
+++ b/pom.xml
             <dependency>
                 <groupId>junit</groupId>
                 <artifactId>junit</artifactId>
-                <version>4.11</version>
+                <version>4.12</version>
             </dependency>
             <dependency>
                 <groupId>org.easymock</groupId>
index 1d178418128d0e80fcf343b5d9366318970fb308..3d2cb151b883f3e873887ae902ff0bd5dbafed2e 100644 (file)
 package com.vaadin.ui;
 
 import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.LinkedHashSet;
 import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
 
 import com.vaadin.event.selection.MultiSelectionEvent;
 import com.vaadin.event.selection.MultiSelectionListener;
+import com.vaadin.server.Resource;
+import com.vaadin.server.ResourceReference;
+import com.vaadin.server.data.DataGenerator;
 import com.vaadin.shared.Registration;
+import com.vaadin.shared.data.selection.MultiSelectServerRpc;
+import com.vaadin.shared.data.selection.SelectionModel;
 import com.vaadin.shared.data.selection.SelectionModel.Multi;
+import com.vaadin.shared.ui.ListingJsonConstants;
 import com.vaadin.util.ReflectTools;
 
+import elemental.json.JsonObject;
+
 /**
  * Base class for listing components that allow selecting multiple items.
+ * <p>
+ * Sends selection information individually for each item.
  *
  * @param <T>
  *            item type
@@ -35,16 +52,240 @@ import com.vaadin.util.ReflectTools;
 public abstract class AbstractMultiSelect<T>
         extends AbstractListing<T, Multi<T>> {
 
+    /**
+     * Simple implementation of multiselectmodel.
+     */
+    protected class SimpleMultiSelectModel implements SelectionModel.Multi<T> {
+
+        private Set<T> selection = new LinkedHashSet<>();
+
+        @Override
+        public void select(T item) {
+            // Not user originated
+            select(item, false);
+        }
+
+        /**
+         * Selects the given item. Depending on the implementation, may cause
+         * other items to be deselected. If the item is already selected, does
+         * nothing.
+         *
+         * @param item
+         *            the item to select, not null
+         * @param userOriginated
+         *            {@code true} if this was used originated, {@code false} if
+         *            not
+         */
+        protected void select(T item, boolean userOriginated) {
+            if (selection.contains(item)) {
+                return;
+            }
+
+            updateSelection(set -> set.add(item), userOriginated);
+        }
+
+        @Override
+        public void updateSelection(Set<T> addedItems, Set<T> removedItems) {
+            updateSelection(addedItems, removedItems, false);
+        }
+
+        /**
+         * Updates the selection by adding and removing the given items.
+         *
+         * @param addedItems
+         *            the items added to selection, not {@code} null
+         * @param removedItems
+         *            the items removed from selection, not {@code} null
+         * @param userOriginated
+         *            {@code true} if this was used originated, {@code false} if
+         *            not
+         */
+        protected void updateSelection(Set<T> addedItems, Set<T> removedItems,
+                boolean userOriginated) {
+            Objects.requireNonNull(addedItems);
+            Objects.requireNonNull(removedItems);
+
+            // if there are duplicates, some item is both added & removed, just
+            // discard that and leave things as was before
+            addedItems.removeIf(item -> removedItems.remove(item));
+
+            if (selection.containsAll(addedItems)
+                    && Collections.disjoint(selection, removedItems)) {
+                return;
+            }
+
+            updateSelection(set -> {
+                // order of add / remove does not matter since no duplicates
+                set.removeAll(removedItems);
+                set.addAll(addedItems);
+            }, userOriginated);
+        }
+
+        @Override
+        public Set<T> getSelectedItems() {
+            return Collections.unmodifiableSet(new LinkedHashSet<>(selection));
+        }
+
+        @Override
+        public void deselect(T item) {
+            // Not user originated
+            deselect(item, false);
+        }
+
+        /**
+         * Deselects the given item. If the item is not currently selected, does
+         * nothing.
+         *
+         * @param item
+         *            the item to deselect, not null
+         * @param userOriginated
+         *            {@code true} if this was used originated, {@code false} if
+         *            not
+         */
+        protected void deselect(T item, boolean userOriginated) {
+            if (!selection.contains(item)) {
+                return;
+            }
+
+            updateSelection(set -> set.remove(item), userOriginated);
+        }
+
+        /**
+         * Removes the given items. Any item that is not currently selected, is
+         * ignored. If none of the items are selected, does nothing.
+         *
+         * @param items
+         *            the items to deselect, not {@code null}
+         * @param userOriginated
+         *            {@code true} if this was used originated, {@code false} if
+         *            not
+         */
+        protected void deselect(Set<T> items, boolean userOriginated) {
+            Objects.requireNonNull(items);
+            if (items.stream().noneMatch(i -> isSelected(i))) {
+                return;
+            }
+
+            updateSelection(set -> set.removeAll(items), userOriginated);
+        }
+
+        @Override
+        public void deselectAll() {
+            if (selection.isEmpty()) {
+                return;
+            }
+
+            updateSelection(Set::clear, false);
+        }
+
+        private void updateSelection(Consumer<Set<T>> handler,
+                boolean userOriginated) {
+            LinkedHashSet<T> oldSelection = new LinkedHashSet<>(selection);
+            handler.accept(selection);
+            LinkedHashSet<T> newSelection = new LinkedHashSet<>(selection);
+
+            fireEvent(new MultiSelectionEvent<>(AbstractMultiSelect.this,
+                    oldSelection, newSelection, userOriginated));
+
+            getDataCommunicator().reset();
+        }
+
+        @Override
+        public boolean isSelected(T item) {
+            return selection.contains(item);
+        }
+    }
+
+    private class MultiSelectServerRpcImpl implements MultiSelectServerRpc {
+        @Override
+        public void updateSelection(Set<String> selectedItemKeys,
+                Set<String> deselectedItemKeys) {
+            getSelectionModel().updateSelection(
+                    getItemsForSelectionChange(selectedItemKeys),
+                    getItemsForSelectionChange(deselectedItemKeys), true);
+        }
+
+        private Set<T> getItemsForSelectionChange(Set<String> keys) {
+            return keys.stream().map(key -> getItemForSelectionChange(key))
+                    .filter(Optional::isPresent).map(Optional::get)
+                    .collect(Collectors.toSet());
+        }
+
+        private Optional<T> getItemForSelectionChange(String key) {
+            T item = getDataCommunicator().getKeyMapper().get(key);
+            if (item == null || !getItemEnabledProvider().test(item)) {
+                return Optional.empty();
+            }
+
+            return Optional.of(item);
+        }
+
+        private SimpleMultiSelectModel getSelectionModel() {
+            return (SimpleMultiSelectModel) AbstractMultiSelect.this
+                    .getSelectionModel();
+        }
+    }
+
+    private class MultiSelectDataGenerator implements DataGenerator<T> {
+        @Override
+        public void generateData(T data, JsonObject jsonObject) {
+            jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_VALUE,
+                    getItemCaptionGenerator().apply(data));
+            Resource icon = getItemIconGenerator().apply(data);
+            if (icon != null) {
+                String iconUrl = ResourceReference
+                        .create(icon, AbstractMultiSelect.this, null).getURL();
+                jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_ICON, iconUrl);
+            }
+            if (!getItemEnabledProvider().test(data)) {
+                jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_DISABLED,
+                        true);
+            }
+
+            if (getSelectionModel().isSelected(data)) {
+                jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_SELECTED,
+                        true);
+            }
+        }
+
+        @Override
+        public void destroyData(T data) {
+        }
+    }
+
     @Deprecated
     private static final Method SELECTION_CHANGE_METHOD = ReflectTools
             .findMethod(MultiSelectionListener.class, "accept",
                     MultiSelectionEvent.class);
 
+    /**
+     * The item icon caption provider.
+     */
+    private ItemCaptionGenerator<T> itemCaptionGenerator = String::valueOf;
+
+    /**
+     * The item icon provider. It is up to the implementing class to support
+     * this or not.
+     */
+    private IconGenerator<T> itemIconGenerator = item -> null;
+
+    /**
+     * The item enabled status provider. It is up to the implementing class to
+     * support this or not.
+     */
+    private Predicate<T> itemEnabledProvider = item -> true;
+
     /**
      * Creates a new multi select with an empty data source.
      */
     protected AbstractMultiSelect() {
-        super();
+        setSelectionModel(new SimpleMultiSelectModel());
+
+        registerRpc(new MultiSelectServerRpcImpl());
+
+        // #FIXME it should be the responsibility of the SelectionModel
+        // (AbstractSelectionModel) to add selection data for item
+        addDataGenerator(new MultiSelectDataGenerator());
     }
 
     /**
@@ -52,7 +293,7 @@ public abstract class AbstractMultiSelect<T>
      * changed either by the user or programmatically.
      *
      * @param listener
-     *            the value change listener, not <code>null</code>
+     *            the value change listener, not {@code null}
      * @return a registration for the listener
      */
     public Registration addSelectionListener(
@@ -63,4 +304,99 @@ public abstract class AbstractMultiSelect<T>
         return () -> removeListener(MultiSelectionEvent.class, listener);
     }
 
+    /**
+     * Gets the item caption generator that is used to produce the strings shown
+     * in the select for each item.
+     *
+     * @return the item caption generator used, not {@code null}
+     * @see #setItemCaptionGenerator(ItemCaptionGenerator)
+     */
+    public ItemCaptionGenerator<T> getItemCaptionGenerator() {
+        return itemCaptionGenerator;
+    }
+
+    /**
+     * Sets the item caption generator that is used to produce the strings shown
+     * in the select for each item. By default, {@link String#valueOf(Object)}
+     * is used.
+     *
+     * @param itemCaptionGenerator
+     *            the item caption generator to use, not {@code null}
+     */
+    public void setItemCaptionGenerator(
+            ItemCaptionGenerator<T> itemCaptionGenerator) {
+        Objects.requireNonNull(itemCaptionGenerator);
+        this.itemCaptionGenerator = itemCaptionGenerator;
+        getDataCommunicator().reset();
+    }
+
+    /**
+     * Returns the item icon generator for this multiselect.
+     * <p>
+     * <em>Implementation note:</em> Override this method and
+     * {@link #setItemIconGenerator(IconGenerator)} as {@code public} and invoke
+     * {@code super} methods to support this feature in the multiselect
+     * component.
+     *
+     * @return the item icon generator, not {@code null}
+     * @see #setItemIconGenerator(IconGenerator)
+     */
+    protected IconGenerator<T> getItemIconGenerator() {
+        return itemIconGenerator;
+    }
+
+    /**
+     * Sets the item icon generator for this multiselect. The icon generator is
+     * queried for each item to optionally display an icon next to the item
+     * caption. If the generator returns null for an item, no icon is displayed.
+     * The default provider always returns null (no icons).
+     * <p>
+     * <em>Implementation note:</em> Override this method and
+     * {@link #getItemIconGenerator()} as {@code public} and invoke
+     * {@code super} methods to support this feature in the multiselect
+     * component.
+     *
+     * @param itemIconGenerator
+     *            the item icon generator to set, not {@code null}
+     */
+    protected void setItemIconGenerator(IconGenerator<T> itemIconGenerator) {
+        Objects.requireNonNull(itemIconGenerator);
+        this.itemIconGenerator = itemIconGenerator;
+    }
+
+    /**
+     * Returns the item enabled provider for this multiselect.
+     * <p>
+     * <em>Implementation note:</em> Override this method and
+     * {@link #setItemEnabledProvider(Predicate)} as {@code public} and invoke
+     * {@code super} methods to support this feature in the multiselect
+     * component.
+     *
+     * @return the item enabled provider, not {@code null}
+     * @see #setItemEnabledProvider(Predicate)
+     */
+    protected Predicate<T> getItemEnabledProvider() {
+        return itemEnabledProvider;
+    }
+
+    /**
+     * Sets the item enabled predicate for this multiselect. The predicate is
+     * applied to each item to determine whether the item should be enabled
+     * ({@code true}) or disabled ({@code false}). Disabled items are displayed
+     * as grayed out and the user cannot select them. The default predicate
+     * always returns {@code true} (all the items are enabled).
+     * <p>
+     * <em>Implementation note:</em> Override this method and
+     * {@link #getItemEnabledProvider()} as {@code public} and invoke
+     * {@code super} methods to support this feature in the multiselect
+     * component.
+     *
+     * @param itemEnabledProvider
+     *            the item enabled provider to set, not {@code null}
+     */
+    protected void setItemEnabledProvider(Predicate<T> itemEnabledProvider) {
+        Objects.requireNonNull(itemEnabledProvider);
+        this.itemEnabledProvider = itemEnabledProvider;
+    }
+
 }
\ No newline at end of file
index d5b2c5a8515fff55a4812ab04b60283090d31f99..0a1e4c71aff806fe075ccc0cf49c00518ef50686 100644 (file)
 package com.vaadin.ui;
 
 import java.util.Collection;
-import java.util.Collections;
-import java.util.LinkedHashSet;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.function.Consumer;
-import java.util.function.Function;
 import java.util.function.Predicate;
 
 import com.vaadin.data.Listing;
-import com.vaadin.event.selection.MultiSelectionEvent;
-import com.vaadin.server.Resource;
-import com.vaadin.server.ResourceReference;
-import com.vaadin.server.data.DataGenerator;
 import com.vaadin.server.data.DataSource;
-import com.vaadin.shared.data.selection.SelectionModel;
-import com.vaadin.shared.data.selection.SelectionServerRpc;
-import com.vaadin.shared.ui.optiongroup.CheckBoxGroupConstants;
 import com.vaadin.shared.ui.optiongroup.CheckBoxGroupState;
 
-import elemental.json.JsonObject;
-
 /**
  * A group of Checkboxes. Individual checkboxes are made from items supplied by
  * a {@link DataSource}. Checkboxes may have captions and icons.
@@ -50,77 +34,6 @@ import elemental.json.JsonObject;
  */
 public class CheckBoxGroup<T> extends AbstractMultiSelect<T> {
 
-    private final class SimpleMultiSelectModel
-            implements SelectionModel.Multi<T> {
-
-        private Set<T> selection = new LinkedHashSet<>();
-
-        @Override
-        public void select(T item) {
-            // Not user originated
-            select(item, false);
-        }
-
-        private void select(T item, boolean userOriginated) {
-            if (selection.contains(item)) {
-                return;
-            }
-
-            updateSelection(set -> set.add(item), userOriginated);
-        }
-
-        @Override
-        public Set<T> getSelectedItems() {
-            return Collections.unmodifiableSet(new LinkedHashSet<>(selection));
-        }
-
-        @Override
-        public void deselect(T item) {
-            // Not user originated
-            deselect(item, false);
-        }
-
-        private void deselect(T item, boolean userOriginated) {
-            if (!selection.contains(item)) {
-                return;
-            }
-
-            updateSelection(set -> set.remove(item), userOriginated);
-        }
-
-        @Override
-        public void deselectAll() {
-            if (selection.isEmpty()) {
-                return;
-            }
-
-            updateSelection(Set::clear, false);
-        }
-
-        private void updateSelection(Consumer<Set<T>> handler,
-                boolean userOriginated) {
-            LinkedHashSet<T> oldSelection = new LinkedHashSet<>(selection);
-            handler.accept(selection);
-            LinkedHashSet<T> newSelection = new LinkedHashSet<>(selection);
-
-            fireEvent(new MultiSelectionEvent<>(CheckBoxGroup.this,
-                    oldSelection, newSelection, userOriginated));
-
-            getDataCommunicator().reset();
-        }
-
-        @Override
-        public boolean isSelected(T item) {
-            return selection.contains(item);
-        }
-    }
-
-    private Function<T, Resource> itemIconProvider = item -> null;
-
-    private Function<T, String> itemCaptionProvider = String::valueOf;
-
-    private Predicate<T> itemEnabledProvider = item -> true;
-
     /**
      * Constructs a new CheckBoxGroup with caption.
      *
@@ -167,65 +80,6 @@ public class CheckBoxGroup<T> extends AbstractMultiSelect<T> {
      * @see Listing#setDataSource(DataSource)
      */
     public CheckBoxGroup() {
-        setSelectionModel(new SimpleMultiSelectModel());
-
-        registerRpc(new SelectionServerRpc() {
-
-            @Override
-            public void select(String key) {
-                getItemForSelectionChange(key).ifPresent(
-                        item -> getSelectionModel().select(item, true));
-            }
-
-            @Override
-            public void deselect(String key) {
-                getItemForSelectionChange(key).ifPresent(
-                        item -> getSelectionModel().deselect(item, true));
-            }
-
-            private Optional<T> getItemForSelectionChange(String key) {
-                T item = getDataCommunicator().getKeyMapper().get(key);
-                if (item == null || !itemEnabledProvider.test(item)) {
-                    return Optional.empty();
-                }
-
-                return Optional.of(item);
-            }
-
-            private SimpleMultiSelectModel getSelectionModel() {
-                return (SimpleMultiSelectModel) CheckBoxGroup.this
-                        .getSelectionModel();
-            }
-        });
-
-        addDataGenerator(new DataGenerator<T>() {
-            @Override
-            public void generateData(T data, JsonObject jsonObject) {
-                jsonObject.put(CheckBoxGroupConstants.JSONKEY_ITEM_VALUE,
-                        itemCaptionProvider.apply(data));
-                Resource icon = itemIconProvider.apply(data);
-                if (icon != null) {
-                    String iconUrl = ResourceReference
-                            .create(icon, CheckBoxGroup.this, null).getURL();
-                    jsonObject.put(CheckBoxGroupConstants.JSONKEY_ITEM_ICON,
-                            iconUrl);
-                }
-                if (!itemEnabledProvider.test(data)) {
-                    jsonObject.put(CheckBoxGroupConstants.JSONKEY_ITEM_DISABLED,
-                            true);
-                }
-
-                if (getSelectionModel().isSelected(data)) {
-                    jsonObject.put(CheckBoxGroupConstants.JSONKEY_ITEM_SELECTED,
-                            true);
-                }
-            }
-
-            @Override
-            public void destroyData(T data) {
-            }
-        });
-
     }
 
     /**
@@ -263,77 +117,23 @@ public class CheckBoxGroup<T> extends AbstractMultiSelect<T> {
         return (CheckBoxGroupState) super.getState(markAsDirty);
     }
 
-    /**
-     * Returns the item icons provider.
-     *
-     * @return the icons provider for items
-     * @see #setItemIconProvider
-     */
-    public Function<T, Resource> getItemIconProvider() {
-        return itemIconProvider;
-    }
-
-    /**
-     * Sets the item icon provider for this checkbox group. The icon provider is
-     * queried for each item to optionally display an icon next to the item
-     * caption. If the provider returns null for an item, no icon is displayed.
-     * The default provider always returns null (no icons).
-     *
-     * @param itemIconProvider
-     *            icons provider, not null
-     */
-    public void setItemIconProvider(Function<T, Resource> itemIconProvider) {
-        Objects.nonNull(itemIconProvider);
-        this.itemIconProvider = itemIconProvider;
-    }
-
-    /**
-     * Returns the item caption provider.
-     *
-     * @return the captions provider
-     * @see #setItemCaptionProvider
-     */
-    public Function<T, String> getItemCaptionProvider() {
-        return itemCaptionProvider;
+    @Override
+    public IconGenerator<T> getItemIconGenerator() {
+        return super.getItemIconGenerator();
     }
 
-    /**
-     * Sets the item caption provider for this checkbox group. The caption
-     * provider is queried for each item to optionally display an item textual
-     * representation. The default provider returns
-     * {@code String.valueOf(item)}.
-     *
-     * @param itemCaptionProvider
-     *            the item caption provider, not null
-     */
-    public void setItemCaptionProvider(
-            Function<T, String> itemCaptionProvider) {
-        Objects.nonNull(itemCaptionProvider);
-        this.itemCaptionProvider = itemCaptionProvider;
+    @Override
+    public void setItemIconGenerator(IconGenerator<T> itemIconGenerator) {
+        super.setItemIconGenerator(itemIconGenerator);
     }
 
-    /**
-     * Returns the item enabled predicate.
-     *
-     * @return the item enabled predicate
-     * @see #setItemEnabledProvider
-     */
+    @Override
     public Predicate<T> getItemEnabledProvider() {
-        return itemEnabledProvider;
+        return super.getItemEnabledProvider();
     }
 
-    /**
-     * Sets the item enabled predicate for this checkbox group. The predicate is
-     * applied to each item to determine whether the item should be enabled
-     * (true) or disabled (false). Disabled items are displayed as grayed out
-     * and the user cannot select them. The default predicate always returns
-     * true (all the items are enabled).
-     *
-     * @param itemEnabledProvider
-     *            the item enable predicate, not null
-     */
+    @Override
     public void setItemEnabledProvider(Predicate<T> itemEnabledProvider) {
-        Objects.nonNull(itemEnabledProvider);
-        this.itemEnabledProvider = itemEnabledProvider;
+        super.setItemEnabledProvider(itemEnabledProvider);
     }
 }
index e3cc1892b0dff1c3f0552cfe4fcf21087b5e91a2..f22e4a953556a7a4a529cc2b4b178d786a0fc654 100644 (file)
@@ -21,7 +21,7 @@ import com.vaadin.server.Resource;
 import com.vaadin.server.ResourceReference;
 import com.vaadin.server.data.DataGenerator;
 import com.vaadin.server.data.DataSource;
-import com.vaadin.shared.ui.optiongroup.RadioButtonGroupConstants;
+import com.vaadin.shared.ui.ListingJsonConstants;
 import com.vaadin.shared.ui.optiongroup.RadioButtonGroupState;
 import elemental.json.JsonObject;
 
@@ -98,22 +98,22 @@ public class RadioButtonGroup<T> extends AbstractSingleSelect<T> {
         addDataGenerator(new DataGenerator<T>() {
             @Override
             public void generateData(T data, JsonObject jsonObject) {
-                jsonObject.put(RadioButtonGroupConstants.JSONKEY_ITEM_VALUE,
+                jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_VALUE,
                         itemCaptionProvider.apply(data));
                 Resource icon = itemIconProvider.apply(data);
                 if (icon != null) {
                     String iconUrl = ResourceReference
                             .create(icon, RadioButtonGroup.this, null).getURL();
-                    jsonObject.put(RadioButtonGroupConstants.JSONKEY_ITEM_ICON,
+                    jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_ICON,
                             iconUrl);
                 }
                 if (!itemEnabledProvider.test(data)) {
-                    jsonObject.put(RadioButtonGroupConstants.JSONKEY_ITEM_DISABLED,
+                    jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_DISABLED,
                             true);
                 }
 
                 if (getSelectionModel().isSelected(data)) {
-                    jsonObject.put(RadioButtonGroupConstants.JSONKEY_ITEM_SELECTED,
+                    jsonObject.put(ListingJsonConstants.JSONKEY_ITEM_SELECTED,
                             true);
                 }
             }
diff --git a/server/src/main/java/com/vaadin/ui/TwinColSelect.java b/server/src/main/java/com/vaadin/ui/TwinColSelect.java
new file mode 100644 (file)
index 0000000..4b32155
--- /dev/null
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2000-2016 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.ui;
+
+import java.util.Collection;
+
+import com.vaadin.server.data.DataSource;
+import com.vaadin.shared.ui.twincolselect.TwinColSelectState;
+
+/**
+ * Multiselect component with two lists: left side for available items and right
+ * side for selected items.
+ *
+ * @author Vaadin Ltd
+ *
+ * @param <T>
+ *            item type
+ */
+public class TwinColSelect<T> extends AbstractMultiSelect<T> {
+
+    /**
+     * Constructs a new TwinColSelect.
+     */
+    public TwinColSelect() {
+    }
+
+    /**
+     * Constructs a new TwinColSelect with the given caption.
+     *
+     * @param caption
+     *            the caption to set, can be {@code null}
+     */
+    public TwinColSelect(String caption) {
+        this();
+        setCaption(caption);
+    }
+
+    /**
+     * Constructs a new TwinColSelect with caption and data source for options.
+     *
+     * @param caption
+     *            the caption to set, can be {@code null}
+     * @param dataSource
+     *            the data source, not {@code null}
+     */
+    public TwinColSelect(String caption, DataSource<T> dataSource) {
+        this(caption);
+        setDataSource(dataSource);
+    }
+
+    /**
+     * Constructs a new TwinColSelect with caption and the given options.
+     *
+     * @param caption
+     *            the caption to set, can be {@code null}
+     * @param options
+     *            the options, cannot be {@code null}
+     */
+    public TwinColSelect(String caption, Collection<T> options) {
+        this(caption, DataSource.create(options));
+    }
+
+    /**
+     * Returns the number of rows in the selects.
+     *
+     * @return the number of rows visible
+     */
+    public int getRows() {
+        return getState(false).rows;
+    }
+
+    /**
+     * Sets the number of rows in the selects. If the number of rows is set to 0
+     * or less, the actual number of displayed rows is determined implicitly by
+     * the selects.
+     * <p>
+     * If a height if set (using {@link #setHeight(String)} or
+     * {@link #setHeight(float, int)}) it overrides the number of rows. Leave
+     * the height undefined to use this method.
+     *
+     * @param rows
+     *            the number of rows to set.
+     */
+    public void setRows(int rows) {
+        if (rows < 0) {
+            rows = 0;
+        }
+        if (getState(false).rows != rows) {
+            getState().rows = rows;
+        }
+    }
+
+    /**
+     * Sets the text shown above the right column. {@code null} clears the
+     * caption.
+     *
+     * @param rightColumnCaption
+     *            The text to show, {@code null} to clear
+     */
+    public void setRightColumnCaption(String rightColumnCaption) {
+        getState().rightColumnCaption = rightColumnCaption;
+    }
+
+    /**
+     * Returns the text shown above the right column.
+     *
+     * @return The text shown or {@code null} if not set.
+     */
+    public String getRightColumnCaption() {
+        return getState(false).rightColumnCaption;
+    }
+
+    /**
+     * Sets the text shown above the left column. {@code null} clears the
+     * caption.
+     *
+     * @param leftColumnCaption
+     *            The text to show, {@code null} to clear
+     */
+    public void setLeftColumnCaption(String leftColumnCaption) {
+        getState().leftColumnCaption = leftColumnCaption;
+        markAsDirty();
+    }
+
+    /**
+     * Returns the text shown above the left column.
+     *
+     * @return The text shown or {@code null} if not set.
+     */
+    public String getLeftColumnCaption() {
+        return getState(false).leftColumnCaption;
+    }
+
+    @Override
+    protected TwinColSelectState getState() {
+        return (TwinColSelectState) super.getState();
+    }
+
+    @Override
+    protected TwinColSelectState getState(boolean markAsDirty) {
+        return (TwinColSelectState) super.getState(markAsDirty);
+    }
+
+}
diff --git a/server/src/test/java/com/vaadin/tests/server/component/twincolselect/TwinColSelectDeclarativeTest.java b/server/src/test/java/com/vaadin/tests/server/component/twincolselect/TwinColSelectDeclarativeTest.java
new file mode 100644 (file)
index 0000000..6ec4f16
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2000-2014 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.tests.server.component.twincolselect;
+
+import org.junit.Test;
+
+import com.vaadin.tests.design.DeclarativeTestBase;
+import com.vaadin.ui.TwinColSelect;
+
+/**
+ * Test cases for reading the properties of selection components.
+ *
+ * @author Vaadin Ltd
+ */
+public class TwinColSelectDeclarativeTest
+        extends DeclarativeTestBase<TwinColSelect<String>> {
+
+    public String getBasicDesign() {
+        return "<vaadin-twin-col-select rows=5 right-column-caption='Selected values' left-column-caption='Unselected values'>\n"
+                + "        <option>First item</option>\n"
+                + "        <option selected>Second item</option>\n"
+                + "        <option selected>Third item</option>\n"
+                + "</vaadin-twin-col-select>";
+
+    }
+
+    public TwinColSelect<String> getBasicExpected() {
+        TwinColSelect<String> s = new TwinColSelect<>();
+        s.setRightColumnCaption("Selected values");
+        s.setLeftColumnCaption("Unselected values");
+        s.setItems("First item", "Second item", "Third item");
+        s.getSelectionModel().select("Second item");
+        s.getSelectionModel().select("Third item");
+        s.setRows(5);
+        return s;
+    }
+
+    @Test
+    public void testReadBasic() {
+        testRead(getBasicDesign(), getBasicExpected());
+    }
+
+    @Test
+    public void testWriteBasic() {
+        testWrite(stripOptionTags(getBasicDesign()), getBasicExpected());
+    }
+
+    @Test
+    public void testReadEmpty() {
+        testRead("<vaadin-twin-col-select />", new TwinColSelect());
+    }
+
+    @Test
+    public void testWriteEmpty() {
+        testWrite("<vaadin-twin-col-select />", new TwinColSelect());
+    }
+
+}
\ No newline at end of file
diff --git a/server/src/test/java/com/vaadin/ui/AbstractMultiSelectTest.java b/server/src/test/java/com/vaadin/ui/AbstractMultiSelectTest.java
new file mode 100644 (file)
index 0000000..3505e4e
--- /dev/null
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2000-2016 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.ui;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+import com.vaadin.server.data.DataSource;
+import com.vaadin.shared.Registration;
+import com.vaadin.shared.data.selection.MultiSelectServerRpc;
+import com.vaadin.shared.data.selection.SelectionModel.Multi;
+
+@RunWith(Parameterized.class)
+public class AbstractMultiSelectTest {
+
+    @Parameters(name = "{0}")
+    public static Iterable<AbstractMultiSelect<String>> multiSelects() {
+        return Arrays.asList(new CheckBoxGroup<>(), new TwinColSelect<>());
+    }
+
+    @Parameter
+    public AbstractMultiSelect<String> selectToTest;
+
+    private Multi<String> selectionModel;
+    private MultiSelectServerRpc rpc;
+
+    private Registration registration;
+
+    @Before
+    public void setUp() {
+        selectToTest.getSelectionModel().deselectAll();
+        // Intentional deviation from upcoming selection order
+        selectToTest.setDataSource(
+                DataSource.create("3", "2", "1", "5", "8", "7", "4", "6"));
+        selectionModel = selectToTest.getSelectionModel();
+        rpc = ComponentTest.getRpcProxy(selectToTest,
+                MultiSelectServerRpc.class);
+    }
+
+    @After
+    public void tearDown() {
+        if (registration != null) {
+            registration.remove();
+            registration = null;
+        }
+    }
+
+    @Test
+    public void stableSelectionOrder() {
+        selectionModel.select("1");
+        selectionModel.select("2");
+        selectionModel.select("3");
+
+        assertSelectionOrder(selectionModel, "1", "2", "3");
+
+        selectionModel.deselect("1");
+        assertSelectionOrder(selectionModel, "2", "3");
+
+        selectionModel.select("1");
+        assertSelectionOrder(selectionModel, "2", "3", "1");
+
+        selectionModel.selectItems("7", "8", "4");
+        assertSelectionOrder(selectionModel, "2", "3", "1", "7", "8", "4");
+
+        selectionModel.deselectItems("2", "1", "4", "5");
+        assertSelectionOrder(selectionModel, "3", "7", "8");
+    }
+
+    @Test
+    public void apiSelectionChange_notUserOriginated() {
+        AtomicInteger listenerCount = new AtomicInteger(0);
+        listenerCount.set(0);
+
+        registration = selectToTest.addSelectionListener(event -> {
+            listenerCount.incrementAndGet();
+            Assert.assertFalse(event.isUserOriginated());
+        });
+
+        selectToTest.select("1");
+        selectToTest.select("2");
+
+        selectToTest.deselect("2");
+        selectToTest.getSelectionModel().deselectAll();
+
+        selectToTest.getSelectionModel().selectItems("2", "3", "4");
+        selectToTest.getSelectionModel().deselectItems("1", "4");
+
+        Assert.assertEquals(6, listenerCount.get());
+
+        // select partly selected
+        selectToTest.getSelectionModel().selectItems("2", "3", "4");
+        Assert.assertEquals(7, listenerCount.get());
+
+        // select completely selected
+        selectToTest.getSelectionModel().selectItems("2", "3", "4");
+        Assert.assertEquals(7, listenerCount.get());
+
+        // deselect partly not selected
+        selectToTest.getSelectionModel().selectItems("1", "4");
+        Assert.assertEquals(8, listenerCount.get());
+
+        // deselect completely not selected
+        selectToTest.getSelectionModel().selectItems("1", "4");
+        Assert.assertEquals(8, listenerCount.get());
+    }
+
+    @Test
+    public void rpcSelectionChange_userOriginated() {
+        AtomicInteger listenerCount = new AtomicInteger(0);
+
+        registration = selectToTest.addSelectionListener(event -> {
+            listenerCount.incrementAndGet();
+            Assert.assertTrue(event.isUserOriginated());
+        });
+
+        rpcSelect("1");
+        assertSelectionOrder(selectionModel, "1");
+
+        rpcSelect("2");
+        assertSelectionOrder(selectionModel, "1", "2");
+        rpcDeselect("2");
+        assertSelectionOrder(selectionModel, "1");
+        rpcSelect("3", "6");
+        assertSelectionOrder(selectionModel, "1", "3", "6");
+        rpcDeselect("1", "3");
+        assertSelectionOrder(selectionModel, "6");
+
+        Assert.assertEquals(5, listenerCount.get());
+
+        // select partly selected
+        rpcSelect("2", "3", "4");
+        Assert.assertEquals(6, listenerCount.get());
+        assertSelectionOrder(selectionModel, "6", "2", "3", "4");
+
+        // select completely selected
+        rpcSelect("2", "3", "4");
+        Assert.assertEquals(6, listenerCount.get());
+        assertSelectionOrder(selectionModel, "6", "2", "3", "4");
+
+        // deselect partly not selected
+        rpcDeselect("1", "4");
+        Assert.assertEquals(7, listenerCount.get());
+        assertSelectionOrder(selectionModel, "6", "2", "3");
+
+        // deselect completely not selected
+        rpcDeselect("1", "4");
+        Assert.assertEquals(7, listenerCount.get());
+        assertSelectionOrder(selectionModel, "6", "2", "3");
+
+        // select completely selected and deselect completely not selected
+        rpcUpdateSelection(new String[] { "3" }, new String[] { "1", "4" });
+        Assert.assertEquals(7, listenerCount.get());
+        assertSelectionOrder(selectionModel, "6", "2", "3");
+
+        // select partly selected and deselect completely not selected
+        rpcUpdateSelection(new String[] { "4", "2" },
+                new String[] { "1", "8" });
+        Assert.assertEquals(8, listenerCount.get());
+        assertSelectionOrder(selectionModel, "6", "2", "3", "4");
+
+        // select completely selected and deselect partly not selected
+        rpcUpdateSelection(new String[] { "4", "3" },
+                new String[] { "1", "2" });
+        Assert.assertEquals(9, listenerCount.get());
+        assertSelectionOrder(selectionModel, "6", "3", "4");
+
+        // duplicate case - ignored
+        rpcUpdateSelection(new String[] { "2" }, new String[] { "2" });
+        Assert.assertEquals(9, listenerCount.get());
+        assertSelectionOrder(selectionModel, "6", "3", "4");
+
+        // duplicate case - duplicate removed
+        rpcUpdateSelection(new String[] { "2" }, new String[] { "2", "3" });
+        Assert.assertEquals(10, listenerCount.get());
+        assertSelectionOrder(selectionModel, "6", "4");
+
+        // duplicate case - duplicate removed
+        rpcUpdateSelection(new String[] { "6", "8" }, new String[] { "6" });
+        Assert.assertEquals(11, listenerCount.get());
+        assertSelectionOrder(selectionModel, "6", "4", "8");
+    }
+
+    private void rpcSelect(String... keysToSelect) {
+        rpcUpdateSelection(keysToSelect, new String[] {});
+    }
+
+    private void rpcDeselect(String... keysToDeselect) {
+        rpcUpdateSelection(new String[] {}, keysToDeselect);
+    }
+
+    private void rpcUpdateSelection(String[] added, String[] removed) {
+        rpc.updateSelection(
+                new LinkedHashSet<>(Stream.of(added).map(this::getItemKey)
+                        .collect(Collectors.toList())),
+                new LinkedHashSet<>(Stream.of(removed).map(this::getItemKey)
+                        .collect(Collectors.toList())));
+    }
+
+    private String getItemKey(String dataObject) {
+        return selectToTest.getDataCommunicator().getKeyMapper()
+                .key(dataObject);
+    }
+
+    private static void assertSelectionOrder(Multi<String> selectionModel,
+            String... selectionOrder) {
+        Assert.assertEquals(Arrays.asList(selectionOrder),
+                new ArrayList<>(selectionModel.getSelectedItems()));
+    }
+}
index e8594757d0524b1bb0d41ca4d1a4c22a116ce7df..c89d95a4b29d0736c0326b269fa049fdcb95e1c4 100644 (file)
@@ -23,24 +23,19 @@ import java.util.EnumSet;
  * @author Vaadin Ltd
  * @since 8.0
  */
-public class CheckBoxGroupBoVTest
-{
+public class CheckBoxGroupBoVTest {
     public enum Status {
-        STATE_A,
-        STATE_B,
-        STATE_C,
-        STATE_D;
+        STATE_A, STATE_B, STATE_C, STATE_D;
 
         public String getCaption() {
             return "** " + toString();
         }
     }
 
-
     public void createOptionGroup() {
         CheckBoxGroup<Status> s = new CheckBoxGroup<>();
         s.setItems(EnumSet.allOf(Status.class));
-        s.setItemCaptionProvider(Status::getCaption);
+        s.setItemCaptionGenerator(Status::getCaption);
     }
 
 }
diff --git a/server/src/test/java/com/vaadin/ui/CheckBoxGroupTest.java b/server/src/test/java/com/vaadin/ui/CheckBoxGroupTest.java
deleted file mode 100644 (file)
index 192dcb3..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * Copyright 2000-2016 Vaadin Ltd.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not
- * use this file except in compliance with the License. You may obtain a copy of
- * the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
- * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
- * License for the specific language governing permissions and limitations under
- * the License.
- */
-package com.vaadin.ui;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.concurrent.atomic.AtomicInteger;
-
-import org.junit.Assert;
-import org.junit.Before;
-import org.junit.Test;
-
-import com.vaadin.server.data.DataSource;
-import com.vaadin.shared.data.selection.SelectionModel.Multi;
-import com.vaadin.shared.data.selection.SelectionServerRpc;
-
-public class CheckBoxGroupTest {
-    private CheckBoxGroup<String> checkBoxGroup;
-    private Multi<String> selectionModel;
-
-    @Before
-    public void setUp() {
-        checkBoxGroup = new CheckBoxGroup<>();
-        // Intentional deviation from upcoming selection order
-        checkBoxGroup
-                .setDataSource(DataSource.create("Third", "Second", "First"));
-        selectionModel = checkBoxGroup.getSelectionModel();
-    }
-
-    @Test
-    public void stableSelectionOrder() {
-        selectionModel.select("First");
-        selectionModel.select("Second");
-        selectionModel.select("Third");
-
-        assertSelectionOrder(selectionModel, "First", "Second", "Third");
-
-        selectionModel.deselect("First");
-        assertSelectionOrder(selectionModel, "Second", "Third");
-
-        selectionModel.select("First");
-        assertSelectionOrder(selectionModel, "Second", "Third", "First");
-    }
-
-    @Test
-    public void apiSelectionChange_notUserOriginated() {
-        AtomicInteger listenerCount = new AtomicInteger(0);
-
-        checkBoxGroup.addSelectionListener(event -> {
-            listenerCount.incrementAndGet();
-            Assert.assertFalse(event.isUserOriginated());
-        });
-
-        checkBoxGroup.select("First");
-        checkBoxGroup.select("Second");
-
-        checkBoxGroup.deselect("Second");
-        checkBoxGroup.getSelectionModel().deselectAll();
-
-        Assert.assertEquals(4, listenerCount.get());
-    }
-
-    @Test
-    public void rpcSelectionChange_userOriginated() {
-        AtomicInteger listenerCount = new AtomicInteger(0);
-
-        checkBoxGroup.addSelectionListener(event -> {
-            listenerCount.incrementAndGet();
-            Assert.assertTrue(event.isUserOriginated());
-        });
-
-        SelectionServerRpc rpc = ComponentTest.getRpcProxy(checkBoxGroup,
-                SelectionServerRpc.class);
-
-        rpc.select(getItemKey("First"));
-        rpc.select(getItemKey("Second"));
-        rpc.deselect(getItemKey("Second"));
-
-        Assert.assertEquals(3, listenerCount.get());
-    }
-
-    private String getItemKey(String dataObject) {
-        return checkBoxGroup.getDataCommunicator().getKeyMapper()
-                .key(dataObject);
-    }
-
-    private static void assertSelectionOrder(Multi<String> selectionModel,
-            String... selectionOrder) {
-        Assert.assertEquals(Arrays.asList(selectionOrder),
-                new ArrayList<>(selectionModel.getSelectedItems()));
-    }
-}
diff --git a/shared/src/main/java/com/vaadin/shared/data/selection/MultiSelectServerRpc.java b/shared/src/main/java/com/vaadin/shared/data/selection/MultiSelectServerRpc.java
new file mode 100644 (file)
index 0000000..a924f00
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2000-2016 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.shared.data.selection;
+
+import java.util.Set;
+
+import com.vaadin.shared.communication.ServerRpc;
+
+/**
+ * Transmits SelectionModel selection changes from the client to the server.
+ *
+ * @author Vaadin Ltd
+ *
+ * @since 8.0
+ */
+public interface MultiSelectServerRpc extends ServerRpc {
+
+    /**
+     * Updates the selected items based on their keys.
+     *
+     * @param addedItemKeys
+     *            the item keys added to selection
+     * @param removedItemKeys
+     *            the item keys removed from selection
+     */
+    void updateSelection(Set<String> addedItemKeys,
+            Set<String> removedItemKeys);
+}
index 8711d6a9c8099abe207ccf1ca2b7def7359edb4c..c56fe8c4d9987b8347cb2842cf539bd4f03b8779 100644 (file)
 package com.vaadin.shared.data.selection;
 
 import java.io.Serializable;
+import java.util.Arrays;
 import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Stream;
 
 /**
  * Models the selection logic of a {@code Listing} component. Determines how
@@ -60,7 +64,7 @@ public interface SelectionModel<T> extends Serializable {
         /**
          * Sets the current selection to the given item, or clears selection if
          * given {@code null}.
-         * 
+         *
          * @param item
          *            the item to select or {@code null} to clear selection
          */
@@ -78,11 +82,11 @@ public interface SelectionModel<T> extends Serializable {
          *
          * @return a singleton set of the selected item if any, an empty set
          *         otherwise
-         * 
+         *
          * @see #getSelectedItem()
          */
         @Override
-        default Set<T> getSelectedItems() {
+        public default Set<T> getSelectedItems() {
             return getSelectedItem().map(Collections::singleton)
                     .orElse(Collections.emptySet());
         }
@@ -98,10 +102,77 @@ public interface SelectionModel<T> extends Serializable {
     public interface Multi<T> extends SelectionModel<T> {
 
         /**
-         * Adds the given items to the set of currently selected items.
+         * Adds the given item to the set of currently selected items.
+         * <p>
+         * By default this does not clear any previous selection. To do that,
+         * use {@link #deselectAll()}.
+         * <p>
+         * If the the item was already selected, this is a NO-OP.
+         *
+         * @param item
+         *            the item to add to selection, not {@code null}
          */
         @Override
-        public void select(T item);
+        public default void select(T item) {
+            Objects.requireNonNull(item);
+            selectItems(item);
+        };
+
+        /**
+         * Adds the given items to the set of currently selected items.
+         * <p>
+         * By default this does not clear any previous selection. To do that,
+         * use {@link #deselectAll()}.
+         * <p>
+         * If the all the items were already selected, this is a NO-OP.
+         * <p>
+         * This is a short-hand for {@link #updateSelection(Set, Set)} with
+         * nothing to deselect.
+         *
+         * @param items
+         *            to add to selection, not {@code null}
+         */
+        public default void selectItems(T... items) {
+            Objects.requireNonNull(items);
+            Stream.of(items).forEach(Objects::requireNonNull);
+
+            updateSelection(new LinkedHashSet<>(Arrays.asList(items)),
+                    Collections.emptySet());
+        }
+
+        /**
+         * Removes the given items from the set of currently selected items.
+         * <p>
+         * If the none of the items were selected, this is a NO-OP.
+         * <p>
+         * This is a short-hand for {@link #updateSelection(Set, Set)} with
+         * nothing to select.
+         *
+         * @param items
+         *            to remove from selection, not {@code null}
+         */
+        public default void deselectItems(T... items) {
+            Objects.requireNonNull(items);
+            Stream.of(items).forEach(Objects::requireNonNull);
+
+            updateSelection(Collections.emptySet(),
+                    new LinkedHashSet<>(Arrays.asList(items)));
+        }
+
+        /**
+         * Updates the selection by adding and removing the given items from it.
+         * <p>
+         * If all the added items were already selected and the removed items
+         * were not selected, this is a NO-OP.
+         * <p>
+         * Duplicate items (in both add & remove sets) are ignored.
+         *
+         * @param addedItems
+         *            the items to add, not {@code null}
+         * @param removedItems
+         *            the items to remove, not {@code null}
+         */
+        public void updateSelection(Set<T> addedItems, Set<T> removedItems);
     }
 
     /**
diff --git a/shared/src/main/java/com/vaadin/shared/ui/ListingJsonConstants.java b/shared/src/main/java/com/vaadin/shared/ui/ListingJsonConstants.java
new file mode 100644 (file)
index 0000000..82431bf
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2000-2016 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.shared.ui;
+
+import java.io.Serializable;
+
+public class ListingJsonConstants implements Serializable {
+    public static final String JSONKEY_ITEM_DISABLED = "d";
+
+    public static final String JSONKEY_ITEM_ICON = "i";
+
+    public static final String JSONKEY_ITEM_VALUE = "v";
+
+    public static final String JSONKEY_ITEM_SELECTED = "s";
+}
diff --git a/shared/src/main/java/com/vaadin/shared/ui/optiongroup/CheckBoxGroupConstants.java b/shared/src/main/java/com/vaadin/shared/ui/optiongroup/CheckBoxGroupConstants.java
deleted file mode 100644 (file)
index 6bca438..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Copyright 2000-2016 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.shared.ui.optiongroup;
-
-import java.io.Serializable;
-
-public class CheckBoxGroupConstants implements Serializable {
-    public static final String JSONKEY_ITEM_DISABLED = "d";
-
-    public static final String JSONKEY_ITEM_ICON = "i";
-
-    public static final String JSONKEY_ITEM_VALUE = "v";
-
-    public static final String JSONKEY_ITEM_KEY = "k";
-
-    public static final String JSONKEY_ITEM_SELECTED = "s";
-}
diff --git a/shared/src/main/java/com/vaadin/shared/ui/optiongroup/RadioButtonGroupConstants.java b/shared/src/main/java/com/vaadin/shared/ui/optiongroup/RadioButtonGroupConstants.java
deleted file mode 100644 (file)
index 5278d21..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Copyright 2000-2016 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.shared.ui.optiongroup;
-
-import java.io.Serializable;
-
-public class RadioButtonGroupConstants implements Serializable {
-    public static final String JSONKEY_ITEM_DISABLED = "d";
-
-    public static final String JSONKEY_ITEM_ICON = "i";
-
-    public static final String JSONKEY_ITEM_VALUE = "v";
-
-    public static final String JSONKEY_ITEM_KEY = "k";
-
-    public static final String JSONKEY_ITEM_SELECTED = "s";
-}
diff --git a/shared/src/main/java/com/vaadin/shared/ui/twincolselect/TwinColSelectState.java b/shared/src/main/java/com/vaadin/shared/ui/twincolselect/TwinColSelectState.java
new file mode 100644 (file)
index 0000000..a6b09c0
--- /dev/null
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2000-2016 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.shared.ui.twincolselect;
+
+import com.vaadin.shared.annotations.DelegateToWidget;
+import com.vaadin.shared.ui.TabIndexState;
+
+/**
+ * Shared state for the TwinColSelect component.
+ *
+ * @since 7.0
+ */
+public class TwinColSelectState extends TabIndexState {
+    {
+        primaryStyleName = "v-select-twincol";
+    }
+    @DelegateToWidget
+    public int rows;
+
+    public String leftColumnCaption;
+    public String rightColumnCaption;
+}
diff --git a/uitest/src/main/java/com/vaadin/tests/components/abstractlisting/AbstractMultiSelectTestUI.java b/uitest/src/main/java/com/vaadin/tests/components/abstractlisting/AbstractMultiSelectTestUI.java
new file mode 100644 (file)
index 0000000..fadd94a
--- /dev/null
@@ -0,0 +1,83 @@
+package com.vaadin.tests.components.abstractlisting;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import com.vaadin.shared.data.selection.SelectionModel.Multi;
+import com.vaadin.ui.AbstractMultiSelect;
+
+public abstract class AbstractMultiSelectTestUI<MULTISELECT extends AbstractMultiSelect<Object>>
+        extends AbstractListingTestUI<MULTISELECT> {
+
+    protected final String selectionCategory = "Selection";
+
+    @Override
+    protected void createActions() {
+        super.createActions();
+        createItemCaptionGeneratorMenu();
+        createSelectionMenu();
+        createListenerMenu();
+    }
+
+    protected void createItemCaptionGeneratorMenu() {
+        createBooleanAction("Use Item Caption Generator", "Item Generator",
+                false, this::useItemCaptionProvider);
+    }
+
+    private void useItemCaptionProvider(MULTISELECT select, boolean activate,
+            Object data) {
+        if (activate) {
+            select.setItemCaptionGenerator(
+                    item -> item.toString() + " Caption");
+        } else {
+            select.setItemCaptionGenerator(item -> item.toString());
+        }
+        select.getDataSource().refreshAll();
+    }
+
+    protected void createSelectionMenu() {
+        createClickAction(
+                "Clear selection", selectionCategory, (component, item,
+                        data) -> component.getSelectionModel().deselectAll(),
+                "");
+
+        Command<MULTISELECT, String> toggleSelection = (component, item,
+                data) -> toggleSelection(item);
+
+        List<String> items = IntStream.of(0, 1, 5, 10, 25)
+                .mapToObj(i -> "Item " + i).collect(Collectors.toList());
+        items.forEach(item -> createClickAction("Toggle " + item,
+                selectionCategory, toggleSelection, item));
+
+        Command<MULTISELECT, Boolean> toggleMultiSelection = (component, i,
+                data) -> toggleMultiSelection(i, items);
+
+        createBooleanAction("Toggle items 0, 1, 5, 10, 25", selectionCategory,
+                false, toggleMultiSelection, items);
+    }
+
+    private void toggleSelection(String item) {
+        Multi<Object> selectionModel = getComponent().getSelectionModel();
+        if (selectionModel.isSelected(item)) {
+            selectionModel.deselect(item);
+        } else {
+            selectionModel.select(item);
+        }
+    }
+
+    private void toggleMultiSelection(boolean add, List<String> items) {
+        Multi<Object> selectionModel = getComponent().getSelectionModel();
+        if (add) {
+            selectionModel.selectItems(items.toArray());
+        } else {
+            selectionModel.deselectItems(items.toArray());
+        }
+    }
+
+    protected void createListenerMenu() {
+        createListenerAction("Selection listener", "Listeners",
+                c -> c.addSelectionListener(
+                        e -> log("Selected: " + e.getNewSelection())));
+    }
+}
index 6553c0ae6e653382ebb3ec860578ac74404555e8..95d2c016b1bbbe5b03f89fe2f47ec8086fe1b25a 100644 (file)
  */
 package com.vaadin.tests.components.checkbox;
 
-import java.util.function.Function;
-import java.util.stream.IntStream;
-
 import com.vaadin.server.FontAwesome;
-import com.vaadin.server.Resource;
-import com.vaadin.shared.data.selection.SelectionModel.Multi;
-import com.vaadin.tests.components.abstractlisting.AbstractListingTestUI;
+import com.vaadin.tests.components.abstractlisting.AbstractMultiSelectTestUI;
 import com.vaadin.ui.CheckBoxGroup;
+import com.vaadin.ui.IconGenerator;
 
 /**
  * Test UI for CheckBoxGroup component
@@ -30,11 +26,9 @@ import com.vaadin.ui.CheckBoxGroup;
  * @author Vaadin Ltd
  */
 public class CheckBoxGroupTestUI
-        extends AbstractListingTestUI<CheckBoxGroup<Object>> {
-
-    private final String selectionCategory = "Selection";
+        extends AbstractMultiSelectTestUI<CheckBoxGroup<Object>> {
 
-    private static final Function<Object, Resource> DEFAULT_ICON_PROVIDER = item -> "Item 2"
+    private static final IconGenerator<Object> DEFAULT_ICON_GENERATOR = item -> "Item 2"
             .equals(item) ? ICON_16_HELP_PNG_CACHEABLE : null;
 
     @SuppressWarnings({ "unchecked", "rawtypes" })
@@ -46,7 +40,7 @@ public class CheckBoxGroupTestUI
     @Override
     protected CheckBoxGroup<Object> constructComponent() {
         CheckBoxGroup<Object> checkBoxGroup = super.constructComponent();
-        checkBoxGroup.setItemIconProvider(DEFAULT_ICON_PROVIDER);
+        checkBoxGroup.setItemIconGenerator(DEFAULT_ICON_GENERATOR);
         checkBoxGroup.setItemEnabledProvider(item -> !"Item 10".equals(item));
         return checkBoxGroup;
     }
@@ -54,70 +48,25 @@ public class CheckBoxGroupTestUI
     @Override
     protected void createActions() {
         super.createActions();
-        createListenerMenu();
-        createSelectionMenu();
-        createItemProviderMenu();
-    }
-
-    protected void createSelectionMenu() {
-        createClickAction(
-                "Clear selection", selectionCategory, (component, item,
-                        data) -> component.getSelectionModel().deselectAll(),
-                "");
-
-        Command<CheckBoxGroup<Object>, String> toggleSelection = (component,
-                item, data) -> toggleSelection(item);
-
-        IntStream.of(0, 1, 5, 10, 25).mapToObj(i -> "Item " + i)
-                .forEach(item -> {
-                    createClickAction("Toggle " + item, selectionCategory,
-                            toggleSelection, item);
-                });
-    }
-
-    private void toggleSelection(String item) {
-        Multi<Object> selectionModel = getComponent().getSelectionModel();
-        if (selectionModel.isSelected(item)) {
-            selectionModel.deselect(item);
-        } else {
-            selectionModel.select(item);
-        }
+        createItemIconGenerator();
     }
 
-    private void createItemProviderMenu() {
-        createBooleanAction("Use Item Caption Provider", "Item Provider", false,
-                this::useItemCaptionProvider);
-        createBooleanAction("Use Item Icon Provider", "Item Provider", false,
+    private void createItemIconGenerator() {
+        createBooleanAction("Use Item Icon Generator", "Item Generator", false,
                 this::useItemIconProvider);
     }
 
-    private void useItemCaptionProvider(CheckBoxGroup<Object> group,
-            boolean activate, Object data) {
-        if (activate) {
-            group.setItemCaptionProvider(item -> item.toString() + " Caption");
-        } else {
-            group.setItemCaptionProvider(item -> item.toString());
-        }
-        group.getDataSource().refreshAll();
-    }
-
     private void useItemIconProvider(CheckBoxGroup<Object> group,
             boolean activate, Object data) {
         if (activate) {
-            group.setItemIconProvider(
+            group.setItemIconGenerator(
                     item -> FontAwesome.values()[getIndex(item) + 1]);
         } else {
-            group.setItemIconProvider(DEFAULT_ICON_PROVIDER);
+            group.setItemIconGenerator(DEFAULT_ICON_GENERATOR);
         }
         group.getDataSource().refreshAll();
     }
 
-    protected void createListenerMenu() {
-        createListenerAction("Selection listener", "Listeners",
-                c -> c.addSelectionListener(
-                        e -> log("Selected: " + e.getNewSelection())));
-    }
-
     private int getIndex(Object item) {
         int index = item.toString().indexOf(' ');
         if (index < 0) {
index 563e730681b91b9b369f8d3c8a8059fe4e572abf..6491651c4b13c7151a8802946b6a5b967feb3176 100644 (file)
@@ -3,14 +3,15 @@ package com.vaadin.tests.components.select;
 import com.vaadin.tests.components.TestBase;
 import com.vaadin.ui.Button;
 import com.vaadin.ui.Button.ClickEvent;
-import com.vaadin.v7.ui.TwinColSelect;
+import com.vaadin.ui.TwinColSelect;
 
 public class TwinColSelectCaptionStyles extends TestBase {
 
     @Override
     protected void setup() {
         setTheme("tests-tickets");
-        final TwinColSelect sel = new TwinColSelect("Component caption");
+        final TwinColSelect<String> sel = new TwinColSelect<>(
+                "Component caption");
         sel.setLeftColumnCaption("Left caption");
         sel.setRightColumnCaption("Right caption");
         sel.setStyleName("styled-twincol-captions");
index 7a769de843c50b14d0f6e5cd4557430fd6077188..fbc25670962e6131173651acd407de098114181a 100644 (file)
@@ -1,7 +1,10 @@
 package com.vaadin.tests.components.select;
 
+import java.util.ArrayList;
+import java.util.List;
+
 import com.vaadin.tests.components.ComponentTestCase;
-import com.vaadin.v7.ui.TwinColSelect;
+import com.vaadin.ui.TwinColSelect;
 
 public class TwinColSelects extends ComponentTestCase<TwinColSelect> {
 
@@ -13,7 +16,7 @@ public class TwinColSelects extends ComponentTestCase<TwinColSelect> {
     @Override
     protected void initializeComponents() {
 
-        TwinColSelect tws = createTwinColSelect("400x<auto>");
+        TwinColSelect<String> tws = createTwinColSelect("400x<auto>");
         tws.setWidth("400px");
         tws.setHeight("-1px");
         addTestComponent(tws);
@@ -34,14 +37,13 @@ public class TwinColSelects extends ComponentTestCase<TwinColSelect> {
 
     }
 
-    private TwinColSelect createTwinColSelect(String caption) {
-        TwinColSelect select = new TwinColSelect(caption);
-        select.addContainerProperty(CAPTION, String.class, null);
+    private TwinColSelect<String> createTwinColSelect(String caption) {
+        TwinColSelect<String> select = new TwinColSelect<>(caption);
+        List<String> items = new ArrayList<>();
         for (int i = 0; i < 20; i++) {
-            select.addItem("" + i).getItemProperty(CAPTION)
-                    .setValue("Item " + i);
+            items.add("Item " + i);
         }
-        select.setImmediate(true);
+        select.setItems(items);
         return select;
     }
 
diff --git a/uitest/src/main/java/com/vaadin/tests/components/twincolselect/TwinColSelectTestUI.java b/uitest/src/main/java/com/vaadin/tests/components/twincolselect/TwinColSelectTestUI.java
new file mode 100644 (file)
index 0000000..761c412
--- /dev/null
@@ -0,0 +1,35 @@
+package com.vaadin.tests.components.twincolselect;
+
+import java.util.LinkedHashMap;
+
+import com.vaadin.tests.components.abstractlisting.AbstractMultiSelectTestUI;
+import com.vaadin.ui.TwinColSelect;
+
+public class TwinColSelectTestUI
+        extends AbstractMultiSelectTestUI<TwinColSelect<Object>> {
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    @Override
+    protected Class<TwinColSelect<Object>> getTestClass() {
+        return (Class) TwinColSelect.class;
+    }
+
+    @Override
+    protected void createActions() {
+        super.createActions();
+        createRows();
+    }
+
+    private void createRows() {
+        LinkedHashMap<String, Integer> options = new LinkedHashMap<>();
+        options.put("0", 0);
+        options.put("1", 1);
+        options.put("2", 2);
+        options.put("5", 5);
+        options.put("10 (default)", 10);
+        options.put("50", 50);
+
+        createSelectAction("Rows", CATEGORY_STATE, options, "10 (default)",
+                (c, value, data) -> c.setRows(value), null);
+    }
+}
index a30f5d71e267c2b56cd570acaf300eeaa72dcdb2..4bb1defbf69696cbbee1db1c29879769aae5fe2b 100644 (file)
@@ -1,6 +1,6 @@
 /*
  * Copyright 2000-2014 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
@@ -122,15 +122,16 @@ public class CheckBoxGroupTest extends MultiBrowserTest {
     }
 
     @Test
-    public void itemCaptionProvider() {
-        selectMenuPath("Component", "Item Provider",
-                "Use Item Caption Provider");
+    public void itemCaptionGenerator() {
+        selectMenuPath("Component", "Item Generator",
+                "Use Item Caption Generator");
         assertItems(20, " Caption");
     }
 
     @Test
-    public void itemIconProvider() {
-        selectMenuPath("Component", "Item Provider", "Use Item Icon Provider");
+    public void itemIconGenerator() {
+        selectMenuPath("Component", "Item Generator",
+                "Use Item Icon Generator");
         assertItemSuffices(20);
         List<WebElement> icons = getSelect()
                 .findElements(By.cssSelector(".v-icon.FontAwesome"));
diff --git a/uitest/src/test/java/com/vaadin/tests/components/twincolselect/TwinColSelectTest.java b/uitest/src/test/java/com/vaadin/tests/components/twincolselect/TwinColSelectTest.java
new file mode 100644 (file)
index 0000000..42c8128
--- /dev/null
@@ -0,0 +1,259 @@
+package com.vaadin.tests.components.twincolselect;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.support.ui.Select;
+
+import com.vaadin.testbench.elements.TwinColSelectElement;
+import com.vaadin.tests.tb3.MultiBrowserTest;
+
+public class TwinColSelectTest extends MultiBrowserTest {
+
+    @Before
+    public void setUp() throws Exception {
+        openTestURL();
+    }
+
+    @Test
+    public void initialLoad_containsCorrectItems() {
+        assertItems(20);
+    }
+
+    @Test
+    public void initialItems_reduceItemCount_containsCorrectItems() {
+        selectMenuPath("Component", "Data source", "Items", "5");
+        assertItems(5);
+    }
+
+    @Test
+    public void initialItems_increaseItemCount_containsCorrectItems() {
+        selectMenuPath("Component", "Data source", "Items", "100");
+        assertItems(100);
+    }
+
+    @Test
+    public void itemsMovedFromLeftToRight() {
+        selectMenuPath("Component", "Data source", "Items", "5");
+        assertItems(5);
+
+        selectItems("Item 1", "Item 2", "Item 4");
+
+        assertSelected("Item 1", "Item 2", "Item 4");
+
+        assertOptionTexts("Item 0", "Item 3");
+
+        deselectItems("Item 1", "Item 4");
+
+        assertSelected("Item 2");
+
+        assertOptionTexts("Item 0", "Item 1", "Item 3", "Item 4");
+
+        selectItems("Item 0");
+
+        assertSelected("Item 0", "Item 2");
+        assertOptionTexts("Item 1", "Item 3", "Item 4");
+    }
+
+    @Test
+    public void clickToSelect() {
+        selectMenuPath("Component", "Listeners", "Selection listener");
+
+        selectItems("Item 4");
+        Assert.assertEquals("1. Selected: [Item 4]", getLogRow(0));
+        assertSelected("Item 4");
+
+        // the previous item stays selected
+        selectItems("Item 2");
+        // Selection order (most recently selected is last)
+        Assert.assertEquals("2. Selected: [Item 4, Item 2]", getLogRow(0));
+        assertSelected("Item 2", "Item 4");
+
+        deselectItems("Item 4");
+        Assert.assertEquals("3. Selected: [Item 2]", getLogRow(0));
+        assertSelected("Item 2");
+
+        selectItems("Item 10", "Item 0", "Item 9", "Item 4");
+
+        Assert.assertEquals(
+                "4. Selected: [Item 2, Item 0, Item 4, Item 10, Item 9]",
+                getLogRow(0));
+        assertSelected("Item 0", "Item 2", "Item 4", "Item 9", "Item 10");
+
+        deselectItems("Item 0", "Item 2", "Item 9");
+        Assert.assertEquals("5. Selected: [Item 4, Item 10]", getLogRow(0));
+        assertSelected("Item 4", "Item 10");
+    }
+
+    @Test
+    public void disabled_clickToSelect() {
+        selectMenuPath("Component", "State", "Enabled");
+
+        Assert.assertTrue(getTwinColSelect().findElements(By.tagName("input"))
+                .stream()
+                .allMatch(element -> element.getAttribute("disabled") != null));
+
+        selectMenuPath("Component", "Listeners", "Selection listener");
+
+        String lastLogRow = getLogRow(0);
+
+        selectItems("Item 4");
+        Assert.assertEquals(lastLogRow, getLogRow(0));
+        assertNothingSelected();
+
+        selectItems("Item 2");
+        // Selection order (most recently selected is last)
+        Assert.assertEquals(lastLogRow, getLogRow(0));
+        assertNothingSelected();
+
+        selectItems("Item 4");
+        Assert.assertEquals(lastLogRow, getLogRow(0));
+        assertNothingSelected();
+    }
+
+    @Test
+    public void clickToSelect_reenable() {
+        selectMenuPath("Component", "State", "Enabled");
+        selectMenuPath("Component", "Listeners", "Selection listener");
+
+        selectItems("Item 4");
+        assertNothingSelected();
+
+        selectMenuPath("Component", "State", "Enabled");
+
+        selectItems("Item 5");
+        Assert.assertEquals("3. Selected: [Item 5]", getLogRow(0));
+        assertSelected("Item 5");
+
+        selectItems("Item 2");
+        Assert.assertEquals("4. Selected: [Item 5, Item 2]", getLogRow(0));
+        assertSelected("Item 2", "Item 5");
+
+        deselectItems("Item 5");
+        Assert.assertEquals("5. Selected: [Item 2]", getLogRow(0));
+        assertSelected("Item 2");
+    }
+
+    @Test
+    public void itemCaptionProvider() {
+        selectMenuPath("Component", "Item Generator",
+                "Use Item Caption Generator");
+        assertItems(20, " Caption");
+    }
+
+    @Test
+    public void selectProgramatically() {
+        selectMenuPath("Component", "Listeners", "Selection listener");
+
+        selectMenuPath("Component", "Selection", "Toggle Item 5");
+        Assert.assertEquals("2. Selected: [Item 5]", getLogRow(0));
+        assertSelected("Item 5");
+
+        selectMenuPath("Component", "Selection", "Toggle Item 1");
+        // Selection order (most recently selected is last)
+        Assert.assertEquals("4. Selected: [Item 5, Item 1]", getLogRow(0));
+        // DOM order
+        assertSelected("Item 1", "Item 5");
+
+        selectMenuPath("Component", "Selection", "Toggle Item 5");
+        Assert.assertEquals("6. Selected: [Item 1]", getLogRow(0));
+        assertSelected("Item 1");
+
+        selectMenuPath("Component", "Selection",
+                "Toggle items 0, 1, 5, 10, 25");
+
+        // currently non-existing items are added to selection!
+        Assert.assertEquals(
+                "8. Selected: [Item 1, Item 0, Item 5, Item 10, Item 25]",
+                getLogRow(0));
+        assertSelected("Item 0", "Item 1", "Item 5", "Item 10");
+    }
+
+    private void assertSelected(String... expectedSelection) {
+        Assert.assertEquals(Arrays.asList(expectedSelection),
+                getTwinColSelect().getValues());
+    }
+
+    private void assertNothingSelected() {
+        Assert.assertEquals(0, getTwinColSelect().getValues().size());
+    }
+
+    @Override
+    protected Class<?> getUIClass() {
+        return TwinColSelectTestUI.class;
+    }
+
+    protected TwinColSelectElement getTwinColSelect() {
+        return $(TwinColSelectElement.class).first();
+    }
+
+    protected Select getOptionsElement() {
+        return new Select(getTwinColSelect().findElement(By.tagName("select")));
+    }
+
+    protected Select getSelectedOptionsElement() {
+        return new Select(
+                getTwinColSelect().findElements(By.tagName("select")).get(1));
+    }
+
+    protected WebElement getSelectButton() {
+        return getTwinColSelect().findElements(By.className("v-button")).get(0);
+    }
+
+    protected WebElement getDeselectButton() {
+        return getTwinColSelect().findElements(By.className("v-button")).get(1);
+    }
+
+    protected void selectItems(String... items) {
+        Select options = getOptionsElement();
+        options.deselectAll();
+        Stream.of(items).forEach(text -> options.selectByVisibleText(text));
+        getSelectButton().click();
+    }
+
+    protected void deselectItems(String... items) {
+        Select options = getSelectedOptionsElement();
+        options.deselectAll();
+        Stream.of(items).forEach(text -> options.selectByVisibleText(text));
+        getDeselectButton().click();
+    }
+
+    protected void assertItems(int count) {
+        assertItems(count, "");
+    }
+
+    protected void assertItems(int count, String suffix) {
+        int i = 0;
+        for (String text : getTwinColSelect().getOptions()) {
+            assertEquals("Item " + i + suffix, text);
+            i++;
+        }
+        assertEquals("Number of items", count, i);
+    }
+
+    protected void assertItemSuffices(int count) {
+        int i = 0;
+        for (String text : getTwinColSelect().getOptions()) {
+            assertTrue(text.endsWith("Item " + i));
+            i++;
+        }
+        assertEquals("Number of items", count, i);
+    }
+
+    protected void assertOptionTexts(String... items) {
+        List<String> optionTexts = getOptionsElement().getOptions().stream()
+                .map(element -> element.getText()).collect(Collectors.toList());
+        Assert.assertArrayEquals(items, optionTexts.toArray());
+    }
+
+}