package org.ovirt.engine.ui.common.widget.table;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;
import org.ovirt.engine.ui.common.CommonApplicationConstants;
import org.ovirt.engine.ui.common.CommonApplicationTemplates;
import org.ovirt.engine.ui.common.gin.AssetProvider;
import org.ovirt.engine.ui.common.system.ClientStorage;
import org.ovirt.engine.ui.common.utils.JqueryUtils;
import org.ovirt.engine.ui.common.widget.table.column.AbstractColumn;
import org.ovirt.engine.ui.common.widget.table.column.EmptyColumn;
import org.ovirt.engine.ui.common.widget.table.column.SortableColumn;
import org.ovirt.engine.ui.common.widget.table.header.AbstractCheckboxHeader;
import org.ovirt.engine.ui.common.widget.table.header.AbstractHeader;
import org.ovirt.engine.ui.common.widget.table.header.ResizableHeader;
import org.ovirt.engine.ui.common.widget.table.header.ResizeableCheckboxHeader;
import org.ovirt.engine.ui.common.widget.table.header.SafeHtmlHeader;
import org.ovirt.engine.ui.uicommonweb.HasCleanup;
import org.ovirt.engine.ui.uicommonweb.models.GridController;
import org.ovirt.engine.ui.uicommonweb.models.SearchableListModel;
import org.ovirt.engine.ui.uicommonweb.models.SortedListModel;
import org.ovirt.engine.ui.uicompat.external.StringUtils;
import com.google.gwt.cell.client.Cell.Context;
import com.google.gwt.cell.client.TextCell;
import com.google.gwt.dom.client.BrowserEvents;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.NativeEvent;
import com.google.gwt.dom.client.NodeList;
import com.google.gwt.dom.client.Style.Visibility;
import com.google.gwt.dom.client.TableCellElement;
import com.google.gwt.dom.client.TableElement;
import com.google.gwt.dom.client.TableRowElement;
import com.google.gwt.dom.client.TableSectionElement;
import com.google.gwt.safehtml.shared.SafeHtml;
import com.google.gwt.safehtml.shared.SafeHtmlUtils;
import com.google.gwt.user.cellview.client.CellTable;
import com.google.gwt.user.cellview.client.Column;
import com.google.gwt.user.cellview.client.ColumnSortList.ColumnSortInfo;
import com.google.gwt.user.cellview.client.Header;
import com.google.gwt.user.client.ui.Widget;
import com.google.gwt.view.client.CellPreviewEvent;
import com.google.gwt.view.client.ProvidesKey;
/**
* A {@link CellTable} that supports resizing its columns using mouse.
* <p>
* Column resize feature is disabled by default, use {@link #enableColumnResizing} to enable it.
* <p>
* Use {@link #initModelSortHandler} method to configure column sorting that works with:
* <ul>
* <li>{@link SortedListModel} - client-side sorting
* <li>{@link SearchableListModel} - client-side or server-side sorting
* </ul>
*
* @param <T>
* Table row data type.
*/
public class ColumnResizeCellTable<T> extends CellTable<T> implements HasResizableColumns<T>, ColumnController<T>,
HasCleanup {
/**
* {@link #emptyNoWidthColumn} header that supports handling context menu events.
*/
private class EmptyColumnHeader extends Header<String> {
public EmptyColumnHeader() {
super(new TextCell() {
@Override
public Set<String> getConsumedEvents() {
return new HashSet<>(Collections.singletonList(BrowserEvents.CONTEXTMENU));
}
});
}
@Override
public String getValue() {
return null;
}
@Override
public void onBrowserEvent(Context context, Element elem, NativeEvent event) {
super.onBrowserEvent(context, elem, event);
if (BrowserEvents.CONTEXTMENU.equals(event.getType())) {
ensureContextMenuHandler().onContextMenu(event);
}
}
}
private static final Logger logger = Logger.getLogger(ColumnResizeCellTable.class.getName());
private static final CommonApplicationConstants constants = AssetProvider.getConstants();
private static final CommonApplicationTemplates templates = AssetProvider.getTemplates();
private static final int DEFAULT_MINIMUM_COLUMN_WIDTH = 30;
private int minimumColumnWidth = DEFAULT_MINIMUM_COLUMN_WIDTH;
// Prefix for keys used to store widths of individual columns
private static final String GRID_COLUMN_WIDTH_PREFIX = "GridColumnWidth"; //$NON-NLS-1$
// This is 1px instead of 0px as zero-size columns seem to confuse the cell table.
private static final String HIDDEN_WIDTH = "1px"; //$NON-NLS-1$
// Context menu event handler attached to all column headers
private NativeContextMenuHandler headerContextMenuHandler;
// Empty no-width column used with resizable columns feature
// that occupies remaining horizontal space within the table
private final Column<T, ?> emptyNoWidthColumn = new EmptyColumn<>();
private final Header<?> emptyNoWidthColumnHeader = new EmptyColumnHeader();
private boolean columnResizingEnabled = false;
private boolean columnResizePersistenceEnabled = false;
private boolean applyHeaderStyle = true;
// used to store column width preferences
private ClientStorage clientStorage;
// used to store column width preferences
private GridController gridController;
// Column visibility, controlled via ensureColumnVisible method
private final Map<Column<T, ?>, Boolean> columnVisibleMap = new HashMap<>();
// Column visibility override, controlled via setColumnVisible method
private final Map<Column<T, ?>, Boolean> columnVisibleMapOverride = new HashMap<>();
// Current column widths
private final Map<Column<T, ?>, String> columnWidthMap = new HashMap<>();
// Column header context menu popup
private final ColumnContextPopup<T> contextPopup = new ColumnContextPopup<>(this);
private boolean headerContextMenuEnabled = false;
private int dragIndex = ColumnController.NO_DRAG;
public ColumnResizeCellTable() {
super();
}
public ColumnResizeCellTable(int pageSize, ProvidesKey<T> keyProvider) {
super(pageSize, keyProvider);
}
public ColumnResizeCellTable(int pageSize, CellTable.Resources resources,
ProvidesKey<T> keyProvider, Widget loadingIndicator) {
super(pageSize, resources, keyProvider, loadingIndicator);
}
public ColumnResizeCellTable(int pageSize, CellTable.Resources resources,
ProvidesKey<T> keyProvider) {
super(pageSize, resources, keyProvider);
}
public ColumnResizeCellTable(int pageSize, CellTable.Resources resources) {
super(pageSize, resources);
}
public ColumnResizeCellTable(int pageSize) {
super(pageSize);
}
public ColumnResizeCellTable(ProvidesKey<T> keyProvider) {
super(keyProvider);
}
private NativeContextMenuHandler ensureContextMenuHandler() {
if (headerContextMenuHandler == null) {
headerContextMenuHandler = event -> {
event.preventDefault();
event.stopPropagation();
if (headerContextMenuEnabled) {
contextPopup.getContextMenu().update();
contextPopup.showAndFitToScreen(event.getClientX(), event.getClientY());
}
};
}
return headerContextMenuHandler;
}
@Override
public void addColumn(Column<T, ?> column, Header<?> header) {
// build resizable headers, if necessary
if (columnResizingEnabled && header instanceof AbstractCheckboxHeader) {
header = createResizableCheckboxHeader(header, column);
}
else if (columnResizingEnabled) {
header = createResizableHeader(column, header);
}
else if (applyHeaderStyle && header instanceof SafeHtmlHeader) {
SafeHtmlHeader safeHtmlHeader = (SafeHtmlHeader) header;
// not using Resizeable header, but still want it to look that way.
// nonResizeableColumnHeader does that.
// TODO nonResizeableColumnHeader copy-pastes CSS. fix.
SafeHtml newValue = templates.nonResizeableColumnHeader(safeHtmlHeader.getValue());
header = new SafeHtmlHeader(newValue, safeHtmlHeader.getTooltip());
}
// actually add the column
super.addColumn(column, header);
// Add empty no-width column as the last column
if (columnResizingEnabled) {
if (isColumnPresent(emptyNoWidthColumn)) {
removeColumn(emptyNoWidthColumn);
}
super.addColumn(emptyNoWidthColumn, emptyNoWidthColumnHeader);
}
// Add column to header context menu
if (header instanceof AbstractHeader) {
((AbstractHeader) header).setContextMenuHandler(ensureContextMenuHandler());
contextPopup.getContextMenu().addItem(column);
}
}
public void addColumnAndSetWidth(Column<T, ?> column, Header<?> header, String width) {
addColumn(column, header);
setColumnWidth(column, width);
}
public void addColumn(Column<T, ?> column, String headerText) {
Header<?> header = new SafeHtmlHeader(SafeHtmlUtils.fromTrustedString(headerText));
addColumn(column, header);
}
public void addColumnAndSetWidth(Column<T, ?> column, String headerText, String width) {
addColumn(column, headerText);
setColumnWidth(column, width);
}
public void addColumnWithHtmlHeader(Column<T, ?> column, SafeHtml headerHtml) {
Header<?> header = new SafeHtmlHeader(headerHtml);
addColumn(column, header);
}
public void addColumnWithHtmlHeader(Column<T, ?> column, SafeHtml headerHtml, String width) {
Header<?> header = new SafeHtmlHeader(headerHtml);
addColumn(column, header);
setColumnWidth(column, width);
}
protected Header<?> createResizableHeader(Column<T, ?> column, Header<?> header) {
if (header instanceof SafeHtmlHeader) {
SafeHtmlHeader safeHtmlHeader = (SafeHtmlHeader) header;
return new ResizableHeader<>(safeHtmlHeader, column, this, applyHeaderStyle);
}
return header;
}
/**
* Wraps the given {@code header} passed from user code, if necessary.
* <p>
* This method is called whenever a column is added to the table.
*/
protected Header<?> createResizableCheckboxHeader(Header<?> header, Column<T, ?> column) {
if (header instanceof AbstractCheckboxHeader) {
return new ResizeableCheckboxHeader<>((AbstractCheckboxHeader) header, column, this);
}
return header;
}
/**
* Ensures that the given column is visible or hidden.
*/
public void ensureColumnVisible(Column<T, ?> column, String headerText, boolean visible) {
ensureColumnVisible(column, SafeHtmlUtils.fromTrustedString(headerText), visible);
}
/**
* Ensures that the given column is visible or hidden.
*/
public void ensureColumnVisible(Column<T, ?> column, SafeHtml headerHtml, boolean visible) {
ensureColumnVisible(column, new SafeHtmlHeader(headerHtml), visible, null);
}
/**
* Ensures that the given column is visible or hidden.
*/
public void ensureColumnVisible(Column<T, ?> column, String headerText, boolean visible, String width) {
ensureColumnVisible(column, SafeHtmlUtils.fromTrustedString(headerText), visible, width);
}
/**
* Ensures that the given column is visible or hidden.
*/
public void ensureColumnVisible(Column<T, ?> column, SafeHtml headerHtml, boolean visible, String width) {
ensureColumnVisible(column, new SafeHtmlHeader(headerHtml), visible, width);
}
/**
* Ensures that the given column is visible or hidden.
*/
public void ensureColumnVisible(Column<T, ?> column, SafeHtmlHeader header, boolean visible, String width) {
ensureColumnVisible(column, header, visible, width, true);
}
/**
* Ensures that the given column is visible or hidden.
* <p>
* This method also sets the column width in case the column needs to be added.
*
* @param column The column to update.
* @param header The header for the column (used only when adding new column).
* @param visible {@code true} to ensure the column is visible, {@code false} to ensure the column is hidden.
* @param width The width of the column.
* @param removeFromContextMenuIfNotVisible {@code true} to remove corresponding context menu item when the column is to be hidden.
*/
private void ensureColumnVisible(Column<T, ?> column, SafeHtmlHeader header, boolean visible, String width,
boolean removeFromContextMenuIfNotVisible) {
if (!isColumnPresent(column)) {
// Add the column
if (width == null) {
addColumn(column, header);
} else {
addColumnAndSetWidth(column, header, width);
}
}
columnVisibleMap.put(column, visible);
if (columnResizePersistenceEnabled) {
String persistedWidth = readColumnWidth(column);
if (persistedWidth != null) {
width = persistedWidth;
}
}
setColumnWidth(column, width);
// Update header context menu
if (removeFromContextMenuIfNotVisible && !visible) {
contextPopup.getContextMenu().removeItem(column);
} else if (removeFromContextMenuIfNotVisible && !contextPopup.getContextMenu().containsItem(column)) {
contextPopup.getContextMenu().addItem(column);
}
contextPopup.getContextMenu().update();
}
@Override
public void setColumnWidth(Column<T, ?> column, String width) {
boolean columnVisible = isColumnVisible(column);
if (columnVisible) {
columnWidthMap.put(column, width);
} else {
width = HIDDEN_WIDTH;
}
// Update header cell visibility
TableCellElement headerCell = getHeaderCell(getElement().<TableElement> cast(), getColumnIndex(column));
if (headerCell != null) {
headerCell.getStyle().setVisibility(columnVisible ? Visibility.VISIBLE : Visibility.HIDDEN);
}
// Prevent resizing of "hidden" (1px wide) columns
if (columnResizingEnabled) {
Header<?> header = getHeader(getColumnIndex(column));
if (header instanceof ResizableHeader) {
((ResizableHeader<?>) header).setResizeEnabled(columnVisible);
}
}
super.setColumnWidth(column, width);
}
private TableCellElement getHeaderCell(TableElement tableElement, int columnIndex) {
TableSectionElement tHeadElement = tableElement.getTHead();
if (tHeadElement == null) {
return null;
}
List<TableCellElement> cells = getCells(tHeadElement.getRows(), columnIndex);
return (cells.size() == 1) ? cells.get(0) : null;
}
private List<TableCellElement> getCells(NodeList<TableRowElement> rows, int columnIndex) {
List<TableCellElement> result = new ArrayList<>();
for (int i = 0; i < rows.getLength(); i++) {
TableCellElement cell = rows.getItem(i).getCells().getItem(columnIndex);
if (cell != null) {
result.add(cell);
}
}
return result;
}
private boolean isColumnPresent(Column<T, ?> column) {
return getColumnIndex(column) != -1;
}
@Override
public String getColumnContextMenuTitle(Column<T, ?> column) {
if (!isColumnPresent(column)) {
return null;
}
Header<?> header = getHeader(getColumnIndex(column));
String title = null;
if (column instanceof AbstractColumn) {
// Might return null (no custom title defined)
title = ((AbstractColumn) column).getContextMenuTitle();
}
if (title == null && header instanceof SafeHtmlHeader) {
// Might return empty string (header's HTML contains no text)
title = JqueryUtils.getTextFromHtml(((SafeHtmlHeader) header).getValue().asString());
}
if (StringUtils.isEmpty(title)) {
title = constants.missingColumnContextMenuTitle();
logger.warning("Column with missing context menu title at index " + getColumnIndex(column)); //$NON-NLS-1$
}
return title;
}
@Override
public boolean isColumnVisible(Column<T, ?> column) {
if (!isColumnPresent(column)) {
return false;
}
// Columns are visible by default
boolean visible = true;
if (columnVisibleMap.containsKey(column)) {
visible = columnVisibleMap.get(column);
}
if (visible && columnVisibleMapOverride.containsKey(column)) {
visible = columnVisibleMapOverride.get(column);
}
return visible;
}
@Override
public void setColumnVisible(Column<T, ?> column, boolean visible) {
if (isColumnPresent(column)) {
columnVisibleMapOverride.put(column, visible);
ensureColumnVisible(column, null, visible, columnWidthMap.get(column), false);
}
}
@Override
public void swapColumns(Column<T, ?> columnOne, Column<T, ?> columnTwo) {
if (isColumnPresent(columnOne) && isColumnPresent(columnTwo)) {
int columnOneIndex = getColumnIndex(columnOne);
int columnTwoIndex = getColumnIndex(columnTwo);
boolean oneWasBeforeTwo = columnOneIndex < columnTwoIndex;
// columnOne gets removed first
int columnTwoRemovalIndex = oneWasBeforeTwo ? columnTwoIndex - 1 : columnTwoIndex;
int columnOneInsertionIndex = oneWasBeforeTwo ? columnTwoIndex - 1 : columnTwoIndex;
int columnTwoInsertionIndex = oneWasBeforeTwo ? columnOneIndex : columnOneIndex - 1;
Header<?> columnOneHeader = getHeader(columnOneIndex);
Header<?> columnTwoHeader = getHeader(columnTwoIndex);
removeColumn(columnOneIndex);
removeColumn(columnTwoRemovalIndex);
if (oneWasBeforeTwo) {
insertColumn(columnOneInsertionIndex, columnOne, columnOneHeader);
insertColumn(columnTwoInsertionIndex, columnTwo, columnTwoHeader);
} else {
insertColumn(columnTwoInsertionIndex, columnTwo, columnTwoHeader);
insertColumn(columnOneInsertionIndex, columnOne, columnOneHeader);
}
contextPopup.getContextMenu().update();
}
}
@Override
public int getDragIndex() {
return dragIndex;
}
@Override
public void setDragIndex(int dragIndex) {
this.dragIndex = dragIndex;
}
@Override
public void updateColumnContextMenu() {
contextPopup.getContextMenu().update();
}
/**
* Enables header context menu triggered by right-clicking table header area.
* <p>
* <em>After calling this method, each column must have non-empty header HTML content <b>or</b>
* {@linkplain org.ovirt.engine.ui.common.widget.table.column.AbstractColumn#setContextMenuTitle
* custom context menu title} defined, otherwise the context menu will contain "unnamed column"
* items.</em>
*/
public void enableHeaderContextMenu() {
headerContextMenuEnabled = true;
}
/**
* Allows table columns to be resized by dragging their right-hand border using mouse.
* <p>
* This method should be called before calling any {@code addColumn} methods.
* <p>
* <em>After calling this method, each column must have an explicit width defined in PX units, otherwise the resize
* behavior will not function properly.</em>
*/
public void enableColumnResizing() {
columnResizingEnabled = true;
// Column resize implementation needs table-layout:fixed (disable browser-specific table layout algorithm)
setWidth("100%", true); //$NON-NLS-1$
}
@Override
public void onResizeStart(Column<T, ?> column, Element headerElement) {
// Don't do anything.
}
@Override
public void onResizeEnd(Column<T, ?> column, Element headerElement) {
// Redraw the table
redraw();
if (columnResizePersistenceEnabled) {
String width = getColumnWidth(column);
saveColumnWidth(column, width);
}
}
@Override
public void resizeColumn(Column<T, ?> column, int newWidth) {
setColumnWidth(column, newWidth + "px"); //$NON-NLS-1$
}
@Override
public int getMinimumColumnWidth(Column<T, ?> column) {
return minimumColumnWidth;
}
public void setMinimumColumnWidth(int minimumColumnWidth) {
this.minimumColumnWidth = minimumColumnWidth;
}
/**
* Enables saving this table's column widths to LocalStorage (or a cookie if LocalStorage unsupported).
*/
public void enableColumnWidthPersistence(ClientStorage clientStorage, GridController gridController) {
this.clientStorage = clientStorage;
this.gridController = gridController;
if (clientStorage != null && gridController != null) {
columnResizePersistenceEnabled = true;
}
}
protected String getColumnWidthKey(Column<T, ?> column) {
if (columnResizePersistenceEnabled) {
return GRID_COLUMN_WIDTH_PREFIX + "_" + getGridElementId() + "_" + getColumnIndex(column); //$NON-NLS-1$ //$NON-NLS-2$
}
return null;
}
protected String getGridElementId() {
return gridController.getId();
}
protected void saveColumnWidth(Column<T, ?> column, String width) {
if (columnResizePersistenceEnabled) {
String key = getColumnWidthKey(column);
if (key != null) {
clientStorage.setLocalItem(key, width);
}
}
}
protected String readColumnWidth(Column<T, ?> column) {
if (columnResizePersistenceEnabled) {
String key = getColumnWidthKey(column);
if (key != null) {
return clientStorage.getLocalItem(key);
}
}
return null;
}
protected void dontApplyResizableHeaderStyle() {
applyHeaderStyle = false;
}
/**
* Adds column sort handler that works with {@link SortedListModel} (client-side sorting)
* or {@link SearchableListModel} (client-side or server-side sorting).
* <p>
* The sort handler ensures that column sort definition ({@linkplain SortableColumn#getComparator
* comparator} for client-side sorting, {@linkplain SortableColumn#getSortBy sortBy} for server-side
* sorting) is propagated to the given model, causing model's item collection to be updated.
*
* @param sortedModel
* Model for which to configure column sorting.
*/
@SuppressWarnings("unchecked")
public void initModelSortHandler(final SortedListModel<T> sortedModel) {
final SearchableListModel<?, T> searchableModel = (sortedModel instanceof SearchableListModel)
? (SearchableListModel<?, T>) sortedModel : null;
addColumnSortHandler(event -> {
Column<?, ?> column = event.getColumn();
if (column instanceof SortableColumn) {
SortableColumn<T, ?> sortableColumn = (SortableColumn<T, ?>) column;
boolean sortApplied = false;
Comparator<? super T> comparator = sortableColumn.getComparator();
boolean supportsServerSideSorting = searchableModel != null && searchableModel.supportsServerSideSorting();
// If server-side sorting is supported by the model, but the column
// uses Comparator for client-side sorting, use client-side sorting
if (supportsServerSideSorting && comparator != null) {
sortedModel.setComparator(comparator, event.isSortAscending());
sortApplied = true;
}
// Otherwise, if server-side sorting is supported by the model,
// update model's sort options and reload its items via search query
else if (supportsServerSideSorting) {
sortedModel.setComparator(null);
if (searchableModel.isSearchValidForServerSideSorting()) {
searchableModel.updateSortOptions(sortableColumn.getSortBy(), event.isSortAscending());
sortApplied = true;
} else {
// Search string not valid, cannot perform search query
searchableModel.clearSortOptions();
}
}
// Otherwise, fall back to client-side sorting
else if (comparator != null) {
sortedModel.setComparator(comparator, event.isSortAscending());
sortApplied = true;
// SortedListModel.setComparator does not sort the items
if (searchableModel == null) {
sortedModel.setItems(sortedModel.getItems());
}
}
// Update column sort status, redrawing table headers if necessary
ColumnSortInfo columnSortInfo = event.getColumnSortList().get(0);
if (sortApplied) {
pushColumnSort(columnSortInfo);
} else {
clearColumnSort();
}
}
});
}
/**
* Mark a {@linkplain ColumnSortInfo#getColumn column} as sorted.
*
* @see #getColumnSortList
*/
protected void pushColumnSort(ColumnSortInfo columnSortInfo) {
getColumnSortList().push(columnSortInfo);
}
/**
* Mark all columns as un-sorted.
*
* @see #getColumnSortList
*/
protected void clearColumnSort() {
getColumnSortList().clear();
}
/**
* Adds a {@link CellPreviewEvent} handler for double-click event
* simulated as two {@code click} events fired in succession.
*/
public void addSimulatedDoubleClickHandler(final CellPreviewEvent.Handler<T> handler) {
addCellPreviewHandler(new CellPreviewEvent.Handler<T>() {
private static final long DOUBLE_CLICK_THRESHOLD = 300; // Milliseconds
private long lastClick = -1;
@Override
public void onCellPreview(CellPreviewEvent<T> event) {
if (BrowserEvents.CLICK.equals(event.getNativeEvent().getType())) {
long click = System.currentTimeMillis();
if (lastClick > 0 && (click - lastClick < DOUBLE_CLICK_THRESHOLD)) {
handler.onCellPreview(event);
lastClick = -1;
} else {
lastClick = click;
}
}
}
});
}
@Override
public void cleanup() {
}
}