/*
* 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.io.Serializable;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;
import org.jsoup.nodes.Element;
import com.vaadin.data.HasFilterableDataProvider;
import com.vaadin.data.HasValue;
import com.vaadin.data.provider.CallbackDataProvider;
import com.vaadin.data.provider.DataCommunicator;
import com.vaadin.data.provider.DataKeyMapper;
import com.vaadin.data.provider.DataProvider;
import com.vaadin.data.provider.ListDataProvider;
import com.vaadin.event.FieldEvents;
import com.vaadin.event.FieldEvents.BlurEvent;
import com.vaadin.event.FieldEvents.BlurListener;
import com.vaadin.event.FieldEvents.FocusAndBlurServerRpcDecorator;
import com.vaadin.event.FieldEvents.FocusEvent;
import com.vaadin.event.FieldEvents.FocusListener;
import com.vaadin.server.KeyMapper;
import com.vaadin.server.Resource;
import com.vaadin.server.ResourceReference;
import com.vaadin.server.SerializableBiPredicate;
import com.vaadin.server.SerializableConsumer;
import com.vaadin.server.SerializableFunction;
import com.vaadin.server.SerializableToIntFunction;
import com.vaadin.shared.Registration;
import com.vaadin.shared.data.DataCommunicatorConstants;
import com.vaadin.shared.ui.combobox.ComboBoxConstants;
import com.vaadin.shared.ui.combobox.ComboBoxServerRpc;
import com.vaadin.shared.ui.combobox.ComboBoxState;
import com.vaadin.ui.declarative.DesignAttributeHandler;
import com.vaadin.ui.declarative.DesignContext;
import com.vaadin.ui.declarative.DesignFormatter;
import elemental.json.Json;
import elemental.json.JsonObject;
/**
* A filtering dropdown single-select. Items are filtered based on user input.
* Supports the creation of new items when a handler is set by the user.
*
* @param <T>
* item (bean) type in ComboBox
* @author Vaadin Ltd
*/
@SuppressWarnings("serial")
public class ComboBox<T> extends AbstractSingleSelect<T>
implements HasValue<T>, FieldEvents.BlurNotifier,
FieldEvents.FocusNotifier, HasFilterableDataProvider<T, String> {
/**
* A callback method for fetching items. The callback is provided with a
* non-null string filter, offset index and limit.
*
* @param <T>
* item (bean) type in ComboBox
* @since 8.0
*/
@FunctionalInterface
public interface FetchItemsCallback<T> extends Serializable {
/**
* Returns a stream of items that match the given filter, limiting the
* results with given offset and limit.
* <p>
* This method is called after the size of the data set is asked from a
* related size callback. The offset and limit are promised to be within
* the size of the data set.
*
* @param filter
* a non-null filter string
* @param offset
* the first index to fetch
* @param limit
* the fetched item count
* @return stream of items
*/
public Stream<T> fetchItems(String filter, int offset, int limit);
}
/**
* Handler that adds a new item based on user input when the new items
* allowed mode is active.
*
* @since 8.0
*/
@FunctionalInterface
public interface NewItemHandler extends SerializableConsumer<String> {
}
/**
* Item style generator class for declarative support.
* <p>
* Provides a straightforward mapping between an item and its style.
*
* @param <T>
* item type
* @since 8.0
*/
protected static class DeclarativeStyleGenerator<T>
implements StyleGenerator<T> {
private StyleGenerator<T> fallback;
private Map<T, String> styles = new HashMap<>();
public DeclarativeStyleGenerator(StyleGenerator<T> fallback) {
this.fallback = fallback;
}
@Override
public String apply(T item) {
return styles.containsKey(item) ? styles.get(item)
: fallback.apply(item);
}
/**
* Sets a {@code style} for the {@code item}.
*
* @param item
* a data item
* @param style
* a style for the {@code item}
*/
protected void setStyle(T item, String style) {
styles.put(item, style);
}
}
private ComboBoxServerRpc rpc = new ComboBoxServerRpc() {
@Override
public void createNewItem(String itemValue) {
// New option entered
if (getNewItemHandler() != null && itemValue != null
&& itemValue.length() > 0) {
getNewItemHandler().accept(itemValue);
}
}
@Override
public void setFilter(String filterText) {
currentFilterText = filterText;
filterSlot.accept(filterText);
}
};
/**
* Handler for new items entered by the user.
*/
private NewItemHandler newItemHandler;
private StyleGenerator<T> itemStyleGenerator = item -> null;
private String currentFilterText;
private SerializableConsumer<String> filterSlot = filter -> {
// Just ignore when neither setDataProvider nor setItems has been called
};
/**
* Constructs an empty combo box without a caption. The content of the combo
* box can be set with {@link #setDataProvider(DataProvider)} or
* {@link #setItems(Collection)}
*/
public ComboBox() {
super(new DataCommunicator<T>() {
@Override
protected DataKeyMapper<T> createKeyMapper() {
return new KeyMapper<T>() {
@Override
public void remove(T removeobj) {
// never remove keys from ComboBox to support selection
// of items that are not currently visible
}
};
}
});
init();
}
/**
* Constructs an empty combo box, whose content can be set with
* {@link #setDataProvider(DataProvider)} or {@link #setItems(Collection)}.
*
* @param caption
* the caption to show in the containing layout, null for no
* caption
*/
public ComboBox(String caption) {
this();
setCaption(caption);
}
/**
* Constructs a combo box with a static in-memory data provider with the
* given options.
*
* @param caption
* the caption to show in the containing layout, null for no
* caption
* @param options
* collection of options, not null
*/
public ComboBox(String caption, Collection<T> options) {
this(caption);
setItems(options);
}
/**
* Initialize the ComboBox with default settings and register client to
* server RPC implementation.
*/
private void init() {
registerRpc(rpc);
registerRpc(new FocusAndBlurServerRpcDecorator(this, this::fireEvent));
addDataGenerator((T data, JsonObject jsonObject) -> {
String caption = getItemCaptionGenerator().apply(data);
if (caption == null) {
caption = "";
}
jsonObject.put(DataCommunicatorConstants.NAME, caption);
String style = itemStyleGenerator.apply(data);
if (style != null) {
jsonObject.put(ComboBoxConstants.STYLE, style);
}
Resource icon = getItemIconGenerator().apply(data);
if (icon != null) {
String iconUrl = ResourceReference
.create(icon, ComboBox.this, null).getURL();
jsonObject.put(ComboBoxConstants.ICON, iconUrl);
}
});
}
/**
* {@inheritDoc}
* <p>
* Filtering will use a case insensitive match to show all items where the
* filter text is a substring of the caption displayed for that item.
*/
@Override
public void setItems(Collection<T> items) {
ListDataProvider<T> listDataProvider = DataProvider.ofCollection(items);
setDataProvider(listDataProvider);
}
/**
* {@inheritDoc}
* <p>
* Filtering will use a case insensitive match to show all items where the
* filter text is a substring of the caption displayed for that item.
*/
@Override
public void setItems(Stream<T> streamOfItems) {
// Overridden only to add clarification to javadocs
super.setItems(streamOfItems);
}
/**
* {@inheritDoc}
* <p>
* Filtering will use a case insensitive match to show all items where the
* filter text is a substring of the caption displayed for that item.
*/
@Override
public void setItems(T... items) {
// Overridden only to add clarification to javadocs
super.setItems(items);
}
/**
* Sets a list data provider as the data provider of this combo box.
* Filtering will use a case insensitive match to show all items where the
* filter text is a substring of the caption displayed for that item.
* <p>
* Note that this is a shorthand that calls
* {@link #setDataProvider(DataProvider)} with a wrapper of the provided
* list data provider. This means that {@link #getDataProvider()} will
* return the wrapper instead of the original list data provider.
*
* @param listDataProvider
* the list data provider to use, not <code>null</code>
* @since 8.0
*/
public void setDataProvider(ListDataProvider<T> listDataProvider) {
// Cannot use the case insensitive contains shorthand from
// ListDataProvider since it wouldn't react to locale changes
CaptionFilter defaultCaptionFilter = (itemText, filterText) -> itemText
.toLowerCase(getLocale())
.contains(filterText.toLowerCase(getLocale()));
setDataProvider(defaultCaptionFilter, listDataProvider);
}
/**
* Sets the data items of this listing and a simple string filter with which
* the item string and the text the user has input are compared.
* <p>
* Note that unlike {@link #setItems(Collection)}, no automatic case
* conversion is performed before the comparison.
*
* @param captionFilter
* filter to check if an item is shown when user typed some text
* into the ComboBox
* @param items
* the data items to display
* @since 8.0
*/
public void setItems(CaptionFilter captionFilter, Collection<T> items) {
ListDataProvider<T> listDataProvider = DataProvider.ofCollection(items);
setDataProvider(captionFilter, listDataProvider);
}
/**
* Sets a list data provider with an item caption filter as the data
* provider of this combo box. The caption filter is used to compare the
* displayed caption of each item to the filter text entered by the user.
*
* @param captionFilter
* filter to check if an item is shown when user typed some text
* into the ComboBox
* @param listDataProvider
* the list data provider to use, not <code>null</code>
* @since 8.0
*/
public void setDataProvider(CaptionFilter captionFilter,
ListDataProvider<T> listDataProvider) {
Objects.requireNonNull(listDataProvider,
"List data provider cannot be null");
// Must do getItemCaptionGenerator() for each operation since it might
// not be the same as when this method was invoked
setDataProvider(listDataProvider, filterText -> item -> captionFilter
.test(getItemCaptionGenerator().apply(item), filterText));
}
/**
* Sets the data items of this listing and a simple string filter with which
* the item string and the text the user has input are compared.
* <p>
* Note that unlike {@link #setItems(Collection)}, no automatic case
* conversion is performed before the comparison.
*
* @param captionFilter
* filter to check if an item is shown when user typed some text
* into the ComboBox
* @param items
* the data items to display
* @since 8.0
*/
public void setItems(CaptionFilter captionFilter,
@SuppressWarnings("unchecked") T... items) {
setItems(captionFilter, Arrays.asList(items));
}
/**
* Gets the current placeholder text shown when the combo box would be
* empty.
*
* @see #setPlaceholder(String)
* @return the current placeholder string, or null if not enabled
* @since 8.0
*/
public String getPlaceholder() {
return getState(false).placeholder;
}
/**
* Sets the placeholder string - a textual prompt that is displayed when the
* select would otherwise be empty, to prompt the user for input.
*
* @param placeholder
* the desired placeholder, or null to disable
* @since 8.0
*/
public void setPlaceholder(String placeholder) {
getState().placeholder = placeholder;
}
/**
* Sets whether it is possible to input text into the field or whether the
* field area of the component is just used to show what is selected. By
* disabling text input, the comboBox will work in the same way as a
* {@link NativeSelect}
*
* @see #isTextInputAllowed()
*
* @param textInputAllowed
* true to allow entering text, false to just show the current
* selection
*/
public void setTextInputAllowed(boolean textInputAllowed) {
getState().textInputAllowed = textInputAllowed;
}
/**
* Returns true if the user can enter text into the field to either filter
* the selections or enter a new value if new item handler is set (see
* {@link #setNewItemHandler(NewItemHandler)}. If text input is disabled,
* the comboBox will work in the same way as a {@link NativeSelect}
*
* @return true if text input is allowed
*/
public boolean isTextInputAllowed() {
return getState(false).textInputAllowed;
}
@Override
public Registration addBlurListener(BlurListener listener) {
return addListener(BlurEvent.EVENT_ID, BlurEvent.class, listener,
BlurListener.blurMethod);
}
@Override
public Registration addFocusListener(FocusListener listener) {
return addListener(FocusEvent.EVENT_ID, FocusEvent.class, listener,
FocusListener.focusMethod);
}
/**
* Returns the page length of the suggestion popup.
*
* @return the pageLength
*/
public int getPageLength() {
return getState(false).pageLength;
}
/**
* Returns the suggestion pop-up's width as a CSS string. By default this
* width is set to "100%".
*
* @see #setPopupWidth
* @since 7.7
* @return explicitly set popup width as CSS size string or null if not set
*/
public String getPopupWidth() {
return getState(false).suggestionPopupWidth;
}
/**
* Sets the page length for the suggestion popup. Setting the page length to
* 0 will disable suggestion popup paging (all items visible).
*
* @param pageLength
* the pageLength to set
*/
public void setPageLength(int pageLength) {
getState().pageLength = pageLength;
}
/**
* Returns whether the user is allowed to select nothing in the combo box.
*
* @return true if empty selection is allowed, false otherwise
* @since 8.0
*/
public boolean isEmptySelectionAllowed() {
return getState(false).emptySelectionAllowed;
}
/**
* Sets whether the user is allowed to select nothing in the combo box. When
* true, a special empty item is shown to the user.
*
* @param emptySelectionAllowed
* true to allow not selecting anything, false to require
* selection
* @since 8.0
*/
public void setEmptySelectionAllowed(boolean emptySelectionAllowed) {
getState().emptySelectionAllowed = emptySelectionAllowed;
}
/**
* Returns the empty selection caption.
* <p>
* The empty string {@code ""} is the default empty selection caption.
*
* @see #setEmptySelectionAllowed(boolean)
* @see #isEmptySelectionAllowed()
* @see #setEmptySelectionCaption(String)
* @see #isSelected(Object)
*
* @return the empty selection caption, not {@code null}
* @since 8.0
*/
public String getEmptySelectionCaption() {
return getState(false).emptySelectionCaption;
}
/**
* Sets the empty selection caption.
* <p>
* The empty string {@code ""} is the default empty selection caption.
* <p>
* If empty selection is allowed via the
* {@link #setEmptySelectionAllowed(boolean)} method (it is by default) then
* the empty item will be shown with the given caption.
*
* @param caption
* the caption to set, not {@code null}
* @see #isSelected(Object)
* @since 8.0
*/
public void setEmptySelectionCaption(String caption) {
Objects.nonNull(caption);
getState().emptySelectionCaption = caption;
}
/**
* Sets the suggestion pop-up's width as a CSS string. By using relative
* units (e.g. "50%") it's possible to set the popup's width relative to the
* ComboBox itself.
* <p>
* By default this width is set to "100%" so that the pop-up's width is
* equal to the width of the combobox. By setting width to null the pop-up's
* width will automatically expand beyond 100% relative width to fit the
* content of all displayed items.
*
* @see #getPopupWidth()
* @since 7.7
* @param width
* the width
*/
public void setPopupWidth(String width) {
getState().suggestionPopupWidth = width;
}
/**
* Sets whether to scroll the selected item visible (directly open the page
* on which it is) when opening the combo box popup or not.
* <p>
* This requires finding the index of the item, which can be expensive in
* many large lazy loading containers.
*
* @param scrollToSelectedItem
* true to find the page with the selected item when opening the
* selection popup
*/
public void setScrollToSelectedItem(boolean scrollToSelectedItem) {
getState().scrollToSelectedItem = scrollToSelectedItem;
}
/**
* Returns true if the select should find the page with the selected item
* when opening the popup.
*
* @see #setScrollToSelectedItem(boolean)
*
* @return true if the page with the selected item will be shown when
* opening the popup
*/
public boolean isScrollToSelectedItem() {
return getState(false).scrollToSelectedItem;
}
@Override
public ItemCaptionGenerator<T> getItemCaptionGenerator() {
return super.getItemCaptionGenerator();
}
@Override
public void setItemCaptionGenerator(
ItemCaptionGenerator<T> itemCaptionGenerator) {
super.setItemCaptionGenerator(itemCaptionGenerator);
if (getSelectedItem().isPresent()) {
updateSelectedItemCaption();
}
}
/**
* Sets the style generator that is used to produce custom class names for
* items visible in the popup. The CSS class name that will be added to the
* item is <tt>v-filterselect-item-[style name]</tt>. Returning null from
* the generator results in no custom style name being set.
*
* @see StyleGenerator
*
* @param itemStyleGenerator
* the item style generator to set, not null
* @throws NullPointerException
* if {@code itemStyleGenerator} is {@code null}
* @since 8.0
*/
public void setStyleGenerator(StyleGenerator<T> itemStyleGenerator) {
Objects.requireNonNull(itemStyleGenerator,
"Item style generator must not be null");
this.itemStyleGenerator = itemStyleGenerator;
getDataCommunicator().reset();
}
/**
* Gets the currently used style generator that is used to generate CSS
* class names for items. The default item style provider returns null for
* all items, resulting in no custom item class names being set.
*
* @see StyleGenerator
* @see #setStyleGenerator(StyleGenerator)
*
* @return the currently used item style generator, not null
* @since 8.0
*/
public StyleGenerator<T> getStyleGenerator() {
return itemStyleGenerator;
}
@Override
public void setItemIconGenerator(IconGenerator<T> itemIconGenerator) {
super.setItemIconGenerator(itemIconGenerator);
if (getSelectedItem().isPresent()) {
updateSelectedItemIcon();
}
}
@Override
public IconGenerator<T> getItemIconGenerator() {
return super.getItemIconGenerator();
}
/**
* Sets the handler that is called when user types a new item. The creation
* of new items is allowed when a new item handler has been set.
*
* @param newItemHandler
* handler called for new items, null to only permit the
* selection of existing items
* @since 8.0
*/
public void setNewItemHandler(NewItemHandler newItemHandler) {
this.newItemHandler = newItemHandler;
getState().allowNewItems = newItemHandler != null;
markAsDirty();
}
/**
* Returns the handler called when the user enters a new item (not present
* in the data provider).
*
* @return new item handler or null if none specified
*/
public NewItemHandler getNewItemHandler() {
return newItemHandler;
}
// HasValue methods delegated to the selection model
@Override
public Registration addValueChangeListener(
HasValue.ValueChangeListener<T> listener) {
return addSelectionListener(event -> {
listener.valueChange(new ValueChangeEvent<>(event.getComponent(),
this, event.getOldValue(), event.isUserOriginated()));
});
}
@Override
protected ComboBoxState getState() {
return (ComboBoxState) super.getState();
}
@Override
protected ComboBoxState getState(boolean markAsDirty) {
return (ComboBoxState) super.getState(markAsDirty);
}
@Override
protected void doSetSelectedKey(String key) {
super.doSetSelectedKey(key);
updateSelectedItemCaption();
updateSelectedItemIcon();
}
private void updateSelectedItemCaption() {
String selectedCaption = null;
T value = getDataCommunicator().getKeyMapper().get(getSelectedKey());
if (value != null) {
selectedCaption = getItemCaptionGenerator().apply(value);
}
getState().selectedItemCaption = selectedCaption;
}
private void updateSelectedItemIcon() {
String selectedItemIcon = null;
T value = getDataCommunicator().getKeyMapper().get(getSelectedKey());
if (value != null) {
Resource icon = getItemIconGenerator().apply(value);
if (icon != null) {
selectedItemIcon = ResourceReference
.create(icon, ComboBox.this, null).getURL();
}
}
getState().selectedItemIcon = selectedItemIcon;
}
@Override
protected Element writeItem(Element design, T item, DesignContext context) {
Element element = design.appendElement("option");
String caption = getItemCaptionGenerator().apply(item);
if (caption != null) {
element.html(DesignFormatter.encodeForTextNode(caption));
} else {
element.html(DesignFormatter.encodeForTextNode(item.toString()));
}
element.attr("item", item.toString());
Resource icon = getItemIconGenerator().apply(item);
if (icon != null) {
DesignAttributeHandler.writeAttribute("icon", element.attributes(),
icon, null, Resource.class, context);
}
String style = getStyleGenerator().apply(item);
if (style != null) {
element.attr("style", style);
}
if (isSelected(item)) {
element.attr("selected", "");
}
return element;
}
@Override
protected void readItems(Element design, DesignContext context) {
setStyleGenerator(new DeclarativeStyleGenerator<>(getStyleGenerator()));
super.readItems(design, context);
}
@SuppressWarnings({ "unchecked", "rawtypes" })
@Override
protected T readItem(Element child, Set<T> selected,
DesignContext context) {
T item = super.readItem(child, selected, context);
if (child.hasAttr("style")) {
StyleGenerator<T> styleGenerator = getStyleGenerator();
if (styleGenerator instanceof DeclarativeStyleGenerator) {
((DeclarativeStyleGenerator) styleGenerator).setStyle(item,
child.attr("style"));
} else {
throw new IllegalStateException(String.format(
"Don't know how "
+ "to set style using current style generator '%s'",
styleGenerator.getClass().getName()));
}
}
return item;
}
@Override
public DataProvider<T, ?> getDataProvider() {
return internalGetDataProvider();
}
@Override
public <C> void setDataProvider(DataProvider<T, C> dataProvider,
SerializableFunction<String, C> filterConverter) {
Objects.requireNonNull(dataProvider, "dataProvider cannot be null");
Objects.requireNonNull(filterConverter,
"filterConverter cannot be null");
SerializableFunction<String, C> convertOrNull = filterText -> {
if (filterText == null || filterText.isEmpty()) {
return null;
}
return filterConverter.apply(filterText);
};
SerializableConsumer<C> providerFilterSlot = internalSetDataProvider(
dataProvider, convertOrNull.apply(currentFilterText));
filterSlot = filter -> providerFilterSlot
.accept(convertOrNull.apply(filter));
}
/**
* Sets a CallbackDataProvider using the given fetch items callback and a
* size callback.
* <p>
* This method is a shorthand for making a {@link CallbackDataProvider} that
* handles a partial {@link Query} object.
*
* @param fetchItems
* a callback for fetching items
* @param sizeCallback
* a callback for getting the count of items
*
* @see CallbackDataProvider
* @see #setDataProvider(DataProvider)
*/
public void setDataProvider(FetchItemsCallback<T> fetchItems,
SerializableToIntFunction<String> sizeCallback) {
setDataProvider(new CallbackDataProvider<>(
q -> fetchItems.fetchItems(q.getFilter().orElse(""),
q.getOffset(), q.getLimit()),
q -> sizeCallback.applyAsInt(q.getFilter().orElse(""))));
}
@Override
protected void setSelectedFromClient(String key) {
super.setSelectedFromClient(key);
/*
* The client side for combo box always expects a state change for
* selectedItemKey after it has sent a selection change. This means that
* we must store a value in the diffstate that guarantees that a new
* value will be sent, regardless of what the value actually is at the
* time when changes are sent.
*
* Keys are always strings (or null), so using a non-string type will
* always trigger a diff mismatch and a resend.
*/
updateDiffstate("selectedItemKey", Json.create(0));
}
/**
* Predicate to check {@link ComboBox} item captions against user typed
* strings.
*
* @see #setItems(CaptionFilter, Collection)
* @see #setItems(CaptionFilter, Object[])
* @since 8.0
*/
@FunctionalInterface
public interface CaptionFilter
extends SerializableBiPredicate<String, String> {
/**
* Check item caption against entered text.
*
* @param itemCaption
* the caption of the item to filter, not {@code null}
* @param filterText
* user entered filter, not {@code null}
* @return {@code true} if item passes the filter and should be listed,
* {@code false} otherwise
*/
@Override
public boolean test(String itemCaption, String filterText);
}
}