package org.ovirt.engine.ui.common.widget.action;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.gwtbootstrap3.client.ui.constants.Placement;
import org.ovirt.engine.ui.common.idhandler.HasElementId;
import org.ovirt.engine.ui.common.idhandler.ProvidesElementId;
import org.ovirt.engine.ui.common.system.HeaderOffsetChangeEvent;
import org.ovirt.engine.ui.common.uicommon.model.SearchableModelProvider;
import org.ovirt.engine.ui.common.utils.ElementIdUtils;
import org.ovirt.engine.ui.common.utils.ElementTooltipUtils;
import org.ovirt.engine.ui.common.widget.MenuBar;
import org.ovirt.engine.ui.common.widget.PopupPanel;
import org.ovirt.engine.ui.common.widget.TitleMenuItemSeparator;
import org.ovirt.engine.ui.uicompat.EventArgs;
import org.ovirt.engine.ui.uicompat.IEventListener;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.dom.client.Style.Unit;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.event.dom.client.ContextMenuEvent;
import com.google.gwt.event.shared.EventBus;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.resources.client.ClientBundle;
import com.google.gwt.resources.client.CssResource;
import com.google.gwt.resources.client.ImageResource;
import com.google.gwt.uibinder.client.UiField;
import com.google.gwt.user.client.DOM;
import com.google.gwt.user.client.Window;
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.HasWidgets;
import com.google.gwt.user.client.ui.Image;
import com.google.gwt.user.client.ui.MenuItem;
import com.google.gwt.user.client.ui.MenuItemSeparator;
import com.google.gwt.user.client.ui.PushButton;
import com.google.gwt.user.client.ui.RequiresResize;
import com.google.gwt.user.client.ui.Widget;
/**
* Base class used to implement action panel widgets.
* <p>
* Subclasses are free to style the UI, given that they declare:
* <ul>
* <li>{@link #actionPanel} widget into which action button widgets will be rendered
* </ul>
*
* @param <T>
* Action panel item type.
*/
public abstract class AbstractActionPanel<T> extends Composite implements ActionPanel<T>, HasElementId,
ProvidesElementId, RequiresResize {
/**
* The cascading menu/panel CSS resources.
*/
public interface CascadeActionPanelCss extends CssResource {
String cascadeButton();
String actionPanel();
}
/**
* Resources for the ActionPanel.
*/
public interface ActionPanelResources extends ClientBundle {
@Source("org/ovirt/engine/ui/common/css/CascadeActionPanel.css")
CascadeActionPanelCss actionPanelCss();
@Source("org/ovirt/engine/ui/common/images/cascade_button.png")
ImageResource cascadeButtonArrow();
}
private static final ActionPanelResources RESOURCES = GWT.create(ActionPanelResources.class);
private static final String GWT_PREFIX = "gwt-"; //$NON-NLS-1$
private static final String MIN_WIDTH = "minWidth"; //$NON-NLS-1$
private static final String MAX_WIDTH = "maxWidth"; //$NON-NLS-1$
/**
* The padding to the left of each action button bar, if you change this value also change the value of the
* left-padding in the actionPadding class in SimpleActionTable.ui.xml.
*/
private static final int ACTION_PANEL_PADDING_LEFT = 5; //There seems to be no good way to determine left padding
//No getElement().getStyle().getPaddingLeft() doesn't work.
private final CascadeActionPanelCss style;
@UiField
public FlowPanel actionPanel;
private final FlowPanel contentPanel;
// List of action buttons that show in the tool-bar and context menu
private final List<ActionButtonDefinition<T>> actionButtonList = new ArrayList<>();
// List of buttons that only show in the tool-bar.
private final List<ActionButtonDefinition<T>> toolbarOnlyActionButtonList = new ArrayList<>();
// List of original visibility state for each button
private final Map<Widget, Boolean> originallyVisible = new HashMap<>();
private final SearchableModelProvider<T, ?> dataProvider;
private final EventBus eventBus;
private final PopupPanel contextPopupPanel;
private final MenuBar contextMenuBar;
private final MenuPanelPopup actionPanelPopupPanel;
/**
* The popup panel containing the {@link MenuBar} with the cascaded action buttons.
*/
private final PopupPanel cascadePopupPanel;
/**
* The button used to open up the menu containing the cascaded action buttons.
*/
private final PushButton cascadeButton;
/**
* The menu containing the cascaded action buttons.
*/
private final MenuBar cascadeMenu;
private String elementId = DOM.createUniqueId();
/**
* Handler registration for the resize handler.
*/
private HandlerRegistration resizeHandlerRegistration;
/**
* Minimum width needed to display all the {@code ActionButton}s.
*/
private int widgetMinWidth;
/**
* The width of any {@code Widget}s that are siblings of this {@code AbstractActionPanel} in the DOM tree.
*/
private int siblingWidth;
/**
* Constructor.
* @param dataProvider The data provider.
* @param eventBus The GWT event bus.
*/
public AbstractActionPanel(SearchableModelProvider<T, ?> dataProvider, EventBus eventBus) {
this.dataProvider = dataProvider;
this.eventBus = eventBus;
contextPopupPanel = new PopupPanel(true);
contextMenuBar = new MenuBar(true);
actionPanelPopupPanel = new MenuPanelPopup(true);
//Cascading items.
contentPanel = new FlowPanel();
style = RESOURCES.actionPanelCss();
style.ensureInjected();
cascadePopupPanel = new PopupPanel(true);
cascadeMenu = new MenuBar(true);
cascadeButton = new PushButton(new Image(RESOURCES.cascadeButtonArrow()), getCascadeButtonClickHandler());
configureCascadeMenu();
}
/**
* Returns the model data provider.
* @return The {@code SearchableModelProvider}.
*/
protected SearchableModelProvider<T, ?> getDataProvider() {
return dataProvider;
}
@Override
protected void initWidget(Widget widget) {
super.initWidget(widget);
contextPopupPanel.setWidget(contextMenuBar);
cascadePopupPanel.setWidget(cascadeMenu);
contentPanel.add(cascadeButton);
actionPanel.add(contentPanel);
actionPanel.addStyleName(style.actionPanel());
}
@Override
public void onLoad() {
super.onLoad();
// Defer size calculations until sizes are available.
Scheduler.get().scheduleDeferred(() -> {
int minWidth = calculateWidgetMinWidthNeeded();
contentPanel.getElement().getStyle().setProperty(MIN_WIDTH, minWidth, Unit.PX);
if (widgetMinWidth > 0) {
siblingWidth = calculateSiblingWidth();
}
initializeCascadeMenuPanel();
});
resizeHandlerRegistration = Window.addResizeHandler(resizeEvent -> initializeCascadeMenuPanel());
eventBus.addHandler(HeaderOffsetChangeEvent.getType(),
event -> {
initializeCascadeMenuPanel();
//Unregister the resize handler, we don't need it because resizes trigger the
//HeaderOffsetChangeEvents.
unregisterResizeHandler();
});
}
@Override
public void onResize() {
initializeCascadeMenuPanel();
}
/**
* Initialize the cascade menu panel.
*/
private void initializeCascadeMenuPanel() {
if (widgetMinWidth > 0) {
cascadePopupPanel.hide();
int currentWidth = actionPanel.getParent().getOffsetWidth() - siblingWidth - ACTION_PANEL_PADDING_LEFT;
actionPanel.getElement().getStyle().setProperty(MAX_WIDTH, currentWidth - 1, Unit.PX);
if (currentWidth <= widgetMinWidth) {
cascadeButton.setVisible(true);
} else {
cascadeButton.setVisible(false);
}
toggleVisibleWidgets(currentWidth - cascadeButton.getOffsetWidth());
}
}
/**
* Toggles the visible {@code ActionButton}s on the action panel based on the current width of the panel.
* This method enumerates the buttons and totals the width of each button until we reach the width passed in.
* Any buttons that would pass the width passed in are hidden, the other buttons are visible.
*
* @param currentWidth The width to check against.
*/
private void toggleVisibleWidgets(int currentWidth) {
int widgetWidth = 0;
boolean foundEdge = false;
if (contentPanel.getWidgetCount() > 1) {
for (int i = 0; i < contentPanel.getWidgetCount() - 1; i++) {
Widget widget = contentPanel.getWidget(i);
if (originallyVisible.get(widget)) {
widget.setVisible(true); //temporarily show the widget, so we get the actual width of the widget.
if (foundEdge || (widgetWidth + widget.getOffsetWidth() > currentWidth)) {
widget.setVisible(false);
toolbarOnlyActionButtonList.get(i).setCascaded(true);
foundEdge = true;
} else {
toolbarOnlyActionButtonList.get(i).setCascaded(false);
widgetWidth += widget.getOffsetWidth();
}
}
}
}
}
@Override
public void onUnload() {
super.onUnload();
unregisterResizeHandler();
}
private void unregisterResizeHandler() {
if (resizeHandlerRegistration != null) {
resizeHandlerRegistration.removeHandler();
}
}
@Override
public void setElementId(String elementId) {
this.elementId = elementId;
}
@Override
public String getElementId() {
return elementId;
}
/**
* Adds a new button to the action panel.
* @param buttonDef The button definition.
*/
@Override
public void addActionButton(final ActionButtonDefinition<T> buttonDef) {
addActionButton(buttonDef, createNewActionButton(buttonDef));
}
/**
* Adds a new button to the action panel.
*/
public void addActionButton(final ActionButtonDefinition<T> buttonDef, final ActionButton newActionButton) {
// Configure the button according to its definition
newActionButton.setEnabledHtml(buttonDef.getEnabledHtml());
newActionButton.setDisabledHtml(buttonDef.getDisabledHtml());
// Set button element ID for better accessibility
String buttonId = buttonDef.getUniqueId();
if (buttonId != null) {
newActionButton.asWidget().getElement().setId(
ElementIdUtils.createElementId(elementId, buttonId));
}
// Add the button to the action panel
if (buttonDef.getCommandLocation().equals(CommandLocation.ContextAndToolBar)
|| buttonDef.getCommandLocation().equals(CommandLocation.OnlyFromToolBar)) {
copyStyleToCascadeButton(newActionButton);
contentPanel.insert(newActionButton.asWidget(), contentPanel.getWidgetCount() - 1);
toolbarOnlyActionButtonList.add(buttonDef);
}
// Add the button to the context menu
if (buttonDef.getCommandLocation().equals(CommandLocation.ContextAndToolBar)
|| buttonDef.getCommandLocation().equals(CommandLocation.OnlyFromContext)) {
actionButtonList.add(buttonDef);
}
actionPanelPopupPanel.asPopupPanel()
.addCloseHandler(event -> newActionButton.asToggleButton().setDown(false));
// Add button widget click handler
newActionButton.addClickHandler(event -> {
if (buttonDef instanceof UiMenuBarButtonDefinition) {
actionPanelPopupPanel.asPopupPanel().addAutoHidePartner(newActionButton.asToggleButton()
.getElement());
if (newActionButton.asToggleButton().isDown()) {
updateContextMenu(actionPanelPopupPanel.getMenuBar(),
((UiMenuBarButtonDefinition<T>) buttonDef).getSubActions(),
actionPanelPopupPanel.asPopupPanel());
actionPanelPopupPanel.asPopupPanel()
.showRelativeToAndFitToScreen(newActionButton.asWidget());
} else {
actionPanelPopupPanel.asPopupPanel().hide();
}
} else {
buttonDef.onClick(getSelectedItems());
}
});
registerSelectionChangeHandler(buttonDef);
// Update button whenever its definition gets re-initialized
buttonDef.addInitializeHandler(event -> updateActionButton(newActionButton, buttonDef));
updateActionButton(newActionButton, buttonDef);
}
/**
* Calculate the width of all the sibling widgets to this widget (this is for the case where there are extra
* buttons and other things on the same row).<br />
* <br />
* <b>NOTE</b> This calculation breaks down if the siblings have left or/and right margins. The reported width
* is inaccurate if margins exist.
* @return The total width of all the sibling widgets in pixels.
*/
private int calculateSiblingWidth() {
int width = 0;
Widget parent = actionPanel.getParent();
if (parent instanceof HasWidgets) {
Iterator<Widget> widgetIterator = ((HasWidgets) parent).iterator();
while (widgetIterator.hasNext()) {
Widget widget = widgetIterator.next();
if (widget != actionPanel) {
width += widget.getOffsetWidth();
}
}
}
return width;
}
/**
* Calculate the minimum width needed to display all the {@code ActionButtons} in the action panel. This width
* is needed to determine when to show the button for the cascading menu.
* @return The minimum width needed in pixels.
*/
private int calculateWidgetMinWidthNeeded() {
int minWidth = 0;
if (contentPanel.getWidgetCount() > 1) {
for (int i = 0; i < contentPanel.getWidgetCount() - 1; i++) {
Widget widget = contentPanel.getWidget(i);
boolean widgetVisible = widget.isVisible();
widget.setVisible(true);
minWidth += widget.getElement().getOffsetWidth();
widget.setVisible(widgetVisible);
}
}
// Store this in a variable so we don't have to calculate it all the time.
// This assumes that when resizes/etc happen this gets called to recalculate everything.
widgetMinWidth = minWidth;
return minWidth;
}
/**
* This method copies the appropriate styles from the {@code ActionButton} to the new menu items for
* the cascading menu.
* @param newActionButton The {@code ActionButton} to copy the style from.
*/
private void copyStyleToCascadeButton(ActionButton newActionButton) {
String styleString = ((Widget) newActionButton).getStyleName().trim();
if (styleString != null && !styleString.isEmpty()) {
String[] stylesArray = styleString.split("\\s+"); //$NON-NLS-1$
for (String singleStyle : stylesArray) {
if (!singleStyle.startsWith(GWT_PREFIX)) {
cascadeButton.addStyleName(singleStyle);
}
}
}
}
/**
* Get the cascade drop down button click handler.
* @return The {@code ClickHandler}
*/
private ClickHandler getCascadeButtonClickHandler() {
return event -> {
if (!cascadePopupPanel.isShowing()) {
List<ActionButtonDefinition<T>> cascadeActionButtonList = new ArrayList<>();
for (int i = 0; i < contentPanel.getWidgetCount() - 1; i++) {
if (!contentPanel.getWidget(i).isVisible()) {
cascadeActionButtonList.add(toolbarOnlyActionButtonList.get(i));
}
}
updateContextMenu(cascadeMenu, cascadeActionButtonList, cascadePopupPanel);
cascadePopupPanel.showRelativeToAndFitToScreen(cascadeButton);
} else {
cascadePopupPanel.hide();
}
};
}
/**
* Configure the options of the cascade menu and button.
*/
private void configureCascadeMenu() {
cascadeButton.addStyleName(style.cascadeButton());
cascadeButton.setVisible(false); //Initially hide the button.
cascadePopupPanel.setAutoHideEnabled(true);
cascadePopupPanel.setModal(false);
cascadePopupPanel.setGlassEnabled(false);
cascadePopupPanel.addAutoHidePartner(cascadeButton.getElement());
}
void registerSelectionChangeHandler(final ActionButtonDefinition<T> buttonDef) {
// Update button definition whenever list model item selection changes
final IEventListener<EventArgs> itemSelectionChangeHandler = (ev, sender, args) -> {
// Update action button on item selection change
buttonDef.update();
};
addSelectionChangeListener(itemSelectionChangeHandler);
}
void addSelectionChangeListener(IEventListener<EventArgs> itemSelectionChangeHandler) {
dataProvider.getModel().getSelectedItemChangedEvent().addListener(itemSelectionChangeHandler);
dataProvider.getModel().getSelectedItemsChangedEvent().addListener(itemSelectionChangeHandler);
}
/**
* Adds a context menu handler to the given widget.
* @param widget The widget.
*/
public void addContextMenuHandler(Widget widget) {
widget.addDomHandler(event -> AbstractActionPanel.this.onContextMenu(event), ContextMenuEvent.getType());
}
/**
* Show the context menu.
* @param event The {@code ContextMenuEvent}
*/
protected void onContextMenu(ContextMenuEvent event) {
final int eventX = event.getNativeEvent().getClientX();
final int eventY = event.getNativeEvent().getClientY();
// Suppress default browser context menu
event.preventDefault();
event.stopPropagation();
// Use deferred command to ensure that the context menu
// is shown only after other event handlers do their job
Scheduler.get().scheduleDeferred(() -> {
// Avoid showing empty context menu
if (hasActionButtons()) {
updateContextMenu(contextMenuBar, actionButtonList, contextPopupPanel);
contextPopupPanel.showAndFitToScreen(eventX, eventY);
}
});
}
MenuBar updateContextMenu(MenuBar menuBar, List<ActionButtonDefinition<T>> actions, final PopupPanel popupPanel) {
return updateContextMenu(menuBar, actions, popupPanel, true);
}
/**
* Rebuilds context menu items to match the action button list.
* @param menuBar The menu bar to populate.
* @param actions A list of {@code ActionButtonDefinition}s used to populate the {@code MenuBar}.
* @param popupPanel The pop-up panel containing the {@code MenuBar}.
* @param removeOldItems A flag to indicate if we should remove old items.
* @return A {@code MenuBar} containing all the action buttons as menu items.
*/
MenuBar updateContextMenu(MenuBar menuBar,
List<ActionButtonDefinition<T>> actions,
final PopupPanel popupPanel,
boolean removeOldItems) {
if (removeOldItems) {
ElementTooltipUtils.destroyMenuItemTooltips(menuBar);
menuBar.clearItems();
}
for (final ActionButtonDefinition<T> buttonDef : actions) {
if (buttonDef instanceof UiMenuBarButtonDefinition) {
UiMenuBarButtonDefinition<T> menuBarDef = (UiMenuBarButtonDefinition<T>) buttonDef;
if (menuBarDef.isAsTitle()) {
MenuItemSeparator titleItem = new TitleMenuItemSeparator(buttonDef.getText());
menuBar.addSeparator(titleItem);
titleItem.setVisible(buttonDef.isVisible(getSelectedItems()));
updateContextMenu(menuBar, menuBarDef.getSubActions(), popupPanel, false);
} else {
MenuItem newMenu = new MenuItem(buttonDef.getText(),
updateContextMenu(new MenuBar(true),
menuBarDef.getSubActions(),
popupPanel));
updateMenuItem(newMenu, buttonDef);
menuBar.addItem(newMenu);
}
} else {
MenuItem item = new MenuItem(buttonDef.getText(), () -> {
popupPanel.hide();
buttonDef.onClick(getSelectedItems());
});
updateMenuItem(item, buttonDef);
menuBar.addItem(item);
}
}
return menuBar;
}
/**
* Ensures that the specified action button is visible or hidden and enabled or disabled as it should.
* @param button The {@code ActionButton} to update.
* @param buttonDef The {@code ActionButtonDefinition} used to determine the new state of the button.
*/
void updateActionButton(ActionButton button, ActionButtonDefinition<T> buttonDef) {
button.asWidget().setVisible(buttonDef.isAccessible(getSelectedItems())
&& buttonDef.isVisible(getSelectedItems()) && !buttonDef.isCascaded());
button.setEnabled(buttonDef.isEnabled(getSelectedItems()));
if (buttonDef.getTooltip() != null) {
// this Panel is special. show the tooltips below the buttons because they're too
// hard to read with the default TOP placement.
button.setTooltip(buttonDef.getTooltip(), Placement.BOTTOM);
}
originallyVisible.put(button.asWidget(), buttonDef.isAccessible(getSelectedItems())
&& buttonDef.isVisible(getSelectedItems()));
}
/**
* Ensures that the specified menu item is visible or hidden and enabled or disabled as it should.
* @param item The {@code MenuItem} to enabled/disable/hide based on the {@code ActionButtonDefinition}
* @param buttonDef The button definition to use to change the menu item.
*/
protected void updateMenuItem(MenuItem item, ActionButtonDefinition<T> buttonDef) {
item.setVisible(buttonDef.isAccessible(getSelectedItems()) && buttonDef.isVisible(getSelectedItems()));
item.setEnabled(buttonDef.isEnabled(getSelectedItems()));
if (buttonDef.getMenuItemTooltip() != null) {
ElementTooltipUtils.setTooltipOnElement(item.getElement(), buttonDef.getMenuItemTooltip());
}
}
/**
* @return {@code true} if this action panel has at least one action button, {@code false} otherwise.
*/
boolean hasActionButtons() {
return !actionButtonList.isEmpty();
}
/**
* Returns a new action button widget based on the given definition.
* @param buttonDef The button definition to use to create the {@code ActionButton}
* @return An {@code ActionButton} created from the passed in {@code ActionButtonDefinition}
*/
protected abstract ActionButton createNewActionButton(ActionButtonDefinition<T> buttonDef);
}