Change-Id: I3fe6b2a8ad2b72b91db61135bd6505dcfa53034dtags/7.4.0.alpha2
@@ -191,14 +191,16 @@ abstract class JsniWorkaround { | |||
*/ | |||
protected JavaScriptObject touchEndFunction; | |||
protected TouchHandlerBundle touchHandlerBundle; | |||
protected JsniWorkaround(final Escalator escalator) { | |||
scrollListenerFunction = createScrollListenerFunction(escalator); | |||
mousewheelListenerFunction = createMousewheelListenerFunction(escalator); | |||
final TouchHandlerBundle bundle = new TouchHandlerBundle(escalator); | |||
touchStartFunction = bundle.getTouchStartHandler(); | |||
touchMoveFunction = bundle.getTouchMoveHandler(); | |||
touchEndFunction = bundle.getTouchEndHandler(); | |||
touchHandlerBundle = new TouchHandlerBundle(escalator); | |||
touchStartFunction = touchHandlerBundle.getTouchStartHandler(); | |||
touchMoveFunction = touchHandlerBundle.getTouchMoveHandler(); | |||
touchEndFunction = touchHandlerBundle.getTouchEndHandler(); | |||
} | |||
/** | |||
@@ -431,6 +433,12 @@ public class Escalator extends Widget { | |||
animationHandle = AnimationScheduler.get() | |||
.requestAnimationFrame(mover, escalator.bodyElem); | |||
event.getNativeEvent().preventDefault(); | |||
/* | |||
* this initializes a correct timestamp, and also renders the | |||
* first frame for added responsiveness. | |||
*/ | |||
mover.execute(Duration.currentTimeMillis()); | |||
} | |||
public void touchEnd(@SuppressWarnings("unused") | |||
@@ -440,6 +448,7 @@ public class Escalator extends Widget { | |||
if (touches == 0) { | |||
escalator.scroller.handleFlickScroll(deltaX, deltaY, | |||
lastTime); | |||
escalator.body.domSorter.reschedule(); | |||
} | |||
} | |||
} | |||
@@ -1902,6 +1911,8 @@ public class Escalator extends Widget { | |||
* The order in which row elements are rendered visually in the browser, | |||
* with the help of CSS tricks. Usually has nothing to do with the DOM | |||
* order. | |||
* | |||
* @see #sortDomElements() | |||
*/ | |||
private final LinkedList<Element> visualRowOrder = new LinkedList<Element>(); | |||
@@ -1939,6 +1950,60 @@ public class Escalator extends Widget { | |||
setTopRowLogicalIndex(topRowLogicalIndex + diff); | |||
} | |||
private class DeferredDomSorter { | |||
private static final int SORT_DELAY_MILLIS = 50; | |||
// as it happens, 3 frames = 50ms @ 60fps. | |||
private static final int REQUIRED_FRAMES_PASSED = 3; | |||
private final AnimationCallback frameCounter = new AnimationCallback() { | |||
@Override | |||
public void execute(double timestamp) { | |||
framesPassed++; | |||
boolean domWasSorted = sortIfConditionsMet(); | |||
if (!domWasSorted) { | |||
animationHandle = AnimationScheduler.get() | |||
.requestAnimationFrame(this); | |||
} | |||
} | |||
}; | |||
private int framesPassed; | |||
private double startTime; | |||
private AnimationHandle animationHandle; | |||
public void reschedule() { | |||
resetConditions(); | |||
animationHandle = AnimationScheduler.get() | |||
.requestAnimationFrame(frameCounter); | |||
} | |||
private boolean sortIfConditionsMet() { | |||
boolean enoughFramesHavePassed = framesPassed >= REQUIRED_FRAMES_PASSED; | |||
boolean enoughTimeHasPassed = (Duration.currentTimeMillis() - startTime) >= SORT_DELAY_MILLIS; | |||
boolean conditionsMet = enoughFramesHavePassed | |||
&& enoughTimeHasPassed; | |||
if (conditionsMet) { | |||
resetConditions(); | |||
sortDomElements(); | |||
} | |||
return conditionsMet; | |||
} | |||
private void resetConditions() { | |||
if (animationHandle != null) { | |||
animationHandle.cancel(); | |||
animationHandle = null; | |||
} | |||
startTime = Duration.currentTimeMillis(); | |||
framesPassed = 0; | |||
} | |||
} | |||
private DeferredDomSorter domSorter = new DeferredDomSorter(); | |||
public BodyRowContainer(final Element bodyElement) { | |||
super(bodyElement); | |||
} | |||
@@ -2086,6 +2151,15 @@ public class Escalator extends Widget { | |||
if (rowsWereMoved) { | |||
fireRowVisibilityChangeEvent(); | |||
if (scroller.touchHandlerBundle.touches == 0) { | |||
/* | |||
* this will never be called on touch scrolling. That is | |||
* handled separately and explicitly by | |||
* TouchHandlerBundle.touchEnd(); | |||
*/ | |||
domSorter.reschedule(); | |||
} | |||
} | |||
} | |||
@@ -2187,6 +2261,7 @@ public class Escalator extends Widget { | |||
} | |||
fireRowVisibilityChangeEvent(); | |||
sortDomElements(); | |||
} | |||
return addedRows; | |||
} | |||
@@ -2733,6 +2808,9 @@ public class Escalator extends Widget { | |||
logicalTargetIndex); | |||
} | |||
} | |||
fireRowVisibilityChangeEvent(); | |||
sortDomElements(); | |||
} | |||
updateTopRowLogicalIndex(-removedAbove.length()); | |||
@@ -2742,8 +2820,6 @@ public class Escalator extends Widget { | |||
* or it won't work correctly (due to setScrollTop invocation) | |||
*/ | |||
scroller.recalculateScrollbarsForVirtualViewport(); | |||
fireRowVisibilityChangeEvent(); | |||
} | |||
private void paintRemoveRowsAtMiddle(final Range removedLogicalInside, | |||
@@ -3160,6 +3236,98 @@ public class Escalator extends Widget { | |||
Profiler.leave("Escalator.BodyRowContainer.reapplyDefaultRowHeights"); | |||
} | |||
/** | |||
* Sorts the rows in the DOM to correspond to the visual order. | |||
* | |||
* @see #visualRowOrder | |||
*/ | |||
private void sortDomElements() { | |||
final String profilingName = "Escalator.BodyRowContainer.sortDomElements"; | |||
Profiler.enter(profilingName); | |||
/* | |||
* Focus is lost from an element if that DOM element is (or any of | |||
* its parents are) removed from the document. Therefore, we sort | |||
* everything around that row instead. | |||
*/ | |||
final Element activeRow = getEscalatorRowWithFocus(); | |||
if (activeRow != null) { | |||
assert activeRow.getParentElement() == root : "Trying to sort around a row that doesn't exist in body"; | |||
assert visualRowOrder.contains(activeRow) : "Trying to sort around a row that doesn't exist in visualRowOrder."; | |||
} | |||
/* | |||
* Two cases handled simultaneously: | |||
* | |||
* 1) No focus on rows. We iterate visualRowOrder backwards, and | |||
* take the respective element in the DOM, and place it as the first | |||
* child in the body element. Then we take the next-to-last from | |||
* visualRowOrder, and put that first, pushing the previous row as | |||
* the second child. And so on... | |||
* | |||
* 2) Focus on some row within Escalator body. Again, we iterate | |||
* visualRowOrder backwards. This time, we use the focused row as a | |||
* pivot: Instead of placing rows from the bottom of visualRowOrder | |||
* and placing it first, we place it underneath the focused row. | |||
* Once we hit the focused row, we don't move it (to not reset | |||
* focus) but change sorting mode. After that, we place all rows as | |||
* the first child. | |||
*/ | |||
/* | |||
* If we have a focused row, start in the mode where we put | |||
* everything underneath that row. Otherwise, all rows are placed as | |||
* first child. | |||
*/ | |||
boolean insertFirst = (activeRow == null); | |||
final ListIterator<Element> i = visualRowOrder | |||
.listIterator(visualRowOrder.size()); | |||
while (i.hasPrevious()) { | |||
Element tr = i.previous(); | |||
if (tr == activeRow) { | |||
insertFirst = true; | |||
} else if (insertFirst) { | |||
root.insertFirst(tr); | |||
} else { | |||
root.insertAfter(tr, activeRow); | |||
} | |||
} | |||
Profiler.leave(profilingName); | |||
} | |||
/** | |||
* Get the escalator row that has focus. | |||
* | |||
* @return The escalator row that contains a focused DOM element, or | |||
* <code>null</code> if focus is outside of a body row. | |||
*/ | |||
private Element getEscalatorRowWithFocus() { | |||
Element activeRow = null; | |||
final Element activeElement = Util.getFocusedElement(); | |||
if (root.isOrHasChild(activeElement)) { | |||
Element e = activeElement; | |||
while (e != null && e != root) { | |||
/* | |||
* You never know if there's several tables embedded in a | |||
* cell... We'll take the deepest one. | |||
*/ | |||
if (e.getTagName().equalsIgnoreCase("TR")) { | |||
activeRow = e; | |||
} | |||
e = e.getParentElement(); | |||
} | |||
} | |||
return activeRow; | |||
} | |||
} | |||
private class ColumnConfigurationImpl implements ColumnConfiguration { |
@@ -33,10 +33,15 @@ import com.vaadin.ui.TextField; | |||
@Widgetset(TestingWidgetSet.NAME) | |||
public class BasicEscalator extends AbstractTestUI { | |||
public static final String ESCALATOR = "escalator"; | |||
public static final String INSERT_ROWS_OFFSET = "iro"; | |||
public static final String INSERT_ROWS_AMOUNT = "ira"; | |||
public static final String INSERT_ROWS_BUTTON = "irb"; | |||
public static final String REMOVE_ROWS_OFFSET = "rro"; | |||
public static final String REMOVE_ROWS_AMOUNT = "rra"; | |||
public static final String REMOVE_ROWS_BUTTON = "rrb"; | |||
private final Random random = new Random(); | |||
@Override | |||
@@ -71,8 +76,10 @@ public class BasicEscalator extends AbstractTestUI { | |||
final Layout removeRowsLayout = new HorizontalLayout(); | |||
final TextField removeRowsOffset = new TextField(); | |||
removeRowsOffset.setId(REMOVE_ROWS_OFFSET); | |||
removeRowsLayout.addComponent(removeRowsOffset); | |||
final TextField removeRowsAmount = new TextField(); | |||
removeRowsAmount.setId(REMOVE_ROWS_AMOUNT); | |||
removeRowsLayout.addComponent(removeRowsAmount); | |||
removeRowsLayout.addComponent(new Button("remove rows", | |||
new Button.ClickListener() { | |||
@@ -84,7 +91,11 @@ public class BasicEscalator extends AbstractTestUI { | |||
.getValue()); | |||
grid.removeRows(offset, amount); | |||
} | |||
})); | |||
}) { | |||
{ | |||
setId(REMOVE_ROWS_BUTTON); | |||
} | |||
}); | |||
addComponent(removeRowsLayout); | |||
final Layout insertColumnsLayout = new HorizontalLayout(); |
@@ -17,11 +17,17 @@ package com.vaadin.tests.components.grid; | |||
import static org.junit.Assert.assertEquals; | |||
import static org.junit.Assert.assertNotNull; | |||
import static org.junit.Assert.assertTrue; | |||
import static org.junit.Assert.fail; | |||
import java.util.List; | |||
import java.util.regex.Matcher; | |||
import java.util.regex.Pattern; | |||
import org.junit.Test; | |||
import org.openqa.selenium.By; | |||
import org.openqa.selenium.JavascriptExecutor; | |||
import org.openqa.selenium.Keys; | |||
import org.openqa.selenium.NoSuchElementException; | |||
import org.openqa.selenium.WebDriver; | |||
import org.openqa.selenium.WebElement; | |||
@@ -32,6 +38,11 @@ import com.vaadin.tests.tb3.MultiBrowserTest; | |||
@TestCategory("grid") | |||
public class BasicEscalatorTest extends MultiBrowserTest { | |||
private static final int SLEEP = 300; | |||
private static final Pattern ROW_PATTERN = Pattern | |||
.compile("Row (\\d+): \\d+,\\d+"); | |||
@Test | |||
public void testInitialState() throws Exception { | |||
openTestURL(); | |||
@@ -55,7 +66,7 @@ public class BasicEscalatorTest extends MultiBrowserTest { | |||
*/ | |||
Thread.sleep(100); | |||
scrollEscalatorVerticallyTo(1000); | |||
setScrollTop(getVerticalScrollbar(), 1000); | |||
assertBodyCellWithContentIsFound("Row 50: 0,50"); | |||
} | |||
@@ -70,7 +81,7 @@ public class BasicEscalatorTest extends MultiBrowserTest { | |||
Thread.sleep(100); | |||
// scroll to bottom | |||
scrollEscalatorVerticallyTo(100000000); | |||
setScrollTop(getVerticalScrollbar(), 100000000); | |||
/* | |||
* this test does not test DOM reordering, therefore we don't rely on | |||
@@ -124,11 +135,6 @@ public class BasicEscalatorTest extends MultiBrowserTest { | |||
getDriver().get(testUrl); | |||
} | |||
private void scrollEscalatorVerticallyTo(double px) { | |||
executeScript("arguments[0].scrollTop = " + px, | |||
getGridVerticalScrollbar()); | |||
} | |||
private Object executeScript(String script, WebElement element) { | |||
@SuppressWarnings("hiding") | |||
final WebDriver driver = getDriver(); | |||
@@ -142,9 +148,148 @@ public class BasicEscalatorTest extends MultiBrowserTest { | |||
} | |||
} | |||
private WebElement getGridVerticalScrollbar() { | |||
return getDriver() | |||
.findElement( | |||
By.xpath("//div[contains(@class, \"v-escalator-scroller-vertical\")]")); | |||
@Test | |||
public void domIsInitiallySorted() throws Exception { | |||
openTestURL(); | |||
final List<WebElement> rows = getBodyRows(); | |||
assertTrue("no body rows found", !rows.isEmpty()); | |||
for (int i = 0; i < rows.size(); i++) { | |||
String text = rows.get(i).getText(); | |||
String expected = "Row " + i; | |||
assertTrue("Expected \"" + expected + "...\" but was " + text, | |||
text.startsWith(expected)); | |||
} | |||
} | |||
@Test | |||
public void domIsSortedAfterInsert() throws Exception { | |||
openTestURL(); | |||
final int rowsToInsert = 5; | |||
final int offset = 5; | |||
insertRows(offset, rowsToInsert); | |||
final List<WebElement> rows = getBodyRows(); | |||
int i = 0; | |||
for (; i < offset + rowsToInsert; i++) { | |||
final String expectedStart = "Row " + i; | |||
final String text = rows.get(i).getText(); | |||
assertTrue("Expected \"" + expectedStart + "...\" but was " + text, | |||
text.startsWith(expectedStart)); | |||
} | |||
for (; i < rows.size(); i++) { | |||
final String expectedStart = "Row " + (i - rowsToInsert); | |||
final String text = rows.get(i).getText(); | |||
assertTrue("(post insert) Expected \"" + expectedStart | |||
+ "...\" but was " + text, text.startsWith(expectedStart)); | |||
} | |||
} | |||
@Test | |||
public void domIsSortedAfterRemove() throws Exception { | |||
openTestURL(); | |||
final int rowsToRemove = 5; | |||
final int offset = 5; | |||
removeRows(offset, rowsToRemove); | |||
final List<WebElement> rows = getBodyRows(); | |||
int i = 0; | |||
for (; i < offset; i++) { | |||
final String expectedStart = "Row " + i; | |||
final String text = rows.get(i).getText(); | |||
assertTrue("Expected " + expectedStart + "... but was " + text, | |||
text.startsWith(expectedStart)); | |||
} | |||
/* | |||
* We check only up to 10, since after that, the indices are again | |||
* reset, because new rows have been generated. The row numbers that | |||
* they are given depends on the widget size, and it's too fragile to | |||
* rely on some special assumptions on that. | |||
*/ | |||
for (; i < 10; i++) { | |||
final String expectedStart = "Row " + (i + rowsToRemove); | |||
final String text = rows.get(i).getText(); | |||
assertTrue("(post remove) Expected " + expectedStart | |||
+ "... but was " + text, text.startsWith(expectedStart)); | |||
} | |||
} | |||
@Test | |||
public void domIsSortedAfterScroll() throws Exception { | |||
openTestURL(); | |||
setScrollTop(getVerticalScrollbar(), 500); | |||
/* | |||
* Let the DOM reorder itself. | |||
* | |||
* TODO TestBench currently doesn't know when Grid's DOM structure is | |||
* stable. There are some plans regarding implementing support for this, | |||
* so this test case can (should) be modified once that's implemented. | |||
*/ | |||
sleep(SLEEP); | |||
List<WebElement> rows = getBodyRows(); | |||
int firstRowNumber = parseFirstRowNumber(rows); | |||
for (int i = 0; i < rows.size(); i++) { | |||
final String expectedStart = "Row " + (i + firstRowNumber); | |||
final String text = rows.get(i).getText(); | |||
assertTrue("(post remove) Expected " + expectedStart | |||
+ "... but was " + text, text.startsWith(expectedStart)); | |||
} | |||
} | |||
private static int parseFirstRowNumber(List<WebElement> rows) | |||
throws NumberFormatException { | |||
final WebElement firstRow = rows.get(0); | |||
final String firstRowText = firstRow.getText(); | |||
final Matcher matcher = ROW_PATTERN.matcher(firstRowText); | |||
if (!matcher.find()) { | |||
fail("could not find " + ROW_PATTERN.pattern() + " in \"" | |||
+ firstRowText + "\""); | |||
} | |||
final String number = matcher.group(1); | |||
return Integer.parseInt(number); | |||
} | |||
private void insertRows(final int offset, final int amount) { | |||
final WebElement offsetInput = vaadinElementById(BasicEscalator.INSERT_ROWS_OFFSET); | |||
offsetInput.sendKeys(String.valueOf(offset), Keys.RETURN); | |||
final WebElement amountInput = vaadinElementById(BasicEscalator.INSERT_ROWS_AMOUNT); | |||
amountInput.sendKeys(String.valueOf(amount), Keys.RETURN); | |||
final WebElement button = vaadinElementById(BasicEscalator.INSERT_ROWS_BUTTON); | |||
button.click(); | |||
} | |||
private void removeRows(final int offset, final int amount) { | |||
final WebElement offsetInput = vaadinElementById(BasicEscalator.REMOVE_ROWS_OFFSET); | |||
offsetInput.sendKeys(String.valueOf(offset), Keys.RETURN); | |||
final WebElement amountInput = vaadinElementById(BasicEscalator.REMOVE_ROWS_AMOUNT); | |||
amountInput.sendKeys(String.valueOf(amount), Keys.RETURN); | |||
final WebElement button = vaadinElementById(BasicEscalator.REMOVE_ROWS_BUTTON); | |||
button.click(); | |||
} | |||
private void setScrollTop(WebElement element, long px) { | |||
executeScript("arguments[0].scrollTop = " + px, element); | |||
} | |||
private List<WebElement> getBodyRows() { | |||
return getDriver().findElements(By.xpath("//tbody/tr/td[1]")); | |||
} | |||
private WebElement getVerticalScrollbar() { | |||
return getDriver().findElement( | |||
By.xpath("//div[" | |||
+ "contains(@class, 'v-escalator-scroller-vertical')" | |||
+ "]")); | |||
} | |||
} |