package org.ovirt.engine.ui.common.widget.editor; import java.util.ArrayList; import java.util.Collection; import java.util.List; import org.ovirt.engine.ui.common.CommonApplicationConstants; import org.ovirt.engine.ui.common.gin.AssetProvider; import org.ovirt.engine.ui.common.widget.editor.ListModelTypeAheadListBoxEditor.SuggestBoxRenderer; import org.ovirt.engine.ui.uicompat.external.StringUtils; import com.google.gwt.core.client.GWT; import com.google.gwt.core.client.Scheduler; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.NativeEvent; import com.google.gwt.event.dom.client.BlurEvent; import com.google.gwt.event.dom.client.BlurHandler; import com.google.gwt.event.dom.client.ClickEvent; import com.google.gwt.event.dom.client.FocusEvent; import com.google.gwt.event.dom.client.FocusHandler; import com.google.gwt.event.dom.client.KeyCodes; import com.google.gwt.event.dom.client.MouseDownEvent; import com.google.gwt.event.dom.client.MouseDownHandler; import com.google.gwt.event.dom.client.MouseUpEvent; import com.google.gwt.event.dom.client.MouseUpHandler; import com.google.gwt.event.shared.HandlerRegistration; import com.google.gwt.uibinder.client.UiBinder; import com.google.gwt.uibinder.client.UiField; import com.google.gwt.user.client.Event; import com.google.gwt.user.client.Event.NativePreviewEvent; import com.google.gwt.user.client.Event.NativePreviewHandler; import com.google.gwt.user.client.ui.FlowPanel; import com.google.gwt.user.client.ui.HTMLPanel; import com.google.gwt.user.client.ui.MenuBar; import com.google.gwt.user.client.ui.MenuItem; import com.google.gwt.user.client.ui.MultiWordSuggestOracle; import com.google.gwt.user.client.ui.MultiWordSuggestOracle.MultiWordSuggestion; import com.google.gwt.user.client.ui.SuggestOracle.Suggestion; /** * SuggestBox widget that adapts to UiCommon list model items and looks like a list box. The suggestion content can be rich (html). * <p> * Accepts any objects as soon as the provided renderer can render them. */ public class ListModelTypeAheadListBox<T> extends BaseListModelSuggestBox<T> { private static final CommonApplicationConstants constants = AssetProvider.getConstants(); @UiField FlowPanel mainPanel; @UiField HTMLPanel dropdownIcon; private final SuggestBoxRenderer<T> renderer; /** * This is used to decide whether, when setting the value of the widget to one that doesn't exist among the list of * suggested items {@link #acceptableValues}, the new value should be added to it; see usage in * {@link #addToValidValuesIfNeeded(Object)}. */ private final boolean autoAddToValidValues; private Collection<T> acceptableValues = new ArrayList<>(); private HandlerRegistration eventHandler; interface ViewUiBinder extends UiBinder<FlowPanel, ListModelTypeAheadListBox<?>> { ViewUiBinder uiBinder = GWT.create(ViewUiBinder.class); } public ListModelTypeAheadListBox(SuggestBoxRenderer<T> renderer, boolean autoAddToValidValues, SuggestionMatcher suggestionMatcher) { super(new RenderableSuggestOracle<>(renderer, suggestionMatcher)); this.renderer = renderer; this.autoAddToValidValues = autoAddToValidValues; // this needs to be handled by focus on text box and clicks on drop down image setAutoHideEnabled(false); initWidget(ViewUiBinder.uiBinder.createAndBindUi(this)); suggestBox.getElement().setAttribute("autocomplete", "off"); //$NON-NLS-1$ //$NON-NLS-2$ dropdownIcon.getElement().setAttribute("data-dropdown", "dropdown"); //$NON-NLS-1$ //$NON-NLS-2$ registerListeners(); } private void registerListeners() { SuggestBoxFocusHandler handlers = new SuggestBoxFocusHandler(); handlerRegistrations.add(suggestBox.getValueBox().addBlurHandler(handlers)); handlerRegistrations.add(suggestBox.getValueBox().addFocusHandler(handlers)); // not listening to focus because it would show the suggestions also after the whole browser // gets the focus back (after loosing it) if this was the last element with focus handlerRegistrations.add(suggestBox.getValueBox().addClickHandler(event -> switchSuggestions())); handlerRegistrations.add(dropdownIcon.addDomHandler(event -> switchSuggestions(), ClickEvent.getType())); handlerRegistrations.add(getSuggestionMenu().getParent().addDomHandler(new FocusHandlerEnablingMouseHandlers(handlers), MouseDownEvent.getType())); // no need to do additional switchSuggestions() - it is processed by MenuBar itself handlerRegistrations.add(getSuggestionMenu().getParent().addDomHandler(new FocusHandlerEnablingMouseHandlers(handlers), MouseUpEvent.getType())); handlerRegistrations.add(addValueChangeHandler(event -> { // if the value has been changed using the mouse setValue(event.getValue()); })); } protected void switchSuggestions() { if (!isEnabled()) { return; } if (isSuggestionListShowing()) { hideSuggestions(); adjustSelectedValue(); } else { showAllSuggestions(); } } protected void showAllSuggestions() { // show all the suggestions even if there is already something filled // otherwise it is not obvious that there are more options suggestBox.setText(null); suggestBox.showSuggestionList(); selectInMenu(getValue()); Scheduler.get().scheduleDeferred(() -> setFocus(true)); } protected void selectInMenu(T toSelect) { MenuBar menuBar = getSuggestionMenu(); if (menuBar == null) { return; } List<MenuItem> items = getItems(menuBar); if (items == null) { return; } String selectedReplacementString = renderer.getReplacementString(toSelect); if (selectedReplacementString == null) { return; } int selectedItemIndex = -1; for (T acceptableValue : acceptableValues) { selectedItemIndex ++; String acceptableValueReplacement = renderer.getReplacementString(acceptableValue); if (acceptableValueReplacement != null && acceptableValueReplacement.equals(selectedReplacementString)) { if (items.size() > selectedItemIndex) { menuBar.selectItem(items.get(selectedItemIndex)); scrollSelectedItemIntoView(); } break; } } } protected void scrollSelectedItemIntoView() { MenuBar menuBar = getSuggestionMenu(); if (menuBar == null) { return; } MenuItem item = getSelectedItem(menuBar); if (item != null) { Element toSelect = item.getElement().getParentElement(); toSelect.scrollIntoView(); } } // extremely ugly - there is just no better way how to find the items as MenuItems private native List<MenuItem> getItems(MenuBar menuBar) /*-{ return menuBar.@com.google.gwt.user.client.ui.MenuBar::getItems()(); }-*/; private native MenuItem getSelectedItem(MenuBar menuBar) /*-{ return menuBar.@com.google.gwt.user.client.ui.MenuBar::getSelectedItem()(); }-*/; protected void adjustSelectedValue() { if (acceptableValues.size() == 0) { return; } String providedText = asSuggestBox().getText(); // validate input text try { T newData = asEntity(providedText); // correct provided - use it setValue(newData); } catch (IllegalArgumentException e) { // incorrect - return to previous one render(getValue(), false); } } @Override protected T asEntity(String provided) { if (provided == null) { if (acceptableValues.contains(null)) { return null; } else { throw new IllegalArgumentException("Only non-null arguments are accepted"); //$NON-NLS-1$ } } for (T data : acceptableValues) { String expected = renderer.getReplacementString(data); if (expected == null) { continue; } if (expected.equals(provided)) { return data; } } throw new IllegalArgumentException("The provided value is not acceptable: '" + provided + "'"); //$NON-NLS-1$ //$NON-NLS-2$ } @Override public void setValue(T value, boolean fireEvents) { addToValidValuesIfNeeded(value); super.setValue(value, fireEvents); } @Override public T getValue() { if (super.getValue() != null && acceptableValues.contains(super.getValue())) { return super.getValue(); } return null; } private void addToValidValuesIfNeeded(T value) { if (value != null && !acceptableValues.contains(value) && autoAddToValidValues) { acceptableValues.add(value); } } @Override public void setAcceptableValues(Collection<T> acceptableValues) { this.acceptableValues = acceptableValues; T selected = getValue(); addToValidValuesIfNeeded(selected); RenderableSuggestOracle<T> suggestOracle = (RenderableSuggestOracle<T>) suggestBox.getSuggestOracle(); suggestOracle.setData(acceptableValues); } @Override protected void render(T value, boolean fireEvents) { String replacementString = renderer.getReplacementString(value); boolean empty = StringUtils.isEmpty(replacementString); asSuggestBox().setValue(empty ? constants.emptyListBoxText() : replacementString, fireEvents); grayOutPlaceholderText(empty); } protected void grayOutPlaceholderText(boolean isPlaceholder) { if (isPlaceholder) { asSuggestBox().getElement().getStyle().setColor("gray"); //$NON-NLS-1$ } else { asSuggestBox().getElement().getStyle().clearColor(); } } class SuggestBoxFocusHandler implements FocusHandler, BlurHandler { private boolean enabled = true; @Override public void onBlur(BlurEvent blurEvent) { if (eventHandler != null) { eventHandler.removeHandler(); eventHandler = null; } // process only if it will not be processed by other handlers if (enabled) { // first give the opportunity to the click handler on the menu to process the event, than we can hide it hideSuggestions(); adjustSelectedValue(); } } public void setEnabled(boolean enabled) { this.enabled = enabled; } @Override public void onFocus(FocusEvent event) { if (eventHandler != null) { eventHandler = Event.addNativePreviewHandler(new EnterIgnoringNativePreviewHandler<>(ListModelTypeAheadListBox.this)); } } } class FocusHandlerEnablingMouseHandlers implements MouseDownHandler, MouseUpHandler { private SuggestBoxFocusHandler focusHandler; public FocusHandlerEnablingMouseHandlers(SuggestBoxFocusHandler focusHandler) { this.focusHandler = focusHandler; } @Override public void onMouseDown(MouseDownEvent event) { focusHandler.setEnabled(false); } @Override public void onMouseUp(MouseUpEvent event) { focusHandler.setEnabled(true); } } } class RenderableSuggestion<T> extends MultiWordSuggestion { public RenderableSuggestion(T row, SuggestBoxRenderer<T> renderer) { super(renderer.getReplacementString(row), renderer.getDisplayString(row)); } } class RenderableSuggestOracle<T> extends MultiWordSuggestOracle { private SuggestBoxRenderer<T> renderer; private final SuggestionMatcher matcher; // intended to avoid null checks private Collection<T> data = new ArrayList<>(); public RenderableSuggestOracle(SuggestBoxRenderer<T> renderer, SuggestionMatcher matcher) { this.renderer = renderer; this.matcher = matcher; } @Override public void requestSuggestions(Request request, Callback callback) { List<RenderableSuggestion<T>> suggestions = new ArrayList<>(); String query = request.getQuery(); for (T row : data) { RenderableSuggestion<T> suggestionCandidate = new RenderableSuggestion<>(row, renderer); if (this.matcher.match(query, suggestionCandidate)) { suggestions.add(suggestionCandidate); } } callback.onSuggestionsReady(request, new Response(suggestions)); } @Override public void requestDefaultSuggestions(Request request, Callback callback) { List<RenderableSuggestion<T>> suggestions = new ArrayList<>(); for (T row : data) { suggestions.add(new RenderableSuggestion<>(row, renderer)); } callback.onSuggestionsReady(request, new Response(suggestions)); } public void setData(Collection<T> data) { this.data = data; } } class EnterIgnoringNativePreviewHandler<T> implements NativePreviewHandler { private final ListModelTypeAheadListBox<T> listModelTypeAheadListBox; public EnterIgnoringNativePreviewHandler(ListModelTypeAheadListBox<T> listModelTypeAheadListBox) { this.listModelTypeAheadListBox = listModelTypeAheadListBox; } @Override public void onPreviewNativeEvent(NativePreviewEvent event) { NativeEvent nativeEvent = event.getNativeEvent(); if (nativeEvent.getKeyCode() == KeyCodes.KEY_ENTER && event.getTypeInt() == Event.ONKEYPRESS && !event.isCanceled()) { // swallow the enter key otherwise the whole dialog would get submitted nativeEvent.preventDefault(); nativeEvent.stopPropagation(); event.cancel(); // process the event here directly Suggestion currentSelection = listModelTypeAheadListBox.getCurrentSelection(); if (currentSelection != null) { String replacementString = currentSelection.getReplacementString(); try { listModelTypeAheadListBox.setValue(listModelTypeAheadListBox.asEntity(replacementString), true); } catch (IllegalArgumentException e) { // do not set the value if it is not a correct one } } listModelTypeAheadListBox.hideSuggestions(); } } }