Showing Many Items in a Listing

A common pattern in applications is that the user is first presented with a list of items, from which she selects one or several items to continue working with. These items could be inventory records to survey, messages to respond to or blog drafts to edit or publish.

A Listing is a component that displays one or several properties from a list of items, allowing the user to inspect the data, mark items as selected and in some cases even edit the item directly through the component. While each listing component has its own API for configuring exactly how the data is represented and how it can be manipulated, they all share the same mechanisms for receiving data to show.

The items are generally either loaded directly from memory or lazy loaded from some kind of backend. Regardless of how the items are loaded, the component is configured with one or several callbacks that define how the item should be displayed.

In the following example, a ComboBox that lists status items is configured to use the Status.getCaption() method to represent each status. There is also a Grid, which is configured with one column from the person’s name and another showing the year of birth.

Java

  1. public enum Status {
  2. TODO, IN_PROGRESS, DONE;
  3. public String getCaption() {
  4. switch (this) {
  5. case TODO:
  6. return "To do";
  7. case IN_PROGRESS:
  8. return "In Progress";
  9. case DONE:
  10. return "Done";
  11. }
  12. return "";
  13. }
  14. }

Java

  1. ComboBox<Status> comboBox = new ComboBox<>();
  2. comboBox.setItemCaptionGenerator(Status::getCaption);
  3. Grid<Person> grid = new Grid<>();
  4. grid.addColumn(Person::getName).setCaption("Name");
  5. grid.addColumn(Person::getYearOfBirth)
  6. .setCaption("Year of birth");
Note
In this example, it would not even be necessary to define any item caption provider for the combo box if Status.toString() would be implemented to return a suitable text. ComboBox is by default configured to use toString() for finding a caption to show.
Note
The Year of birth column will use Grid’s default TextRenderer which shows any values as a String. We could use a NumberRenderer instead, and then the renderer would take care of converting the the number according to its configuration with a formatting setting of our choice.

After we have told the component how the data should be shown, we only need to give it some data to actually show. The easiest way of doing that is to directly pass the values to show to setItems.

Java

  1. // Sets items as a collection
  2. comboBox.setItems(EnumSet.allOf(Status.class));
  3. // Sets items using varargs
  4. grid.setItems(
  5. new Person("George Washington", 1732),
  6. new Person("John Adams", 1735),
  7. new Person("Thomas Jefferson", 1743),
  8. new Person("James Madison", 1751)
  9. );

Listing components that allow the user to control the display order of the items are automatically able to sort data by any property as long as the property type implements Comparable.

We can also define a custom Comparator if we want to customize the way a specific column is sorted. The comparator can either be based on the item instances or on the values of the property that is being shown.

Java

  1. grid.addColumn(Person::getName).setCaption("Name")
  2. // Override default natural sorting
  3. .setComparator(
  4. Comparator.comparing(Person::getName)::compare);
Note
This kind of sorting is only supported for in-memory data. Sorting with data that is lazy loaded from a backend is described later in this chapter.

With listing components that let the user filter items, we can in the same way define our own CaptionFilter that is used to decide whether a specific item should be shown when the user has entered a specific text into the text field. The filter is defined as an additional parameter to setItems.

Java

  1. comboBox.setItems(
  2. (itemCaption, filterText) ->
  3. itemCaption.startsWith(filterText),
  4. itemsToShow);
Note
This kind of filtering is only supported for in-memory data. Filtering with data that is lazy loaded from a backend is described later in this chapter.

Instead of directly assigning the item collection as the items that a component should be using, we can instead create a ListDataProvider that contains the items. One list data provider instance can be shared between different components to make them show the same data. The instance can be further configured to filter out some of the items or to present them in a specific order.

For components like Grid that can be separately configured to sort data in a specific way, the sorting configured in the data provider is only used as a fallback. The fallback is used if no sorting is defined through the component and to define the order between items that are considered to be the same according to the component’s sorting. All components will automatically update themselves when the sorting of the data provider is changed.

Java

  1. ListDataProvider<Person> dataProvider =
  2. DataProvider.ofCollection(persons);
  3. dataProvider.setSortOrder(Person::getName,
  4. SortDirection.ASCENDING);
  5. ComboBox<Person> comboBox = new ComboBox<>();
  6. // The combo box shows the persons sorted by name
  7. comboBox.setDataProvider(dataProvider);
  8. // Makes the combo box show persons in descending order
  9. button.addClickListener(event -> {
  10. dataProvider.setSortOrder(Person::getName,
  11. SortDirection.DESCENDING)
  12. });

A ListDataProvider can also be used to further configure filtering beyond what is possible using CaptionFilter. You can configure the data provider to always apply some specific filter to limit which items are shown or to make it filter by data that is not included in the displayed item caption.

Java

  1. ListDataProvider<Person> dataProvider =
  2. DataProvider.ofCollection(persons);
  3. ComboBox<Person> comboBox = new ComboBox<>();
  4. comboBox.setDataProvider(dataProvider);
  5. departmentSelect.addValueChangeListener(event -> {
  6. Department selectedDepartment = event.getValue();
  7. if (selectedDepartment != null) {
  8. dataProvider.setFilterByValue(
  9. Person::getDepartment,
  10. selectedDepartment);
  11. } else {
  12. dataProvider.clearFilters();
  13. }
  14. });

In this example, the department selected in the departmentSelect component is used to dynamically change which persons are shown in the combo box. In addition to setFilterByValue, it is also possible to set a filter based on a predicate that tests each item or the value of some specific property in the item. Multiple filters can also be stacked by using addFilter methods instead of setFilter.

To configure filtering through a component beyond what is possible with CaptionFilter, we can use withConvertedFilter or some variant of filteringBy to create a data provider wrapper that does something based on the text that the user entered into the component.

Java

  1. ListDataProvider<Person> dataProvider =
  2. DataProvider.ofCollection(persons);
  3. comboBox.setDataProvider(dataProvider.filteringBy(
  4. (person, filterText) -> {
  5. if (person.getName().contains(filterText)) {
  6. return true;
  7. }
  8. if (person.getEmail().contains(filterText)) {
  9. return true;
  10. }
  11. return false;
  12. }
  13. ));

When the user types something into the combo box, the lambda expression will be run for each person in the data provider. Any person for which true is returned will be included.

The listing component cannot automatically know about changes to the list of items or to any individual item. We must notify the data provider when items are changed, added or removed so that components using the data will show the new values.

Java

  1. ListDataProvider<Person> dataProvider =
  2. new ListDataProvider<>(persons);
  3. Button addPersonButton = new Button("Add person",
  4. clickEvent -> {
  5. persons.add(new Person("James Monroe", 1758));
  6. dataProvider.refreshAll();
  7. });
  8. Button modifyPersonButton = new Button("Modify person",
  9. clickEvent -> {
  10. Person personToChange = persons.get(0);
  11. personToChange.setName("Changed person");
  12. dataProvider.refreshItem(personToChange);
  13. });

Lazy Loading Data to a Listing

All the previous examples have shown cases with a limited amount of data that can be loaded as item instances in memory. There are also situations where it is more efficient to only load the items that will currently be displayed. This includes situations where all available data would use lots of memory or when it would take a long time to load all the items.

Note
Regardless of how we make the items available to the listing component on the server, components like Grid will always take care of only sending the currently needed items to the browser.

For example, if we have the following existing backend service that fetches items from a database or a REST service .

Java

  1. public interface PersonService {
  2. List<Person> fetchPersons(int offset, int limit);
  3. int getPersonCount();
  4. }

To use this service with a listing component, we need to define one callback for loading specific items and one callback for finding how many items are currently available. Information about which items to fetch as well as some additional details are made available in a Query object that is passed to both callbacks.

Java

  1. DataProvider<Person, Void> dataProvider = DataProvider.fromCallbacks(
  2. // First callback fetches items based on a query
  3. query -> {
  4. // The index of the first item to load
  5. int offset = query.getOffset();
  6. // The number of items to load
  7. int limit = query.getLimit();
  8. List<Person> persons = getPersonService().fetchPersons(offset, limit);
  9. return persons;
  10. },
  11. // Second callback fetches the number of items for a query
  12. query -> getPersonService().getPersonCount()
  13. );
  14. Grid<Person> grid = new Grid<>();
  15. grid.setDataProvider(dataProvider);
  16. // Columns are configured in the same way as before
  17. ...
Note
The results of the first and second callback must be symmetric so that fetching all available items using the first callback returns the number of items indicated by the second callback. Thus if you impose any restrictions on e.g. a database query in the first callback, you must also add the same restrictions for the second callback.
Note
The second type parameter of DataProvider defines how the provider can be filtered. In this case the filter type is Void, meaning that it doesn’t support filtering. Backend filtering will be covered later in this chapter.

Sorting

It is not practical to order items based on a Comparator when the items are loaded on demand, since it would require all items to be loaded and inspected.

Each backend has its own way of defining how the fetched items should be ordered, but they are in general based on a list of property names and information on whether ordering should be ascending or descending.

As an example, there could be a service interface which looks like the following.

Java

  1. public interface PersonService {
  2. List<Person> fetchPersons(
  3. int offset,
  4. int limit,
  5. List<PersonSort> sortOrders);
  6. int getPersonCount();
  7. PersonSort createSort(
  8. String propertyName,
  9. boolean descending);
  10. }

With the above service interface, our data source can be enhanced to convert the provided sorting options into a format expected by the service. The sorting options set through the component will be available through Query.getSortOrders().

Java

  1. DataProvider<Person, Void> dataProvider = DataProvider.fromCallbacks(
  2. query -> {
  3. List<PersonSort> sortOrders = new ArrayList<>();
  4. for(SortOrder<String> queryOrder : query.getSortOrders()) {
  5. PersonSort sort = getPersonService().createSort(
  6. // The name of the sorted property
  7. queryOrder.getSorted(),
  8. // The sort direction for this property
  9. queryOrder.getDirection() == SortDirection.DESCENDING);
  10. sortOrders.add(sort);
  11. }
  12. return getPersonService().fetchPersons(
  13. query.getOffset(),
  14. query.getLimit(),
  15. sortOrders
  16. );
  17. },
  18. // The number of persons is the same regardless of ordering
  19. query -> getPersonService().getPersonCount()
  20. );

We also need to configure our grid so that it can know what property name should be included in the query when the user wants to sort by a specific column. When a data source that does lazy loading is used, Grid and other similar components will only let the user sort by columns for which a sort property name is provided.

Java

  1. Grid<Person> grid = new Grid<>();
  2. grid.setDataProvider(dataProvider);
  3. // Will be sortable by the user
  4. // When sorting by this column, the query will have a SortOrder
  5. // where getSorted() returns "name"
  6. grid.addColumn(Person::getName)
  7. .setCaption("Name")
  8. .setSortProperty("name");
  9. // Will not be sortable since no sorting info is given
  10. grid.addColumn(Person::getYearOfBirth)
  11. .setCaption("Year of birth");

There might also be cases where a single property name is not enough for sorting. This might be the case if the backend needs to sort by multiple properties for one column in the user interface or if the backend sort order should be inverted compared to the sort order defined by the user. In such cases, we can define a callback that generates suitable SortOrder values for the given column.

Java

  1. grid.addColumn("Name",
  2. person -> person.getFirstName() + " " + person.getLastName())
  3. .setSortOrderProvider(
  4. // Sort according to last name, then first name
  5. direction -> Stream.of(
  6. new SortOrder("lastName", direction),
  7. new SortOrder("firstName", direction)
  8. ));

Filtering

Different types of backends support filtering in different ways. Some backends support no filtering at all, some support filtering by a single value of some specific type and some have a complex structure of supported filtering options.

A DataProvider<Person, String> accepts one string to filter by through the query. It’s up to the data provider implementation to decide what it does with that filter value. It might, for instance, look for all persons with a name beginning with the provided string.

A listing component that lets the user control how the displayed data is filtered has some specific filter type that it uses. For ComboBox, the filter is the String that the user has typed into the search field. This means that ComboBox can only be used with a data provider whose filtering type is String.

To use a data provider that filters by some other type, you need to use the withConvertedFilter. This method creates a new data provider that uses the same data but a different filter type; converting the filter value before passing it to the original data provider instance.

We might, for instance, have a data provider that finds any person where the name contains any of the strings in a set. To use that data provider with a combo box, we need to define a converter that receives a single string from the combo box and creates a set of string that the data provider expects.

Java

  1. DataProvider<Person, Set<String>> personProvider = getPersonProvider();
  2. ComboBox<Person> comboBox = new ComboBox();
  3. DataProvider<Person, String> converted =
  4. personProvider.withConvertedFilter(
  5. filterText -> Collections.singleton(filterText);
  6. );
  7. comboBox.setDataProvider(converted);

The filter value passed through the query does typically originate from a component such as ComboBox that lets the user filter by some value. It is also possible to create a data provider wrapper that allows programmatically setting the filter value to include in the query.

You can use the withConfigurableFilter method on a data provider to create a data provider wrapper that allows configuring the filter that is passed through the query. All components that use a data provider will refresh their data when a new filter is set.

Java

  1. DataProvider<Person, String> personProvider = getPersonProvider();
  2. ConfigurableFilterDataProvider<Person, Void, String> wrapper =
  3. personProvider.withConfigurableFilter();
  4. Grid<Person> grid = new Grid<>();
  5. grid.setDataProvider(johnPersons);
  6. grid.addColumn(Person::getName).setCaption("Name");
  7. searchField.addValueChangeListener(event -> {
  8. String filter = event.getValue();
  9. if (filter.trim().isEmpty()) {
  10. // null disables filtering
  11. filter = null;
  12. }
  13. wrapper.setFilter(filter);
  14. });

Note that the filter type of the wrapper instance is Void, which means that the data provider doesn’t support any further filtering through the query. It’s therefore not possible to use the data provider with a combo box.

There is an overload of withConfigurableFilter that uses a callback for combining the configured filter value with a filter value from the query. We can thus wrap our data provider that filters by a set of strings to create a data provider that combines a string from a combo box with a set of strings that are separately configured.

Java

  1. DataProvider<Person, Set<String>> personProvider = getPersonProvider();
  2. ConfigurableFilterDataProvider<Person, String, Set<String>> wrapper =
  3. personProvider.withConfigurableFilter(
  4. (String queryFilter, Set<String> configuredFilters) -> {
  5. Set<String> combinedFilters = new HashSet<>();
  6. combinedFilters.addAll(configuredFilters);
  7. combinedFilters.add(queryFilter);
  8. return combinedFilters;
  9. }
  10. );
  11. wrapper.setFilter(Collections.singleton("John"));
  12. ComboBox<Person> comboBox = new Grid<>();
  13. comboBox.setDataProvider(wrapper);

In this case, wrapper supports a single string as the query filter and Set<String> trough setFilter. The callback combines both into one Set<String> that will be in the query passed to personProvider.

To create a data provider that supports filtering, you only need to look for a filter in the provided query and use that filter when fetching and counting items. withConfigurableFilter and withConvertedFilter are automatically implemented for you.

As an example, our service interface with support for filtering could look like this. Ordering support has been omitted in this example to keep focus on filtering.

Java

  1. public interface PersonService {
  2. List<Person> fetchPersons(
  3. int offset,
  4. int limit,
  5. String namePrefix);
  6. int getPersonCount(String namePrefix);
  7. }

A data provider using this service could use String as its filtering type. It would then look for a string to filter by in the query and pass it to the service method.

Java

  1. DataProvider<Person, String> dataProvider =
  2. DataProvider.fromFilteringCallbacks<>(
  3. query -> {
  4. // getFilter returns Optional<String>
  5. String filter = query.getFilter().orElse(null);
  6. return getPersonService().fetchPersons(
  7. query.getOffset(),
  8. query.getLimit(),
  9. filter
  10. );
  11. },
  12. query -> {
  13. String filter = query.getFilter().orElse(null);
  14. return getPersonService().getPersonCount(filter);
  15. }
  16. );

If we instead have a service that expects multiple different filtering parameters, we can use two different alternatives depending on how the data provider would be used. Both cases would be based on this example service API:

Java

  1. public interface PersonService {
  2. List<Person> fetchPersons(
  3. int offset,
  4. int limit,
  5. String namePrefix
  6. Department department);
  7. int getPersonCount(
  8. String namePrefix,
  9. Department department);
  10. }

The first approach would be to define a simple wrapper class that combines both filter parameters into one instance.

Java

  1. public class PersonFilter {
  2. public final String namePrefix;
  3. public final Department department;
  4. public PersonFilter(String namePrefix, Department department) {
  5. this.namePrefix = namePrefix;
  6. this.department = department;
  7. }
  8. }

We can then define a data provider that is natively filtered by PersonFilter.

Java

  1. DataProvider<Person, PersonFilter> dataProvider =
  2. DataProvider.fromFilteringCallbacks<>(
  3. query -> {
  4. PersonFilter filter = query.getFilter().orElse(null);
  5. return getPersonService().fetchPersons(
  6. query.getOffset(),
  7. query.getLimit(),
  8. filter != null ? filter.namePrefix : null,
  9. filter != null ? filter.department : null
  10. );
  11. },
  12. query -> {
  13. PersonFilter filter = query.getFilter().orElse(null);
  14. return getPersonService().getPersonCount(
  15. filter != null ? filter.namePrefix : null,
  16. filter != null ? filter.department : null
  17. );
  18. }
  19. );

This data provider can then be used in different ways with withConvertedFilter or withConfigurableFilter.

Java

  1. // For use with ComboBox without any department filter
  2. DataProvider<Person, String> onlyString = dataProvider.withConvertedFilter(
  3. filterString -> new PersonFilter(filterString, null)
  4. );
  5. // For use with some external filter, e.g. a search form
  6. ConfigurableFilterDataProvider<Person, Void, PersonFilter> everythingConfigurable =
  7. dataProvider.withConfigurableFilter();
  8. everythingConfigurable.setFilter(
  9. new PersonFilter(someText, someDepartment));
  10. // For use with ComboBox and separate department filtering
  11. ConfigurableFilterDataProvider<Person, String, Department> mixed =
  12. dataProvider.withConfigurableFilter(
  13. // Can be shortened as PersonFilter::new
  14. (filterText, department) -> {
  15. return new PersonFilter(filterText, department);
  16. }
  17. );
  18. mixed.setFilter(someDepartment);

The other alternative for using this kind of service API is to define your own data provider subclass that has setter methods for the filter parameters that should not be passed as the query filter. We might for instance want to receive the name filter through the query from a combo box while the department to filter by is set from application code. We must remember to call refreshAll() when the department filter has been changed so that any components can know that they should fetch new data to show.

Java

  1. public class PersonDataProvider
  2. extends AbstractBackEndDataProvider<Person, String> {
  3. private Department departmentFilter;
  4. public void setDepartmentFilter(Department department) {
  5. this.departmentFilter = department;
  6. refreshAll();
  7. }
  8. @Override
  9. protected Stream<Person> fetchFromBackEnd(Query<Person, String> query) {
  10. return getPersonService().fetchPersons(
  11. query.getOffset(),
  12. query.getLimit(),
  13. query.getFilter().orElse(null),
  14. departmentFilter
  15. ).stream();
  16. }
  17. @Override
  18. protected int sizeInBackEnd(Query<Person, String> query) {
  19. return getPersonService().getPersonCount(
  20. query.getFilter().orElse(null),
  21. departmentFilter
  22. );
  23. }
  24. }

Refreshing

When your application makes changes to the data that is in your backend, you might need to make sure all parts of the application are aware of these changes. All data providers have the refreshAll`and `refreshItem methods. These methods can be used when data in the backend has been updated.

For example Spring Data gives you new instances with every request, and making changes to the repository will make old instances of the same object “stale”. In these cases you should inform any interested component by calling dataProvider.refreshItem(newInstance). This can work out of the box, if your beans have equals and hashCode implementations that check if the objects represent the same data. Since that is not always the case, the user of a CallbackDataProvider can give it a ValueProvider that will provide a stable ID for the data objects. This is usually a method reference, eg. Person::getId.

As an example, our service interface has an update method that returns a new instance of the item. Other functionality has been omitted to keep focus on the updating.

Java

  1. public interface PersonService {
  2. Person save(Person person);
  3. }

Part of the application code wants to update a persons name and save it to the backend.

Java

  1. PersonService service;
  2. DataProvider<Person, String> allPersonsWithId = new CallbackDataProvider<>(
  3. fetchCallback, sizeCallback, Person::getId);
  4. NativeSelect<Person> persons = new NativeSelect<>();
  5. persons.setDataProvider(allPersonsWithId);
  6. Button modifyPersonButton = new Button("Modify person",
  7. clickEvent -> {
  8. Person personToChange = persons.getValue();
  9. personToChange.setName("Changed person");
  10. Person newInstance = service.save(personToChange);
  11. dataProvider.refreshItem(newInstance);
  12. });