/*
* 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.data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
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.google.gwt.thirdparty.guava.common.collect.Maps;
import com.google.gwt.thirdparty.guava.common.collect.Sets;
import com.vaadin.data.Container.Indexed;
import com.vaadin.data.Container.Indexed.ItemAddEvent;
import com.vaadin.data.Container.Indexed.ItemRemoveEvent;
import com.vaadin.data.Container.ItemSetChangeEvent;
import com.vaadin.data.Container.ItemSetChangeListener;
import com.vaadin.data.Container.ItemSetChangeNotifier;
import com.vaadin.data.Property.ValueChangeEvent;
import com.vaadin.data.Property.ValueChangeListener;
import com.vaadin.data.Property.ValueChangeNotifier;
import com.vaadin.data.util.converter.Converter;
import com.vaadin.server.AbstractExtension;
import com.vaadin.server.ClientConnector;
import com.vaadin.server.KeyMapper;
import com.vaadin.shared.data.DataProviderRpc;
import com.vaadin.shared.data.DataRequestRpc;
import com.vaadin.shared.ui.grid.DetailsConnectorChange;
import com.vaadin.shared.ui.grid.GridClientRpc;
import com.vaadin.shared.ui.grid.GridState;
import com.vaadin.shared.ui.grid.Range;
import com.vaadin.shared.util.SharedUtil;
import com.vaadin.ui.Component;
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.DetailsGenerator;
import com.vaadin.ui.Grid.RowReference;
import com.vaadin.ui.Grid.RowStyleGenerator;
import com.vaadin.ui.renderers.Renderer;
import elemental.json.Json;
import elemental.json.JsonArray;
import elemental.json.JsonObject;
import elemental.json.JsonValue;
/**
* Provides Vaadin server-side container data source to a
* {@link com.vaadin.client.ui.grid.GridConnector}. This is currently
* implemented as an Extension hardcoded to support a specific connector type.
* This will be changed once framework support for something more flexible has
* been implemented.
*
* @since 7.4
* @author Vaadin Ltd
*/
public class RpcDataProviderExtension extends AbstractExtension {
/**
* ItemId to Key to ItemId mapper.
*
* This class is used when transmitting information about items in container
* related to Grid. It introduces a consistent way of mapping ItemIds and
* its container to a String that can be mapped back to ItemId.
*
* Technical note: This class also keeps tabs on which indices are
* being shown/selected, and is able to clean up after itself once the
* itemId ⇆ key mapping is not needed anymore. In other words, this
* doesn't leak memory.
*/
public class DataProviderKeyMapper implements Serializable {
private final BiMap itemIdToKey = HashBiMap.create();
private Set pinnedItemIds = new HashSet();
private long rollingIndex = 0;
private DataProviderKeyMapper() {
// private implementation
}
/**
* Sets the currently active rows. This will purge any unpinned rows
* from cache.
*
* @param itemIds
* collection of itemIds to map to row keys
*/
void setActiveRows(Collection> itemIds) {
Set itemSet = new HashSet(itemIds);
Set itemsRemoved = new HashSet();
for (Object itemId : itemIdToKey.keySet()) {
if (!itemSet.contains(itemId) && !isPinned(itemId)) {
itemsRemoved.add(itemId);
}
}
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));
}
}
}
private String nextKey() {
return String.valueOf(rollingIndex++);
}
/**
* Gets the key for a given item id. Creates a new key mapping if no
* existing mapping was found for the given item id.
*
* @since 7.5.0
* @param itemId
* the item id to get the key for
* @return the key for the given item id
*/
public String getKey(Object itemId) {
String key = itemIdToKey.get(itemId);
if (key == null) {
key = nextKey();
itemIdToKey.put(itemId, key);
}
return key;
}
/**
* Gets keys for a collection of item ids.
*
* If the itemIds are currently cached, the existing keys will be used.
* Otherwise new ones will be created.
*
* @param itemIds
* the item ids for which to get keys
* @return keys for the {@code itemIds}
*/
public List getKeys(Collection itemIds) {
if (itemIds == null) {
throw new IllegalArgumentException("itemIds can't be null");
}
ArrayList keys = new ArrayList(itemIds.size());
for (Object itemId : itemIds) {
keys.add(getKey(itemId));
}
return keys;
}
/**
* Gets the registered item id based on its key.
*
* A key is used to identify a particular row on both a server and a
* client. This method can be used to get the item id for the row key
* that the client has sent.
*
* @param key
* the row key for which to retrieve an item id
* @return the item id corresponding to {@code key}
* @throws IllegalStateException
* if the key mapper does not have a record of {@code key} .
*/
public Object getItemId(String key) throws IllegalStateException {
Object itemId = itemIdToKey.inverse().get(key);
if (itemId != null) {
return itemId;
} else {
throw new IllegalStateException("No item id for key " + key
+ " found.");
}
}
/**
* Gets corresponding item ids for each of the keys in a collection.
*
* @param keys
* the keys for which to retrieve item ids
* @return a collection of item ids for the {@code keys}
* @throws IllegalStateException
* if one or more of keys don't have a corresponding item id
* in the cache
*/
public Collection getItemIds(Collection keys)
throws IllegalStateException {
if (keys == null) {
throw new IllegalArgumentException("keys may not be null");
}
ArrayList itemIds = new ArrayList(keys.size());
for (String key : keys) {
itemIds.add(getItemId(key));
}
return itemIds;
}
/**
* Pin an item id to be cached indefinitely.
*
* Normally when an itemId is not an active row, it is discarded from
* the cache. Pinning an item id will make sure that it is kept in the
* cache.
*
* In effect, while an item id is pinned, it always has the same key.
*
* @param itemId
* the item id to pin
* @throws IllegalStateException
* if {@code itemId} was already pinned
* @see #unpin(Object)
* @see #isPinned(Object)
* @see #getItemIds(Collection)
*/
public void pin(Object itemId) throws IllegalStateException {
if (isPinned(itemId)) {
throw new IllegalStateException("Item id " + itemId
+ " was pinned already");
}
pinnedItemIds.add(itemId);
}
/**
* Unpin an item id.
*
* This cancels the effect of pinning an item id. If the item id is
* currently inactive, it will be immediately removed from the cache.
*
* @param itemId
* the item id to unpin
* @throws IllegalStateException
* if {@code itemId} was not pinned
* @see #pin(Object)
* @see #isPinned(Object)
* @see #getItemIds(Collection)
*/
public void unpin(Object itemId) throws IllegalStateException {
if (!isPinned(itemId)) {
throw new IllegalStateException("Item id " + itemId
+ " was not pinned");
}
pinnedItemIds.remove(itemId);
}
/**
* Checks whether an item id is pinned or not.
*
* @param itemId
* the item id to check for pin status
* @return {@code true} iff the item id is currently pinned
*/
public boolean isPinned(Object itemId) {
return pinnedItemIds.contains(itemId);
}
}
/**
* A helper class that handles the client-side Escalator logic relating to
* making sure that whatever is currently visible to the user, is properly
* initialized and otherwise handled on the server side (as far as
* required).
*
* This bookeeping includes, but is not limited to:
*
* listening to the currently visible {@link com.vaadin.data.Property
* Properties'} value changes on the server side and sending those back to
* the client; and
* attaching and detaching {@link com.vaadin.ui.Component Components}
* from the Vaadin Component hierarchy.
*
*/
private class ActiveRowHandler implements Serializable {
/**
* A map from index to the value change listener used for all of column
* properties
*/
private final Map valueChangeListeners = new HashMap();
/**
* The currently active range. Practically, it's the range of row
* indices being cached currently.
*/
private Range activeRange = Range.withLength(0, 0);
/**
* A hook for making sure that appropriate data is "active". All other
* rows should be "inactive".
*
* "Active" can mean different things in different contexts. For
* example, only the Properties in the active range need
* ValueChangeListeners. Also, whenever a row with a Component becomes
* active, it needs to be attached (and conversely, when inactive, it
* needs to be detached).
*
* @param firstActiveRow
* the first active row
* @param activeRowCount
* the number of active rows
*/
public void setActiveRows(Range newActiveRange) {
// TODO [[Components]] attach and detach components
/*-
* Example
*
* New Range: [3, 4, 5, 6, 7]
* Old Range: [1, 2, 3, 4, 5]
* Result: [1, 2][3, 4, 5] []
*/
final Range[] depractionPartition = activeRange
.partitionWith(newActiveRange);
removeValueChangeListeners(depractionPartition[0]);
removeValueChangeListeners(depractionPartition[2]);
/*-
* Example
*
* Old Range: [1, 2, 3, 4, 5]
* New Range: [3, 4, 5, 6, 7]
* Result: [] [3, 4, 5][6, 7]
*/
final Range[] activationPartition = newActiveRange
.partitionWith(activeRange);
addValueChangeListeners(activationPartition[0]);
addValueChangeListeners(activationPartition[2]);
activeRange = newActiveRange;
assert valueChangeListeners.size() == newActiveRange.length() : "Value change listeners not set up correctly!";
}
private void addValueChangeListeners(Range range) {
for (Integer i = range.getStart(); i < range.getEnd(); i++) {
final Object itemId = container.getIdByIndex(i);
final Item item = container.getItem(itemId);
assert valueChangeListeners.get(i) == null : "Overwriting existing listener";
GridValueChangeListener listener = new GridValueChangeListener(
itemId, item);
valueChangeListeners.put(i, listener);
}
}
private void removeValueChangeListeners(Range range) {
for (Integer i = range.getStart(); i < range.getEnd(); i++) {
final GridValueChangeListener listener = valueChangeListeners
.remove(i);
assert listener != null : "Trying to remove nonexisting listener";
listener.removeListener();
}
}
/**
* Manages removed columns in active rows.
*
* This method does not send data again to the client.
*
* @param removedColumns
* the columns that have been removed from the grid
*/
public void columnsRemoved(Collection removedColumns) {
if (removedColumns.isEmpty()) {
return;
}
for (GridValueChangeListener listener : valueChangeListeners
.values()) {
listener.removeColumns(removedColumns);
}
}
/**
* Manages added columns in active rows.
*
* This method sends the data for the changed rows to client side.
*
* @param addedColumns
* the columns that have been added to the grid
*/
public void columnsAdded(Collection addedColumns) {
if (addedColumns.isEmpty()) {
return;
}
for (GridValueChangeListener listener : valueChangeListeners
.values()) {
listener.addColumns(addedColumns);
}
}
/**
* Handles the insertion of rows.
*
* This method's responsibilities are to:
*
* shift the internal bookkeeping by count
if the
* insertion happens above currently active range
* ignore rows inserted below the currently active range
* shift (and deactivate) rows pushed out of view
* activate rows that are inserted in the current viewport
*
*
* @param firstIndex
* the index of the first inserted rows
* @param count
* the number of rows inserted at firstIndex
*/
public void insertRows(int firstIndex, int count) {
if (firstIndex < activeRange.getStart()) {
moveListeners(activeRange, count);
activeRange = activeRange.offsetBy(count);
} else if (firstIndex < activeRange.getEnd()) {
int end = activeRange.getEnd();
// Move rows from first added index by count
Range movedRange = Range.between(firstIndex, end);
moveListeners(movedRange, count);
// Remove excess listeners from extra rows
removeValueChangeListeners(Range.withLength(end, count));
// Add listeners for new rows
final Range freshRange = Range.withLength(firstIndex, count);
addValueChangeListeners(freshRange);
} else {
// out of view, noop
}
}
/**
* Handles the removal of rows.
*
* This method's responsibilities are to:
*
* shift the internal bookkeeping by count
if the
* removal happens above currently active range
* ignore rows removed below the currently active range
*
*
* @param firstIndex
* the index of the first removed rows
* @param count
* the number of rows removed at firstIndex
*/
public void removeRows(int firstIndex, int count) {
Range removed = Range.withLength(firstIndex, count);
if (removed.intersects(activeRange)) {
final Range[] deprecated = activeRange.partitionWith(removed);
// Remove the listeners that are no longer existing
removeValueChangeListeners(deprecated[1]);
// Move remaining listeners to fill the listener map correctly
moveListeners(deprecated[2], -deprecated[1].length());
activeRange = Range.withLength(activeRange.getStart(),
activeRange.length() - deprecated[1].length());
} else {
if (removed.getEnd() < activeRange.getStart()) {
/* firstIndex < lastIndex < start */
moveListeners(activeRange, -count);
activeRange = activeRange.offsetBy(-count);
}
/* else: end <= firstIndex, no need to do anything */
}
}
/**
* Moves value change listeners in map with given index range by count
*/
private void moveListeners(Range movedRange, int diff) {
if (diff < 0) {
for (Integer i = movedRange.getStart(); i < movedRange.getEnd(); ++i) {
moveListener(i, i + diff);
}
} else if (diff > 0) {
for (Integer i = movedRange.getEnd() - 1; i >= movedRange
.getStart(); --i) {
moveListener(i, i + diff);
}
} else {
// diff == 0 should not happen. If it does, should be no-op
return;
}
}
private void moveListener(Integer oldIndex, Integer newIndex) {
assert valueChangeListeners.get(newIndex) == null : "Overwriting existing listener";
GridValueChangeListener listener = valueChangeListeners
.remove(oldIndex);
assert listener != null : "Moving nonexisting listener.";
valueChangeListeners.put(newIndex, listener);
}
}
/**
* A class to listen to changes in property values in the Container added
* with {@link Grid#setContainerDatasource(Container.Indexed)}, and notifies
* the data source to update the client-side representation of the modified
* item.
*
* One instance of this class can (and should) be reused for all the
* properties in an item, since this class will inform that the entire row
* needs to be re-evaluated (in contrast to a property-based change
* management)
*
* Since there's no Container-wide possibility to listen to any kind of
* value changes, an instance of this class needs to be attached to each and
* every Item's Property in the container.
*
* @see Grid#addValueChangeListener(Container, Object, Object)
* @see Grid#valueChangeListeners
*/
private class GridValueChangeListener implements ValueChangeListener {
private final Object itemId;
private final Item item;
public GridValueChangeListener(Object itemId, Item item) {
/*
* Using an assert instead of an exception throw, just to optimize
* prematurely
*/
assert itemId != null : "null itemId not accepted";
this.itemId = itemId;
this.item = item;
internalAddColumns(getGrid().getColumns());
}
@Override
public void valueChange(ValueChangeEvent event) {
updateRowData(itemId);
}
public void removeListener() {
removeColumns(getGrid().getColumns());
}
public void addColumns(Collection addedColumns) {
internalAddColumns(addedColumns);
updateRowData(itemId);
}
private void internalAddColumns(Collection addedColumns) {
for (final Column column : addedColumns) {
final Property> property = item.getItemProperty(column
.getPropertyId());
if (property instanceof ValueChangeNotifier) {
((ValueChangeNotifier) property)
.addValueChangeListener(this);
}
}
}
public void removeColumns(Collection removedColumns) {
for (final Column column : removedColumns) {
final Property> property = item.getItemProperty(column
.getPropertyId());
if (property instanceof ValueChangeNotifier) {
((ValueChangeNotifier) property)
.removeValueChangeListener(this);
}
}
}
}
/**
* A class that makes detail component related internal communication
* possible between {@link RpcDataProviderExtension} and grid.
*
* @since 7.5.0
* @author Vaadin Ltd
*/
public static final class DetailComponentManager implements Serializable {
/**
* This map represents all the components that have been requested for
* each item id.
*
* 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)}.
*
* This is easily checked: if {@link #unattachedComponents} is
* {@link Collection#isEmpty() empty}, then this field is consistent
* with the connector hierarchy.
*/
private final Map visibleDetailsComponents = Maps
.newHashMap();
/** A lookup map for which row contains which details component. */
private BiMap 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 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.
*
* 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 unattachedComponents = Sets.newHashSet();
/**
* Keeps tabs on all the details that did not get a component during
* {@link #createDetails(Object, int)}.
*/
private final Map emptyDetails = Maps.newHashMap();
private Grid grid;
/**
* Creates a details component by the request of the client side, with
* the help of the user-defined {@link DetailsGenerator}.
*
* Also keeps internal bookkeeping up to date.
*
* @param itemId
* the item id for which to create the details component.
* Assumed not null
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);
if (visibleDetailsComponents.containsKey(itemId)) {
// Don't overwrite existing components
return;
}
RowReference rowReference = new RowReference(grid);
rowReference.set(itemId);
DetailsGenerator detailsGenerator = grid.getDetailsGenerator();
Component details = detailsGenerator.getDetails(rowReference);
if (details != null) {
String generatorName = detailsGenerator.getClass().getName();
if (details.getParent() != null) {
throw new IllegalStateException(generatorName
+ " generated a details component that already "
+ "was attached. (itemId: " + itemId + ", row: "
+ rowIndex + ", component: " + details);
}
if (rowIndexToDetails.containsValue(details)) {
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 assertItemIdHasNotMovedAndNothingIsOverwritten(itemId,
newRowIndex);
emptyDetails.put(itemId, newRowIndex);
}
/*
* Don't attach the components here. It's done by
* GridServerRpc.sendDetailsComponents in a separate roundtrip.
*/
}
private boolean assertItemIdHasNotMovedAndNothingIsOverwritten(
Object itemId, Integer newRowIndex) {
Integer oldRowIndex = emptyDetails.get(itemId);
if (!SharedUtil.equals(oldRowIndex, newRowIndex)) {
assert !emptyDetails.containsKey(itemId) : "Unexpected "
+ "change of empty details row index for itemId "
+ itemId + " from " + oldRowIndex + " to "
+ newRowIndex;
assert !emptyDetails.containsValue(newRowIndex) : "Bookkeeping"
+ " already had another itemId for this empty index "
+ "(index: " + newRowIndex + ", new itemId: " + itemId
+ ")";
}
return true;
}
/**
* Destroys correctly a details component, by the request of the client
* side.
*
* 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);
grid.markAsDirty();
}
/**
* Gets all details components that are currently attached to the grid.
*
* Used internally by the Grid object.
*
* @return all details components that are currently attached to the
* grid
*/
public Collection getComponents() {
Set components = new HashSet(
visibleDetailsComponents.values());
components.removeAll(unattachedComponents);
return components;
}
/**
* Gets information on how the connectors have changed.
*
* 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.
*
* Used internally by the Grid object.
*
* @return information on how the connectors have changed
*/
public Set getAndResetConnectorChanges() {
Set changes = new HashSet();
// populate diff with added/changed
for (Entry 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);
unattachedComponents.remove(component);
}
if (!SharedUtil.equals(oldIndex, newIndex)) {
changes.add(new DetailsConnectorChange(component, oldIndex,
newIndex, emptyDetails.containsKey(component)));
}
}
// populate diff with removed
for (Entry 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, emptyDetails.containsValue(oldIndex)));
}
}
// 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());
}
void setGrid(Grid grid) {
if (this.grid != null) {
throw new IllegalStateException("Grid may injected only once.");
}
this.grid = grid;
}
}
private final Indexed container;
private final ActiveRowHandler activeRowHandler = new ActiveRowHandler();
private DataProviderRpc rpc;
private final ItemSetChangeListener itemListener = new ItemSetChangeListener() {
@Override
public void containerItemSetChange(ItemSetChangeEvent event) {
if (event instanceof ItemAddEvent) {
ItemAddEvent addEvent = (ItemAddEvent) event;
int firstIndex = addEvent.getFirstIndex();
int count = addEvent.getAddedItemsCount();
insertRowData(firstIndex, count);
}
else if (event instanceof ItemRemoveEvent) {
ItemRemoveEvent removeEvent = (ItemRemoveEvent) event;
int firstIndex = removeEvent.getFirstIndex();
int count = removeEvent.getRemovedItemsCount();
removeRowData(firstIndex, count);
}
else {
/*
* Clear everything we have in view, and let the client
* re-request for whatever it needs.
*
* Why this shortcut? Well, since anything could've happened, we
* don't know what has happened. There are a lot of use-cases we
* can cover at once with this carte blanche operation:
*
* 1) Grid is scrolled somewhere in the middle and all the
* rows-inview are removed. We need a new pageful.
*
* 2) Grid is scrolled somewhere in the middle and none of the
* visible rows are removed. We need no new rows.
*
* 3) Grid is scrolled all the way to the bottom, and the last
* rows are being removed. Grid needs to scroll up and request
* for more rows at the top.
*
* 4) Grid is scrolled pretty much to the bottom, and the last
* rows are being removed. Grid needs to be aware that some
* scrolling is needed, but not to compensate for all the
* removed rows. And it also needs to request for some more rows
* to the top.
*
* 5) Some ranges of rows are removed from view. We need to
* collapse the gaps with existing rows and load the missing
* rows.
*
* 6) The ultimate use case! Grid has 1.5 pages of rows and
* scrolled a bit down. One page of rows is removed. We need to
* make sure that new rows are loaded, but not all old slots are
* occupied, since the page can't be filled with new row data.
* It also needs to be scrolled to the top.
*
* So, it's easier (and safer) to do the simple thing instead of
* taking all the corner cases into account.
*/
Map listeners = activeRowHandler.valueChangeListeners;
for (GridValueChangeListener listener : listeners.values()) {
listener.removeListener();
}
// Wipe clean all details.
HashSet detailItemIds = new HashSet(
detailComponentManager.visibleDetailsComponents
.keySet());
for (Object itemId : detailItemIds) {
detailComponentManager.destroyDetails(itemId);
}
listeners.clear();
activeRowHandler.activeRange = Range.withLength(0, 0);
/* Mark as dirty to push changes in beforeClientResponse */
bareItemSetTriggeredSizeChange = true;
markAsDirty();
}
}
};
private final DataProviderKeyMapper keyMapper = new DataProviderKeyMapper();
private KeyMapper columnKeys;
/** RpcDataProvider should send the current cache again. */
private boolean refreshCache = false;
private RowReference rowReference;
private CellReference cellReference;
/** Set of updated item ids */
private Set updatedItemIds = new LinkedHashSet();
/**
* Queued RPC calls for adding and removing rows. Queue will be handled in
* {@link beforeClientResponse}
*/
private List rowChanges = new ArrayList();
/** Size possibly changed with a bare ItemSetChangeEvent */
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 visibleDetails = new HashSet();
private final DetailComponentManager detailComponentManager = new DetailComponentManager();
/**
* Creates a new data provider using the given container.
*
* @param container
* the container to make available
*/
public RpcDataProviderExtension(Indexed container) {
this.container = container;
rpc = getRpcProxy(DataProviderRpc.class);
registerRpc(new DataRequestRpc() {
@Override
public void requestRows(int firstRow, int numberOfRows,
int firstCachedRowIndex, int cacheSize) {
pushRowData(firstRow, numberOfRows, firstCachedRowIndex,
cacheSize);
}
@Override
public void setPinned(String key, boolean isPinned) {
Object itemId = keyMapper.getItemId(key);
if (isPinned) {
// Row might already be pinned if it was selected from the
// server
if (!keyMapper.isPinned(itemId)) {
keyMapper.pin(itemId);
}
} else {
keyMapper.unpin(itemId);
}
}
});
if (container instanceof ItemSetChangeNotifier) {
((ItemSetChangeNotifier) container)
.addItemSetChangeListener(itemListener);
}
}
/**
* {@inheritDoc}
*
* RpcDataProviderExtension makes all actual RPC calls from this function
* based on changes in the container.
*/
@Override
public void beforeClientResponse(boolean initial) {
if (initial || bareItemSetTriggeredSizeChange) {
/*
* Push initial set of rows, assuming Grid will initially be
* rendered scrolled to the top and with a decent amount of rows
* visible. If this guess is right, initial data can be shown
* without a round-trip and if it's wrong, the data will simply be
* discarded.
*/
int size = container.size();
rpc.resetDataAndSize(size);
int numberOfRows = Math.min(40, size);
pushRowData(0, numberOfRows, 0, 0);
} else {
// Only do row changes if not initial response.
for (Runnable r : rowChanges) {
r.run();
}
// Send current rows again if needed.
if (refreshCache) {
int firstRow = activeRowHandler.activeRange.getStart();
int numberOfRows = activeRowHandler.activeRange.length();
pushRowData(firstRow, numberOfRows, firstRow, numberOfRows);
}
}
for (Object itemId : updatedItemIds) {
internalUpdateRowData(itemId);
}
// Clear all changes.
rowChanges.clear();
refreshCache = false;
updatedItemIds.clear();
bareItemSetTriggeredSizeChange = false;
super.beforeClientResponse(initial);
}
private void pushRowData(int firstRowToPush, int numberOfRows,
int firstCachedRowIndex, int cacheSize) {
Range newRange = Range.withLength(firstRowToPush, numberOfRows);
Range cached = Range.withLength(firstCachedRowIndex, cacheSize);
Range fullRange = newRange;
if (!cached.isEmpty()) {
fullRange = newRange.combineWith(cached);
}
List> itemIds = container.getItemIds(fullRange.getStart(),
fullRange.length());
keyMapper.setActiveRows(itemIds);
JsonArray rows = Json.createArray();
// Offset the index to match the wanted range.
int diff = 0;
if (!cached.isEmpty() && newRange.getStart() > cached.getStart()) {
diff = cached.length();
}
for (int i = 0; i < newRange.length() && i + diff < itemIds.size(); ++i) {
Object itemId = itemIds.get(i + diff);
rows.set(i, getRowData(getGrid().getColumns(), itemId));
}
rpc.setRowData(firstRowToPush, rows);
activeRowHandler.setActiveRows(fullRange);
}
private JsonValue getRowData(Collection columns, Object itemId) {
Item item = container.getItem(itemId);
JsonObject rowData = Json.createObject();
Grid grid = getGrid();
for (Column column : columns) {
Object propertyId = column.getPropertyId();
Object propertyValue = item.getItemProperty(propertyId).getValue();
JsonValue encodedValue = encodeValue(propertyValue,
column.getRenderer(), column.getConverter(),
grid.getLocale());
rowData.put(columnKeys.key(propertyId), encodedValue);
}
final JsonObject rowObject = Json.createObject();
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();
if (cellStyleGenerator != null) {
setGeneratedCellStyles(cellStyleGenerator, rowObject, columns);
}
RowStyleGenerator rowStyleGenerator = grid.getRowStyleGenerator();
if (rowStyleGenerator != null) {
setGeneratedRowStyles(rowStyleGenerator, rowObject);
}
return rowObject;
}
private void setGeneratedCellStyles(CellStyleGenerator generator,
JsonObject rowObject, Collection columns) {
JsonObject cellStyles = null;
for (Column column : columns) {
Object propertyId = column.getPropertyId();
cellReference.set(propertyId);
String style = generator.getStyle(cellReference);
if (style != null && !style.isEmpty()) {
if (cellStyles == null) {
cellStyles = Json.createObject();
}
String columnKey = columnKeys.key(propertyId);
cellStyles.put(columnKey, style);
}
}
if (cellStyles != null) {
rowObject.put(GridState.JSONKEY_CELLSTYLES, cellStyles);
}
}
private void setGeneratedRowStyles(RowStyleGenerator generator,
JsonObject rowObject) {
String rowStyle = generator.getStyle(rowReference);
if (rowStyle != null && !rowStyle.isEmpty()) {
rowObject.put(GridState.JSONKEY_ROWSTYLE, rowStyle);
}
}
/**
* Makes the data source available to the given {@link Grid} component.
*
* @param component
* the remote data grid component to extend
* @param columnKeys
* the key mapper for columns
*/
public void extend(Grid component, KeyMapper columnKeys) {
this.columnKeys = columnKeys;
detailComponentManager.setGrid(component);
super.extend(component);
}
/**
* Informs the client side that new rows have been inserted into the data
* source.
*
* @param index
* the index at which new rows have been inserted
* @param count
* the number of rows inserted at index
*/
private void insertRowData(final int index, final int count) {
if (rowChanges.isEmpty()) {
markAsDirty();
}
/*
* Since all changes should be processed in a consistent order, we don't
* send the RPC call immediately. beforeClientResponse will decide
* whether to send these or not. Valid situation to not send these is
* initial response or bare ItemSetChange event.
*/
rowChanges.add(new Runnable() {
@Override
public void run() {
rpc.insertRowData(index, count);
}
});
activeRowHandler.insertRows(index, count);
}
/**
* Informs the client side that rows have been removed from the data source.
*
* @param index
* the index of the first row removed
* @param count
* the number of rows removed
* @param firstItemId
* the item id of the first removed item
*/
private void removeRowData(final int index, final int count) {
if (rowChanges.isEmpty()) {
markAsDirty();
}
/* See comment in insertRowData */
rowChanges.add(new Runnable() {
@Override
public void run() {
rpc.removeRowData(index, count);
}
});
activeRowHandler.removeRows(index, count);
}
/**
* Informs the client side that data of a row has been modified in the data
* source.
*
* @param itemId
* the item Id the row that was updated
*/
public void updateRowData(Object itemId) {
if (updatedItemIds.isEmpty()) {
// At least one new item will be updated. Mark as dirty to actually
// update before response to client.
markAsDirty();
}
updatedItemIds.add(itemId);
}
private void internalUpdateRowData(Object itemId) {
int index = container.indexOfId(itemId);
if (index >= 0) {
JsonValue row = getRowData(getGrid().getColumns(), itemId);
JsonArray rowArray = Json.createArray();
rowArray.set(0, row);
rpc.setRowData(index, rowArray);
if (isDetailsVisible(itemId)) {
detailComponentManager.createDetails(itemId, index);
}
}
}
/**
* Pushes a new version of all the rows in the active cache range.
*/
public void refreshCache() {
if (!refreshCache) {
refreshCache = true;
markAsDirty();
}
}
@Override
public void setParent(ClientConnector parent) {
if (parent == null) {
// We're being detached, release various listeners
activeRowHandler
.removeValueChangeListeners(activeRowHandler.activeRange);
if (container instanceof ItemSetChangeNotifier) {
((ItemSetChangeNotifier) container)
.removeItemSetChangeListener(itemListener);
}
} else if (parent instanceof Grid) {
Grid grid = (Grid) parent;
rowReference = new RowReference(grid);
cellReference = new CellReference(rowReference);
} else {
throw new IllegalStateException(
"Grid is the only accepted parent type");
}
super.setParent(parent);
}
/**
* Informs this data provider that given columns have been removed from
* grid.
*
* @param removedColumns
* a list of removed columns
*/
public void columnsRemoved(List removedColumns) {
activeRowHandler.columnsRemoved(removedColumns);
}
/**
* Informs this data provider that given columns have been added to grid.
*
* @param addedColumns
* a list of added columns
*/
public void columnsAdded(List addedColumns) {
activeRowHandler.columnsAdded(addedColumns);
}
public DataProviderKeyMapper getKeyMapper() {
return keyMapper;
}
protected Grid getGrid() {
return (Grid) getParent();
}
/**
* Converts and encodes the given data model property value using the given
* converter and renderer. This method is public only for testing purposes.
*
* @param renderer
* the renderer to use
* @param converter
* the converter to use
* @param modelValue
* the value to convert and encode
* @param locale
* the locale to use in conversion
* @return an encoded value ready to be sent to the client
*/
public static JsonValue encodeValue(Object modelValue,
Renderer renderer, Converter, ?> converter, Locale locale) {
Class presentationType = renderer.getPresentationType();
T presentationValue;
if (converter == null) {
try {
presentationValue = presentationType.cast(modelValue);
} catch (ClassCastException e) {
if (presentationType == String.class) {
// If there is no converter, just fallback to using
// toString().
// modelValue can't be null as Class.cast(null) will always
// succeed
presentationValue = (T) modelValue.toString();
} else {
throw new Converter.ConversionException(
"Unable to convert value of type "
+ modelValue.getClass().getName()
+ " to presentation type "
+ presentationType.getName()
+ ". No converter is set and the types are not compatible.");
}
}
} else {
assert presentationType.isAssignableFrom(converter
.getPresentationType());
@SuppressWarnings("unchecked")
Converter safeConverter = (Converter) converter;
presentationValue = safeConverter.convertToPresentation(modelValue,
safeConverter.getPresentationType(), locale);
}
JsonValue encodedValue = renderer.encode(presentationValue);
return encodedValue;
}
private static Logger getLogger() {
return Logger.getLogger(RpcDataProviderExtension.class.getName());
}
/**
* Marks a row's details to be visible or hidden.
*
* If that row is currently in the client side's cache, this information
* will be sent over to the client.
*
* @since 7.5.0
* @param itemId
* the id of the item of which to change the details visibility
* @param visible
* true
to show the details, false
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 7.5.0
* @param itemId
* the id of the item of which to check the visibility
* @return true
iff the detials are visible for the item. This
* might return true
even if the row is not currently
* visible in the DOM
*/
public boolean isDetailsVisible(Object itemId) {
return visibleDetails.contains(itemId);
}
/**
* Refreshes all visible detail sections.
*
* @since 7.5.0
*/
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);
}
/**
* Gets the detail component manager for this data provider
*
* @since 7.5.0
* @return the detail component manager
* */
public DetailComponentManager getDetailComponentManager() {
return detailComponentManager;
}
@Override
public void detach() {
for (Object itemId : ImmutableSet.copyOf(visibleDetails)) {
detailComponentManager.destroyDetails(itemId);
}
super.detach();
}
}