diff options
author | Aleksi Hietanen <aleksi@vaadin.com> | 2017-03-16 08:53:38 +0200 |
---|---|---|
committer | Pekka Hyvönen <pekka@vaadin.com> | 2017-03-16 08:53:38 +0200 |
commit | 71679dfd1626737081b86127e6c547e37c77923f (patch) | |
tree | f0813ec2bde85fbd7f82d80b2b8f7eebaf9d6725 | |
parent | e5488dff791afe585bf7ab42e268c3e1f342c142 (diff) | |
download | vaadin-framework-71679dfd1626737081b86127e6c547e37c77923f.tar.gz vaadin-framework-71679dfd1626737081b86127e6c547e37c77923f.zip |
Hierarchical data (#8842)
* Initial HierarchicalDataProvider for TreeGrid
* Initial in-memory hierarchical data implementation
* TreeGrid declarative support
Fixes #8611, Fixes #8620
29 files changed, 3165 insertions, 344 deletions
diff --git a/client/src/main/java/com/vaadin/client/connectors/data/DataCommunicatorConnector.java b/client/src/main/java/com/vaadin/client/connectors/data/DataCommunicatorConnector.java index cf3c3c10d8..1b55d61a4e 100644 --- a/client/src/main/java/com/vaadin/client/connectors/data/DataCommunicatorConnector.java +++ b/client/src/main/java/com/vaadin/client/connectors/data/DataCommunicatorConnector.java @@ -78,6 +78,16 @@ public class DataCommunicatorConnector extends AbstractExtensionConnector { updateRowData(data.getObject(i)); } } + + @Override + public void insertRows(int firstRowIndex, int count) { + insertRowData(firstRowIndex, count); + } + + @Override + public void removeRows(int firstRowIndex, int count) { + removeRowData(firstRowIndex, count); + } }); } @@ -85,7 +95,8 @@ public class DataCommunicatorConnector extends AbstractExtensionConnector { protected void requestRows(int firstRowIndex, int numberOfRows, RequestRowsCallback<JsonObject> callback) { getRpcProxy(DataRequestRpc.class).requestRows(firstRowIndex, - numberOfRows, 0, 0); + numberOfRows, getCachedRange().getStart(), + getCachedRange().length()); JsonArray dropped = Json.createArray(); int i = 0; @@ -112,13 +123,16 @@ public class DataCommunicatorConnector extends AbstractExtensionConnector { /** * Updates row data based on row key. * - * @param row + * @param rowData * new row object */ - protected void updateRowData(JsonObject row) { - int index = indexOfKey(getRowKey(row)); + protected void updateRowData(JsonObject rowData) { + int index = indexOfKey(getRowKey(rowData)); if (index >= 0) { - setRowData(index, Collections.singletonList(row)); + JsonObject oldRowData = getRow(index); + onRowDataUpdate(rowData, oldRowData); + + setRowData(index, Collections.singletonList(rowData)); } } } @@ -135,6 +149,25 @@ public class DataCommunicatorConnector extends AbstractExtensionConnector { } } + /** + * Called row updates from server side. + * <p> + * This method exists for making it possible to copy data from the old + * object to the new one, if e.g. some data is not available in the server + * side when doing updates and would be missed otherwise. + * + * @param newRowData + * the new row data + * @param oldRowData + * the previous row data + * + * @since 8.1 + */ + protected void onRowDataUpdate(JsonObject newRowData, + JsonObject oldRowData) { + // NOOP, see overrides for concrete use cases + } + @Override public DataCommunicatorState getState() { return (DataCommunicatorState) super.getState(); diff --git a/client/src/main/java/com/vaadin/client/connectors/data/HierarchicalDataCommunicatorConnector.java b/client/src/main/java/com/vaadin/client/connectors/data/HierarchicalDataCommunicatorConnector.java new file mode 100644 index 0000000000..f80a849fc9 --- /dev/null +++ b/client/src/main/java/com/vaadin/client/connectors/data/HierarchicalDataCommunicatorConnector.java @@ -0,0 +1,59 @@ +/* + * Copyright 2000-2016 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.client.connectors.data; + +import com.vaadin.data.provider.HierarchicalDataCommunicator; +import com.vaadin.shared.ui.Connect; +import com.vaadin.shared.ui.treegrid.TreeGridCommunicationConstants; + +import elemental.json.JsonObject; + +/** + * A connector for HierarchicalDataCommunicator class. + * + * @author Vaadin Ltd + * @since + */ +@Connect(HierarchicalDataCommunicator.class) +public class HierarchicalDataCommunicatorConnector + extends DataCommunicatorConnector { + + @Override + protected void onRowDataUpdate(JsonObject newRowData, + JsonObject oldRowData) { + assert newRowData.hasKey( + TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION); + assert oldRowData.hasKey( + TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION); + + /* + * Since server side can't know the index of a random item, any + * refreshItem(..) cannot know the depth. Thus need to copy it from + * previous item. + */ + JsonObject hierarchyData = newRowData.getObject( + TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION); + if (!hierarchyData.hasKey(TreeGridCommunicationConstants.ROW_DEPTH)) { + hierarchyData.put(TreeGridCommunicationConstants.ROW_DEPTH, + oldRowData + .getObject( + TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION) + .getNumber( + TreeGridCommunicationConstants.ROW_DEPTH)); + } + } + +} diff --git a/client/src/main/java/com/vaadin/client/connectors/treegrid/TreeGridConnector.java b/client/src/main/java/com/vaadin/client/connectors/treegrid/TreeGridConnector.java index 0cccb6cd6f..e96bdffc06 100644 --- a/client/src/main/java/com/vaadin/client/connectors/treegrid/TreeGridConnector.java +++ b/client/src/main/java/com/vaadin/client/connectors/treegrid/TreeGridConnector.java @@ -34,7 +34,6 @@ import com.vaadin.client.widget.grid.events.GridClickEvent; import com.vaadin.client.widget.treegrid.TreeGrid; import com.vaadin.client.widget.treegrid.events.TreeGridClickEvent; import com.vaadin.client.widgets.Grid; -import com.vaadin.shared.data.DataCommunicatorConstants; import com.vaadin.shared.ui.Connect; import com.vaadin.shared.ui.treegrid.NodeCollapseRpc; import com.vaadin.shared.ui.treegrid.TreeGridCommunicationConstants; @@ -81,7 +80,7 @@ public class TreeGridConnector extends GridConnector { void updateHierarchyColumn() { Scheduler.get().scheduleFinally(() -> { // Id of old hierarchy column - String oldHierarchyColumnId = this.hierarchyColumnId; + String oldHierarchyColumnId = hierarchyColumnId; // Id of new hierarchy column. Choose first when nothing explicitly // set @@ -112,7 +111,7 @@ public class TreeGridConnector extends GridConnector { // setRenderer() replaces DOM elements getWidget().setFrozenColumnCount(getState().frozenColumnCount); - this.hierarchyColumnId = newHierarchyColumnId; + hierarchyColumnId = newHierarchyColumnId; } else { Logger.getLogger(TreeGridConnector.class.getName()).warning( "Couldn't find column: " + newHierarchyColumnId); @@ -137,7 +136,9 @@ public class TreeGridConnector extends GridConnector { @Override public void onClick( ClickableRenderer.RendererClickEvent<JsonObject> event) { - toggleCollapse(getRowKey(event.getRow())); + toggleCollapse(getRowKey(event.getRow()), + event.getCell().getRowIndex(), + !isCollapsed(event.getRow())); event.stopPropagation(); event.preventDefault(); } @@ -187,8 +188,9 @@ public class TreeGridConnector extends GridConnector { return cell.getColumn().getRenderer() instanceof HierarchyRenderer; } - private void toggleCollapse(String rowKey) { - getRpcProxy(NodeCollapseRpc.class).toggleCollapse(rowKey); + private void toggleCollapse(String rowKey, int rowIndex, boolean collapse) { + getRpcProxy(NodeCollapseRpc.class).setNodeCollapsed(rowKey, rowIndex, + collapse); } /** @@ -260,31 +262,26 @@ public class TreeGridConnector extends GridConnector { // Hierarchy metadata boolean collapsed, leaf; - if (event.getCell().getRow().hasKey( + JsonObject rowData = event.getCell().getRow(); + if (rowData.hasKey( TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION)) { - JsonObject rowDescription = event.getCell().getRow() - .getObject( - TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION); - collapsed = rowDescription.getBoolean( - TreeGridCommunicationConstants.ROW_COLLAPSED); + collapsed = isCollapsed(rowData); + JsonObject rowDescription = rowData.getObject( + TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION); leaf = rowDescription.getBoolean( TreeGridCommunicationConstants.ROW_LEAF); - switch (domEvent.getKeyCode()) { case KeyCodes.KEY_RIGHT: - if (!leaf) { - if (collapsed) { - toggleCollapse( - event.getCell().getRow().getString( - DataCommunicatorConstants.KEY)); - } + if (!leaf && collapsed) { + toggleCollapse(getRowKey(rowData), + event.getCell().getRowIndex(), true); } break; case KeyCodes.KEY_LEFT: if (!collapsed) { // collapse node - toggleCollapse(event.getCell().getRow() - .getString(DataCommunicatorConstants.KEY)); + toggleCollapse(getRowKey(rowData), + event.getCell().getRowIndex(), false); } break; } @@ -294,4 +291,24 @@ public class TreeGridConnector extends GridConnector { } } } + + private static boolean isCollapsed(JsonObject rowData) { + assert rowData + .hasKey(TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION) : "missing hierarchy data for row " + + rowData.asString(); + return rowData + .getObject( + TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION) + .getBoolean(TreeGridCommunicationConstants.ROW_COLLAPSED); + } + + private static int getDepth(JsonObject rowData) { + assert rowData + .hasKey(TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION) : "missing hierarchy data for row " + + rowData.asString(); + return (int) rowData + .getObject( + TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION) + .getNumber(TreeGridCommunicationConstants.ROW_DEPTH); + } } diff --git a/server/src/main/java/com/vaadin/data/HasItems.java b/server/src/main/java/com/vaadin/data/HasItems.java index 18dd9fc99d..c5b5382c9e 100644 --- a/server/src/main/java/com/vaadin/data/HasItems.java +++ b/server/src/main/java/com/vaadin/data/HasItems.java @@ -87,7 +87,7 @@ public interface HasItems<T> extends Component, Serializable { * <pre> * <code> * HasDataProvider<String> listing = new CheckBoxGroup<>(); - * listing.setItems(Arrays.asList("a","b")); + * listing.setItems("a","b"); * ... * * Collection<String> collection = ((ListDataProvider<String>)listing.getDataProvider()).getItems(); @@ -122,7 +122,7 @@ public interface HasItems<T> extends Component, Serializable { * <pre> * <code> * HasDataProvider<String> listing = new CheckBoxGroup<>(); - * listing.setItems(Arrays.asList("a","b")); + * listing.setItems(Stream.of("a","b")); * ... * * Collection<String> collection = ((ListDataProvider<String>)listing.getDataProvider()).getItems(); diff --git a/server/src/main/java/com/vaadin/data/HierarchyData.java b/server/src/main/java/com/vaadin/data/HierarchyData.java new file mode 100644 index 0000000000..df5fbbbbf5 --- /dev/null +++ b/server/src/main/java/com/vaadin/data/HierarchyData.java @@ -0,0 +1,263 @@ +/* + * Copyright 2000-2016 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.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +/** + * Class for representing hierarchical data. + * + * @author Vaadin Ltd + * @since 8.1 + * + * @param <T> + * data type + */ +public class HierarchyData<T> implements Serializable { + + private static class HierarchyWrapper<T> implements Serializable { + private T item; + private T parent; + private List<T> children; + + public HierarchyWrapper(T item, T parent) { + this.item = item; + this.parent = parent; + children = new ArrayList<>(); + } + + public T getItem() { + return item; + } + + public void setItem(T item) { + this.item = item; + } + + public T getParent() { + return parent; + } + + public void setParent(T parent) { + this.parent = parent; + } + + public List<T> getChildren() { + return children; + } + + public void setChildren(List<T> children) { + this.children = children; + } + + public void addChild(T child) { + children.add(child); + } + + public void removeChild(T child) { + children.remove(child); + } + } + + private final Map<T, HierarchyWrapper<T>> itemToWrapperMap; + + /** + * Creates an initially empty hierarchical data representation to which + * items can be added or removed. + */ + public HierarchyData() { + itemToWrapperMap = new LinkedHashMap<>(); + itemToWrapperMap.put(null, new HierarchyWrapper<>(null, null)); + } + + /** + * Adds a data item as a child of {@code parent}. Call with {@code null} as + * parent to add a root level item. The given parent item must already exist + * in this structure, and an item can only be added to this structure once. + * + * @param parent + * the parent item for which the items are added as children + * @param item + * the item to add + * @return this + * + * @throws IllegalArgumentException + * if parent is not null and not already added to this structure + * @throws IllegalArgumentException + * if the item has already been added to this structure + * @throws NullPointerException + * if item is null + */ + public HierarchyData<T> addItem(T parent, T item) { + Objects.requireNonNull(item, "Item cannot be null"); + if (parent != null && !contains(parent)) { + throw new IllegalArgumentException( + "Parent needs to be added before children. " + + "To add root items, call with parent as null"); + } + if (contains(item)) { + throw new IllegalArgumentException( + "Cannot add the same item multiple times: " + item); + } + putItem(item, parent); + return this; + } + + /** + * Adds a list of data items as children of {@code parent}. Call with + * {@code null} as parent to add root level items. The given parent item + * must already exist in this structure, and an item can only be added to + * this structure once. + * + * @param parent + * the parent item for which the items are added as children + * @param items + * the list of items to add + * @return this + * + * @throws IllegalArgumentException + * if parent is not null and not already added to this structure + * @throws IllegalArgumentException + * if any of the given items have already been added to this + * structure + * @throws NullPointerException + * if any of the items are null + */ + public HierarchyData<T> addItems(T parent, + @SuppressWarnings("unchecked") T... items) { + Arrays.asList(items).stream().forEach(item -> addItem(parent, item)); + return this; + } + + /** + * Adds a list of data items as children of {@code parent}. Call with + * {@code null} as parent to add root level items. The given parent item + * must already exist in this structure, and an item can only be added to + * this structure once. + * + * @param parent + * the parent item for which the items are added as children + * @param items + * the collection of items to add + * @return this + * + * @throws IllegalArgumentException + * if parent is not null and not already added to this structure + * @throws IllegalArgumentException + * if any of the given items have already been added to this + * structure + * @throws NullPointerException + * if any of the items are null + */ + public HierarchyData<T> addItems(T parent, Collection<T> items) { + items.stream().forEach(item -> addItem(parent, item)); + return this; + } + + /** + * Adds data items contained in a stream as children of {@code parent}. Call + * with {@code null} as parent to add root level items. The given parent + * item must already exist in this structure, and an item can only be added + * to this structure once. + * + * @param parent + * the parent item for which the items are added as children + * @param items + * stream of items to add + * @return this + * + * @throws IllegalArgumentException + * if parent is not null and not already added to this structure + * @throws IllegalArgumentException + * if any of the given items have already been added to this + * structure + * @throws NullPointerException + * if any of the items are null + */ + public HierarchyData<T> addItems(T parent, Stream<T> items) { + items.forEach(item -> addItem(parent, item)); + return this; + } + + /** + * Remove a given item from this structure. Additionally, this will + * recursively remove any descendants of the item. + * + * @param item + * the item to remove, or null to clear all data + * @return this + * + * @throws IllegalArgumentException + * if the item does not exist in this structure + */ + public HierarchyData<T> removeItem(T item) { + if (!contains(item)) { + throw new IllegalArgumentException( + "Item '" + item + "' not in the hierarchy"); + } + new ArrayList<>(getChildren(item)).forEach(child -> removeItem(child)); + itemToWrapperMap.get(itemToWrapperMap.get(item).getParent()) + .removeChild(item); + return this; + } + + /** + * Clear all items from this structure. Shorthand for calling + * {@link #removeItem(Object)} with null. + */ + public void clear() { + removeItem(null); + } + + /** + * Get the immediate child items for the given item. + * + * @param item + * the item for which to retrieve child items for, null to + * retrieve all root items + * @return a list of child items for the given item + * + * @throws IllegalArgumentException + * if the item does not exist in this structure + */ + public List<T> getChildren(T item) { + if (!contains(item)) { + throw new IllegalArgumentException( + "Item '" + item + "' not in the hierarchy"); + } + return itemToWrapperMap.get(item).getChildren(); + } + + private boolean contains(T item) { + return itemToWrapperMap.containsKey(item); + } + + private void putItem(T item, T parent) { + HierarchyWrapper<T> wrappedItem = new HierarchyWrapper<>(item, parent); + if (itemToWrapperMap.containsKey(parent)) { + itemToWrapperMap.get(parent).addChild(item); + } + itemToWrapperMap.put(item, wrappedItem); + } +} diff --git a/server/src/main/java/com/vaadin/data/provider/AbstractHierarchicalDataProvider.java b/server/src/main/java/com/vaadin/data/provider/AbstractHierarchicalDataProvider.java new file mode 100644 index 0000000000..4b9825dcf7 --- /dev/null +++ b/server/src/main/java/com/vaadin/data/provider/AbstractHierarchicalDataProvider.java @@ -0,0 +1,34 @@ +/* + * Copyright 2000-2016 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.provider; + +/** + * Abstract hierarchical data provider implementation which takes care of item + * refreshes and associated events. + * + * @author Vaadin Ltd + * @since 8.1 + * + * @param <T> + * data type + * @param <F> + * filter type + */ +public abstract class AbstractHierarchicalDataProvider<T, F> + extends AbstractDataProvider<T, F> + implements HierarchicalDataProvider<T, F> { + +} diff --git a/server/src/main/java/com/vaadin/data/provider/DataCommunicator.java b/server/src/main/java/com/vaadin/data/provider/DataCommunicator.java index 9080ad3442..ffa461e19f 100644 --- a/server/src/main/java/com/vaadin/data/provider/DataCommunicator.java +++ b/server/src/main/java/com/vaadin/data/provider/DataCommunicator.java @@ -66,15 +66,13 @@ public class DataCommunicator<T> extends AbstractExtension { @Override public void requestRows(int firstRowIndex, int numberOfRows, int firstCachedRowIndex, int cacheSize) { - pushRows = Range.withLength(firstRowIndex, numberOfRows); - markAsDirty(); + onRequestRows(firstRowIndex, numberOfRows, firstCachedRowIndex, + cacheSize); } @Override public void dropRows(JsonArray keys) { - for (int i = 0; i < keys.length(); ++i) { - handler.dropActiveData(keys.getString(i)); - } + onDropRows(keys); } } @@ -190,11 +188,11 @@ public class DataCommunicator<T> extends AbstractExtension { private final ActiveDataHandler handler = new ActiveDataHandler(); /** Empty default data provider */ - private DataProvider<T, ?> dataProvider = new CallbackDataProvider<>( + protected DataProvider<T, ?> dataProvider = new CallbackDataProvider<>( q -> Stream.empty(), q -> 0); private final DataKeyMapper<T> keyMapper; - private boolean reset = false; + protected boolean reset = false; private final Set<T> updatedData = new HashSet<>(); private int minPushSize = 40; private Range pushRows = Range.withLength(0, minPushSize); @@ -224,6 +222,72 @@ public class DataCommunicator<T> extends AbstractExtension { } /** + * Set the range of rows to push for next response. + * + * @param pushRows + */ + protected void setPushRows(Range pushRows) { + this.pushRows = pushRows; + } + + /** + * Get the current range of rows to push in the next response. + * + * @return the range of rows to push + */ + protected Range getPushRows() { + return pushRows; + } + + /** + * Get the object used for filtering in this data communicator. + * + * @return the filter object of this data communicator + */ + protected Object getFilter() { + return filter; + } + + /** + * Get the client rpc interface for this data communicator. + * + * @return the client rpc interface for this data communicator + */ + protected DataCommunicatorClientRpc getClientRpc() { + return rpc; + } + + /** + * Request the given rows to be available on the client side. + * + * @param firstRowIndex + * the index of the first requested row + * @param numberOfRows + * the number of requested rows + * @param firstCachedRowIndex + * the index of the first cached row + * @param cacheSize + * the number of cached rows + */ + protected void onRequestRows(int firstRowIndex, int numberOfRows, + int firstCachedRowIndex, int cacheSize) { + setPushRows(Range.withLength(firstRowIndex, numberOfRows)); + markAsDirty(); + } + + /** + * Triggered when rows have been dropped from the client side cache. + * + * @param keys + * the keys of the rows that have been dropped + */ + protected void onDropRows(JsonArray keys) { + for (int i = 0; i < keys.length(); ++i) { + handler.dropActiveData(keys.getString(i)); + } + } + + /** * Initially and in the case of a reset all data should be pushed to the * client. */ @@ -231,6 +295,16 @@ public class DataCommunicator<T> extends AbstractExtension { public void beforeClientResponse(boolean initial) { super.beforeClientResponse(initial); + sendDataToClient(initial); + } + + /** + * Send the needed data and updates to the client side. + * + * @param initial + * {@code true} if initial data load, {@code false} if not + */ + protected void sendDataToClient(boolean initial) { if (getDataProvider() == null) { return; } @@ -241,9 +315,10 @@ public class DataCommunicator<T> extends AbstractExtension { rpc.reset(dataProviderSize); } - if (!pushRows.isEmpty()) { - int offset = pushRows.getStart(); - int limit = pushRows.length(); + Range requestedRows = getPushRows(); + if (!requestedRows.isEmpty()) { + int offset = requestedRows.getStart(); + int limit = requestedRows.length(); @SuppressWarnings({ "rawtypes", "unchecked" }) Stream<T> rowsToPush = getDataProvider().fetch(new Query(offset, @@ -261,7 +336,7 @@ public class DataCommunicator<T> extends AbstractExtension { rpc.updateData(dataArray); } - pushRows = Range.withLength(0, 0); + setPushRows(Range.withLength(0, 0)); reset = false; updatedData.clear(); } @@ -343,6 +418,15 @@ public class DataCommunicator<T> extends AbstractExtension { } /** + * Returns the active data handler. + * + * @return the active data handler + */ + protected ActiveDataHandler getActiveDataHandler() { + return handler; + } + + /** * Drops data objects identified by given keys from memory. This will invoke * {@link DataGenerator#destroyData} for each of those objects. * @@ -401,6 +485,15 @@ public class DataCommunicator<T> extends AbstractExtension { } /** + * Returns the currently set updated data. + * + * @return the set of data that should be updated on the next response + */ + protected Set<T> getUpdatedData() { + return updatedData; + } + + /** * Sets the {@link Comparator} to use with in-memory sorting. * * @param comparator @@ -412,6 +505,15 @@ public class DataCommunicator<T> extends AbstractExtension { } /** + * Returns the {@link Comparator} to use with in-memory sorting. + * + * @return comparator used to sort data + */ + public Comparator<T> getInMemorySorting() { + return inMemorySorting; + } + + /** * Sets the {@link QuerySortOrder}s to use with backend sorting. * * @param sortOrder @@ -424,6 +526,15 @@ public class DataCommunicator<T> extends AbstractExtension { } /** + * Returns the {@link QuerySortOrder} to use with backend sorting. + * + * @return list of sort order information to pass to a query + */ + public List<QuerySortOrder> getBackEndSorting() { + return backEndSorting; + } + + /** * Creates a {@link DataKeyMapper} to use with this DataCommunicator. * <p> * This method is called from the constructor. @@ -492,7 +603,7 @@ public class DataCommunicator<T> extends AbstractExtension { * (and theoretically allows to the client doesn't request more data in * a happy path). */ - pushRows = Range.between(0, getMinPushSize()); + setPushRows(Range.between(0, getMinPushSize())); if (isAttached()) { attachDataProviderListener(); } diff --git a/server/src/main/java/com/vaadin/data/provider/HierarchicalDataCommunicator.java b/server/src/main/java/com/vaadin/data/provider/HierarchicalDataCommunicator.java new file mode 100644 index 0000000000..75e131df57 --- /dev/null +++ b/server/src/main/java/com/vaadin/data/provider/HierarchicalDataCommunicator.java @@ -0,0 +1,390 @@ +/* + * Copyright 2000-2016 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.provider; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.vaadin.data.HierarchyData; +import com.vaadin.data.provider.HierarchyMapper.TreeLevelQuery; +import com.vaadin.server.SerializableConsumer; +import com.vaadin.shared.Range; +import com.vaadin.shared.extension.datacommunicator.HierarchicalDataCommunicatorState; +import com.vaadin.shared.ui.treegrid.TreeGridCommunicationConstants; + +import elemental.json.Json; +import elemental.json.JsonArray; +import elemental.json.JsonObject; + +/** + * Data communicator that handles requesting hierarchical data from + * {@link HierarchicalDataProvider} and sending it to client side. + * + * @param <T> + * the bean type + * @author Vaadin Ltd + * @since + */ +public class HierarchicalDataCommunicator<T> extends DataCommunicator<T> { + + private static final Logger LOGGER = Logger + .getLogger(HierarchicalDataCommunicator.class.getName()); + + /** + * The amount of root level nodes to fetch and push to the client. + */ + private static final int INITIAL_FETCH_SIZE = 100; + + private HierarchyMapper mapper = new HierarchyMapper(); + + /** + * The captured client side cache size. + */ + private int latestCacheSize = INITIAL_FETCH_SIZE; + + /** + * Construct a new hierarchical data communicator backed by a + * {@link InMemoryHierarchicalDataProvider}. + */ + public HierarchicalDataCommunicator() { + super(); + dataProvider = new InMemoryHierarchicalDataProvider<>( + new HierarchyData<>()); + } + + @Override + protected HierarchicalDataCommunicatorState getState() { + return (HierarchicalDataCommunicatorState) super.getState(); + } + + @Override + protected HierarchicalDataCommunicatorState getState(boolean markAsDirty) { + return (HierarchicalDataCommunicatorState) super.getState(markAsDirty); + } + + @Override + protected void sendDataToClient(boolean initial) { + // on purpose do not call super + if (getDataProvider() == null) { + return; + } + + if (initial || reset) { + loadInitialData(); + } else { + loadRequestedRows(); + } + + if (!getUpdatedData().isEmpty()) { + JsonArray dataArray = Json.createArray(); + int i = 0; + for (T data : getUpdatedData()) { + dataArray.set(i++, createDataObject(data, -1)); + } + getClientRpc().updateData(dataArray); + getUpdatedData().clear(); + } + } + + private void loadInitialData() { + int rootSize = doSizeQuery(null); + mapper.reset(rootSize); + + if (rootSize != 0) { + Range initialRange = getInitialRowsToPush(rootSize); + assert !initialRange + .isEmpty() : "Initial range should never be empty."; + Stream<T> rootItems = doFetchQuery(initialRange.getStart(), + initialRange.length(), null); + + // for now just fetching data for the root level as everything is + // collapsed by default + List<T> items = rootItems.collect(Collectors.toList()); + List<JsonObject> dataObjects = items.stream() + .map(item -> createDataObject(item, 0)) + .collect(Collectors.toList()); + + getClientRpc().reset(rootSize); + sendData(0, dataObjects); + getActiveDataHandler().addActiveData(items.stream()); + getActiveDataHandler().cleanUp(items.stream()); + } + + setPushRows(Range.withLength(0, 0)); + // any updated data is ignored at this point + getUpdatedData().clear(); + reset = false; + } + + private void loadRequestedRows() { + final Range requestedRows = getPushRows(); + if (!requestedRows.isEmpty()) { + Stream<TreeLevelQuery> levelQueries = mapper + .splitRangeToLevelQueries(requestedRows.getStart(), + requestedRows.getEnd() - 1); + + JsonObject[] dataObjects = new JsonObject[requestedRows.length()]; + BiConsumer<JsonObject, Integer> rowDataMapper = (object, + index) -> dataObjects[index + - requestedRows.getStart()] = object; + List<T> fetchedItems = new ArrayList<>(dataObjects.length); + + levelQueries.forEach(query -> { + List<T> results = doFetchQuery(query.startIndex, query.size, + getKeyMapper().get(query.node.getParentKey())) + .collect(Collectors.toList()); + // TODO if the size differers from expected, all goes to hell + fetchedItems.addAll(results); + List<JsonObject> rowData = results.stream() + .map(item -> createDataObject(item, query.depth)) + .collect(Collectors.toList()); + mapper.reorderLevelQueryResultsToFlatOrdering(rowDataMapper, + query, rowData); + }); + verifyNoNullItems(dataObjects, requestedRows); + + sendData(requestedRows.getStart(), Arrays.asList(dataObjects)); + getActiveDataHandler().addActiveData(fetchedItems.stream()); + getActiveDataHandler().cleanUp(fetchedItems.stream()); + } + + setPushRows(Range.withLength(0, 0)); + } + + /* + * Verify that there are no null objects in the array, to fail eagerly and + * not just on the client side. + */ + private void verifyNoNullItems(JsonObject[] dataObjects, + Range requestedRange) { + List<Integer> nullItems = new ArrayList<>(0); + AtomicInteger indexCounter = new AtomicInteger(); + Stream.of(dataObjects).forEach(object -> { + int index = indexCounter.getAndIncrement(); + if (object == null) { + nullItems.add(index); + } + }); + if (!nullItems.isEmpty()) { + throw new IllegalStateException("For requested rows " + + requestedRange + ", there was null items for indexes " + + nullItems.stream().map(Object::toString) + .collect(Collectors.joining(", "))); + } + } + + private JsonObject createDataObject(T item, int depth) { + JsonObject dataObject = getDataObject(item); + + JsonObject hierarchyData = Json.createObject(); + if (depth != -1) { + hierarchyData.put(TreeGridCommunicationConstants.ROW_DEPTH, depth); + } + + boolean isLeaf = !getDataProvider().hasChildren(item); + if (isLeaf) { + hierarchyData.put(TreeGridCommunicationConstants.ROW_LEAF, true); + } else { + String key = getKeyMapper().key(item); + hierarchyData.put(TreeGridCommunicationConstants.ROW_COLLAPSED, + mapper.isCollapsed(key)); + hierarchyData.put(TreeGridCommunicationConstants.ROW_LEAF, false); + } + + // add hierarchy information to row as metadata + dataObject.put(TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION, + hierarchyData); + + return dataObject; + } + + private void sendData(int startIndex, List<JsonObject> dataObjects) { + JsonArray dataArray = Json.createArray(); + int i = 0; + for (JsonObject dataObject : dataObjects) { + dataArray.set(i++, dataObject); + } + + getClientRpc().setData(startIndex, dataArray); + } + + /** + * Returns the range of rows to push on initial response. + * + * @param rootLevelSize + * the amount of rows on the root level + * @return the range of rows to push initially + */ + private Range getInitialRowsToPush(int rootLevelSize) { + // TODO optimize initial level to avoid unnecessary requests + return Range.between(0, Math.min(rootLevelSize, latestCacheSize)); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private Stream<T> doFetchQuery(int start, int length, T parentItem) { + return getDataProvider() + .fetch(new HierarchicalQuery(start, length, getBackEndSorting(), + getInMemorySorting(), getFilter(), parentItem)); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private int doSizeQuery(T parentItem) { + return getDataProvider() + .getChildCount(new HierarchicalQuery(getFilter(), parentItem)); + } + + @Override + protected void onRequestRows(int firstRowIndex, int numberOfRows, + int firstCachedRowIndex, int cacheSize) { + super.onRequestRows(firstRowIndex, numberOfRows, firstCachedRowIndex, + cacheSize); + } + + @Override + protected void onDropRows(JsonArray keys) { + for (int i = 0; i < keys.length(); i++) { + // cannot drop expanded rows since the parent item is needed always + // when fetching more rows + String itemKey = keys.getString(i); + if (mapper.isCollapsed(itemKey)) { + getActiveDataHandler().dropActiveData(itemKey); + } + } + } + + @Override + public HierarchicalDataProvider<T, ?> getDataProvider() { + return (HierarchicalDataProvider<T, ?>) super.getDataProvider(); + } + + /** + * Set the current hierarchical data provider for this communicator. + * + * @param dataProvider + * the data provider to set, not <code>null</code> + * @param initialFilter + * the initial filter value to use, or <code>null</code> to not + * use any initial filter value + * + * @param <F> + * the filter type + * + * @return a consumer that accepts a new filter value to use + */ + public <F> SerializableConsumer<F> setDataProvider( + HierarchicalDataProvider<T, F> dataProvider, F initialFilter) { + return super.setDataProvider(dataProvider, initialFilter); + } + + /** + * Set the current hierarchical data provider for this communicator. + * + * @param dataProvider + * the data provider to set, must extend + * {@link HierarchicalDataProvider}, not <code>null</code> + * @param initialFilter + * the initial filter value to use, or <code>null</code> to not + * use any initial filter value + * + * @param <F> + * the filter type + * + * @return a consumer that accepts a new filter value to use + */ + @Override + public <F> SerializableConsumer<F> setDataProvider( + DataProvider<T, F> dataProvider, F initialFilter) { + if (dataProvider instanceof HierarchicalDataProvider) { + return super.setDataProvider(dataProvider, initialFilter); + } + throw new IllegalArgumentException( + "Only " + HierarchicalDataProvider.class.getName() + + " and subtypes supported."); + } + + /** + * Collapses given row, removing all its subtrees. + * + * @param collapsedRowKey + * the key of the row, not {@code null} + * @param collapsedRowIndex + * the index of row to collapse + */ + public void doCollapse(String collapsedRowKey, int collapsedRowIndex) { + if (collapsedRowIndex < 0 | collapsedRowIndex >= mapper.getTreeSize()) { + throw new IllegalArgumentException("Invalid row index " + + collapsedRowIndex + " when tree grid size of " + + mapper.getTreeSize()); + } + Objects.requireNonNull(collapsedRowKey, "Row key cannot be null"); + T collapsedItem = getKeyMapper().get(collapsedRowKey); + Objects.requireNonNull(collapsedItem, + "Cannot find item for given key " + collapsedItem); + + int collapsedSubTreeSize = mapper.collapse(collapsedRowKey, + collapsedRowIndex); + + getClientRpc().removeRows(collapsedRowIndex + 1, + collapsedSubTreeSize); + // FIXME seems like a slight overkill to do this just for refreshing + // expanded status + refresh(collapsedItem); + } + + /** + * Expands the given row. + * + * @param expandedRowKey + * the key of the row, not {@code null} + * @param expandedRowIndex + * the index of the row to expand + */ + public void doExpand(String expandedRowKey, final int expandedRowIndex) { + if (expandedRowIndex < 0 | expandedRowIndex >= mapper.getTreeSize()) { + throw new IllegalArgumentException("Invalid row index " + + expandedRowIndex + " when tree grid size of " + + mapper.getTreeSize()); + } + Objects.requireNonNull(expandedRowKey, "Row key cannot be null"); + final T expandedItem = getKeyMapper().get(expandedRowKey); + Objects.requireNonNull(expandedItem, + "Cannot find item for given key " + expandedRowKey); + + final int expandedNodeSize = doSizeQuery(expandedItem); + if (expandedNodeSize == 0) { + // TODO handle 0 size -> not expandable + throw new IllegalStateException("Row with index " + expandedRowIndex + + " returned no child nodes."); + } + + mapper.expand(expandedRowKey, expandedRowIndex, expandedNodeSize); + + // TODO optimize by sending "enough" of the expanded items directly + getClientRpc().insertRows(expandedRowIndex + 1, expandedNodeSize); + // expanded node needs to be updated to be marked as expanded + // FIXME seems like a slight overkill to do this just for refreshing + // expanded status + refresh(expandedItem); + } + +} diff --git a/server/src/main/java/com/vaadin/data/provider/HierarchicalDataProvider.java b/server/src/main/java/com/vaadin/data/provider/HierarchicalDataProvider.java index ec54a3a138..8dabdce2cc 100644 --- a/server/src/main/java/com/vaadin/data/provider/HierarchicalDataProvider.java +++ b/server/src/main/java/com/vaadin/data/provider/HierarchicalDataProvider.java @@ -15,25 +15,93 @@ */ package com.vaadin.data.provider; +import java.util.stream.Stream; + /** - * + * A common interface for fetching hierarchical data from a data source, such as + * an in-memory collection or a backend database. + * * @author Vaadin Ltd * @since 8.1 - * + * * @param <T> + * data type * @param <F> + * filter type */ public interface HierarchicalDataProvider<T, F> extends DataProvider<T, F> { - public int getDepth(T item); + /** + * Get the number of immediate child data items for the parent item returned + * by a given query. + * + * @param query + * given query to request the count for + * @return the count of child data items for the data item + * {@link HierarchicalQuery#getParent()} + * + * @throws IllegalArgumentException + * if the query is not of type HierarchicalQuery + */ + @Override + public default int size(Query<T, F> query) { + if (query instanceof HierarchicalQuery<?, ?>) { + return getChildCount((HierarchicalQuery<T, F>) query); + } + throw new IllegalArgumentException( + "Hierarchical data provider doesn't support non-hierarchical queries"); + } - public boolean isRoot(T item); + /** + * Fetches data from this HierarchicalDataProvider using given + * {@code query}. Only the immediate children of + * {@link HierarchicalQuery#getParent()} will be returned. + * + * @param query + * given query to request data with + * @return a stream of data objects resulting from the query + * + * @throws IllegalArgumentException + * if the query is not of type HierarchicalQuery + */ + @Override + public default Stream<T> fetch(Query<T, F> query) { + if (query instanceof HierarchicalQuery<?, ?>) { + return fetchChildren((HierarchicalQuery<T, F>) query); + } + throw new IllegalArgumentException( + "Hierarchical data provider doesn't support non-hierarchical queries"); + } - public T getParent(T item); + /** + * Get the number of immediate child data items for the parent item returned + * by a given query. + * + * @param query + * given query to request the count for + * @return the count of child data items for the data item + * {@link HierarchicalQuery#getParent()} + */ + public int getChildCount(HierarchicalQuery<T, F> query); - public boolean isCollapsed(T item); + /** + * Fetches data from this HierarchicalDataProvider using given + * {@code query}. Only the immediate children of + * {@link HierarchicalQuery#getParent()} will be returned. + * + * @param query + * given query to request data with + * @return a stream of data objects resulting from the query + */ + public Stream<T> fetchChildren(HierarchicalQuery<T, F> query); + /** + * Check whether a given item has any children associated with it. + * + * @param item + * the item to check for children + * @return whether the given item has children + */ public boolean hasChildren(T item); - public void setCollapsed(T item, boolean b); } diff --git a/server/src/main/java/com/vaadin/data/provider/HierarchicalQuery.java b/server/src/main/java/com/vaadin/data/provider/HierarchicalQuery.java new file mode 100644 index 0000000000..6671464b31 --- /dev/null +++ b/server/src/main/java/com/vaadin/data/provider/HierarchicalQuery.java @@ -0,0 +1,93 @@ +/* + * Copyright 2000-2016 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.provider; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * Immutable hierarchical query object used to request data from a backend. + * Contains the parent node, index limits, sorting and filtering information. + * + * @param <T> + * bean type + * @param <F> + * filter type + * + * @since 8.0 + */ +public class HierarchicalQuery<T, F> extends Query<T, F> { + + private final T parent; + + /** + * Constructs a new hierarchical query object with given filter and parent + * node. + * + * @param filter + * filtering for fetching; can be <code>null</code> + * @param parent + * the hierarchical parent object, can be <code>null</code> + */ + public HierarchicalQuery(F filter, T parent) { + super(filter); + this.parent = parent; + } + + /** + * Constructs a new hierarchical query object with given offset, limit, + * sorting and filtering. + * + * @param offset + * first index to fetch + * @param limit + * fetched item count + * @param sortOrders + * sorting order for fetching; used for sorting backends + * @param inMemorySorting + * comparator for sorting in-memory data + * @param filter + * filtering for fetching; can be <code>null</code> + * @param parent + * the hierarchical parent object, can be <code>null</code> + */ + public HierarchicalQuery(int offset, int limit, + List<QuerySortOrder> sortOrders, Comparator<T> inMemorySorting, + F filter, T parent) { + super(offset, limit, sortOrders, inMemorySorting, filter); + this.parent = parent; + } + + /** + * Get the hierarchical parent object, can be <code>null</code>. + * + * @return the hierarchical parent object, can be <code>null</code> + */ + public T getParent() { + return parent; + } + + /** + * Get an Optional of the hierarchical parent object. + * + * @see #getParent() + * @return the result of {@link #getParent()} wrapped by an Optional + */ + public Optional<T> getParentOptional() { + return Optional.ofNullable(parent); + } +} diff --git a/server/src/main/java/com/vaadin/data/provider/HierarchyMapper.java b/server/src/main/java/com/vaadin/data/provider/HierarchyMapper.java new file mode 100644 index 0000000000..7f423b39c9 --- /dev/null +++ b/server/src/main/java/com/vaadin/data/provider/HierarchyMapper.java @@ -0,0 +1,445 @@ +/* + * Copyright 2000-2016 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.provider; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Mapper for hierarchical data. + * <p> + * Keeps track of the expanded nodes, and size of of the subtrees for each + * expanded node. + * <p> + * This class is framework internal implementation details, and can be changed / + * moved at any point. This means that you should not directly use this for + * anything. + * + * @author Vaadin Ltd + * @since + */ +class HierarchyMapper implements Serializable { + + private static final Logger LOGGER = Logger + .getLogger(HierarchyMapper.class.getName()); + + /** + * A POJO that represents a query data for a certain tree level. + */ + static class TreeLevelQuery { // not serializable since not stored + /** + * The tree node that the query is for. Only used for fetching parent + * key. + */ + final TreeNode node; + /** The start index of the query, from 0 to level's size - 1. */ + final int startIndex; + /** The number of rows to fetch. s */ + final int size; + /** The depth of this node. */ + final int depth; + /** The first row index in grid, including all the nodes. */ + final int firstRowIndex; + /** The direct subtrees for the node that effect the indexing. */ + final List<TreeNode> subTrees; + + TreeLevelQuery(TreeNode node, int startIndex, int size, int depth, + int firstRowIndex, List<TreeNode> subTrees) { + this.node = node; + this.startIndex = startIndex; + this.size = size; + this.depth = depth; + this.firstRowIndex = firstRowIndex; + this.subTrees = subTrees; + } + } + + /** + * A level in the tree, either the root level or an expanded subtree level. + * <p> + * Comparable based on the {@link #startIndex}, which is flat from 0 to data + * size - 1. + */ + static class TreeNode implements Serializable, Comparable<TreeNode> { + + /** The key for the expanded item that this is a subtree of. */ + private final String parentKey; + /** The first index on this level. */ + private int startIndex; + /** The last index on this level, INCLUDING subtrees. */ + private int endIndex; + + TreeNode(String parentKey, int startIndex, int size) { + this.parentKey = parentKey; + this.startIndex = startIndex; + endIndex = startIndex + size - 1; + } + + TreeNode(int startIndex) { + parentKey = "INVALID"; + this.startIndex = startIndex; + } + + int getStartIndex() { + return startIndex; + } + + int getEndIndex() { + return endIndex; + } + + String getParentKey() { + return parentKey; + } + + private void push(int offset) { + startIndex += offset; + endIndex += offset; + } + + private void pushEnd(int offset) { + endIndex += offset; + } + + @Override + public int compareTo(TreeNode other) { + return Integer.valueOf(startIndex).compareTo(other.startIndex); + } + + @Override + public String toString() { + return "TreeNode [parent=" + parentKey + ", start=" + startIndex + + ", end=" + getEndIndex() + "]"; + } + + } + + /** The expanded nodes in the tree. */ + private final TreeSet<TreeNode> nodes = new TreeSet<>(); + + /** + * Resets the tree, sets given the root level size. + * + * @param rootLevelSize + * the number of items in the root level + */ + public void reset(int rootLevelSize) { + nodes.clear(); + nodes.add(new TreeNode(null, 0, rootLevelSize)); + } + + /** + * Returns the complete size of the tree, including all expanded subtrees. + * + * @return the size of the tree + */ + public int getTreeSize() { + TreeNode rootNode = getNodeForKey(null) + .orElse(new TreeNode(null, 0, 0)); + return rootNode.endIndex + 1; + } + + /** + * Returns whether the node with the given is collapsed or not. + * + * @param itemKey + * the key of node to check + * @return {@code true} if collapsed, {@code false} if expanded + */ + public boolean isCollapsed(String itemKey) { + return !getNodeForKey(itemKey).isPresent(); + } + + /** + * Return the depth of expanded node's subtree. + * <p> + * The root node depth is 0. + * + * @param expandedNodeKey + * the item key of the expanded node + * @return the depth of the expanded node + * @throws IllegalArgumentException + * if the node was not expanded + */ + protected int getDepth(String expandedNodeKey) { + Optional<TreeNode> node = getNodeForKey(expandedNodeKey); + if (!node.isPresent()) { + throw new IllegalArgumentException("No node with given key " + + expandedNodeKey + " was expanded."); + } + TreeNode treeNode = node.get(); + AtomicInteger start = new AtomicInteger(treeNode.startIndex); + AtomicInteger end = new AtomicInteger(treeNode.getEndIndex()); + AtomicInteger depth = new AtomicInteger(); + nodes.headSet(treeNode, false).descendingSet().forEach(higherNode -> { + if (higherNode.startIndex < start.get() + && higherNode.getEndIndex() >= end.get()) { + start.set(higherNode.startIndex); + depth.incrementAndGet(); + } + }); + + return depth.get(); + } + + /** + * Returns the tree node for the given expanded item key, or an empty + * optional if the item was not expanded. + * + * @param expandedNodeKey + * the key of the item + * @return the tree node for the expanded item, or an empty optional if not + * expanded + */ + protected Optional<TreeNode> getNodeForKey(String expandedNodeKey) { + return nodes.stream() + .filter(node -> Objects.equals(node.parentKey, expandedNodeKey)) + .findAny(); + } + + /** + * Expands the node in the given index and with the given key. + * + * @param expanedRowKey + * the key of the expanded item + * @param expandedRowIndex + * the index of the expanded item + * @param expandedNodeSize + * the size of the subtree of the expanded node + * @throws IllegalStateException + * if the node was expanded already + */ + protected void expand(String expanedRowKey, int expandedRowIndex, + int expandedNodeSize) { + if (expandedNodeSize < 1) { + throw new IllegalArgumentException( + "The expanded node's size cannot be less than 1, was " + + expandedNodeSize); + } + TreeNode newNode = new TreeNode(expanedRowKey, expandedRowIndex + 1, + expandedNodeSize); + + boolean added = nodes.add(newNode); + if (!added) { + throw new IllegalStateException("Node in index " + expandedRowIndex + + " was expanded already."); + } + + // push end indexes for parent nodes + List<TreeNode> updated = nodes.headSet(newNode, false).stream() + .filter(node -> node.getEndIndex() >= expandedRowIndex) + .collect(Collectors.toList()); + nodes.removeAll(updated); + updated.stream().forEach(node -> node.pushEnd(expandedNodeSize)); + nodes.addAll(updated); + + // push start and end indexes for later nodes + updated = nodes.tailSet(newNode, false).stream() + .collect(Collectors.toList()); + nodes.removeAll(updated); + updated.stream().forEach(node -> node.push(expandedNodeSize)); + nodes.addAll(updated); + } + + /** + * Collapses the node in the given index. + * + * @param key + * the key of the collapsed item + * @param collapsedRowIndex + * the index of the collapsed item + * @return the size of the complete subtree that was collapsed + * @throws IllegalStateException + * if the node was not collapsed, or if the given key is not the + * same as it was when the node has been expanded + */ + protected int collapse(String key, int collapsedRowIndex) { + Objects.requireNonNull(key, + "The key for the item to collapse cannot be null."); + TreeNode collapsedNode = nodes + .ceiling(new TreeNode(collapsedRowIndex + 1)); + if (collapsedNode == null + || collapsedNode.startIndex != collapsedRowIndex + 1) { + throw new IllegalStateException( + "Could not find expanded node for index " + + collapsedRowIndex + ", node was not collapsed"); + } + if (!Objects.equals(key, collapsedNode.parentKey)) { + throw new IllegalStateException("The expected parent key " + key + + " is different for the collapsed node " + collapsedNode); + } + + // remove complete subtree + AtomicInteger removedSubTreeSize = new AtomicInteger( + collapsedNode.getEndIndex() - collapsedNode.startIndex + 1); + nodes.tailSet(collapsedNode, false).removeIf( + node -> node.startIndex <= collapsedNode.getEndIndex()); + + final int offset = -1 * removedSubTreeSize.get(); + // adjust parent end indexes + List<TreeNode> updated = nodes.headSet(collapsedNode, false).stream() + .filter(node -> node.getEndIndex() >= collapsedRowIndex) + .collect(Collectors.toList()); + nodes.removeAll(updated); + updated.stream().forEach(node -> node.pushEnd(offset)); + nodes.addAll(updated); + + // adjust start and end indexes for latter nodes + updated = nodes.tailSet(collapsedNode, false).stream() + .collect(Collectors.toList()); + nodes.removeAll(updated); + updated.stream().forEach(node -> node.push(offset)); + nodes.addAll(updated); + + nodes.remove(collapsedNode); + + return removedSubTreeSize.get(); + } + + /** + * Splits the given range into queries per tree level. + * + * @param firstRow + * the first row to fetch + * @param lastRow + * the last row to fetch + * @return a stream of query data per level + * @see #reorderLevelQueryResultsToFlatOrdering(BiConsumer, TreeLevelQuery, + * List) + */ + protected Stream<TreeLevelQuery> splitRangeToLevelQueries( + final int firstRow, final int lastRow) { + return nodes.stream() + // filter to parts intersecting with the range + .filter(node -> node.startIndex <= lastRow + && firstRow <= node.getEndIndex()) + // split into queries per level with level based indexing + .map(node -> { + + // calculate how subtrees effect indexing and size + int depth = getDepth(node.parentKey); + List<TreeNode> directSubTrees = nodes.tailSet(node, false) + .stream() + // find subtrees + .filter(subTree -> node.startIndex < subTree + .getEndIndex() + && subTree.startIndex < node.getEndIndex()) + // filter to direct subtrees + .filter(subTree -> getDepth( + subTree.parentKey) == (depth + 1)) + .collect(Collectors.toList()); + // first intersecting index in flat order + AtomicInteger firstIntersectingRowIndex = new AtomicInteger( + Math.max(node.startIndex, firstRow)); + // last intersecting index in flat order + final int lastIntersectingRowIndex = Math + .min(node.getEndIndex(), lastRow); + // start index for this level + AtomicInteger start = new AtomicInteger( + firstIntersectingRowIndex.get() - node.startIndex); + // how many nodes should be fetched for this level + AtomicInteger size = new AtomicInteger( + lastIntersectingRowIndex + - firstIntersectingRowIndex.get() + 1); + + // reduce subtrees before requested index + directSubTrees.stream().filter(subtree -> subtree + .getEndIndex() < firstIntersectingRowIndex.get()) + .forEachOrdered(subtree -> { + start.addAndGet(-1 * (subtree.getEndIndex() + - subtree.startIndex + 1)); + }); + // if requested start index is in the middle of a + // subtree, start is after that + List<TreeNode> intersectingSubTrees = new ArrayList<>(); + directSubTrees.stream() + .filter(subtree -> subtree.startIndex <= firstIntersectingRowIndex + .get() && firstIntersectingRowIndex + .get() <= subtree.getEndIndex()) + .findFirst().ifPresent(subtree -> { + int previous = firstIntersectingRowIndex + .getAndSet(subtree.getEndIndex() + 1); + int delta = previous + - firstIntersectingRowIndex.get(); + start.addAndGet(subtree.startIndex - previous); + size.addAndGet(delta); + intersectingSubTrees.add(subtree); + }); + // reduce size of subtrees after first row that intersect + // with requested range + directSubTrees.stream() + .filter(subtree -> firstIntersectingRowIndex + .get() < subtree.startIndex + && subtree.endIndex <= lastIntersectingRowIndex) + .forEachOrdered(subtree -> { + // reduce subtree size that is part of the + // requested range from query size + size.addAndGet( + -1 * (Math.min(subtree.getEndIndex(), + lastIntersectingRowIndex) + - subtree.startIndex + 1)); + intersectingSubTrees.add(subtree); + }); + return new TreeLevelQuery(node, start.get(), size.get(), + depth, firstIntersectingRowIndex.get(), + intersectingSubTrees); + + }).filter(query -> query.size > 0); + + } + + /** + * Merges the tree level query results into flat grid ordering. + * + * @param rangePositionCallback + * the callback to place the results into + * @param query + * the query data for the results + * @param results + * the results to reorder + * @param <T> + * the type of the results + */ + protected <T> void reorderLevelQueryResultsToFlatOrdering( + BiConsumer<T, Integer> rangePositionCallback, TreeLevelQuery query, + List<T> results) { + AtomicInteger nextPossibleIndex = new AtomicInteger( + query.firstRowIndex); + for (T item : results) { + // search for any intersecting subtrees and push index if necessary + query.subTrees.stream().filter( + subTree -> subTree.startIndex <= nextPossibleIndex.get() + && nextPossibleIndex.get() <= subTree.getEndIndex()) + .findAny().ifPresent(intersecting -> { + nextPossibleIndex.addAndGet(intersecting.getEndIndex() + - intersecting.startIndex + 1); + query.subTrees.remove(intersecting); + }); + rangePositionCallback.accept(item, + nextPossibleIndex.getAndIncrement()); + } + } + +} diff --git a/server/src/main/java/com/vaadin/data/provider/InMemoryHierarchicalDataProvider.java b/server/src/main/java/com/vaadin/data/provider/InMemoryHierarchicalDataProvider.java new file mode 100644 index 0000000000..b86c3186c6 --- /dev/null +++ b/server/src/main/java/com/vaadin/data/provider/InMemoryHierarchicalDataProvider.java @@ -0,0 +1,235 @@ +/* + * Copyright 2000-2016 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.provider; + +import java.util.Comparator; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import com.vaadin.data.HierarchyData; +import com.vaadin.data.ValueProvider; +import com.vaadin.server.SerializableComparator; +import com.vaadin.server.SerializableFunction; +import com.vaadin.server.SerializablePredicate; +import com.vaadin.shared.data.sort.SortDirection; + +/** + * A {@link DataProvider} for in-memory hierarchical data. + * + * @see HierarchyData + * + * @author Vaadin Ltd + * @since 8.1 + * + * @param <T> + * data type + */ +public class InMemoryHierarchicalDataProvider<T> extends + AbstractHierarchicalDataProvider<T, SerializablePredicate<T>> implements + ConfigurableFilterDataProvider<T, SerializablePredicate<T>, SerializablePredicate<T>> { + + private final HierarchyData<T> hierarchyData; + + private SerializablePredicate<T> filter = null; + + private SerializableComparator<T> sortOrder = null; + + /** + * Constructs a new InMemoryHierarchicalDataProvider. + * <p> + * All changes made to the given HierarchyData object will also be visible + * through this data provider. + * + * @param hierarchyData + * the backing HierarchyData for this provider + */ + public InMemoryHierarchicalDataProvider(HierarchyData<T> hierarchyData) { + this.hierarchyData = hierarchyData; + } + + /** + * Return the underlying hierarchical data of this provider. + * + * @return the underlying data of this provider + */ + public HierarchyData<T> getData() { + return hierarchyData; + } + + @Override + public boolean isInMemory() { + return true; + } + + @Override + public boolean hasChildren(T item) { + return !hierarchyData.getChildren(item).isEmpty(); + } + + @Override + public int getChildCount( + HierarchicalQuery<T, SerializablePredicate<T>> query) { + return (int) fetchChildren(query).count(); + } + + @Override + public Stream<T> fetchChildren( + HierarchicalQuery<T, SerializablePredicate<T>> query) { + Stream<T> childStream = getFilteredStream( + hierarchyData.getChildren(query.getParent()).stream(), + query.getFilter()); + + Optional<Comparator<T>> comparing = Stream + .of(query.getInMemorySorting(), sortOrder) + .filter(c -> c != null) + .reduce((c1, c2) -> c1.thenComparing(c2)); + + if (comparing.isPresent()) { + childStream = childStream.sorted(comparing.get()); + } + + return childStream.skip(query.getOffset()).limit(query.getLimit()); + } + + @Override + public void setFilter(SerializablePredicate<T> filter) { + this.filter = filter; + refreshAll(); + } + + /** + * Adds a filter to be applied to all queries. The filter will be used in + * addition to any filter that has been set or added previously. + * + * @see #addFilter(ValueProvider, SerializablePredicate) + * @see #addFilterByValue(ValueProvider, Object) + * @see #setFilter(SerializablePredicate) + * + * @param filter + * the filter to add, not <code>null</code> + */ + public void addFilter(SerializablePredicate<T> filter) { + Objects.requireNonNull(filter, "Filter cannot be null"); + + if (this.filter == null) { + setFilter(filter); + } else { + SerializablePredicate<T> oldFilter = this.filter; + setFilter(item -> oldFilter.test(item) && filter.test(item)); + } + } + + /** + * Sets the comparator to use as the default sorting for this data provider. + * This overrides the sorting set by any other method that manipulates the + * default sorting of this data provider. + * <p> + * The default sorting is used if the query defines no sorting. The default + * sorting is also used to determine the ordering of items that are + * considered equal by the sorting defined in the query. + * + * @see #setSortOrder(ValueProvider, SortDirection) + * @see #addSortComparator(SerializableComparator) + * + * @param comparator + * a comparator to use, or <code>null</code> to clear any + * previously set sort order + */ + public void setSortComparator(SerializableComparator<T> comparator) { + sortOrder = comparator; + refreshAll(); + } + + /** + * Adds a comparator to the default sorting for this data provider. If no + * default sorting has been defined, then the provided comparator will be + * used as the default sorting. If a default sorting has been defined, then + * the provided comparator will be used to determine the ordering of items + * that are considered equal by the previously defined default sorting. + * <p> + * The default sorting is used if the query defines no sorting. The default + * sorting is also used to determine the ordering of items that are + * considered equal by the sorting defined in the query. + * + * @see #setSortComparator(SerializableComparator) + * @see #addSortOrder(ValueProvider, SortDirection) + * + * @param comparator + * a comparator to add, not <code>null</code> + */ + public void addSortComparator(SerializableComparator<T> comparator) { + Objects.requireNonNull(comparator, "Sort order to add cannot be null"); + SerializableComparator<T> originalComparator = sortOrder; + if (originalComparator == null) { + setSortComparator(comparator); + } else { + setSortComparator((a, b) -> { + int result = originalComparator.compare(a, b); + if (result == 0) { + result = comparator.compare(a, b); + } + return result; + }); + } + } + + @Override + public <C> DataProvider<T, C> withConvertedFilter( + SerializableFunction<C, SerializablePredicate<T>> filterConverter) { + Objects.requireNonNull(filterConverter, + "Filter converter can't be null"); + return new DataProviderWrapper<T, C, SerializablePredicate<T>>(this) { + + @Override + protected SerializablePredicate<T> getFilter(Query<T, C> query) { + return query.getFilter().map(filterConverter).orElse(null); + } + + @Override + public int size(Query<T, C> t) { + if (t instanceof HierarchicalQuery<?, ?>) { + return dataProvider.size(new HierarchicalQuery<>( + t.getOffset(), t.getLimit(), t.getSortOrders(), + t.getInMemorySorting(), getFilter(t), + ((HierarchicalQuery<T, C>) t).getParent())); + } + throw new IllegalArgumentException( + "Hierarchical data provider doesn't support non-hierarchical queries"); + } + + @Override + public Stream<T> fetch(Query<T, C> t) { + if (t instanceof HierarchicalQuery<?, ?>) { + return dataProvider.fetch(new HierarchicalQuery<>( + t.getOffset(), t.getLimit(), t.getSortOrders(), + t.getInMemorySorting(), getFilter(t), + ((HierarchicalQuery<T, C>) t).getParent())); + } + throw new IllegalArgumentException( + "Hierarchical data provider doesn't support non-hierarchical queries"); + } + }; + } + + private Stream<T> getFilteredStream(Stream<T> stream, + Optional<SerializablePredicate<T>> queryFilter) { + if (filter != null) { + stream = stream.filter(filter); + } + return queryFilter.map(stream::filter).orElse(stream); + } +} diff --git a/server/src/main/java/com/vaadin/data/provider/ListDataProvider.java b/server/src/main/java/com/vaadin/data/provider/ListDataProvider.java index 8fc6d4a364..fba2f94d9d 100644 --- a/server/src/main/java/com/vaadin/data/provider/ListDataProvider.java +++ b/server/src/main/java/com/vaadin/data/provider/ListDataProvider.java @@ -31,8 +31,7 @@ import com.vaadin.shared.data.sort.SortDirection; import com.vaadin.ui.UI; /** - * {@link DataProvider} wrapper for {@link Collection}s. This class does not - * actually handle the {@link Query} parameters. + * {@link DataProvider} wrapper for {@link Collection}s. * * @param <T> * data type diff --git a/server/src/main/java/com/vaadin/ui/Grid.java b/server/src/main/java/com/vaadin/ui/Grid.java index 19222d8c85..175b7d1ae5 100644 --- a/server/src/main/java/com/vaadin/ui/Grid.java +++ b/server/src/main/java/com/vaadin/ui/Grid.java @@ -2084,20 +2084,7 @@ public class Grid<T> extends AbstractListing<T> implements HasComponents, * @see #withPropertySet(PropertySet) */ public Grid() { - this(new PropertySet<T>() { - @Override - public Stream<PropertyDefinition<T, ?>> getProperties() { - // No columns configured by default - return Stream.empty(); - } - - @Override - public Optional<PropertyDefinition<T, ?>> getProperty(String name) { - throw new IllegalStateException( - "A Grid created without a bean type class literal or a custom property set" - + " doesn't support finding properties by name."); - } - }); + this(new DataCommunicator<>()); } /** @@ -2117,6 +2104,32 @@ public class Grid<T> extends AbstractListing<T> implements HasComponents, } /** + * Creates a new grid with the given data communicator and without support + * for creating columns based on property names. + * + * @param dataCommunicator + * the custom data communicator to set + * @see #Grid() + * @see #Grid(PropertySet, DataCommunicator) + */ + protected Grid(DataCommunicator<T> dataCommunicator) { + this(new PropertySet<T>() { + @Override + public Stream<PropertyDefinition<T, ?>> getProperties() { + // No columns configured by default + return Stream.empty(); + } + + @Override + public Optional<PropertyDefinition<T, ?>> getProperty(String name) { + throw new IllegalStateException( + "A Grid created without a bean type class literal or a custom property set" + + " doesn't support finding properties by name."); + } + }, dataCommunicator); + } + + /** * Creates a grid using a custom {@link PropertySet} implementation for * configuring the initial columns and resolving property names for * {@link #addColumn(String)} and @@ -2128,6 +2141,27 @@ public class Grid<T> extends AbstractListing<T> implements HasComponents, * the property set implementation to use, not <code>null</code>. */ protected Grid(PropertySet<T> propertySet) { + this(propertySet, new DataCommunicator<>()); + } + + /** + * Creates a grid using a custom {@link PropertySet} implementation and + * custom data communicator. + * <p> + * Property set is used for configuring the initial columns and resolving + * property names for {@link #addColumn(String)} and + * {@link Column#setEditorComponent(HasValue)}. + * + * @see #withPropertySet(PropertySet) + * + * @param propertySet + * the property set implementation to use, not <code>null</code>. + * @param dataCommunicator + * the data communicator to use, not<code>null</code> + */ + protected Grid(PropertySet<T> propertySet, + DataCommunicator<T> dataCommunicator) { + super(dataCommunicator); registerRpc(new GridServerRpcImpl()); setDefaultHeaderRow(appendHeaderRow()); setSelectionModel(new SingleSelectionModelImpl<>()); @@ -3844,7 +3878,7 @@ public class Grid<T> extends AbstractListing<T> implements HasComponents, } } - private void readData(Element body, + protected void readData(Element body, List<DeclarativeValueProvider<T>> providers) { getSelectionModel().deselectAll(); List<T> items = new ArrayList<>(); @@ -3883,8 +3917,7 @@ public class Grid<T> extends AbstractListing<T> implements HasComponents, if (designContext.shouldWriteData(this)) { Element bodyElement = tableElement.appendElement("tbody"); - getDataProvider().fetch(new Query<>()).forEach( - item -> writeRow(bodyElement, item, designContext)); + writeData(bodyElement, designContext); } if (getFooter().getRowCount() > 0) { @@ -3893,6 +3926,11 @@ public class Grid<T> extends AbstractListing<T> implements HasComponents, } } + protected void writeData(Element body, DesignContext designContext) { + getDataProvider().fetch(new Query<>()) + .forEach(item -> writeRow(body, item, designContext)); + } + private void writeRow(Element container, T item, DesignContext context) { Element tableRow = container.appendElement("tr"); tableRow.attr("item", serializeDeclarativeRepresentation(item)); diff --git a/server/src/main/java/com/vaadin/ui/TreeGrid.java b/server/src/main/java/com/vaadin/ui/TreeGrid.java index 46e6c99b59..82c857ac86 100644 --- a/server/src/main/java/com/vaadin/ui/TreeGrid.java +++ b/server/src/main/java/com/vaadin/ui/TreeGrid.java @@ -15,21 +15,32 @@ */ package com.vaadin.ui; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.stream.Stream; +import org.jsoup.nodes.Attributes; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import com.vaadin.data.HierarchyData; import com.vaadin.data.ValueProvider; import com.vaadin.data.provider.DataProvider; +import com.vaadin.data.provider.HierarchicalDataCommunicator; import com.vaadin.data.provider.HierarchicalDataProvider; +import com.vaadin.data.provider.HierarchicalQuery; +import com.vaadin.data.provider.InMemoryHierarchicalDataProvider; import com.vaadin.shared.ui.treegrid.NodeCollapseRpc; -import com.vaadin.shared.ui.treegrid.TreeGridCommunicationConstants; import com.vaadin.shared.ui.treegrid.TreeGridState; +import com.vaadin.ui.declarative.DesignAttributeHandler; +import com.vaadin.ui.declarative.DesignContext; +import com.vaadin.ui.declarative.DesignFormatter; import com.vaadin.ui.renderers.AbstractRenderer; import com.vaadin.ui.renderers.Renderer; -import elemental.json.Json; -import elemental.json.JsonObject; /** * A grid component for displaying hierarchical tabular data. @@ -43,55 +54,130 @@ import elemental.json.JsonObject; public class TreeGrid<T> extends Grid<T> { public TreeGrid() { - super(); - - // Attaches hierarchy data to the row - addDataGenerator((item, rowData) -> { - - JsonObject hierarchyData = Json.createObject(); - hierarchyData.put(TreeGridCommunicationConstants.ROW_DEPTH, - getDataProvider().getDepth(item)); - - boolean isLeaf = !getDataProvider().hasChildren(item); - if (isLeaf) { - hierarchyData.put(TreeGridCommunicationConstants.ROW_LEAF, - true); - } else { - hierarchyData.put(TreeGridCommunicationConstants.ROW_COLLAPSED, - getDataProvider().isCollapsed(item)); - hierarchyData.put(TreeGridCommunicationConstants.ROW_LEAF, - false); - } - - // add hierarchy information to row as metadata - rowData.put( - TreeGridCommunicationConstants.ROW_HIERARCHY_DESCRIPTION, - hierarchyData); - }); + super(new HierarchicalDataCommunicator<>()); registerRpc(new NodeCollapseRpc() { @Override - public void toggleCollapse(String rowKey) { - T item = getDataCommunicator().getKeyMapper().get(rowKey); - TreeGrid.this.toggleCollapse(item); + public void setNodeCollapsed(String rowKey, int rowIndex, + boolean collapse) { + if (collapse) { + getDataCommunicator().doCollapse(rowKey, rowIndex); + } else { + getDataCommunicator().doExpand(rowKey, rowIndex); + } } }); } - // TODO: construct a "flat" in memory hierarchical data provider? + /** + * Sets the data items of this component provided as a collection. + * <p> + * The provided items are wrapped into a + * {@link InMemoryHierarchicalDataProvider} backed by a flat + * {@link HierarchyData} structure. The data provider instance is used as a + * parameter for the {@link #setDataProvider(DataProvider)} method. It means + * that the items collection can be accessed later on via + * {@link InMemoryHierarchicalDataProvider#getData()}: + * + * <pre> + * <code> + * TreeGrid<String> treeGrid = new TreeGrid<>(); + * treeGrid.setItems(Arrays.asList("a","b")); + * ... + * + * HierarchyData<String> data = ((InMemoryHierarchicalDataProvider<String>)treeGrid.getDataProvider()).getData(); + * </code> + * </pre> + * <p> + * The returned HierarchyData instance may be used as-is to add, remove or + * modify items in the hierarchy. These modifications to the object are not + * automatically reflected back to the TreeGrid. Items modified should be + * refreshed with {@link HierarchicalDataProvider#refreshItem(Object)} and + * when adding or removing items + * {@link HierarchicalDataProvider#refreshAll()} should be called. + * + * @param items + * the data items to display, not null + */ @Override public void setItems(Collection<T> items) { - throw new UnsupportedOperationException("Not implemented"); + Objects.requireNonNull(items, "Given collection may not be null"); + setDataProvider(new InMemoryHierarchicalDataProvider<>( + new HierarchyData<T>().addItems(null, items))); } + /** + * Sets the data items of this component provided as a stream. + * <p> + * The provided items are wrapped into a + * {@link InMemoryHierarchicalDataProvider} backed by a flat + * {@link HierarchyData} structure. The data provider instance is used as a + * parameter for the {@link #setDataProvider(DataProvider)} method. It means + * that the items collection can be accessed later on via + * {@link InMemoryHierarchicalDataProvider#getData()}: + * + * <pre> + * <code> + * TreeGrid<String> treeGrid = new TreeGrid<>(); + * treeGrid.setItems(Stream.of("a","b")); + * ... + * + * HierarchyData<String> data = ((InMemoryHierarchicalDataProvider<String>)treeGrid.getDataProvider()).getData(); + * </code> + * </pre> + * <p> + * The returned HierarchyData instance may be used as-is to add, remove or + * modify items in the hierarchy. These modifications to the object are not + * automatically reflected back to the TreeGrid. Items modified should be + * refreshed with {@link HierarchicalDataProvider#refreshItem(Object)} and + * when adding or removing items + * {@link HierarchicalDataProvider#refreshAll()} should be called. + * + * @param items + * the data items to display, not null + */ @Override public void setItems(Stream<T> items) { - throw new UnsupportedOperationException("Not implemented"); + Objects.requireNonNull(items, "Given stream may not be null"); + setDataProvider(new InMemoryHierarchicalDataProvider<>( + new HierarchyData<T>().addItems(null, items))); } + /** + * Sets the data items of this listing. + * <p> + * The provided items are wrapped into a + * {@link InMemoryHierarchicalDataProvider} backed by a flat + * {@link HierarchyData} structure. The data provider instance is used as a + * parameter for the {@link #setDataProvider(DataProvider)} method. It means + * that the items collection can be accessed later on via + * {@link InMemoryHierarchicalDataProvider#getData()}: + * + * <pre> + * <code> + * TreeGrid<String> treeGrid = new TreeGrid<>(); + * treeGrid.setItems("a","b"); + * ... + * + * HierarchyData<String> data = ((InMemoryHierarchicalDataProvider<String>)treeGrid.getDataProvider()).getData(); + * </code> + * </pre> + * <p> + * The returned HierarchyData instance may be used as-is to add, remove or + * modify items in the hierarchy. These modifications to the object are not + * automatically reflected back to the TreeGrid. Items modified should be + * refreshed with {@link HierarchicalDataProvider#refreshItem(Object)} and + * when adding or removing items + * {@link HierarchicalDataProvider#refreshAll()} should be called. + * + * @param items + * the data items to display, not null + */ @Override - public void setItems(T... items) { - throw new UnsupportedOperationException("Not implemented"); + public void setItems(@SuppressWarnings("unchecked") T... items) { + Objects.requireNonNull(items, "Given items may not be null"); + setDataProvider(new InMemoryHierarchicalDataProvider<>( + new HierarchyData<T>().addItems(null, items))); } @Override @@ -138,34 +224,97 @@ public class TreeGrid<T> extends Grid<T> { return (TreeGridState) super.getState(markAsDirty); } - /** - * Toggle the expansion of an item in this grid. If the item is already - * expanded, it will be collapsed. - * <p> - * Toggling expansion on a leaf item in the hierarchy will have no effect. - * - * @param item - * the item to toggle expansion for - */ - public void toggleCollapse(T item) { - getDataProvider().setCollapsed(item, - !getDataProvider().isCollapsed(item)); - getDataCommunicator().reset(); + @Override + public HierarchicalDataCommunicator<T> getDataCommunicator() { + return (HierarchicalDataCommunicator<T>) super.getDataCommunicator(); } @Override public HierarchicalDataProvider<T, ?> getDataProvider() { - DataProvider<T, ?> dataProvider = super.getDataProvider(); - // FIXME DataCommunicator by default has a CallbackDataProvider if no - // DataProvider is set, resulting in a class cast exception if we don't - // check it here. + if (!(super.getDataProvider() instanceof HierarchicalDataProvider)) { + return null; + } + return (HierarchicalDataProvider<T, ?>) super.getDataProvider(); + } - // Once fixed, remove this method from the exclude list in - // StateGetDoesNotMarkDirtyTest - if (!(dataProvider instanceof HierarchicalDataProvider)) { - throw new IllegalStateException("No data provider has been set."); + @Override + protected void doReadDesign(Element design, DesignContext context) { + super.doReadDesign(design, context); + Attributes attrs = design.attributes(); + if (attrs.hasKey("hierarchy-column")) { + setHierarchyColumn(DesignAttributeHandler + .readAttribute("hierarchy-column", attrs, String.class)); + } + } + + @Override + protected void readData(Element body, + List<DeclarativeValueProvider<T>> providers) { + getSelectionModel().deselectAll(); + List<T> selectedItems = new ArrayList<>(); + HierarchyData<T> data = new HierarchyData<T>(); + + for (Element row : body.children()) { + T item = deserializeDeclarativeRepresentation(row.attr("item")); + T parent = null; + if (row.hasAttr("parent")) { + parent = deserializeDeclarativeRepresentation( + row.attr("parent")); + } + data.addItem(parent, item); + if (row.hasAttr("selected")) { + selectedItems.add(item); + } + Elements cells = row.children(); + int i = 0; + for (Element cell : cells) { + providers.get(i).addValue(item, cell.html()); + i++; + } + } + + setDataProvider(new InMemoryHierarchicalDataProvider<>(data)); + selectedItems.forEach(getSelectionModel()::select); + } + + @Override + protected void doWriteDesign(Element design, DesignContext designContext) { + super.doWriteDesign(design, designContext); + if (getColumnByInternalId(getState(false).hierarchyColumnId) != null) { + String hierarchyColumn = getColumnByInternalId( + getState(false).hierarchyColumnId).getId(); + DesignAttributeHandler.writeAttribute("hierarchy-column", + design.attributes(), hierarchyColumn, null, String.class, + designContext); + } + } + + @Override + protected void writeData(Element body, DesignContext designContext) { + getDataProvider().fetch(new HierarchicalQuery<>(null, null)) + .forEach(item -> writeRow(body, item, null, designContext)); + } + + private void writeRow(Element container, T item, T parent, + DesignContext context) { + Element tableRow = container.appendElement("tr"); + tableRow.attr("item", serializeDeclarativeRepresentation(item)); + if (parent != null) { + tableRow.attr("parent", serializeDeclarativeRepresentation(parent)); + } + if (getSelectionModel().isSelected(item)) { + tableRow.attr("selected", ""); + } + for (Column<T, ?> column : getColumns()) { + Object value = column.getValueProvider().apply(item); + tableRow.appendElement("td") + .append(Optional.ofNullable(value).map(Object::toString) + .map(DesignFormatter::encodeForTextNode) + .orElse("")); } - return (HierarchicalDataProvider<T, ?>) dataProvider; + getDataProvider().fetch(new HierarchicalQuery<>(null, item)) + .forEach(childItem -> writeRow(container, childItem, item, + context)); } @Override diff --git a/server/src/test/java/com/vaadin/data/provider/HierarchyMapperTest.java b/server/src/test/java/com/vaadin/data/provider/HierarchyMapperTest.java new file mode 100644 index 0000000000..1f48578eea --- /dev/null +++ b/server/src/test/java/com/vaadin/data/provider/HierarchyMapperTest.java @@ -0,0 +1,147 @@ +package com.vaadin.data.provider; + +import java.util.Optional; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import com.vaadin.data.provider.HierarchyMapper.TreeNode; + +public class HierarchyMapperTest { + + private HierarchyMapper mapper; + + @Before + public void setup() { + mapper = new HierarchyMapper(); + } + + @Test + public void testExpandCollapse_rootLevel_indexesUpdated() { + mapper.reset(3); + verifyRootLevel(0, 2); + + mapper.expand("1", 1, 3); + + verifyTreeTotalSize(6); + verifyRootLevel(0, 5); + verifyNodeExists("1", 2, 4); + + mapper.expand("0", 0, 3); + + verifyRootLevel(0, 8); + verifyNodeExists("0", 1, 3); + verifyNodeExists("1", 5, 7); + verifyTreeTotalSize(9); + + mapper.collapse("0", 0); + + verifyRootLevel(0, 5); + verifyNodeExists("1", 2, 4); + verifyTreeTotalSize(6); + verifyNoNodeExists("0"); + } + + @Test + public void testExpandCollapse_secondLevelLastNode_indexesUpdated() { + mapper.reset(3); + verifyRootLevel(0, 2); + + mapper.expand("1", 1, 3); + + verifyTreeTotalSize(6); + verifyRootLevel(0, 5); + verifyNodeExists("1", 2, 4); + + mapper.expand("0", 0, 3); + + verifyRootLevel(0, 8); + verifyNodeExists("0", 1, 3); + verifyNodeExists("1", 5, 7); + verifyTreeTotalSize(9); + + mapper.expand("2", 3, 3); + + verifyRootLevel(0, 11); + verifyNodeExists("0", 1, 6); + verifyNodeExists("1", 8, 10); + verifyNodeExists("2", 4, 6); + verifyTreeTotalSize(12); + + mapper.collapse("2", 3); + + verifyRootLevel(0, 8); + verifyNodeExists("0", 1, 3); + verifyNodeExists("1", 5, 7); + verifyNoNodeExists("2"); + verifyTreeTotalSize(9); + + mapper.collapse("0", 0); + + verifyRootLevel(0, 5); + verifyNodeExists("1", 2, 4); + verifyNoNodeExists("0"); + verifyTreeTotalSize(6); + } + + @Test + public void testCollapse_multipleLevels_wholeSubtreeDropped() { + // expand hierarchy up to 3 level + mapper.reset(5); + verifyRootLevel(0, 4); + + mapper.expand("1", 2, 2); + + verifyRootLevel(0, 6); + verifyNodeExists("1", 3, 4); + verifyTreeTotalSize(7); + + mapper.expand("2", 3, 2); + + verifyRootLevel(0, 8); + verifyNodeExists("1", 3, 6); + verifyNodeExists("2", 4, 5); + verifyTreeTotalSize(9); + + mapper.expand("3", 6, 2); + verifyRootLevel(0, 10); + verifyNodeExists("1", 3, 8); + verifyNodeExists("2", 4, 5); + verifyNodeExists("3", 7, 8); + verifyTreeTotalSize(11); + + // collapse root level node + mapper.collapse("1", 2); + verifyRootLevel(0, 4); + verifyNoNodeExists("1", "2", "3"); + } + + private void verifyRootLevel(int start, int end) { + verifyNode(start, end, mapper.getNodeForKey(null).get()); + } + + private void verifyNodeExists(String key, int start, int end) { + Optional<TreeNode> node = mapper.getNodeForKey(key); + Assert.assertTrue("NO NODE FOUND FOR KEY: " + key, node.isPresent()); + verifyNode(start, end, node.get()); + } + + private void verifyNoNodeExists(String... nodeKeys) { + for (String key : nodeKeys) { + Assert.assertFalse("No node should exist for key " + key, + mapper.getNodeForKey(key).isPresent()); + } + } + + private void verifyNode(int start, int end, TreeNode node) { + Assert.assertEquals("Invalid start for node " + node, start, + node.getStartIndex()); + Assert.assertEquals("Invalid end for node " + node, end, + node.getEndIndex()); + } + + private void verifyTreeTotalSize(int size) { + Assert.assertEquals("Invalid tree size", size, mapper.getTreeSize()); + } +} diff --git a/server/src/test/java/com/vaadin/data/provider/InMemoryHierarchicalDataProviderTest.java b/server/src/test/java/com/vaadin/data/provider/InMemoryHierarchicalDataProviderTest.java new file mode 100644 index 0000000000..6e4d66bb34 --- /dev/null +++ b/server/src/test/java/com/vaadin/data/provider/InMemoryHierarchicalDataProviderTest.java @@ -0,0 +1,271 @@ +package com.vaadin.data.provider; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.Assert; +import org.junit.Test; + +import com.vaadin.data.HierarchyData; +import com.vaadin.server.SerializablePredicate; + +public class InMemoryHierarchicalDataProviderTest extends + DataProviderTestBase<InMemoryHierarchicalDataProvider<StrBean>> { + + private HierarchyData<StrBean> data; + private List<StrBean> flattenedData; + private List<StrBean> rootData; + + @Override + public void setUp() { + List<StrBean> randomBeans = StrBean.generateRandomBeans(20); + flattenedData = new ArrayList<>(); + rootData = new ArrayList<>(); + + data = new HierarchyData<>(); + data.addItems(null, randomBeans.subList(0, 5)); + data.addItems(randomBeans.get(0), randomBeans.subList(5, 10)); + data.addItems(randomBeans.get(5), randomBeans.subList(10, 15)); + data.addItems(null, randomBeans.subList(15, 20)); + + flattenedData.add(randomBeans.get(0)); + flattenedData.add(randomBeans.get(5)); + flattenedData.addAll(randomBeans.subList(10, 15)); + flattenedData.addAll(randomBeans.subList(6, 10)); + flattenedData.addAll(randomBeans.subList(1, 5)); + flattenedData.addAll(randomBeans.subList(15, 20)); + + rootData.addAll(randomBeans.subList(0, 5)); + rootData.addAll(randomBeans.subList(15, 20)); + + super.setUp(); + } + + @Test(expected = IllegalArgumentException.class) + public void hierarchyData_add_item_parent_not_in_hierarchy_throws() { + new HierarchyData<>().addItem(new StrBean("", 0, 0), + new StrBean("", 0, 0)); + } + + @Test(expected = NullPointerException.class) + public void hierarchyData_add_null_item_throws() { + new HierarchyData<>().addItem(null, null); + } + + @Test(expected = IllegalArgumentException.class) + public void hierarchyData_add_item_already_in_hierarchy_throws() { + StrBean bean = new StrBean("", 0, 0); + new HierarchyData<>().addItem(null, bean).addItem(null, bean); + } + + @Test + public void hierarchyData_remove_root_item() { + data.removeItem(null); + Assert.assertTrue(data.getChildren(null).isEmpty()); + } + + @Test + public void hierarchyData_clear() { + data.clear(); + Assert.assertTrue(data.getChildren(null).isEmpty()); + } + + @Test + public void setFilter() { + getDataProvider().setFilter(item -> item.getValue().equals("Xyz") + || item.getValue().equals("Baz")); + + Assert.assertEquals(10, sizeWithUnfilteredQuery()); + + getDataProvider().setFilter(item -> !item.getValue().equals("Foo") + && !item.getValue().equals("Xyz")); + + Assert.assertEquals( + "Previous filter should be replaced when setting a new one", 6, + sizeWithUnfilteredQuery()); + + getDataProvider().setFilter(null); + + Assert.assertEquals("Setting filter to null should remove all filters", + 20, sizeWithUnfilteredQuery()); + } + + @Test + public void addFilter() { + getDataProvider().addFilter(item -> item.getId() <= 10); + getDataProvider().addFilter(item -> item.getId() >= 5); + Assert.assertEquals(5, sizeWithUnfilteredQuery()); + } + + @Override + public void filteringListDataProvider_convertFilter() { + DataProvider<StrBean, String> strFilterDataProvider = getDataProvider() + .withConvertedFilter( + text -> strBean -> strBean.getValue().contains(text)); + Assert.assertEquals("Only one item should match 'Xyz'", 1, + strFilterDataProvider + .size(new HierarchicalQuery<>("Xyz", null))); + Assert.assertEquals("No item should match 'Zyx'", 0, + strFilterDataProvider + .size(new HierarchicalQuery<>("Zyx", null))); + Assert.assertEquals("Unexpected number of matches for 'Foo'", 3, + strFilterDataProvider + .size(new HierarchicalQuery<>("Foo", null))); + Assert.assertEquals("No items should've been filtered out", + rootData.size(), strFilterDataProvider + .size(new HierarchicalQuery<>(null, null))); + } + + @Override + public void filteringListDataProvider_defaultFilterType() { + Assert.assertEquals("Only one item should match 'Xyz'", 1, + getDataProvider().size(new HierarchicalQuery<>( + strBean -> strBean.getValue().contains("Xyz"), null))); + Assert.assertEquals("No item should match 'Zyx'", 0, + dataProvider.size(new HierarchicalQuery<>( + strBean -> strBean.getValue().contains("Zyx"), null))); + Assert.assertEquals("Unexpected number of matches for 'Foo'", 3, + getDataProvider() + .size(new HierarchicalQuery<>(fooFilter, null))); + } + + @Override + public void testDefaultSortWithSpecifiedPostSort() { + Comparator<StrBean> comp = Comparator.comparing(StrBean::getValue) + .thenComparing(Comparator.comparing(StrBean::getId).reversed()); + setSortOrder(QuerySortOrder.asc("value").thenDesc("id").build(), comp); + + List<StrBean> list = getDataProvider() + .fetch(createQuery(QuerySortOrder.asc("randomNumber").build(), + Comparator.comparing(StrBean::getRandomNumber), null, + null)) + .collect(Collectors.toList()); + + Assert.assertEquals("Sorted data and original data sizes don't match", + getDataProvider().fetch(new HierarchicalQuery<>(null, null)) + .count(), + list.size()); + + for (int i = 1; i < list.size(); ++i) { + StrBean prev = list.get(i - 1); + StrBean cur = list.get(i); + // Test specific sort + Assert.assertTrue( + "Failure: " + prev.getRandomNumber() + " > " + + cur.getRandomNumber(), + prev.getRandomNumber() <= cur.getRandomNumber()); + + if (prev.getRandomNumber() == cur.getRandomNumber()) { + // Test default sort + Assert.assertTrue( + prev.getValue().compareTo(cur.getValue()) <= 0); + if (prev.getValue().equals(cur.getValue())) { + Assert.assertTrue(prev.getId() > cur.getId()); + } + } + } + } + + @Override + public void testDefaultSortWithFunction() { + setSortOrder(QuerySortOrder.asc("value").build(), + Comparator.comparing(StrBean::getValue)); + + List<StrBean> list = getDataProvider() + .fetch(new HierarchicalQuery<>(null, null)) + .collect(Collectors.toList()); + + Assert.assertEquals("Sorted data and original data sizes don't match", + rootData.size(), list.size()); + + for (int i = 1; i < list.size(); ++i) { + StrBean prev = list.get(i - 1); + StrBean cur = list.get(i); + + // Test default sort + Assert.assertTrue(prev.getValue().compareTo(cur.getValue()) <= 0); + } + } + + @Override + public void testListContainsAllData() { + assertHierarchyCorrect(); + } + + @Override + public void testSortByComparatorListsDiffer() { + Comparator<StrBean> comp = Comparator.comparing(StrBean::getValue) + .thenComparing(StrBean::getRandomNumber) + .thenComparing(StrBean::getId); + + List<StrBean> list = getDataProvider().fetch( + createQuery(QuerySortOrder.asc("value").thenAsc("randomNumber") + .thenAsc("id").build(), comp, null, null)) + .collect(Collectors.toList()); + + Assert.assertNotEquals("First value should not match", rootData.get(0), + list.get(0)); + + Assert.assertEquals("Sorted data and original data sizes don't match", + rootData.size(), list.size()); + + rootData.sort(comp); + for (int i = 0; i < rootData.size(); ++i) { + Assert.assertEquals("Sorting result differed", rootData.get(i), + list.get(i)); + } + } + + @Override + protected InMemoryHierarchicalDataProvider<StrBean> createDataProvider() { + return new InMemoryHierarchicalDataProvider<>(data); + } + + @Override + protected void setSortOrder(List<QuerySortOrder> sortOrder, + Comparator<StrBean> comp) { + getDataProvider().setSortComparator(comp::compare); + } + + @Override + protected long sizeWithUnfilteredQuery() { + return getFlattenedDataFromProvider(new ArrayList<>(), null).size(); + } + + private void assertHierarchyCorrect() { + Assert.assertEquals(flattenedData, + getFlattenedData(new ArrayList<>(), null)); + Assert.assertEquals(flattenedData, + getFlattenedDataFromProvider(new ArrayList<>(), null)); + } + + private List<StrBean> getFlattenedData(List<StrBean> flattened, + StrBean item) { + if (item != null) { + flattened.add(item); + } + data.getChildren(item) + .forEach(child -> getFlattenedData(flattened, child)); + return flattened; + } + + private List<StrBean> getFlattenedDataFromProvider(List<StrBean> flattened, + StrBean item) { + if (item != null) { + flattened.add(item); + } + getDataProvider().fetchChildren(new HierarchicalQuery<>(null, item)) + .forEach(child -> getFlattenedDataFromProvider(flattened, + child)); + return flattened; + } + + private HierarchicalQuery<StrBean, SerializablePredicate<StrBean>> createQuery( + List<QuerySortOrder> sortOrder, Comparator<StrBean> comp, + SerializablePredicate<StrBean> filter, StrBean parent) { + return new HierarchicalQuery<>(0, Integer.MAX_VALUE, sortOrder, comp, + filter, parent); + } +} diff --git a/server/src/test/java/com/vaadin/tests/server/ClassesSerializableTest.java b/server/src/test/java/com/vaadin/tests/server/ClassesSerializableTest.java index 5659ec7c56..ec1032c797 100644 --- a/server/src/test/java/com/vaadin/tests/server/ClassesSerializableTest.java +++ b/server/src/test/java/com/vaadin/tests/server/ClassesSerializableTest.java @@ -76,6 +76,7 @@ public class ClassesSerializableTest { "com\\.vaadin\\.buildhelpers.*", // "com\\.vaadin\\.util\\.EncodeUtil.*", // "com\\.vaadin\\.util\\.ReflectTools.*", // + "com\\.vaadin\\.data\\.provider\\.HierarchyMapper\\$TreeLevelQuery", "com\\.vaadin\\.data\\.util\\.ReflectTools.*", // "com\\.vaadin\\.data\\.util\\.JsonUtil.*", // "com\\.vaadin\\.data\\.util.BeanItemContainerGenerator.*", diff --git a/server/src/test/java/com/vaadin/tests/server/component/treegrid/TreeGridDeclarativeTest.java b/server/src/test/java/com/vaadin/tests/server/component/treegrid/TreeGridDeclarativeTest.java new file mode 100644 index 0000000000..c8a51a1928 --- /dev/null +++ b/server/src/test/java/com/vaadin/tests/server/component/treegrid/TreeGridDeclarativeTest.java @@ -0,0 +1,116 @@ +package com.vaadin.tests.server.component.treegrid; + +import java.lang.reflect.InvocationTargetException; + +import org.junit.Assert; + +import com.vaadin.data.HierarchyData; +import com.vaadin.data.provider.HierarchicalQuery; +import com.vaadin.data.provider.InMemoryHierarchicalDataProvider; +import com.vaadin.tests.data.bean.Person; +import com.vaadin.tests.server.component.abstractlisting.AbstractListingDeclarativeTest; +import com.vaadin.ui.TreeGrid; + +public class TreeGridDeclarativeTest + extends AbstractListingDeclarativeTest<TreeGrid> { + + @SuppressWarnings("unchecked") + @Override + public void dataSerialization() throws InstantiationException, + IllegalAccessException, InvocationTargetException { + TreeGrid<Person> grid = new TreeGrid<>(); + + Person person1 = createPerson("a", "last-name"); + Person person2 = createPerson("aa", "last-name"); + Person person3 = createPerson("ab", "last-name"); + Person person4 = createPerson("b", "last-name"); + Person person5 = createPerson("c", "last-name"); + Person person6 = createPerson("ca", "last-name"); + Person person7 = createPerson("caa", "last-name"); + + HierarchyData<Person> data = new HierarchyData<>(); + data.addItems(null, person1, person4, person5); + data.addItems(person1, person2, person3); + data.addItem(person5, person6); + data.addItem(person6, person7); + + grid.addColumn(Person::getFirstName).setCaption("First Name"); + grid.addColumn(Person::getLastName).setId("id").setCaption("Id"); + + grid.setHierarchyColumn("id"); + grid.setDataProvider(new InMemoryHierarchicalDataProvider<>(data)); + + String design = String.format( + "<%s hierarchy-column='id'><table><colgroup>" + + "<col column-id='column0' sortable>" + + "<col column-id='id' sortable></colgroup><thead>" + + "<tr default><th plain-text column-ids='column0'>First Name</th>" + + "<th plain-text column-ids='id'>Id</th></tr>" + + "</thead><tbody>" + + "<tr item='%s'><td>%s</td><td>%s</td></tr>" + + "<tr item='%s' parent='%s'><td>%s</td><td>%s</td></tr>" + + "<tr item='%s' parent='%s'><td>%s</td><td>%s</td></tr>" + + "<tr item='%s'><td>%s</td><td>%s</td></tr>" + + "<tr item='%s'><td>%s</td><td>%s</td></tr>" + + "<tr item='%s' parent='%s'><td>%s</td><td>%s</td></tr>" + + "<tr item='%s' parent='%s'><td>%s</td><td>%s</td></tr>" + + "</tbody></table></%s>", + getComponentTag(), person1.toString(), person1.getFirstName(), + person1.getLastName(), person2.toString(), person1.toString(), + person2.getFirstName(), person2.getLastName(), + person3.toString(), person1.toString(), person3.getFirstName(), + person3.getLastName(), person4.toString(), + person4.getFirstName(), person4.getLastName(), + person5.toString(), person5.getFirstName(), + person5.getLastName(), person6.toString(), person5.toString(), + person6.getFirstName(), person6.getLastName(), + person7.toString(), person6.toString(), person7.getFirstName(), + person7.getLastName(), getComponentTag()); + + TreeGrid<String> readGrid = testRead(design, grid); + Assert.assertEquals(3, readGrid.getDataProvider() + .size(new HierarchicalQuery<>(null, null))); + Assert.assertEquals(2, readGrid.getDataProvider() + .size(new HierarchicalQuery<>(null, person1.toString()))); + Assert.assertEquals(1, readGrid.getDataProvider() + .size(new HierarchicalQuery<>(null, person5.toString()))); + Assert.assertEquals(1, readGrid.getDataProvider() + .size(new HierarchicalQuery<>(null, person6.toString()))); + testWrite(design, grid, true); + } + + + @Override + public void valueSerialization() throws InstantiationException, + IllegalAccessException, InvocationTargetException { + // Tested by GridDeclarativeTest + } + + @Override + public void readOnlySelection() throws InstantiationException, + IllegalAccessException, InvocationTargetException { + // Tested by GridDeclarativeTest + } + + @Override + protected String getComponentTag() { + return "vaadin-tree-grid"; + } + + @Override + protected Class<? extends TreeGrid> getComponentClass() { + return TreeGrid.class; + } + + private Person createPerson(String name, String lastName) { + Person person = new Person() { + @Override + public String toString() { + return getFirstName() + " " + getLastName(); + } + }; + person.setFirstName(name); + person.setLastName(lastName); + return person; + } +} diff --git a/shared/src/main/java/com/vaadin/shared/data/DataCommunicatorClientRpc.java b/shared/src/main/java/com/vaadin/shared/data/DataCommunicatorClientRpc.java index 501b803cd2..d268c887c4 100644 --- a/shared/src/main/java/com/vaadin/shared/data/DataCommunicatorClientRpc.java +++ b/shared/src/main/java/com/vaadin/shared/data/DataCommunicatorClientRpc.java @@ -56,5 +56,24 @@ public interface DataCommunicatorClientRpc extends ClientRpc { */ void updateData(JsonArray data); - // TODO: Notify add / remove + /** + * Informs that new data has been inserted from the server. + * + * @param firstRowIndex + * the destination index of the new row data + * @param count + * the number of rows inserted + */ + void insertRows(int firstRowIndex, int count); + + /** + * Informs that the server has removed data. + * + * @param firstRowIndex + * the index of the first removed row + * @param count + * the number of removed rows, starting from + * <code>firstRowIndex</code> + */ + void removeRows(int firstRowIndex, int count); } diff --git a/shared/src/main/java/com/vaadin/shared/extension/datacommunicator/HierarchicalDataCommunicatorState.java b/shared/src/main/java/com/vaadin/shared/extension/datacommunicator/HierarchicalDataCommunicatorState.java new file mode 100644 index 0000000000..e56dbfe8a2 --- /dev/null +++ b/shared/src/main/java/com/vaadin/shared/extension/datacommunicator/HierarchicalDataCommunicatorState.java @@ -0,0 +1,25 @@ +/* + * Copyright 2000-2016 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.extension.datacommunicator; + +/** + * Shared state for HierarchicalDataCommunicator. + * + * @since + */ +public class HierarchicalDataCommunicatorState extends DataCommunicatorState { + +} diff --git a/shared/src/main/java/com/vaadin/shared/ui/treegrid/NodeCollapseRpc.java b/shared/src/main/java/com/vaadin/shared/ui/treegrid/NodeCollapseRpc.java index 719e5ba183..1b16203648 100644 --- a/shared/src/main/java/com/vaadin/shared/ui/treegrid/NodeCollapseRpc.java +++ b/shared/src/main/java/com/vaadin/shared/ui/treegrid/NodeCollapseRpc.java @@ -20,12 +20,22 @@ import com.vaadin.shared.communication.ServerRpc; /** * RPC to handle client originated collapse and expand actions on hierarchical * rows in TreeGrid. - * + * * @author Vaadin Ltd * @since 8.1 */ @FunctionalInterface public interface NodeCollapseRpc extends ServerRpc { - void toggleCollapse(String rowKey); + /** + * Sets the collapse state of a hierarchical row in TreeGrid. + * + * @param rowKey + * the row's key + * @param rowIndex + * index where the row is in grid (all rows) + * @param collapse + * {@code true} to collapse, {@code false} to expand + */ + void setNodeCollapsed(String rowKey, int rowIndex, boolean collapse); } diff --git a/testbench-api/src/main/java/com/vaadin/testbench/elements/TreeGridElement.java b/testbench-api/src/main/java/com/vaadin/testbench/elements/TreeGridElement.java index 71c47bcc6e..0ca81a56ef 100644 --- a/testbench-api/src/main/java/com/vaadin/testbench/elements/TreeGridElement.java +++ b/testbench-api/src/main/java/com/vaadin/testbench/elements/TreeGridElement.java @@ -15,6 +15,11 @@ */ package com.vaadin.testbench.elements; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; + +import com.vaadin.testbench.By; + /** * TestBench Element API for TreeGrid * @@ -22,4 +27,111 @@ package com.vaadin.testbench.elements; */ public class TreeGridElement extends GridElement { + /** + * Expands the row at the given index in the grid. This expects the first + * column to have the hierarchy data. + * + * @param rowIndex + * 0-based row index to expand + * @see #expandWithClick(int, int) + */ + public void expandWithClick(int rowIndex) { + expandWithClick(rowIndex, 0); + } + + /** + * Expands the row at the given index in the grid with the given + * hierarchical column index. + * + * @param rowIndex + * 0-based row index to expand + * @param hierarchyColumnIndex + * 0-based index of the hierarchy column + */ + public void expandWithClick(int rowIndex, int hierarchyColumnIndex) { + if (isRowExpanded(rowIndex, hierarchyColumnIndex)) { + throw new IllegalStateException( + "The element at row " + rowIndex + " was expanded already"); + } + getExpandElement(rowIndex, hierarchyColumnIndex).click(); + } + + /** + * Collapses the row at the given index in the grid. This expects the first + * column to have the hierarchy data. + * + * @param rowIndex + * 0-based row index to collapse + * @see #collapseWithClick(int, int) + */ + public void collapseWithClick(int rowIndex) { + collapseWithClick(rowIndex, 0); + } + + /** + * Collapses the row at the given index in the grid with the given + * hierarchical column index. + * + * @param rowIndex + * 0-based row index to collapse + * @param hierarchyColumnIndex + * 0-based index of the hierarchy column + */ + public void collapseWithClick(int rowIndex, int hierarchyColumnIndex) { + if (isRowCollapsed(rowIndex, hierarchyColumnIndex)) { + throw new IllegalStateException("The element at row " + rowIndex + + " was collapsed already"); + } + getExpandElement(rowIndex, hierarchyColumnIndex).click(); + } + + /** + * Returns whether the row at the given index is expanded or not. + * + * @param rowIndex + * 0-based row index + * @param hierarchyColumnIndex + * 0-based index of the hierarchy column + * @return {@code true} if expanded, {@code false} if collapsed + */ + public boolean isRowExpanded(int rowIndex, int hierarchyColumnIndex) { + WebElement expandElement = getExpandElement(rowIndex, + hierarchyColumnIndex); + return expandElement.getAttribute("expanded") != null + && expandElement.getAttribute("collapsed") == null; + } + + /** + * Returns whether the row at the given index is collapsed or not. + * + * @param rowIndex + * 0-based row index + * @param hierarchyColumnIndex + * 0-based index of the hierarchy column + * @return {@code true} if collapsed, {@code false} if expanded + */ + public boolean isRowCollapsed(int rowIndex, int hierarchyColumnIndex) { + WebElement expandElement = getExpandElement(rowIndex, + hierarchyColumnIndex); + return expandElement.getAttribute("collapsed") != null + && expandElement.getAttribute("expanded") == null; + } + + /** + * Gets the expand/collapse element for the given row. + * + * @param rowIndex + * 0-based row index + * @param hierarchyColumnIndex + * 0-based index of the hierarchy column + * @return the {@code span} element that is clicked for expanding/collapsing + * a rows + * @throws NoSuchElementException + * if there is no expand element for this row + */ + public WebElement getExpandElement(int rowIndex, int hierarchyColumnIndex) { + return getCell(rowIndex, hierarchyColumnIndex) + .findElement(By.className("v-tree-grid-expander")); + + } } diff --git a/uitest/src/main/java/com/vaadin/tests/components/treegrid/LazyHierarchicalDataProvider.java b/uitest/src/main/java/com/vaadin/tests/components/treegrid/LazyHierarchicalDataProvider.java new file mode 100644 index 0000000000..33c8f15a47 --- /dev/null +++ b/uitest/src/main/java/com/vaadin/tests/components/treegrid/LazyHierarchicalDataProvider.java @@ -0,0 +1,63 @@ +package com.vaadin.tests.components.treegrid; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import com.vaadin.data.provider.AbstractHierarchicalDataProvider; +import com.vaadin.data.provider.HierarchicalQuery; +import com.vaadin.tests.components.treegrid.TreeGridBasicFeatures.HierarchicalTestBean; + +public class LazyHierarchicalDataProvider + extends AbstractHierarchicalDataProvider<HierarchicalTestBean, Void> { + + private final int nodesPerLevel; + private final int depth; + + public LazyHierarchicalDataProvider(int nodesPerLevel, int depth) { + this.nodesPerLevel = nodesPerLevel; + this.depth = depth; + } + + @Override + public int getChildCount( + HierarchicalQuery<HierarchicalTestBean, Void> query) { + + Optional<Integer> count = query.getParentOptional() + .flatMap(parent -> Optional.of(Integer.valueOf( + (internalHasChildren(parent) ? nodesPerLevel : 0)))); + + return count.orElse(nodesPerLevel); + } + + @Override + public Stream<HierarchicalTestBean> fetchChildren( + HierarchicalQuery<HierarchicalTestBean, Void> query) { + final int depth = query.getParentOptional().isPresent() + ? query.getParent().getDepth() + 1 : 0; + final Optional<String> parentKey = query.getParentOptional() + .flatMap(parent -> Optional.of(parent.getId())); + + List<HierarchicalTestBean> list = new ArrayList<>(); + for (int i = 0; i < query.getLimit(); i++) { + list.add(new HierarchicalTestBean(parentKey.orElse(null), depth, + i + query.getOffset())); + } + return list.stream(); + } + + @Override + public boolean hasChildren(HierarchicalTestBean item) { + return internalHasChildren(item); + } + + private boolean internalHasChildren(HierarchicalTestBean node) { + return node.getDepth() < depth; + } + + @Override + public boolean isInMemory() { + return false; + } +} diff --git a/uitest/src/main/java/com/vaadin/tests/components/treegrid/TreeGridBasicFeatures.java b/uitest/src/main/java/com/vaadin/tests/components/treegrid/TreeGridBasicFeatures.java index 77e1e2eefa..7998537a6e 100644 --- a/uitest/src/main/java/com/vaadin/tests/components/treegrid/TreeGridBasicFeatures.java +++ b/uitest/src/main/java/com/vaadin/tests/components/treegrid/TreeGridBasicFeatures.java @@ -1,28 +1,24 @@ package com.vaadin.tests.components.treegrid; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import com.vaadin.data.provider.DataProviderListener; -import com.vaadin.data.provider.HierarchicalDataProvider; -import com.vaadin.data.provider.Query; -import com.vaadin.shared.Registration; +import com.vaadin.annotations.Theme; +import com.vaadin.annotations.Widgetset; +import com.vaadin.data.HierarchyData; +import com.vaadin.data.provider.DataProvider; +import com.vaadin.data.provider.InMemoryHierarchicalDataProvider; import com.vaadin.tests.components.AbstractComponentTest; -import com.vaadin.ui.MenuBar.MenuItem; import com.vaadin.ui.TreeGrid; +@Theme("valo") +@Widgetset("com.vaadin.DefaultWidgetSet") public class TreeGridBasicFeatures extends AbstractComponentTest<TreeGrid> { - private TreeGrid<TestBean> grid; - private TestDataProvider dataProvider = new TestDataProvider(); + private TreeGrid<HierarchicalTestBean> grid; + private InMemoryHierarchicalDataProvider<HierarchicalTestBean> inMemoryDataProvider; + private LazyHierarchicalDataProvider lazyDataProvider; @Override public TreeGrid getComponent() { @@ -36,12 +32,17 @@ public class TreeGridBasicFeatures extends AbstractComponentTest<TreeGrid> { @Override protected void initializeComponents() { + initializeDataProviders(); grid = new TreeGrid<>(); grid.setSizeFull(); - grid.addColumn(TestBean::getStringValue).setId("First column"); - grid.addColumn(TestBean::getStringValue).setId("Second column"); - grid.setHierarchyColumn("First column"); - grid.setDataProvider(dataProvider); + grid.addColumn(HierarchicalTestBean::toString).setCaption("String") + .setId("string"); + grid.addColumn(HierarchicalTestBean::getDepth).setCaption("Depth") + .setId("depth"); + grid.addColumn(HierarchicalTestBean::getIndex) + .setCaption("Index on this depth").setId("index"); + grid.setHierarchyColumn("string"); + grid.setDataProvider(new LazyHierarchicalDataProvider(3, 2)); grid.setId("testComponent"); addTestComponent(grid); @@ -51,8 +52,44 @@ public class TreeGridBasicFeatures extends AbstractComponentTest<TreeGrid> { protected void createActions() { super.createActions(); + createDataProviderSelect(); createHierarchyColumnSelect(); - createToggleCollapseSelect(); + } + + private void initializeDataProviders() { + HierarchyData<HierarchicalTestBean> data = new HierarchyData<>(); + + List<Integer> ints = Arrays.asList(0, 1, 2); + + ints.stream().forEach(index -> { + HierarchicalTestBean bean = new HierarchicalTestBean(null, 0, + index); + data.addItem(null, bean); + ints.stream().forEach(childIndex -> { + HierarchicalTestBean childBean = new HierarchicalTestBean( + bean.getId(), 1, childIndex); + data.addItem(bean, childBean); + ints.stream() + .forEach(grandChildIndex -> data.addItem(childBean, + new HierarchicalTestBean(childBean.getId(), 2, + grandChildIndex))); + }); + }); + + inMemoryDataProvider = new InMemoryHierarchicalDataProvider<>(data); + lazyDataProvider = new LazyHierarchicalDataProvider(3, 2); + } + + @SuppressWarnings("unchecked") + private void createDataProviderSelect() { + @SuppressWarnings("rawtypes") + LinkedHashMap<String, DataProvider> options = new LinkedHashMap<>(); + options.put("LazyHierarchicalDataProvider", lazyDataProvider); + options.put("InMemoryHierarchicalDataProvider", inMemoryDataProvider); + + createSelectAction("Set data provider", CATEGORY_FEATURES, options, + "LazyHierarchicalDataProvider", + (treeGrid, value, data) -> treeGrid.setDataProvider(value)); } private void createHierarchyColumnSelect() { @@ -65,205 +102,64 @@ public class TreeGridBasicFeatures extends AbstractComponentTest<TreeGrid> { (treeGrid, value, data) -> treeGrid.setHierarchyColumn(value)); } - private void createToggleCollapseSelect() { - MenuItem menu = createCategory("Toggle expand", CATEGORY_FEATURES); - dataProvider.getAllItems().forEach(testBean -> { - createClickAction(testBean.getStringValue(), "Toggle expand", - (grid, bean, data) -> grid.toggleCollapse(bean), testBean); - }); - } - - private static class TestBean { - - private String stringValue; - - public TestBean(String stringValue) { - this.stringValue = stringValue; - } - - public String getStringValue() { - return stringValue; - } - - public void setStringValue(String stringValue) { - this.stringValue = stringValue; - } - } - - private static class TestDataProvider - implements HierarchicalDataProvider<TestBean, Void> { - - private static class HierarchyWrapper<T> { - private T item; - private T parent; - private Set<T> children; - private boolean collapsed; - - public HierarchyWrapper(T item, T parent, boolean collapsed) { - this.item = item; - this.parent = parent; - this.collapsed = collapsed; - children = new LinkedHashSet<>(); - } - - public T getItem() { - return item; - } - - public void setItem(T item) { - this.item = item; - } - - public T getParent() { - return parent; - } - - public void setParent(T parent) { - this.parent = parent; - } - - public Set<T> getChildren() { - return children; - } - - public void setChildren(Set<T> children) { - this.children = children; - } - - public boolean isCollapsed() { - return collapsed; - } - - public void setCollapsed(boolean collapsed) { - this.collapsed = collapsed; - } - } - - private Map<TestBean, HierarchyWrapper<TestBean>> itemToWrapperMap; - private Map<HierarchyWrapper<TestBean>, TestBean> wrapperToItemMap; - private Map<TestBean, HierarchyWrapper<TestBean>> rootNodes; - - public TestDataProvider() { - itemToWrapperMap = new LinkedHashMap<>(); - wrapperToItemMap = new LinkedHashMap<>(); - rootNodes = new LinkedHashMap<>(); + static class HierarchicalTestBean { - List<String> strings = Arrays.asList("a", "b", "c"); + private final String id; + private final int depth; + private final int index; - strings.stream().forEach(string -> { - TestBean rootBean = new TestBean(string); - - HierarchyWrapper<TestBean> wrappedParent = new HierarchyWrapper<>( - rootBean, null, true); - itemToWrapperMap.put(rootBean, wrappedParent); - wrapperToItemMap.put(wrappedParent, rootBean); - - List<TestBean> children = strings.stream().map(string2 -> { - TestBean childBean = new TestBean(string + "/" + string2); - HierarchyWrapper<TestBean> wrappedChild = new HierarchyWrapper<>( - new TestBean(string + "/" + string2), rootBean, - true); - itemToWrapperMap.put(childBean, wrappedChild); - wrapperToItemMap.put(wrappedChild, childBean); - return childBean; - }).collect(Collectors.toList()); - - wrappedParent.setChildren(new LinkedHashSet<>(children)); - - rootNodes.put(rootBean, wrappedParent); - }); + public HierarchicalTestBean(String parentId, int depth, int index) { + id = (parentId == null ? "" : parentId) + "/" + depth + "/" + index; + this.depth = depth; + this.index = index; } - @Override - public int getDepth(TestBean item) { - int depth = 0; - while (getItem(item) != null) { - item = getItem(item).getParent(); - depth++; - } + public int getDepth() { return depth; } - @Override - public boolean isInMemory() { - return true; - } - - @Override - public void refreshItem(TestBean item) { - // NO-OP - } - - @Override - public void refreshAll() { - // NO-OP - } - - @Override - public Registration addDataProviderListener( - DataProviderListener<TestBean> listener) { - return () -> { - }; - } - - private List<TestBean> getAllItems() { - return new ArrayList<>(itemToWrapperMap.keySet()); - } - - private List<TestBean> getVisibleItemsRecursive( - Collection<HierarchyWrapper<TestBean>> wrappedItems) { - List<TestBean> items = new ArrayList<>(); - - wrappedItems.forEach(wrappedItem -> { - items.add(wrapperToItemMap.get(wrappedItem)); - if (!wrappedItem.isCollapsed()) { - List<HierarchyWrapper<TestBean>> wrappedChildren = wrappedItem - .getChildren().stream() - .map(childItem -> getItem(childItem)) - .collect(Collectors.toList()); - items.addAll(getVisibleItemsRecursive(wrappedChildren)); - } - }); - return items; + public int getIndex() { + return index; } - @Override - public int size(Query<TestBean, Void> query) { - return getVisibleItemsRecursive(rootNodes.values()).size(); - } - - @Override - public Stream<TestBean> fetch(Query<TestBean, Void> query) { - return getVisibleItemsRecursive(rootNodes.values()).stream(); - } - - @Override - public boolean isRoot(TestBean item) { - return getItem(item).getParent() == null; + public String getId() { + return id; } @Override - public TestBean getParent(TestBean item) { - return getItem(item).getParent(); + public String toString() { + return depth + " | " + index; } @Override - public boolean isCollapsed(TestBean item) { - return getItem(item).isCollapsed(); + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((id == null) ? 0 : id.hashCode()); + return result; } @Override - public boolean hasChildren(TestBean item) { - return !getItem(item).getChildren().isEmpty(); - } - - @Override - public void setCollapsed(TestBean item, boolean b) { - getItem(item).setCollapsed(b); + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + HierarchicalTestBean other = (HierarchicalTestBean) obj; + if (id == null) { + if (other.id != null) { + return false; + } + } else if (!id.equals(other.id)) { + return false; + } + return true; } - private HierarchyWrapper<TestBean> getItem(TestBean item) { - return itemToWrapperMap.get(item); - } } } diff --git a/uitest/src/main/java/com/vaadin/tests/components/treegrid/TreeGridScrolling.java b/uitest/src/main/java/com/vaadin/tests/components/treegrid/TreeGridScrolling.java new file mode 100644 index 0000000000..8d859918b8 --- /dev/null +++ b/uitest/src/main/java/com/vaadin/tests/components/treegrid/TreeGridScrolling.java @@ -0,0 +1,42 @@ +package com.vaadin.tests.components.treegrid; + +import com.vaadin.annotations.Widgetset; +import com.vaadin.server.VaadinRequest; +import com.vaadin.tests.components.AbstractTestUI; +import com.vaadin.tests.components.treegrid.TreeGridBasicFeatures.HierarchicalTestBean; +import com.vaadin.ui.TreeGrid; + +@Widgetset("com.vaadin.DefaultWidgetSet") +public class TreeGridScrolling extends AbstractTestUI { + + public static final int DEFAULT_NODES = 20; + public static final int DEFAULT_DEPTH = 3; + public static final String NODES_PARAMETER = "nodes"; + public static final String DEPTH_PARAMETER = "depth"; + + @Override + protected void setup(VaadinRequest request) { + int depth = DEFAULT_DEPTH; + if (request.getParameter(DEPTH_PARAMETER) != null) { + depth = Integer.parseInt(request.getParameter(DEPTH_PARAMETER)); + } + int nodes = DEFAULT_NODES; + if (request.getParameter(NODES_PARAMETER) != null) { + nodes = Integer.parseInt(request.getParameter(NODES_PARAMETER)); + } + + TreeGrid<HierarchicalTestBean> grid = new TreeGrid<>(); + grid.setSizeFull(); + grid.addColumn(HierarchicalTestBean::toString).setCaption("String") + .setId("string"); + grid.addColumn(HierarchicalTestBean::getDepth).setCaption("Depth") + .setId(DEPTH_PARAMETER); + grid.addColumn(HierarchicalTestBean::getIndex) + .setCaption("Index on this depth").setId("index"); + grid.setHierarchyColumn("string"); + grid.setDataProvider(new LazyHierarchicalDataProvider(nodes, depth)); + + addComponent(grid); + } + +} diff --git a/uitest/src/main/java/com/vaadin/tests/components/treetable/TreeTableCacheOnPartialUpdates.java b/uitest/src/main/java/com/vaadin/tests/components/treetable/TreeTableCacheOnPartialUpdates.java index 3824c6c5d0..e008dde0f5 100644 --- a/uitest/src/main/java/com/vaadin/tests/components/treetable/TreeTableCacheOnPartialUpdates.java +++ b/uitest/src/main/java/com/vaadin/tests/components/treetable/TreeTableCacheOnPartialUpdates.java @@ -62,7 +62,7 @@ public class TreeTableCacheOnPartialUpdates extends TestBase { @Override public String toString() { - return "TestBean [col1=" + col1 + ", col2=" + col2 + "]"; + return "HierarchicalTestBean [col1=" + col1 + ", col2=" + col2 + "]"; } } diff --git a/uitest/src/test/java/com/vaadin/tests/components/treegrid/TreeGridBasicFeaturesTest.java b/uitest/src/test/java/com/vaadin/tests/components/treegrid/TreeGridBasicFeaturesTest.java index c84c96232c..b01a6273e2 100644 --- a/uitest/src/test/java/com/vaadin/tests/components/treegrid/TreeGridBasicFeaturesTest.java +++ b/uitest/src/test/java/com/vaadin/tests/components/treegrid/TreeGridBasicFeaturesTest.java @@ -1,19 +1,38 @@ package com.vaadin.tests.components.treegrid; +import java.util.Arrays; +import java.util.Collection; + import org.junit.Assert; import org.junit.Before; +import org.junit.Ignore; import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized.Parameters; import org.openqa.selenium.Keys; import org.openqa.selenium.interactions.Actions; import com.vaadin.testbench.By; import com.vaadin.testbench.elements.TreeGridElement; import com.vaadin.tests.tb3.MultiBrowserTest; +import com.vaadin.tests.tb3.ParameterizedTB3Runner; +@RunWith(ParameterizedTB3Runner.class) public class TreeGridBasicFeaturesTest extends MultiBrowserTest { private TreeGridElement grid; + public void setDataProvider(String dataProviderString) { + selectMenuPath("Component", "Features", "Set data provider", + dataProviderString); + } + + @Parameters + public static Collection<String> getDataProviders() { + return Arrays.asList("LazyHierarchicalDataProvider", + "InMemoryHierarchicalDataProvider"); + } + @Before public void before() { openTestURL("theme=valo"); @@ -21,56 +40,59 @@ public class TreeGridBasicFeaturesTest extends MultiBrowserTest { } @Test + @Ignore // currently no implementation exists for toggling from the server + // side public void toggle_collapse_server_side() { Assert.assertEquals(3, grid.getRowCount()); - assertCellTexts(0, 0, new String[] { "a", "b", "c" }); + assertCellTexts(0, 0, new String[] { "0 | 0", "0 | 1", "0 | 2" }); - selectMenuPath("Component", "Features", "Toggle expand", "a"); + selectMenuPath("Component", "Features", "Toggle expand", "0 | 0"); Assert.assertEquals(6, grid.getRowCount()); - assertCellTexts(1, 0, new String[] { "a/a", "a/b", "a/c" }); + assertCellTexts(1, 0, new String[] { "1 | 0", "1 | 1", "1 | 2" }); - selectMenuPath("Component", "Features", "Toggle expand", "a"); + selectMenuPath("Component", "Features", "Toggle expand", "0 | 0"); Assert.assertEquals(3, grid.getRowCount()); - assertCellTexts(0, 0, new String[] { "a", "b", "c" }); + assertCellTexts(0, 0, new String[] { "0 | 0", "0 | 1", "0 | 2" }); // collapsing a leaf should have no effect - selectMenuPath("Component", "Features", "Toggle expand", "a/a"); + selectMenuPath("Component", "Features", "Toggle expand", "1 | 0"); Assert.assertEquals(3, grid.getRowCount()); } @Test public void non_leaf_collapse_on_click() { Assert.assertEquals(3, grid.getRowCount()); - assertCellTexts(0, 0, new String[] { "a", "b", "c" }); + assertCellTexts(0, 0, new String[] { "0 | 0", "0 | 1", "0 | 2" }); - // click the expander corresponding to "a" + // Should expand "0 | 0" grid.getRow(0).getCell(0) .findElement(By.className("v-tree-grid-expander")).click(); Assert.assertEquals(6, grid.getRowCount()); - assertCellTexts(1, 0, new String[] { "a/a", "a/b", "a/c" }); + assertCellTexts(1, 0, new String[] { "1 | 0", "1 | 1", "1 | 2" }); - // click the expander corresponding to "a" + // Should collapse "0 | 0" grid.getRow(0).getCell(0) .findElement(By.className("v-tree-grid-expander")).click(); Assert.assertEquals(3, grid.getRowCount()); - assertCellTexts(0, 0, new String[] { "a", "b", "c" }); + assertCellTexts(0, 0, new String[] { "0 | 0", "0 | 1", "0 | 2" }); } @Test + @Ignore // FIXME: remove ignore annotation once #8758 is done public void keyboard_navigation() { grid.getRow(0).getCell(0).click(); - // Should expand "a" + // Should expand "0 | 0" new Actions(getDriver()).keyDown(Keys.ALT).sendKeys(Keys.RIGHT) .keyUp(Keys.ALT).perform(); Assert.assertEquals(6, grid.getRowCount()); - assertCellTexts(1, 0, new String[] { "a/a", "a/b", "a/c" }); + assertCellTexts(1, 0, new String[] { "1 | 0", "1 | 1", "1 | 2" }); - // Should collapse "a" + // Should collapse "0 | 0" new Actions(getDriver()).keyDown(Keys.ALT).sendKeys(Keys.LEFT) .keyUp(Keys.ALT).perform(); Assert.assertEquals(3, grid.getRowCount()); - assertCellTexts(0, 0, new String[] { "a", "b", "c" }); + assertCellTexts(0, 0, new String[] { "0 | 0", "0 | 1", "0 | 2" }); } @Test @@ -81,7 +103,7 @@ public class TreeGridBasicFeaturesTest extends MultiBrowserTest { .isElementPresent(By.className("v-tree-grid-expander"))); selectMenuPath("Component", "Features", "Set hierarchy column", - "Second column"); + "depth"); Assert.assertFalse(grid.getRow(0).getCell(0) .isElementPresent(By.className("v-tree-grid-expander"))); @@ -89,7 +111,7 @@ public class TreeGridBasicFeaturesTest extends MultiBrowserTest { .isElementPresent(By.className("v-tree-grid-expander"))); selectMenuPath("Component", "Features", "Set hierarchy column", - "First column"); + "string"); Assert.assertTrue(grid.getRow(0).getCell(0) .isElementPresent(By.className("v-tree-grid-expander"))); diff --git a/uitest/src/test/java/com/vaadin/tests/components/treegrid/TreeGridScrollingTest.java b/uitest/src/test/java/com/vaadin/tests/components/treegrid/TreeGridScrollingTest.java new file mode 100644 index 0000000000..be7953ba35 --- /dev/null +++ b/uitest/src/test/java/com/vaadin/tests/components/treegrid/TreeGridScrollingTest.java @@ -0,0 +1,163 @@ +package com.vaadin.tests.components.treegrid; + +import org.junit.Assert; +import org.junit.Test; + +import com.vaadin.testbench.elements.TreeGridElement; +import com.vaadin.tests.tb3.SingleBrowserTest; + +public class TreeGridScrollingTest extends SingleBrowserTest { + + @Test + public void testScrollingTree_expandCollapseFromBeginning_correctItemsShown() { + // TODO refactor this test to verify each row against a model, e.g. a + // InMemoryDataProvider, or the used lazy hierarchical data provider + openTestURL(); + + TreeGridElement grid = $(TreeGridElement.class).first(); + + Assert.assertEquals(grid.getRowCount(), + TreeGridScrolling.DEFAULT_NODES); + + verifyRow(0, 0, 0); + verifyRow(10, 0, 10); + verifyRow(19, 0, 19); + verifyRow(10, 0, 10); + verifyRow(0, 0, 0); + + grid.expandWithClick(0); + + verifyRow(0, 0, 0); + verifyRow(1, 1, 0); + verifyRow(11, 1, 10); + verifyRow(20, 1, 19); + verifyRow(39, 0, 19); + + // verifying in reverse order causes scrolling up + verifyRow(20, 1, 19); + verifyRow(11, 1, 10); + verifyRow(1, 1, 0); + verifyRow(0, 0, 0); + + grid.expandWithClick(3); + + verifyRow(0, 0, 0); + + verifyRow(1, 1, 0); + verifyRow(2, 1, 1); + verifyRow(3, 1, 2); + + verifyRow(4, 2, 0); + + verifyRow(14, 2, 10); + verifyRow(23, 2, 19); + verifyRow(24, 1, 3); + verifyRow(40, 1, 19); + verifyRow(59, 0, 19); + + // scroll back up + + verifyRow(40, 1, 19); + verifyRow(24, 1, 3); + verifyRow(23, 2, 19); + verifyRow(14, 2, 10); + + verifyRow(4, 2, 0); + verifyRow(2, 1, 1); + verifyRow(3, 1, 2); + verifyRow(1, 1, 0); + verifyRow(0, 0, 0); + + grid.expandWithClick(2); + + verifyRow(0, 0, 0); + + verifyRow(1, 1, 0); + verifyRow(2, 1, 1); + verifyRow(3, 2, 0); + verifyRow(22, 2, 19); + + verifyRow(23, 1, 2); + verifyRow(24, 2, 0); + + verifyRow(43, 2, 19); + verifyRow(44, 1, 3); + verifyRow(60, 1, 19); + verifyRow(79, 0, 19); + + // scroll back up + verifyRow(60, 1, 19); + verifyRow(44, 1, 3); + verifyRow(43, 2, 19); + + verifyRow(24, 2, 0); + verifyRow(23, 1, 2); + + verifyRow(22, 2, 19); + verifyRow(3, 2, 0); + verifyRow(2, 1, 1); + verifyRow(1, 1, 0); + + verifyRow(0, 0, 0); + + grid.collapseWithClick(2); + + verifyRow(0, 0, 0); + + verifyRow(1, 1, 0); + verifyRow(2, 1, 1); + verifyRow(3, 1, 2); + + verifyRow(4, 2, 0); + + verifyRow(14, 2, 10); + verifyRow(23, 2, 19); + verifyRow(24, 1, 3); + verifyRow(40, 1, 19); + verifyRow(59, 0, 19); + + // scroll back up + + verifyRow(40, 1, 19); + verifyRow(24, 1, 3); + verifyRow(23, 2, 19); + verifyRow(14, 2, 10); + + verifyRow(4, 2, 0); + verifyRow(2, 1, 1); + verifyRow(3, 1, 2); + verifyRow(1, 1, 0); + verifyRow(0, 0, 0); + + grid.expandWithClick(3); + + verifyRow(0, 0, 0); + verifyRow(1, 1, 0); + verifyRow(11, 1, 10); + verifyRow(20, 1, 19); + verifyRow(39, 0, 19); + + // scroll back up + + verifyRow(20, 1, 19); + verifyRow(11, 1, 10); + verifyRow(1, 1, 0); + verifyRow(0, 0, 0); + + grid.expandWithClick(0); + + verifyRow(0, 0, 0); + verifyRow(10, 0, 10); + verifyRow(19, 0, 19); + verifyRow(10, 0, 10); + verifyRow(0, 0, 0); + } + + private void verifyRow(int rowActualIndex, int depth, int levelIndex) { + TreeGridElement grid = $(TreeGridElement.class).first(); + + Assert.assertEquals("Invalid row at index " + rowActualIndex, + depth + " | " + levelIndex, + grid.getCell(rowActualIndex, 0).getText()); + } +} |