Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,11 +399,11 @@ on locators. Use whichever fits — they search the same component tree.

`find(Class)`, `findInView(Class)` and the typed locators all walk the same
thing: the server-side component tree. A component that another component
renders per item does not exist until something renders it, and the content of
an overlay is attached only while the overlay is open. Neither is in the tree
until then — reach it through that component's tester instead. The lookup
returns an empty result rather than an error, so the failure reads as "the
component was never created".
renders per item is rendered into that component and not into the tree, and
the content of an overlay is attached only while the overlay is open. Neither
is reachable that way — reach it through that component's tester instead. The
lookup returns an empty result rather than an error, so the failure reads as
"the component was never created".

### Components rendered per item

Expand All @@ -412,20 +412,28 @@ grid.addComponentColumn(person -> new Checkbox(person.isSubscriber()))
.setKey("subscriber");
```

No checkbox exists until the renderer is asked to render a *specific* item, so
`find(Checkbox.class)` finds none. `GridTester` renders the cell on demand:
A grid renders that checkbox into the column, not into the grid, so
`find(Checkbox.class)` finds none of them, no matter how many rows are on
screen. `GridTester` hands out the one the grid rendered:

```java
var checkbox = (Checkbox) test(grid).getCellComponent(0, "subscriber");
test(checkbox).click();
```

- `getCellComponent(int row, int column)` / `getCellComponent(int row, String
columnKey)` — the component a `ComponentRenderer` column renders for a row.
Every call renders the cell again and attaches the new instance to the grid,
so asking twice for the same cell leaves two instances behind, and a later
`find()` reports both. Hold on to the component the tester returns instead of
asking for it again.
columnKey)` — the component the grid rendered for the cell, which is the one
the browser shows. Reading the same cell twice gives the same instance, and
the instance is replaced when the row is rendered anew, for example after
`refreshItem(...)`. A row the client has not asked for yet is scrolled into
view first, the way a user reaches it. A cell the grid does not render at
all, such as one in a hidden column, throws.
- `renderCellComponent(int row, int column)` / `renderCellComponent(int row,
String columnKey)` — renders the cell on its own, without the grid, and
attaches the copy to the grid so that it can be used. Every call renders the
cell again and leaves the copy behind, so a later `find()` reports every one
of them. It is for the cells the grid does not render, and for tests written
against the old behaviour of `getCellComponent`.
- `getCellText(int row, int column)` — the text the cell sends to the client,
for both value and component renderers.
- `getLitRendererPropertyValue(...)` / `invokeLitRendererFunction(...)` — for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,10 @@ public <T extends Component> T navigate(String location,
* <p>
* The query walks the server-side component tree. A component that another
* component renders per item, such as the component a
* {@code ComponentRenderer} column renders for a grid row, does not exist
* until something renders it, and the content of an overlay, such as a
* context menu, is attached only while the overlay is open. Neither is in
* the tree until then, and the lookup returns an empty result rather than
* {@code ComponentRenderer} column renders for a grid row, is rendered into
* the column and not into the tree, and the content of an overlay, such as
* a context menu, is attached only while the overlay is open. Neither is
* reachable this way, and the lookup returns an empty result rather than
* failing, so reach those components through the owning component tester
* instead: {@code GridTester.getCellComponent(row, column)} for grid cells,
* {@code ContextMenuTester.open()} and then {@code clickItem(...)} for a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.IntStream;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
Expand Down Expand Up @@ -374,23 +375,115 @@ void getCellComponent_columnByKey_returnsInstantiatedComponent() {
}

@Test
void find_componentRenderedIntoCell_notInTreeButReachableThroughTester() {
// A ComponentRenderer component does not exist until the renderer is
// asked to render a specific item, so there is nothing for find() to
// walk into.
void getCellComponent_readTwice_returnsTheComponentTheGridRendered() {
// A ComponentRenderer component is created when the grid renders the
// row for the client, and it is rendered into the column rather than
// into the grid, so there is nothing for find() to walk into.
Assertions.assertEquals(0, find(Button.class).all().size(),
"a component rendered into a grid cell should not be reachable through find()");

final Component cellComponent = test(view.basicGrid).getCellComponent(1,
BasicGridView.BUTTON_KEY);
final Component renderedAgain = test(view.basicGrid).getCellComponent(1,
final Component readAgain = test(view.basicGrid).getCellComponent(1,
BasicGridView.BUTTON_KEY);

Assertions.assertInstanceOf(Button.class, cellComponent);
Assertions.assertNotSame(cellComponent, renderedAgain,
Assertions.assertSame(cellComponent, readAgain,
"reading the same cell twice should give the one component the grid renders for it");
Assertions.assertEquals(0, find(Button.class).all().size(),
"reading a cell should not attach a component to the grid");
}

@Test
void getCellComponent_itemRefreshed_returnsTheComponentRenderedAnew() {
final Component beforeRefresh = test(view.basicGrid).getCellComponent(1,
BasicGridView.BUTTON_KEY);

view.basicGrid.getListDataView().refreshItem(view.person2);

final Component afterRefresh = test(view.basicGrid).getCellComponent(1,
BasicGridView.BUTTON_KEY);
Assertions.assertNotSame(beforeRefresh, afterRefresh,
"a refreshed row is rendered anew, so its cell has a new component");
Assertions.assertFalse(beforeRefresh.isAttached(),
"the component the refreshed row replaced should be gone");
Assertions.assertTrue(afterRefresh.isAttached(),
"the component of the refreshed row should be the rendered one");
}

@Test
void getCellComponent_rowOutsideRenderedRange_scrollsItIntoView() {
// the client has only asked for the first row
view.basicGrid.setPageSize(1);

final Component cellComponent = test(view.basicGrid).getCellComponent(1,
4);

Assertions.assertInstanceOf(Button.class, cellComponent);
Assertions.assertSame(cellComponent,
test(view.basicGrid).getCellComponent(1, 4),
"the row scrolled into view should stay rendered");
}

@Test
void getCellComponent_hiddenColumn_throwsAndSuggestsRendering() {
GridTester<Grid<Person>, Person> grid_ = test(view.basicGrid);

// the browser shows no cell for a hidden column, so the grid renders
// no component for it
Assertions.assertThrows(IllegalStateException.class, () -> grid_
.getCellComponent(1, BasicGridView.HIDDEN_BUTTON_KEY));

Assertions.assertInstanceOf(Button.class,
grid_.renderCellComponent(1, BasicGridView.HIDDEN_BUTTON_KEY));
}

@Test
void getCellComponent_itemsNotEqualAcrossFetches_throwsAndSuggestsRendering() {
// a data provider that hands out a new item instance on every fetch:
// the grid cannot tell that the item on the row is the item it
// rendered the row for
final Grid<Person> lazyGrid = new Grid<>();
lazyGrid.addComponentColumn(person -> new Button(person.getFirstName()))
.setKey(BasicGridView.BUTTON_KEY);
lazyGrid.setItems(query -> IntStream
.range(query.getOffset(), query.getOffset() + query.getLimit())
.mapToObj(index -> {
final Person person = new Person();
person.setFirstName("Person " + index);
return person;
}), query -> 100);
view.add(lazyGrid);

GridTester<Grid<Person>, Person> lazyGrid_ = test(lazyGrid);
final IllegalStateException exception = Assertions.assertThrows(
IllegalStateException.class,
() -> lazyGrid_.getCellComponent(0, BasicGridView.BUTTON_KEY));
Assertions.assertTrue(
exception.getMessage().contains("Grid rendered no component")
&& exception.getMessage()
.contains("renderCellComponent"),
"the failure should say the grid rendered nothing and point at the way to render the cell anyway");

Assertions.assertInstanceOf(Button.class,
lazyGrid_.renderCellComponent(0, BasicGridView.BUTTON_KEY));
}

@Test
void renderCellComponent_rendersACopyAndAttachesItToTheGrid() {
final Component rendered = test(view.basicGrid).renderCellComponent(1,
4);
final Component renderedAgain = test(view.basicGrid)
.renderCellComponent(1, 4);

Assertions.assertNotSame(rendered, renderedAgain,
"every call should render the cell anew");
Assertions.assertEquals(2, find(Button.class).all().size(),
"every rendered instance is attached to the grid and found from then on");
"every rendered copy is attached to the grid and found from then on");
Assertions.assertNotSame(rendered,
test(view.basicGrid).getCellComponent(1,
BasicGridView.BUTTON_KEY),
"a rendered copy is not the component the grid shows");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class BasicGridView extends Component implements HasComponents {
static final String SUBSCRIBER_KEY = "Subscriber";
static final String DECEASED_KEY = "Deceased";
static final String BUTTON_KEY = "Button";
static final String HIDDEN_BUTTON_KEY = "Hidden Button";

final Grid<Person> basicGrid;
final Person person1;
Expand All @@ -57,6 +58,9 @@ public BasicGridView() {
.addComponentColumn(person -> new Button("Click",
e -> Notification.show("Clicked!")))
.setKey(BUTTON_KEY).setHeader("Button");
basicGrid.addComponentColumn(person -> new Button("Hidden"))
.setKey(HIDDEN_BUTTON_KEY).setHeader("Hidden Button")
.setVisible(false);

add(basicGrid);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import com.vaadin.browserless.BrowserlessTest;
import com.vaadin.browserless.ViewPackages;
import com.vaadin.flow.component.Text;
import com.vaadin.flow.router.RouteConfiguration;

@ViewPackages
Expand All @@ -47,8 +48,17 @@ void getCellText_componentRenderer_getTextRecursively() {
}

@Test
void getCellText_renderNull_getsNull() {
Assertions.assertNull(grid_.getCellText(0, 1));
void getCellText_renderNull_getsEmptyString() {
// a renderer that returns no component renders an empty cell: the
// grid puts an empty text node in its place
Assertions.assertEquals("", grid_.getCellText(0, 1));
}

@Test
void getCellComponent_renderNull_getsTheEmptyTextTheGridRenders() {
// the grid renders an empty text node where the renderer produced
// nothing, so there is a component to hand out
Assertions.assertInstanceOf(Text.class, grid_.getCellComponent(0, 1));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,10 +446,10 @@ public <T extends ComponentTester<Y>, Y extends Component> T test(
* <p>
* The query walks the server-side component tree. A component that another
* component renders per item, such as the component a
* {@code ComponentRenderer} column renders for a grid row, does not exist
* until something renders it, and the content of an overlay, such as a
* context menu, is attached only while the overlay is open. Neither is in
* the tree until then, and the lookup returns an empty result rather than
* {@code ComponentRenderer} column renders for a grid row, is rendered into
* the column and not into the tree, and the content of an overlay, such as
* a context menu, is attached only while the overlay is open. Neither is
* reachable this way, and the lookup returns an empty result rather than
* failing, so reach those components through the owning component tester
* instead: {@code GridTester.getCellComponent(row, column)} for grid cells,
* {@code ContextMenuTester.open()} and then {@code clickItem(...)} for a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,10 @@ public static BrowserlessUIContext forComponent(
* <p>
* The query walks the server-side component tree. A component that another
* component renders per item, such as the component a
* {@code ComponentRenderer} column renders for a grid row, does not exist
* until something renders it, and the content of an overlay, such as a
* context menu, is attached only while the overlay is open. Neither is in
* the tree until then, and the lookup returns an empty result rather than
* {@code ComponentRenderer} column renders for a grid row, is rendered into
* the column and not into the tree, and the content of an overlay, such as
* a context menu, is attached only while the overlay is open. Neither is
* reachable this way, and the lookup returns an empty result rather than
* failing, so reach those components through the owning component tester
* instead: {@code GridTester.getCellComponent(row, column)} for grid cells,
* {@code ContextMenuTester.open()} and then {@code clickItem(...)} for a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@ public void setModal(boolean modal) {
* <p>
* The query walks the server-side component tree. A component that another
* component renders per item, such as the component a
* {@code ComponentRenderer} column renders for a grid row, does not exist
* until something renders it, and the content of an overlay, such as a
* context menu, is attached only while the overlay is open. Neither is in
* the tree until then, and the lookup returns an empty result rather than
* {@code ComponentRenderer} column renders for a grid row, is rendered into
* the column and not into the tree, and the content of an overlay, such as
* a context menu, is attached only while the overlay is open. Neither is
* reachable this way, and the lookup returns an empty result rather than
* failing, so reach those components through the owning component tester
* instead: {@code GridTester.getCellComponent(row, column)} for grid cells,
* {@code ContextMenuTester.open()} and then {@code clickItem(...)} for a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright 2000-2026 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.browserless.internal;

import java.lang.reflect.Field;
import java.util.Collection;
import java.util.Map;

import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.data.provider.AbstractComponentDataGenerator;
import com.vaadin.flow.data.provider.CompositeDataGenerator;
import com.vaadin.flow.data.provider.DataGenerator;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.internal.ReflectTools;

/**
* Reaches the components a {@link ComponentRenderer} has rendered for the rows
* the client has asked for.
* <p>
* For internal use only.
*/
public final class RenderedComponentSupport {

private RenderedComponentSupport() {
}

/**
* Gets the component the column has rendered for the item with the given
* key.
* <p>
* A component renderer keeps one component per item for as long as the
* client has the row, and that is the component the browser shows. Flow
* exposes no API for it: the mapping lives in
* {@code AbstractComponentDataGenerator.renderedComponents}, and the
* generator itself is reachable only through the private data generators of
* the column, so both are read reflectively. The accessor belongs upstream,
* on {@code Grid.Column} in {@code vaadin/flow-components} or on
* {@link AbstractComponentDataGenerator} in {@code vaadin/flow}.
*
* @param column
* the column that renders the cell
* @param itemKey
* key of the item the cell shows
* @return the rendered component, or {@literal null} when the column has
* rendered no component for the item
*/
public static Component getRenderedComponent(Grid.Column<?> column,
String itemKey) {
DataGenerator<?> columnDataGenerator = (DataGenerator<?>) read(
Grid.Column.class, "compositeDataGenerator", column);
AbstractComponentDataGenerator<?> generator = findComponentDataGenerator(
columnDataGenerator);
if (generator == null) {
return null;
}
if (read(AbstractComponentDataGenerator.class, "renderedComponents",
generator) instanceof Map<?, ?> rendered) {
return (Component) rendered.get(itemKey);
}
return null;
}

private static AbstractComponentDataGenerator<?> findComponentDataGenerator(
DataGenerator<?> generator) {
if (generator instanceof AbstractComponentDataGenerator<?> componentDataGenerator) {
return componentDataGenerator;
}
if (generator instanceof CompositeDataGenerator<?> composite
&& read(CompositeDataGenerator.class, "dataGenerators",
composite) instanceof Collection<?> nested) {
for (Object child : nested) {
AbstractComponentDataGenerator<?> found = findComponentDataGenerator(
(DataGenerator<?>) child);
if (found != null) {
return found;
}
}
}
return null;
}

private static Object read(Class<?> owner, String fieldName,
Object instance) {
Field field = ReflectTools.findDeclaredField(owner, fieldName)
.orElseThrow(() -> new IllegalStateException("Unable to find "
+ owner.getSimpleName() + "." + fieldName));
try {
return field.get(instance);
} catch (IllegalAccessException e) {
throw new IllegalStateException(
"Unable to read " + owner.getSimpleName() + "." + fieldName,
e);
}
}
}
Loading
Loading