diff options
14 files changed, 1317 insertions, 15 deletions
diff --git a/client/src/com/vaadin/client/connectors/GridConnector.java b/client/src/com/vaadin/client/connectors/GridConnector.java index d3045ee13b..0807690023 100644 --- a/client/src/com/vaadin/client/connectors/GridConnector.java +++ b/client/src/com/vaadin/client/connectors/GridConnector.java @@ -31,12 +31,15 @@ import java.util.logging.Logger; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.ScheduledCommand; import com.google.gwt.dom.client.NativeEvent; +import com.google.gwt.user.client.Timer; import com.google.gwt.user.client.ui.Widget; import com.vaadin.client.ComponentConnector; import com.vaadin.client.ConnectorHierarchyChangeEvent; +import com.vaadin.client.DeferredWorker; import com.vaadin.client.MouseEventDetailsBuilder; import com.vaadin.client.annotations.OnStateChange; import com.vaadin.client.communication.StateChangeEvent; +import com.vaadin.client.connectors.RpcDataSourceConnector.DetailsListener; import com.vaadin.client.connectors.RpcDataSourceConnector.RpcDataSource; import com.vaadin.client.data.DataSource.RowHandle; import com.vaadin.client.renderers.Renderer; @@ -45,6 +48,7 @@ import com.vaadin.client.ui.AbstractHasComponentsConnector; import com.vaadin.client.ui.SimpleManagedLayout; import com.vaadin.client.widget.grid.CellReference; import com.vaadin.client.widget.grid.CellStyleGenerator; +import com.vaadin.client.widget.grid.DetailsGenerator; import com.vaadin.client.widget.grid.EditorHandler; import com.vaadin.client.widget.grid.RowReference; import com.vaadin.client.widget.grid.RowStyleGenerator; @@ -72,8 +76,10 @@ import com.vaadin.client.widgets.Grid.FooterCell; import com.vaadin.client.widgets.Grid.FooterRow; import com.vaadin.client.widgets.Grid.HeaderCell; import com.vaadin.client.widgets.Grid.HeaderRow; +import com.vaadin.shared.Connector; import com.vaadin.shared.data.sort.SortDirection; import com.vaadin.shared.ui.Connect; +import com.vaadin.shared.ui.grid.DetailsConnectorChange; import com.vaadin.shared.ui.grid.EditorClientRpc; import com.vaadin.shared.ui.grid.EditorServerRpc; import com.vaadin.shared.ui.grid.GridClientRpc; @@ -103,7 +109,7 @@ import elemental.json.JsonValue; */ @Connect(com.vaadin.ui.Grid.class) public class GridConnector extends AbstractHasComponentsConnector implements - SimpleManagedLayout { + SimpleManagedLayout, DeferredWorker { private static final class CustomCellStyleGenerator implements CellStyleGenerator<JsonObject> { @@ -382,6 +388,127 @@ public class GridConnector extends AbstractHasComponentsConnector implements } }; + private static class CustomDetailsGenerator implements DetailsGenerator { + + private final Map<Integer, ComponentConnector> indexToDetailsMap = new HashMap<Integer, ComponentConnector>(); + + @Override + @SuppressWarnings("boxing") + public Widget getDetails(int rowIndex) { + ComponentConnector componentConnector = indexToDetailsMap + .get(rowIndex); + if (componentConnector != null) { + return componentConnector.getWidget(); + } else { + return null; + } + } + + public void setDetailsConnectorChanges( + Set<DetailsConnectorChange> changes) { + /* + * To avoid overwriting connectors while moving them about, we'll + * take all the affected connectors, first all remove those that are + * removed or moved, then we add back those that are moved or added. + */ + + /* Remove moved/removed connectors from bookkeeping */ + for (DetailsConnectorChange change : changes) { + Integer oldIndex = change.getOldIndex(); + Connector removedConnector = indexToDetailsMap.remove(oldIndex); + + Connector connector = change.getConnector(); + assert removedConnector == null || connector == null + || removedConnector.equals(connector) : "Index " + + oldIndex + " points to " + removedConnector + + " while " + connector + " was expected"; + } + + /* Add moved/added connectors to bookkeeping */ + for (DetailsConnectorChange change : changes) { + Integer newIndex = change.getNewIndex(); + ComponentConnector connector = (ComponentConnector) change + .getConnector(); + + if (connector != null) { + assert newIndex != null : "An existing connector has a missing new index."; + + ComponentConnector prevConnector = indexToDetailsMap.put( + newIndex, connector); + + assert prevConnector == null : "Connector collision at index " + + newIndex + + " between old " + + prevConnector + + " and new " + connector; + } + } + } + } + + @SuppressWarnings("boxing") + private class DetailsConnectorFetcher implements DeferredWorker { + + /** A flag making sure that we don't call scheduleFinally many times. */ + private boolean fetcherHasBeenCalled = false; + + /** A rolling counter for unique values. */ + private int detailsFetchCounter = 0; + + /** A collection that tracks the amount of requests currently underway. */ + private Set<Integer> pendingFetches = new HashSet<Integer>(5); + + private final ScheduledCommand lazyDetailsFetcher = new ScheduledCommand() { + @Override + public void execute() { + int currentFetchId = detailsFetchCounter++; + pendingFetches.add(currentFetchId); + getRpcProxy(GridServerRpc.class).sendDetailsComponents( + currentFetchId); + fetcherHasBeenCalled = false; + + assert assertRequestDoesNotTimeout(currentFetchId); + } + }; + + public void schedule() { + if (!fetcherHasBeenCalled) { + Scheduler.get().scheduleFinally(lazyDetailsFetcher); + fetcherHasBeenCalled = true; + } + } + + public void responseReceived(int fetchId) { + /* Ignore negative fetchIds (they're pushed, not fetched) */ + if (fetchId >= 0) { + boolean success = pendingFetches.remove(fetchId); + assert success : "Received a response with an unidentified fetch id"; + } + } + + @Override + public boolean isWorkPending() { + return fetcherHasBeenCalled || !pendingFetches.isEmpty(); + } + + private boolean assertRequestDoesNotTimeout(final int fetchId) { + /* + * This method will not be compiled without asserts enabled. This + * only makes sure that any request does not time out. + * + * TODO Should this be an explicit check? Is it worth the overhead? + */ + new Timer() { + @Override + public void run() { + assert !pendingFetches.contains(fetchId) : "Fetch id " + + fetchId + " timed out."; + } + }.schedule(1000); + return true; + } + } + /** * Maps a generated column id to a grid column instance */ @@ -438,6 +565,29 @@ public class GridConnector extends AbstractHasComponentsConnector implements private String lastKnownTheme = null; + private final CustomDetailsGenerator customDetailsGenerator = new CustomDetailsGenerator(); + + private final DetailsConnectorFetcher detailsConnectorFetcher = new DetailsConnectorFetcher(); + + private final DetailsListener detailsListener = new DetailsListener() { + @Override + public void reapplyDetailsVisibility(int rowIndex, JsonObject row) { + if (row.hasKey(GridState.JSONKEY_DETAILS_VISIBLE) + && row.getBoolean(GridState.JSONKEY_DETAILS_VISIBLE)) { + getWidget().setDetailsVisible(rowIndex, true); + } else { + getWidget().setDetailsVisible(rowIndex, false); + } + + detailsConnectorFetcher.schedule(); + } + + @Override + public void closeDetails(int rowIndex) { + getWidget().setDetailsVisible(rowIndex, false); + } + }; + @Override @SuppressWarnings("unchecked") public Grid<JsonObject> getWidget() { @@ -490,6 +640,36 @@ public class GridConnector extends AbstractHasComponentsConnector implements public void recalculateColumnWidths() { getWidget().recalculateColumnWidths(); } + + @Override + @SuppressWarnings("boxing") + public void setDetailsConnectorChanges( + Set<DetailsConnectorChange> connectorChanges, int fetchId) { + customDetailsGenerator + .setDetailsConnectorChanges(connectorChanges); + + // refresh moved/added details rows + for (DetailsConnectorChange change : connectorChanges) { + Integer oldIndex = change.getOldIndex(); + Integer newIndex = change.getNewIndex(); + + assert oldIndex == null || oldIndex >= 0 : "Got an " + + "invalid old index: " + oldIndex + + " (connector: " + change.getConnector() + ")"; + assert newIndex == null || newIndex >= 0 : "Got an " + + "invalid new index: " + newIndex + + " (connector: " + change.getConnector() + ")"; + + Integer index = newIndex; + if (index == null) { + index = oldIndex; + } + + getWidget().setDetailsVisible(index, false); + getWidget().setDetailsVisible(index, true); + } + detailsConnectorFetcher.responseReceived(fetchId); + } }); getWidget().addSelectionHandler(internalSelectionChangeHandler); @@ -532,10 +712,10 @@ public class GridConnector extends AbstractHasComponentsConnector implements }); getWidget().setEditorHandler(new CustomEditorHandler()); - getWidget().addColumnReorderHandler(columnReorderHandler); - + getWidget().setDetailsGenerator(customDetailsGenerator); getLayoutManager().registerDependency(this, getWidget().getElement()); + layout(); } @@ -826,7 +1006,7 @@ public class GridConnector extends AbstractHasComponentsConnector implements column.setSortable(state.sortable); column.setHidden(state.hidden); - column.setHideable(state.hidable); + column.setHidable(state.hidable); column.setEditable(state.editable); column.setEditorConnector((AbstractFieldConnector) state.editorConnector); @@ -1034,4 +1214,13 @@ public class GridConnector extends AbstractHasComponentsConnector implements public void layout() { getWidget().onResize(); } + + @Override + public boolean isWorkPending() { + return detailsConnectorFetcher.isWorkPending(); + } + + public DetailsListener getDetailsListener() { + return detailsListener; + } } diff --git a/client/src/com/vaadin/client/connectors/RpcDataSourceConnector.java b/client/src/com/vaadin/client/connectors/RpcDataSourceConnector.java index f8d6ebcb62..e8c7ee5286 100644 --- a/client/src/com/vaadin/client/connectors/RpcDataSourceConnector.java +++ b/client/src/com/vaadin/client/connectors/RpcDataSourceConnector.java @@ -17,6 +17,7 @@ package com.vaadin.client.connectors; import java.util.ArrayList; +import java.util.List; import com.vaadin.client.ServerConnector; import com.vaadin.client.data.AbstractRemoteDataSource; @@ -43,6 +44,36 @@ import elemental.json.JsonObject; @Connect(com.vaadin.data.RpcDataProviderExtension.class) public class RpcDataSourceConnector extends AbstractExtensionConnector { + /** + * A callback interface to let {@link GridConnector} know that detail + * visibilities might have changed. + * + * @since + * @author Vaadin Ltd + */ + interface DetailsListener { + + /** + * A request to verify (and correct) the visibility for a row, given + * updated metadata. + * + * @param rowIndex + * the index of the row that should be checked + * @param row + * the row object to check visibility for + * @see GridState#JSONKEY_DETAILS_VISIBLE + */ + void reapplyDetailsVisibility(int rowIndex, JsonObject row); + + /** + * Closes details for a row. + * + * @param rowIndex + * the index of the row for which to close details + */ + void closeDetails(int rowIndex); + } + public class RpcDataSource extends AbstractRemoteDataSource<JsonObject> { protected RpcDataSource() { @@ -56,27 +87,28 @@ public class RpcDataSourceConnector extends AbstractExtensionConnector { rows.add(rowObject); } - dataSource.setRowData(firstRow, rows); + RpcDataSource.this.setRowData(firstRow, rows); } @Override public void removeRowData(int firstRow, int count) { - dataSource.removeRowData(firstRow, count); + RpcDataSource.this.removeRowData(firstRow, count); } @Override public void insertRowData(int firstRow, int count) { - dataSource.insertRowData(firstRow, count); + RpcDataSource.this.insertRowData(firstRow, count); } @Override public void resetDataAndSize(int size) { - dataSource.resetDataAndSize(size); + RpcDataSource.this.resetDataAndSize(size); } }); } private DataRequestRpc rpcProxy = getRpcProxy(DataRequestRpc.class); + private DetailsListener detailsListener; @Override protected void requestRows(int firstRowIndex, int numberOfRows, @@ -170,7 +202,29 @@ public class RpcDataSourceConnector extends AbstractExtensionConnector { if (!handle.isPinned()) { rpcProxy.setPinned(key, false); } + } + + void setDetailsListener(DetailsListener detailsListener) { + this.detailsListener = detailsListener; + } + + @Override + protected void setRowData(int firstRowIndex, List<JsonObject> rowData) { + super.setRowData(firstRowIndex, rowData); + /* + * Intercepting details information from the data source, rerouting + * them back to the GridConnector (as a details listener) + */ + for (int i = 0; i < rowData.size(); i++) { + detailsListener.reapplyDetailsVisibility(firstRowIndex + i, + rowData.get(i)); + } + } + + @Override + protected void onDropFromCache(int rowIndex) { + detailsListener.closeDetails(rowIndex); } } @@ -178,6 +232,8 @@ public class RpcDataSourceConnector extends AbstractExtensionConnector { @Override protected void extend(ServerConnector target) { - ((GridConnector) target).setDataSource(dataSource); + GridConnector gridConnector = (GridConnector) target; + dataSource.setDetailsListener(gridConnector.getDetailsListener()); + gridConnector.setDataSource(dataSource); } } diff --git a/client/src/com/vaadin/client/data/AbstractRemoteDataSource.java b/client/src/com/vaadin/client/data/AbstractRemoteDataSource.java index 1de271c646..152b66f2ca 100644 --- a/client/src/com/vaadin/client/data/AbstractRemoteDataSource.java +++ b/client/src/com/vaadin/client/data/AbstractRemoteDataSource.java @@ -332,9 +332,23 @@ public abstract class AbstractRemoteDataSource<T> implements DataSource<T> { for (int i = range.getStart(); i < range.getEnd(); i++) { T removed = indexToRowMap.remove(Integer.valueOf(i)); keyToIndexMap.remove(getRowKey(removed)); + + onDropFromCache(i); } } + /** + * A hook that can be overridden to do something whenever a row is dropped + * from the cache. + * + * @since + * @param rowIndex + * the index of the dropped row + */ + protected void onDropFromCache(int rowIndex) { + // noop + } + private void handleMissingRows(Range range) { if (range.isEmpty()) { return; @@ -570,6 +584,7 @@ public abstract class AbstractRemoteDataSource<T> implements DataSource<T> { Profiler.leave("AbstractRemoteDataSource.insertRowData"); } + @SuppressWarnings("boxing") private void moveRowFromIndexToIndex(int oldIndex, int newIndex) { T row = indexToRowMap.remove(oldIndex); if (indexToRowMap.containsKey(newIndex)) { diff --git a/client/src/com/vaadin/client/widget/grid/DetailsGenerator.java b/client/src/com/vaadin/client/widget/grid/DetailsGenerator.java index 264aa4e614..309e3f1ea3 100644 --- a/client/src/com/vaadin/client/widget/grid/DetailsGenerator.java +++ b/client/src/com/vaadin/client/widget/grid/DetailsGenerator.java @@ -25,6 +25,7 @@ import com.google.gwt.user.client.ui.Widget; */ public interface DetailsGenerator { + /** A details generator that provides no details */ public static final DetailsGenerator NULL = new DetailsGenerator() { @Override public Widget getDetails(int rowIndex) { diff --git a/client/src/com/vaadin/client/widgets/Grid.java b/client/src/com/vaadin/client/widgets/Grid.java index c6ac35bba0..174f2dde38 100644 --- a/client/src/com/vaadin/client/widgets/Grid.java +++ b/client/src/com/vaadin/client/widgets/Grid.java @@ -7263,10 +7263,17 @@ public class Grid<T> extends ResizeComposite implements * @since * @param detailsGenerator * the details generator to set + * @throws IllegalArgumentException + * if detailsGenerator is <code>null</code>; */ public void setDetailsGenerator(DetailsGenerator detailsGenerator) throws IllegalArgumentException { + if (detailsGenerator == null) { + throw new IllegalArgumentException( + "Details generator may not be null"); + } + this.detailsGenerator = detailsGenerator; // this will refresh all visible spacers @@ -7317,12 +7324,13 @@ public class Grid<T> extends ResizeComposite implements * see GridSpacerUpdater.init for implementation details. */ - if (visible && !isDetailsVisible(rowIndex)) { + boolean isVisible = isDetailsVisible(rowIndex); + if (visible && !isVisible) { escalator.getBody().setSpacer(rowIndex, DETAILS_ROW_INITIAL_HEIGHT); visibleDetails.add(rowIndexInteger); } - else if (!visible && isDetailsVisible(rowIndex)) { + else if (!visible && isVisible) { escalator.getBody().setSpacer(rowIndex, -1); visibleDetails.remove(rowIndexInteger); } diff --git a/server/src/com/vaadin/data/RpcDataProviderExtension.java b/server/src/com/vaadin/data/RpcDataProviderExtension.java index 5fb0742164..8d7b654468 100644 --- a/server/src/com/vaadin/data/RpcDataProviderExtension.java +++ b/server/src/com/vaadin/data/RpcDataProviderExtension.java @@ -30,6 +30,7 @@ import java.util.logging.Logger; import com.google.gwt.thirdparty.guava.common.collect.BiMap; import com.google.gwt.thirdparty.guava.common.collect.HashBiMap; +import com.google.gwt.thirdparty.guava.common.collect.ImmutableSet; import com.vaadin.data.Container.Indexed; import com.vaadin.data.Container.Indexed.ItemAddEvent; import com.vaadin.data.Container.Indexed.ItemRemoveEvent; @@ -51,6 +52,7 @@ import com.vaadin.ui.Grid; import com.vaadin.ui.Grid.CellReference; import com.vaadin.ui.Grid.CellStyleGenerator; import com.vaadin.ui.Grid.Column; +import com.vaadin.ui.Grid.DetailComponentManager; import com.vaadin.ui.Grid.RowReference; import com.vaadin.ui.Grid.RowStyleGenerator; import com.vaadin.ui.renderers.Renderer; @@ -110,11 +112,16 @@ public class RpcDataProviderExtension extends AbstractExtension { } for (Object itemId : itemsRemoved) { + detailComponentManager.destroyDetails(itemId); itemIdToKey.remove(itemId); } for (Object itemId : itemSet) { itemIdToKey.put(itemId, getKey(itemId)); + if (visibleDetails.contains(itemId)) { + detailComponentManager.createDetails(itemId, + indexOf(itemId)); + } } } @@ -122,7 +129,7 @@ public class RpcDataProviderExtension extends AbstractExtension { return String.valueOf(rollingIndex++); } - String getKey(Object itemId) { + public String getKey(Object itemId) { String key = itemIdToKey.get(itemId); if (key == null) { key = nextKey(); @@ -673,13 +680,23 @@ public class RpcDataProviderExtension extends AbstractExtension { private boolean bareItemSetTriggeredSizeChange = false; /** + * This map represents all the details that are user-defined as visible. + * This does not reflect the status in the DOM. + */ + private Set<Object> visibleDetails = new HashSet<Object>(); + + private DetailComponentManager detailComponentManager; + + /** * Creates a new data provider using the given container. * * @param container * the container to make available */ - public RpcDataProviderExtension(Indexed container) { + public RpcDataProviderExtension(Indexed container, + DetailComponentManager detailComponentManager) { this.container = container; + this.detailComponentManager = detailComponentManager; rpc = getRpcProxy(DataProviderRpc.class); registerRpc(new DataRequestRpc() { @@ -814,6 +831,10 @@ public class RpcDataProviderExtension extends AbstractExtension { rowObject.put(GridState.JSONKEY_DATA, rowData); rowObject.put(GridState.JSONKEY_ROWKEY, keyMapper.getKey(itemId)); + if (visibleDetails.contains(itemId)) { + rowObject.put(GridState.JSONKEY_DETAILS_VISIBLE, true); + } + rowReference.set(itemId); CellStyleGenerator cellStyleGenerator = grid.getCellStyleGenerator(); @@ -949,6 +970,10 @@ public class RpcDataProviderExtension extends AbstractExtension { JsonArray rowArray = Json.createArray(); rowArray.set(0, row); rpc.setRowData(index, rowArray); + + if (isDetailsVisible(itemId)) { + detailComponentManager.createDetails(itemId, index); + } } } @@ -1071,4 +1096,79 @@ public class RpcDataProviderExtension extends AbstractExtension { return Logger.getLogger(RpcDataProviderExtension.class.getName()); } + /** + * Marks a row's details to be visible or hidden. + * <p> + * If that row is currently in the client side's cache, this information + * will be sent over to the client. + * + * @since + * @param itemId + * the id of the item of which to change the details visibility + * @param visible + * <code>true</code> to show the details, <code>false</code> to + * hide + */ + public void setDetailsVisible(Object itemId, boolean visible) { + final boolean modified; + + if (visible) { + modified = visibleDetails.add(itemId); + + /* + * We don't want to create the component here, since the component + * might be out of view, and thus we don't know where the details + * should end up on the client side. This is also a great thing to + * optimize away, so that in case a lot of things would be opened at + * once, a huge chunk of data doesn't get sent over immediately. + */ + + } else { + modified = visibleDetails.remove(itemId); + + /* + * Here we can try to destroy the component no matter what. The + * component has been removed and should be detached from the + * component hierarchy. The details row will be closed on the client + * side automatically. + */ + detailComponentManager.destroyDetails(itemId); + } + + int rowIndex = indexOf(itemId); + boolean modifiedRowIsActive = activeRowHandler.activeRange + .contains(rowIndex); + if (modified && modifiedRowIsActive) { + updateRowData(itemId); + } + } + + /** + * Checks whether the details for a row is marked as visible. + * + * @since + * @param itemId + * the id of the item of which to check the visibility + * @return <code>true</code> iff the detials are visible for the item. This + * might return <code>true</code> even if the row is not currently + * visible in the DOM + */ + public boolean isDetailsVisible(Object itemId) { + return visibleDetails.contains(itemId); + } + + public void refreshDetails() { + for (Object itemId : ImmutableSet.copyOf(visibleDetails)) { + detailComponentManager.refresh(itemId); + } + } + + private int indexOf(Object itemId) { + /* + * It would be great if we could optimize this method away, since the + * normal usage of Grid doesn't need any indices to be known. It was + * already optimized away once, maybe we can do away with these as well. + */ + return container.indexOfId(itemId); + } } diff --git a/server/src/com/vaadin/ui/Grid.java b/server/src/com/vaadin/ui/Grid.java index 31a25d8f8f..396e3c5a77 100644 --- a/server/src/com/vaadin/ui/Grid.java +++ b/server/src/com/vaadin/ui/Grid.java @@ -37,6 +37,9 @@ import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; +import com.google.gwt.thirdparty.guava.common.collect.BiMap; +import com.google.gwt.thirdparty.guava.common.collect.HashBiMap; +import com.google.gwt.thirdparty.guava.common.collect.Maps; import com.google.gwt.thirdparty.guava.common.collect.Sets; import com.google.gwt.thirdparty.guava.common.collect.Sets.SetView; import com.vaadin.data.Container; @@ -77,6 +80,7 @@ import com.vaadin.server.KeyMapper; import com.vaadin.server.VaadinSession; import com.vaadin.shared.MouseEventDetails; import com.vaadin.shared.data.sort.SortDirection; +import com.vaadin.shared.ui.grid.DetailsConnectorChange; import com.vaadin.shared.ui.grid.EditorClientRpc; import com.vaadin.shared.ui.grid.EditorServerRpc; import com.vaadin.shared.ui.grid.GridClientRpc; @@ -228,6 +232,39 @@ public class Grid extends AbstractComponent implements SelectionNotifier, } /** + * A callback interface for generating details for a particular row in Grid. + * + * @since + * @author Vaadin Ltd + */ + public interface DetailsGenerator extends Serializable { + + /** A details generator that provides no details */ + public DetailsGenerator NULL = new DetailsGenerator() { + @Override + public Component getDetails(RowReference rowReference) { + return null; + } + }; + + /** + * This method is called for whenever a new details row needs to be + * generated. + * <p> + * <em>Note:</em> If a component gets generated, it may not be manually + * attached anywhere, nor may it be a reused instance – each + * invocation of this method should produce a unique and isolated + * component instance. + * + * @param rowReference + * the reference for the row for which to generate details + * @return the details for the given row, or <code>null</code> to leave + * the details empty. + */ + Component getDetails(RowReference rowReference); + } + + /** * Custom field group that allows finding property types before an item has * been bound. */ @@ -2957,6 +2994,246 @@ public class Grid extends AbstractComponent implements SelectionNotifier, } /** + * A class that makes detail component related internal communication + * possible between {@link RpcDataProviderExtension} and grid. + * + * @since + * @author Vaadin Ltd + */ + public final class DetailComponentManager implements Serializable { + /** + * This map represents all the components that have been requested for + * each item id. + * <p> + * Normally this map is consistent with what is displayed in the + * component hierarchy (and thus the DOM). The only time this map is out + * of sync with the DOM is between the any calls to + * {@link #createDetails(Object, int)} or + * {@link #destroyDetails(Object)}, and + * {@link GridClientRpc#setDetailsConnectorChanges(Set)}. + * <p> + * This is easily checked: if {@link #unattachedComponents} is + * {@link Collection#isEmpty() empty}, then this field is consistent + * with the connector hierarchy. + */ + private final Map<Object, Component> visibleDetailsComponents = Maps + .newHashMap(); + + /** A lookup map for which row contains which details component. */ + private BiMap<Integer, Component> rowIndexToDetails = HashBiMap + .create(); + + /** + * A copy of {@link #rowIndexToDetails} from its last stable state. Used + * for creating a diff against {@link #rowIndexToDetails}. + * + * @see #getAndResetConnectorChanges() + */ + private BiMap<Integer, Component> prevRowIndexToDetails = HashBiMap + .create(); + + /** + * A set keeping track on components that have been created, but not + * attached. They should be attached at some later point in time. + * <p> + * This isn't strictly requried, but it's a handy explicit log. You + * could find out the same thing by taking out all the other components + * and checking whether Grid is their parent or not. + */ + private final Set<Component> unattachedComponents = Sets.newHashSet(); + + /** + * Keeps tabs on all the details that did not get a component during + * {@link #createDetails(Object, int)}. + */ + private final Map<Object, Integer> emptyDetails = Maps.newHashMap(); + + /** + * Creates a details component by the request of the client side, with + * the help of the user-defined {@link DetailsGenerator}. + * <p> + * Also keeps internal bookkeeping up to date. + * + * @param itemId + * the item id for which to create the details component. + * Assumed not <code>null</code> and that a component is not + * currently present for this item previously + * @param rowIndex + * the row index for {@code itemId} + * @throws IllegalStateException + * if the current details generator provides a component + * that was manually attached, or if the same instance has + * already been provided + */ + public void createDetails(Object itemId, int rowIndex) + throws IllegalStateException { + assert itemId != null : "itemId was null"; + Integer newRowIndex = Integer.valueOf(rowIndex); + + assert !visibleDetailsComponents.containsKey(itemId) : "itemId " + + "already has a component. Should be destroyed first."; + + RowReference rowReference = new RowReference(Grid.this); + rowReference.set(itemId); + + Component details = getDetailsGenerator().getDetails(rowReference); + if (details != null) { + if (details.getParent() != null) { + String generatorName = getDetailsGenerator().getClass() + .getName(); + throw new IllegalStateException(generatorName + + " generated a details component that already " + + "was attached. (itemId: " + itemId + ", row: " + + rowIndex + ", component: " + details); + } + + if (rowIndexToDetails.containsValue(details)) { + String generatorName = getDetailsGenerator().getClass() + .getName(); + throw new IllegalStateException(generatorName + + " provided a details component that already " + + "exists in Grid. (itemId: " + itemId + ", row: " + + rowIndex + ", component: " + details); + } + + visibleDetailsComponents.put(itemId, details); + rowIndexToDetails.put(newRowIndex, details); + unattachedComponents.add(details); + + assert !emptyDetails.containsKey(itemId) : "Bookeeping thinks " + + "itemId is empty even though we just created a " + + "component for it (" + itemId + ")"; + } else { + assert !emptyDetails.containsKey(itemId) : "Bookkeeping has " + + "already itemId marked as empty (itemId: " + itemId + + ", old index: " + emptyDetails.get(itemId) + + ", new index: " + newRowIndex + ")"; + assert !emptyDetails.containsValue(newRowIndex) : "Bookkeeping" + + " already had another itemId for this empty index " + + "(index: " + newRowIndex + ", new itemId: " + itemId + + ")"; + emptyDetails.put(itemId, newRowIndex); + } + + /* + * Don't attach the components here. It's done by + * GridServerRpc.sendDetailsComponents in a separate roundtrip. + */ + } + + /** + * Destroys correctly a details component, by the request of the client + * side. + * <p> + * Also keeps internal bookkeeping up to date. + * + * @param itemId + * the item id for which to destroy the details component + */ + public void destroyDetails(Object itemId) { + emptyDetails.remove(itemId); + + Component removedComponent = visibleDetailsComponents + .remove(itemId); + if (removedComponent == null) { + return; + } + + rowIndexToDetails.inverse().remove(removedComponent); + + removedComponent.setParent(null); + markAsDirty(); + } + + /** + * Gets all details components that are currently attached to the grid. + * <p> + * Used internally by the Grid object. + * + * @return all details components that are currently attached to the + * grid + */ + Collection<Component> getComponents() { + Set<Component> components = new HashSet<Component>( + visibleDetailsComponents.values()); + components.removeAll(unattachedComponents); + return components; + } + + /** + * Gets information on how the connectors have changed. + * <p> + * This method only returns the changes that have been made between two + * calls of this method. I.e. Calling this method once will reset the + * state for the next state. + * <p> + * Used internally by the Grid object. + * + * @return information on how the connectors have changed + */ + Set<DetailsConnectorChange> getAndResetConnectorChanges() { + Set<DetailsConnectorChange> changes = new HashSet<DetailsConnectorChange>(); + + // populate diff with added/changed + for (Entry<Integer, Component> entry : rowIndexToDetails.entrySet()) { + Component component = entry.getValue(); + assert component != null : "rowIndexToDetails contains a null component"; + + Integer newIndex = entry.getKey(); + Integer oldIndex = prevRowIndexToDetails.inverse().get( + component); + + /* + * only attach components. Detaching already happened in + * destroyDetails. + */ + if (newIndex != null && oldIndex == null) { + assert unattachedComponents.contains(component) : "unattachedComponents does not contain component for index " + + newIndex + " (" + component + ")"; + component.setParent(Grid.this); + unattachedComponents.remove(component); + } + + if (!SharedUtil.equals(oldIndex, newIndex)) { + changes.add(new DetailsConnectorChange(component, oldIndex, + newIndex)); + } + } + + // populate diff with removed + for (Entry<Integer, Component> entry : prevRowIndexToDetails + .entrySet()) { + Integer oldIndex = entry.getKey(); + Component component = entry.getValue(); + Integer newIndex = rowIndexToDetails.inverse().get(component); + if (newIndex == null) { + changes.add(new DetailsConnectorChange(null, oldIndex, null)); + } + } + + // reset diff map + prevRowIndexToDetails = HashBiMap.create(rowIndexToDetails); + + return changes; + } + + public void refresh(Object itemId) { + Component component = visibleDetailsComponents.get(itemId); + Integer rowIndex = null; + if (component != null) { + rowIndex = rowIndexToDetails.inverse().get(component); + destroyDetails(itemId); + } else { + rowIndex = emptyDetails.remove(itemId); + } + + assert rowIndex != null : "Given itemId does not map to an existing detail row (" + + itemId + ")"; + createDetails(itemId, rowIndex.intValue()); + } + } + + /** * The data source attached to the grid */ private Container.Indexed datasource; @@ -3063,6 +3340,15 @@ public class Grid extends AbstractComponent implements SelectionNotifier, private EditorErrorHandler editorErrorHandler = new DefaultEditorErrorHandler(); + /** + * The user-defined details generator. + * + * @see #setDetailsGenerator(DetailsGenerator) + */ + private DetailsGenerator detailsGenerator = DetailsGenerator.NULL; + + private final DetailComponentManager detailComponentManager = new DetailComponentManager(); + private static final Method SELECTION_CHANGE_METHOD = ReflectTools .findMethod(SelectionListener.class, "select", SelectionEvent.class); @@ -3310,6 +3596,13 @@ public class Grid extends AbstractComponent implements SelectionNotifier, markAsDirty(); } } + + @Override + public void sendDetailsComponents(int fetchId) { + getRpcProxy(GridClientRpc.class).setDetailsConnectorChanges( + detailComponentManager.getAndResetConnectorChanges(), + fetchId); + } }); registerRpc(new EditorServerRpc() { @@ -3470,7 +3763,8 @@ public class Grid extends AbstractComponent implements SelectionNotifier, sortOrder.clear(); } - datasourceExtension = new RpcDataProviderExtension(container); + datasourceExtension = new RpcDataProviderExtension(container, + detailComponentManager); datasourceExtension.extend(this, columnKeys); /* @@ -4852,6 +5146,9 @@ public class Grid extends AbstractComponent implements SelectionNotifier, } componentList.addAll(getEditorFields()); + + componentList.addAll(detailComponentManager.getComponents()); + return componentList.iterator(); } @@ -5401,4 +5698,66 @@ public class Grid extends AbstractComponent implements SelectionNotifier, isUserOriginated)); } + /** + * Sets a new details generator for row details. + * <p> + * The currently opened row details will be re-rendered. + * + * @since + * @param detailsGenerator + * the details generator to set + * @throws IllegalArgumentException + * if detailsGenerator is <code>null</code>; + */ + public void setDetailsGenerator(DetailsGenerator detailsGenerator) + throws IllegalArgumentException { + if (detailsGenerator == null) { + throw new IllegalArgumentException( + "Details generator may not be null"); + } else if (detailsGenerator == this.detailsGenerator) { + return; + } + + this.detailsGenerator = detailsGenerator; + + datasourceExtension.refreshDetails(); + getRpcProxy(GridClientRpc.class).setDetailsConnectorChanges( + detailComponentManager.getAndResetConnectorChanges(), -1); + } + + /** + * Gets the current details generator for row details. + * + * @since + * @return the detailsGenerator the current details generator + */ + public DetailsGenerator getDetailsGenerator() { + return detailsGenerator; + } + + /** + * Shows or hides the details for a specific item. + * + * @since + * @param itemId + * the id of the item for which to set details visibility + * @param visible + * <code>true</code> to show the details, or <code>false</code> + * to hide them + */ + public void setDetailsVisible(Object itemId, boolean visible) { + datasourceExtension.setDetailsVisible(itemId, visible); + } + + /** + * Checks whether details are visible for the given item. + * + * @since + * @param itemId + * the id of the item for which to check details visibility + * @return <code>true</code> iff the details are visible + */ + public boolean isDetailsVisible(Object itemId) { + return datasourceExtension.isDetailsVisible(itemId); + } } diff --git a/server/tests/src/com/vaadin/tests/server/component/grid/DataProviderExtension.java b/server/tests/src/com/vaadin/tests/server/component/grid/DataProviderExtension.java index 9ecf131c5b..54f5dcdbc7 100644 --- a/server/tests/src/com/vaadin/tests/server/component/grid/DataProviderExtension.java +++ b/server/tests/src/com/vaadin/tests/server/component/grid/DataProviderExtension.java @@ -47,7 +47,7 @@ public class DataProviderExtension { container = new IndexedContainer(); populate(container); - dataProvider = new RpcDataProviderExtension(container); + dataProvider = new RpcDataProviderExtension(container, null); keyMapper = dataProvider.getKeyMapper(); } diff --git a/shared/src/com/vaadin/shared/ui/grid/DetailsConnectorChange.java b/shared/src/com/vaadin/shared/ui/grid/DetailsConnectorChange.java new file mode 100644 index 0000000000..40f4541fb1 --- /dev/null +++ b/shared/src/com/vaadin/shared/ui/grid/DetailsConnectorChange.java @@ -0,0 +1,147 @@ +/* + * 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.shared.ui.grid; + +import java.io.Serializable; + +import com.vaadin.shared.Connector; + +/** + * A description of an indexing modification for a connector. This is used by + * Grid by internal bookkeeping updates. + * + * @since + * @author Vaadin Ltd + */ +public class DetailsConnectorChange implements Serializable { + + private Connector connector; + private Integer oldIndex; + private Integer newIndex; + + /** Create a new connector index change */ + public DetailsConnectorChange() { + } + + /** + * Convenience constructor for setting all the fields in one line. + * <p> + * Calling this constructor will also assert that the state of the pojo is + * consistent by internal assumptions. + * + * @param connector + * the changed connector + * @param oldIndex + * the old index + * @param newIndex + * the new index + */ + public DetailsConnectorChange(Connector connector, Integer oldIndex, + Integer newIndex) { + this.connector = connector; + this.oldIndex = oldIndex; + this.newIndex = newIndex; + + assert assertStateIsOk(); + } + + private boolean assertStateIsOk() { + boolean connectorAndNewIndexIsNotNull = connector != null + && newIndex != null; + boolean connectorAndNewIndexIsNullThenOldIndexIsSet = connector == null + && newIndex == null && oldIndex != null; + + assert (connectorAndNewIndexIsNotNull || connectorAndNewIndexIsNullThenOldIndexIsSet) : "connector: " + + nullityString(connector) + + ", oldIndex: " + + nullityString(oldIndex) + + ", newIndex: " + + nullityString(newIndex); + return true; + } + + private static String nullityString(Object object) { + return object == null ? "null" : "non-null"; + } + + /** + * Gets the old index for the connector. + * <p> + * If <code>null</code>, the connector is recently added. This means that + * {@link #getConnector()} is expected not to return <code>null</code>. + * + * @return the old index for the connector + */ + public Integer getOldIndex() { + assert assertStateIsOk(); + return oldIndex; + } + + /** + * Gets the new index for the connector. + * <p> + * If <code>null</code>, the connector should be removed. This means that + * {@link #getConnector()} is expected to return <code>null</code> as well. + * + * @return the new index for the connector + */ + public Integer getNewIndex() { + assert assertStateIsOk(); + return newIndex; + } + + /** + * Gets the changed connector. + * + * @return the changed connector. Might be <code>null</code> + */ + public Connector getConnector() { + assert assertStateIsOk(); + return connector; + } + + /** + * Sets the changed connector. + * + * @param connector + * the changed connector. May be <code>null</code> + */ + public void setConnector(Connector connector) { + this.connector = connector; + } + + /** + * Sets the old index + * + * @param oldIndex + * the old index. May be <code>null</code> if a new connector is + * being inserted + */ + public void setOldIndex(Integer oldIndex) { + this.oldIndex = oldIndex; + } + + /** + * Sets the new index + * + * @param newIndex + * the new index. May be <code>null</code> if a connector is + * being removed + */ + public void setNewIndex(Integer newIndex) { + this.newIndex = newIndex; + } +} diff --git a/shared/src/com/vaadin/shared/ui/grid/GridClientRpc.java b/shared/src/com/vaadin/shared/ui/grid/GridClientRpc.java index 4ba081b5df..98e7fac567 100644 --- a/shared/src/com/vaadin/shared/ui/grid/GridClientRpc.java +++ b/shared/src/com/vaadin/shared/ui/grid/GridClientRpc.java @@ -15,6 +15,8 @@ */ package com.vaadin.shared.ui.grid; +import java.util.Set; + import com.vaadin.shared.communication.ClientRpc; /** @@ -55,4 +57,18 @@ public interface GridClientRpc extends ClientRpc { */ public void recalculateColumnWidths(); + /** + * Informs the GridConnector on how the indexing of details connectors has + * changed. + * + * @since + * @param connectorChanges + * the indexing changes of details connectors + * @param fetchId + * the id of the request for fetching the changes. A negative + * number indicates a push (not requested by the client side) + */ + public void setDetailsConnectorChanges( + Set<DetailsConnectorChange> connectorChanges, int fetchId); + } diff --git a/shared/src/com/vaadin/shared/ui/grid/GridServerRpc.java b/shared/src/com/vaadin/shared/ui/grid/GridServerRpc.java index 4dec5530aa..28f59ea93a 100644 --- a/shared/src/com/vaadin/shared/ui/grid/GridServerRpc.java +++ b/shared/src/com/vaadin/shared/ui/grid/GridServerRpc.java @@ -59,4 +59,21 @@ public interface GridServerRpc extends ServerRpc { */ void columnsReordered(List<String> newColumnOrder, List<String> oldColumnOrder); + + /** + * This is a trigger for Grid to send whatever has changed regarding the + * details components. + * <p> + * The components can't be sent eagerly, since they are generated as a side + * effect in + * {@link com.vaadin.data.RpcDataProviderExtension#beforeClientResponse(boolean)} + * , and that is too late to change the hierarchy. So we need this + * round-trip to work around that limitation. + * + * @since + * @param fetchId + * an unique identifier for the request + * @see com.vaadin.ui.Grid#setDetailsVisible(Object, boolean) + */ + void sendDetailsComponents(int fetchId); } diff --git a/shared/src/com/vaadin/shared/ui/grid/GridState.java b/shared/src/com/vaadin/shared/ui/grid/GridState.java index ab42a52424..e039f70988 100644 --- a/shared/src/com/vaadin/shared/ui/grid/GridState.java +++ b/shared/src/com/vaadin/shared/ui/grid/GridState.java @@ -103,6 +103,16 @@ public class GridState extends AbstractComponentState { public static final String JSONKEY_CELLSTYLES = "cs"; /** + * The key that tells whether details are visible for the row + * + * @see com.vaadin.ui.Grid#setDetailsGenerator(com.vaadin.ui.Grid.DetailsGenerator) + * @see com.vaadin.ui.Grid#setDetailsVisible(Object, boolean) + * @see com.vaadin.shared.data.DataProviderRpc#setRowData(int, + * elemental.json.JsonArray) + * */ + public static final String JSONKEY_DETAILS_VISIBLE = "dv"; + + /** * Columns in grid. */ public List<GridColumnState> columns = new ArrayList<GridColumnState>(); diff --git a/uitest/src/com/vaadin/tests/components/grid/basicfeatures/GridBasicFeatures.java b/uitest/src/com/vaadin/tests/components/grid/basicfeatures/GridBasicFeatures.java index aeeaa25ac3..c8c0e54123 100644 --- a/uitest/src/com/vaadin/tests/components/grid/basicfeatures/GridBasicFeatures.java +++ b/uitest/src/com/vaadin/tests/components/grid/basicfeatures/GridBasicFeatures.java @@ -46,6 +46,8 @@ import com.vaadin.tests.components.AbstractComponentTest; import com.vaadin.ui.Button; import com.vaadin.ui.Button.ClickEvent; import com.vaadin.ui.Button.ClickListener; +import com.vaadin.ui.Component; +import com.vaadin.ui.CssLayout; import com.vaadin.ui.Grid; import com.vaadin.ui.Grid.CellReference; import com.vaadin.ui.Grid.CellStyleGenerator; @@ -54,6 +56,7 @@ import com.vaadin.ui.Grid.ColumnReorderEvent; import com.vaadin.ui.Grid.ColumnReorderListener; import com.vaadin.ui.Grid.ColumnVisibilityChangeEvent; import com.vaadin.ui.Grid.ColumnVisibilityChangeListener; +import com.vaadin.ui.Grid.DetailsGenerator; import com.vaadin.ui.Grid.FooterCell; import com.vaadin.ui.Grid.HeaderCell; import com.vaadin.ui.Grid.HeaderRow; @@ -62,6 +65,9 @@ import com.vaadin.ui.Grid.RowReference; import com.vaadin.ui.Grid.RowStyleGenerator; import com.vaadin.ui.Grid.SelectionMode; import com.vaadin.ui.Grid.SelectionModel; +import com.vaadin.ui.Label; +import com.vaadin.ui.Notification; +import com.vaadin.ui.Panel; import com.vaadin.ui.renderers.DateRenderer; import com.vaadin.ui.renderers.HtmlRenderer; import com.vaadin.ui.renderers.NumberRenderer; @@ -132,6 +138,55 @@ public class GridBasicFeatures extends AbstractComponentTest<Grid> { } }; + private Panel detailsPanel; + + private final DetailsGenerator detailedDetailsGenerator = new DetailsGenerator() { + @Override + public Component getDetails(final RowReference rowReference) { + CssLayout cssLayout = new CssLayout(); + cssLayout.setHeight("200px"); + cssLayout.setWidth("100%"); + + Item item = rowReference.getItem(); + for (Object propertyId : item.getItemPropertyIds()) { + Property<?> prop = item.getItemProperty(propertyId); + String string = prop.getValue().toString(); + cssLayout.addComponent(new Label(string)); + } + + final int rowIndex = grid.getContainerDataSource().indexOfId( + rowReference.getItemId()); + ClickListener clickListener = new ClickListener() { + @Override + public void buttonClick(ClickEvent event) { + Notification.show("You clicked on the " + + "button in the details for " + "row " + rowIndex); + } + }; + cssLayout.addComponent(new Button("Press me", clickListener)); + return cssLayout; + } + }; + + private final DetailsGenerator watchingDetailsGenerator = new DetailsGenerator() { + private int id = 0; + + @Override + public Component getDetails(RowReference rowReference) { + return new Label("You are watching item id " + + rowReference.getItemId() + " (" + (id++) + ")"); + } + }; + + private final DetailsGenerator hierarchicalDetailsGenerator = new DetailsGenerator() { + @Override + public Component getDetails(RowReference rowReference) { + detailsPanel = new Panel(); + detailsPanel.setContent(new Label("One")); + return detailsPanel; + } + }; + @Override @SuppressWarnings("unchecked") protected Grid constructComponent() { @@ -273,6 +328,8 @@ public class GridBasicFeatures extends AbstractComponentTest<Grid> { addInternalActions(); + createDetailsActions(); + this.grid = grid; return grid; } @@ -1165,6 +1222,64 @@ public class GridBasicFeatures extends AbstractComponentTest<Grid> { }, null); } + private void createDetailsActions() { + Command<Grid, DetailsGenerator> swapDetailsGenerator = new Command<Grid, DetailsGenerator>() { + @Override + public void execute(Grid c, DetailsGenerator generator, Object data) { + grid.setDetailsGenerator(generator); + } + }; + + Command<Grid, Boolean> openOrCloseItemId = new Command<Grid, Boolean>() { + @Override + @SuppressWarnings("boxing") + public void execute(Grid g, Boolean visible, Object itemId) { + g.setDetailsVisible(itemId, visible); + } + }; + + createCategory("Generators", "Details"); + createClickAction("NULL", "Generators", swapDetailsGenerator, + DetailsGenerator.NULL); + createClickAction("\"Watching\"", "Generators", swapDetailsGenerator, + watchingDetailsGenerator); + createClickAction("Detailed", "Generators", swapDetailsGenerator, + detailedDetailsGenerator); + createClickAction("Hierarchical", "Generators", swapDetailsGenerator, + hierarchicalDetailsGenerator); + + createClickAction("- Change Component", "Generators", + new Command<Grid, Void>() { + @Override + public void execute(Grid c, Void value, Object data) { + Label label = (Label) detailsPanel.getContent(); + if (label.getValue().equals("One")) { + detailsPanel.setContent(new Label("Two")); + } else { + detailsPanel.setContent(new Label("One")); + } + } + }, null); + + createClickAction("Toggle firstItemId", "Details", + new Command<Grid, Void>() { + @Override + public void execute(Grid g, Void value, Object data) { + Object firstItemId = g.getContainerDataSource() + .firstItemId(); + boolean toggle = g.isDetailsVisible(firstItemId); + g.setDetailsVisible(firstItemId, !toggle); + g.setDetailsVisible(firstItemId, toggle); + } + }, null); + + createBooleanAction("Open firstItemId", "Details", false, + openOrCloseItemId, ds.firstItemId()); + + createBooleanAction("Open 995", "Details", false, openOrCloseItemId, + ds.getIdByIndex(995)); + } + @Override protected Integer getTicketNumber() { return 12829; diff --git a/uitest/src/com/vaadin/tests/components/grid/basicfeatures/server/GridDetailsServerTest.java b/uitest/src/com/vaadin/tests/components/grid/basicfeatures/server/GridDetailsServerTest.java new file mode 100644 index 0000000000..7ddd903161 --- /dev/null +++ b/uitest/src/com/vaadin/tests/components/grid/basicfeatures/server/GridDetailsServerTest.java @@ -0,0 +1,269 @@ +/* + * 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.components.grid.basicfeatures.server; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; + +import com.vaadin.testbench.TestBenchElement; +import com.vaadin.testbench.elements.NotificationElement; +import com.vaadin.tests.components.grid.basicfeatures.GridBasicFeaturesTest; + +public class GridDetailsServerTest extends GridBasicFeaturesTest { + /** + * The reason to why last item details wasn't selected is that since it will + * exist only after the viewport has been scrolled into view, we wouldn't be + * able to scroll that particular details row into view, making tests + * awkward with two scroll commands back to back. + */ + private static final int ALMOST_LAST_INDEX = 995; + private static final String[] OPEN_ALMOST_LAST_ITEM_DETAILS = new String[] { + "Component", "Details", "Open " + ALMOST_LAST_INDEX }; + private static final String[] OPEN_FIRST_ITEM_DETAILS = new String[] { + "Component", "Details", "Open firstItemId" }; + private static final String[] TOGGLE_FIRST_ITEM_DETAILS = new String[] { + "Component", "Details", "Toggle firstItemId" }; + private static final String[] DETAILS_GENERATOR_NULL = new String[] { + "Component", "Details", "Generators", "NULL" }; + private static final String[] DETAILS_GENERATOR_WATCHING = new String[] { + "Component", "Details", "Generators", "\"Watching\"" }; + private static final String[] DETAILS_GENERATOR_HIERARCHICAL = new String[] { + "Component", "Details", "Generators", "Hierarchical" }; + private static final String[] CHANGE_HIERARCHY = new String[] { + "Component", "Details", "Generators", "- Change Component" }; + + @Before + public void setUp() { + openTestURL(); + } + + @Test + public void openVisibleDetails() { + try { + getGridElement().getDetails(0); + fail("Expected NoSuchElementException"); + } catch (NoSuchElementException ignore) { + // expected + } + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + assertNotNull("details should've opened", getGridElement() + .getDetails(0)); + } + + @Test(expected = NoSuchElementException.class) + public void closeVisibleDetails() { + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + + getGridElement().getDetails(0); + } + + @Test + public void openDetailsOutsideOfActiveRange() throws InterruptedException { + getGridElement().scroll(10000); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + getGridElement().scroll(0); + Thread.sleep(50); + assertNotNull("details should've been opened", getGridElement() + .getDetails(0)); + } + + @Test(expected = NoSuchElementException.class) + public void closeDetailsOutsideOfActiveRange() { + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + getGridElement().scroll(10000); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + getGridElement().scroll(0); + getGridElement().getDetails(0); + } + + @Test + public void componentIsVisibleClientSide() { + selectMenuPath(DETAILS_GENERATOR_WATCHING); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + + TestBenchElement details = getGridElement().getDetails(0); + assertNotNull("No widget detected inside details", + details.findElement(By.className("v-widget"))); + } + + @Test + public void openingDetailsTwice() { + selectMenuPath(DETAILS_GENERATOR_WATCHING); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); // open + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); // close + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); // open + + TestBenchElement details = getGridElement().getDetails(0); + assertNotNull("No widget detected inside details", + details.findElement(By.className("v-widget"))); + } + + @Test(expected = NoSuchElementException.class) + public void scrollingDoesNotCreateAFloodOfDetailsRows() { + selectMenuPath(DETAILS_GENERATOR_WATCHING); + + // scroll somewhere to hit uncached rows + getGridElement().scrollToRow(101); + + // this should throw + getGridElement().getDetails(100); + } + + @Test + public void openingDetailsOutOfView() { + getGridElement().scrollToRow(500); + + selectMenuPath(DETAILS_GENERATOR_WATCHING); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + + getGridElement().scrollToRow(0); + + // if this fails, it'll fail before the assertNotNull + assertNotNull("unexpected null details row", getGridElement() + .getDetails(0)); + } + + @Test + public void togglingAVisibleDetailsRowWithOneRoundtrip() { + selectMenuPath(DETAILS_GENERATOR_WATCHING); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); // open + + assertTrue("Unexpected generator content", + getGridElement().getDetails(0).getText().endsWith("(0)")); + selectMenuPath(TOGGLE_FIRST_ITEM_DETAILS); + assertTrue("New component was not displayed in the client", + getGridElement().getDetails(0).getText().endsWith("(1)")); + } + + @Test + public void almostLastItemIdIsRendered() { + selectMenuPath(DETAILS_GENERATOR_WATCHING); + selectMenuPath(OPEN_ALMOST_LAST_ITEM_DETAILS); + scrollGridVerticallyTo(100000); + + TestBenchElement details = getGridElement().getDetails( + ALMOST_LAST_INDEX); + assertNotNull(details); + assertTrue("Unexpected details content", + details.getText().endsWith(ALMOST_LAST_INDEX + " (0)")); + } + + @Test + public void hierarchyChangesWorkInDetails() { + selectMenuPath(DETAILS_GENERATOR_HIERARCHICAL); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + assertEquals("One", getGridElement().getDetails(0).getText()); + selectMenuPath(CHANGE_HIERARCHY); + assertEquals("Two", getGridElement().getDetails(0).getText()); + } + + @Ignore("This use case is not currently supported by Grid. If the detail " + + "is out of view, the component is detached from the UI and a " + + "new instance is generated when scrolled back. Support will " + + "maybe be incorporated at a later time") + @Test + public void hierarchyChangesWorkInDetailsWhileOutOfView() { + selectMenuPath(DETAILS_GENERATOR_HIERARCHICAL); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + scrollGridVerticallyTo(10000); + selectMenuPath(CHANGE_HIERARCHY); + scrollGridVerticallyTo(0); + assertEquals("Two", getGridElement().getDetails(0).getText()); + } + + @Test + public void swappingDetailsGenerators_noDetailsShown() { + selectMenuPath(DETAILS_GENERATOR_WATCHING); + selectMenuPath(DETAILS_GENERATOR_NULL); + assertFalse("Got some errors", $(NotificationElement.class).exists()); + } + + @Test + public void swappingDetailsGenerators_shownDetails() { + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + assertTrue("Details should be empty at the start", getGridElement() + .getDetails(0).getText().isEmpty()); + + selectMenuPath(DETAILS_GENERATOR_WATCHING); + assertFalse("Details should not be empty after swapping generator", + getGridElement().getDetails(0).getText().isEmpty()); + } + + @Test + public void swappingDetailsGenerators_whileDetailsScrolledOut_showNever() { + scrollGridVerticallyTo(1000); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + selectMenuPath(DETAILS_GENERATOR_WATCHING); + assertFalse("Got some errors", $(NotificationElement.class).exists()); + } + + @Test + public void swappingDetailsGenerators_whileDetailsScrolledOut_showAfter() { + scrollGridVerticallyTo(1000); + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + selectMenuPath(DETAILS_GENERATOR_WATCHING); + scrollGridVerticallyTo(0); + + assertFalse("Got some errors", $(NotificationElement.class).exists()); + assertNotNull("Could not find a details", getGridElement() + .getDetails(0)); + } + + @Test + public void swappingDetailsGenerators_whileDetailsScrolledOut_showBefore() { + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + selectMenuPath(DETAILS_GENERATOR_WATCHING); + scrollGridVerticallyTo(1000); + + assertFalse("Got some errors", $(NotificationElement.class).exists()); + assertNotNull("Could not find a details", getGridElement() + .getDetails(0)); + } + + @Test + public void swappingDetailsGenerators_whileDetailsScrolledOut_showBeforeAndAfter() { + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + selectMenuPath(DETAILS_GENERATOR_WATCHING); + scrollGridVerticallyTo(1000); + scrollGridVerticallyTo(0); + + assertFalse("Got some errors", $(NotificationElement.class).exists()); + assertNotNull("Could not find a details", getGridElement() + .getDetails(0)); + } + + @Test + public void nullDetailComponentToggling() { + selectMenuPath(OPEN_FIRST_ITEM_DETAILS); + selectMenuPath(DETAILS_GENERATOR_WATCHING); + selectMenuPath(DETAILS_GENERATOR_NULL); + assertTrue("Details should be empty with null component", + getGridElement().getDetails(0).getText().isEmpty()); + selectMenuPath(DETAILS_GENERATOR_WATCHING); + assertFalse("Details should be not empty with details component", + getGridElement().getDetails(0).getText().isEmpty()); + } +} |