Documentation

Documentation versions (currently viewing)

Auto Grid

Used for displaying tabular data based on a Java backend service.

Auto Grid is a component for displaying tabular data based on a Java backend service.

Basic Usage

Auto Grid requires a Java service that implements the ListService<T> interface. In the example below, the ProductService class extends ListRepositoryService<T, ID, R>, which in turn implements the ListService<T>:

@BrowserCallable
@AnonymousAllowed
public class ProductService
        extends ListRepositoryService<Product, Long, ProductRepository> {
}

Hilla generates TypeScript objects for each @BrowserCallable service implementing the ListService<T> interface. These TypeScript objects have callable methods that execute the corresponding Java service methods, enabling lazy data loading with sorting and filtering.

For the ProductService example, you can import the generated TypeScript object in your React view, and use it as a value for the <AutoGrid service={}/> property to display the data.

import { AutoGrid } from '@vaadin/hilla-react-crud';
import { ProductService } from 'Frontend/generated/endpoints';
import ProductModel from 'Frontend/generated/com/vaadin/demo/fusion/crud/ProductModel';

function Example() {
  return <AutoGrid service={ProductService} model={ProductModel} />;
}

The Auto Grid component renders a grid with columns for all properties of the Product entity with sorting and filtering, loading the data lazily from the backend by calling the ListService<T>.list(Pageable pageable, @Nullable Filter filter) method.

Here’s what the rendered grid looks like:

Open in a
new tab
import { AutoGrid } from '@vaadin/hilla-react-crud';
import { ProductService } from 'Frontend/generated/endpoints';
import ProductModel from 'Frontend/generated/com/vaadin/demo/fusion/crud/ProductModel';

function Example() {
  return <AutoGrid service={ProductService} model={ProductModel} />;
}

As you can see, Auto Grid automatically expands properties of @OneToOne relationships — in this case the properties of the supplier — and it displays them as columns in the grid. However, properties annotated with @Id or @Version are excluded by default. To show them, though, you can use the visibleColumns property to specify which columns to display. You can find more information on this in the next section.

Auto Grid supports working with plain, non-JPA classes. This can be useful, for example, if you want to use the Data Transfer Object (DTO) pattern. See the Working with Plain Java Classes section.

Customizing Columns

It’s possible to customize the columns displayed by Auto Grid. How this is done is covered in the sub-sections here.

Visibility & Order

To choose which of the data properties should be displayed as columns in Auto Grid, and to specify the column order, set the property names to the visibleColumns property.

The following example uses only the category, name, supplier.supplierName, and price properties — in that order:

Open in a
new tab
<AutoGrid
  service={ProductService}
  model={ProductModel}
  visibleColumns={['category', 'name', 'supplier.supplierName', 'price']}
/>

When trying to access nested properties, use the Dot Notation, as shown in the example above. To access the supplierName of the supplier property, for example, you might use supplier.supplierName.

Alternatively, you can also use the hiddenColumns property to specify which columns should be hidden.

Caution
Don’t Expose Sensitive Data on Client-Side
When using Auto Grid, it’s important to check that you don’t expose sensitive data to the client-side. For example, if you have a password property in your entity, hiding it using the visibleColumns or hiddenColumns property won’t prevent the data from being sent to the client-side. Make sure your service implementation doesn’t expose sensitive data to the client-side. You can do this, for example, by using a @JsonIgnore annotation on the property.

Column Options

Individual columns can be customized using the columnOptions property. It takes an object in which each key is the name of a data property, and the value is a property’s object that is passed when rendering the column for that property.

The following example uses column options to define a custom renderer for the price column that highlights prices in different colors, depending on their value:

Open in a
new tab
function PriceRenderer({ item }: { item: Product }) {
  const { price } = item;
  const color = price > 4 ? 'red' : 'green';
  return <span style={{ fontWeight: 'bold', color }}>{price}</span>;
}

function Example() {
  return (
    <AutoGrid
      service={ProductService}
      model={ProductModel}
      columnOptions={{
        price: {
          renderer: PriceRenderer,
        },
      }}
    />
  );
}

You can pass the same options as when rendering a column in a regular grid. See the Grid documentation page for more information and examples.

Adding Custom Columns

To add a custom column that, for example, joins the values of multiple properties and displays them in a single column, use the customColumns property. This property takes an array of GridColumn instances and renders them after the auto columns. Refer to the Grid documentation page for more information on the GridColumn type.

The following example uses the customColumns property to render a custom column that concatenates and displays the product’s supplier name and headquarterCity properties in the same column:

Open in a
new tab
function SupplierRenderer({ item }: { item: Product }) {
  const { supplier } = item;
  return (
    <span>
      {supplier?.supplierName} - {supplier?.headquarterCity}
    </span>
  );
}

return (
  <AutoGrid
    service={ProductService}
    model={ProductModel}
    customColumns={[
      <GridColumn key="supplierInfo" renderer={SupplierRenderer} header="Supplier" autoWidth />,
    ]}
  />
);
Note
Customizing vs. Adding Columns
In general, for customizing automatically rendered columns, you can use the columnOptions property. As for adding or customizing the custom columns, use customColumns.

Custom Columns Order

By default, custom columns are added after the automatically rendered columns. You can also define the order of appearance for custom columns by using the visibleColumns property. Custom columns can be freely placed before, in between, or after other columns. To do this, you’ll need to provide the key property of the custom column in the visibleColumns property.

The following example shows a custom column with supplierInfo as the key placed between two automatically rendered columns:

<AutoGrid
  service={ProductService}
  model={ProductModel}
  visibleColumns={['name', 'supplierInfo', 'category']}
  customColumns={[
    <GridColumn key="supplierInfo"
                renderer={SupplierRenderer}
                header="Supplier"
                autoWidth
    />
  ]}
/>
Note
Custom Columns Visibility
If the visibleColumns property is present, only the custom columns for which their keys are mentioned in the visibleColumns are rendered according to the specified order.

Sorting & Filtering

Auto Grid has sorting and filtering enabled by default for all columns with a supported Java type. That includes all primitive types (e.g., strings, numbers, booleans), as well as some complex types (e.g. enums, java.time.LocalDate and java.time.LocalTime). It sorts by the first column, and shows a filter field in the header of each column.

You can disable sorting and filtering for individual columns by configuring the column’s sortable and filterable properties in the columnOptions object. This can be useful for columns that show a computed property for which no sorting and filtering is implemented in the ListService, or if there isn’t any meaningful way to sort or filter.

This example uses Auto Grid with sorting and filtering disabled for the displayName column:

<AutoGrid
  service={ProductService}
  model={ProductModel}
  columnOptions={{
    displayName: {
      filterable: false,
      sortable: false,
    },
  }}
/>

Alternatively, you can disable all header filters by setting the noHeaderFilters property.

<AutoGrid
  service={ProductService}
  model={ProductModel}
  noHeaderFilters
/>

Instead of using the built-in header filters, Auto Grid supports external filtering by passing a custom filter definition to the experimentalFilter property.

In the example below, the data in Auto Grid is filtered using the contains matcher for the name property, combined with the equals matcher for category when a specific category is selected:

Open in a
new tab
const categoryFilterValue = useSignal(categories[0].value!);
const nameFilterValue = useSignal('');
const filter = useComputed(() => {
  const categoryFilter: PropertyStringFilter = {
    propertyId: 'category',
    filterValue: categoryFilterValue.value,
    matcher: Matcher.EQUALS,
    '@type': 'propertyString',
  };

  const nameFilter: PropertyStringFilter = {
    propertyId: 'name',
    filterValue: nameFilterValue.value,
    matcher: Matcher.CONTAINS,
    '@type': 'propertyString',
  };

  const andFilter: AndFilter = {
    '@type': 'and',
    children: [nameFilter, categoryFilter],
  };

  return categoryFilterValue.value === 'All' ? nameFilter : andFilter;
});

return (
  <div className="flex flex-col items-start gap-m">
    <div className="flex items-baseline gap-m">
      <Select
        label="Filter by category"
        items={categories}
        value={categoryFilterValue.value}
        onValueChanged={(e) => {
          categoryFilterValue.value = e.detail.value;
        }}
      />
      <TextField
        label="Filter by name"
        value={nameFilterValue.value}
        onValueChanged={(e) => {
          nameFilterValue.value = e.detail.value;
        }}
      />
    </div>
    <AutoGrid
      service={ProductService}
      model={ProductModel}
      experimentalFilter={filter.value}
      noHeaderFilters
    />
  </div>
);

You can combine several filtering criteria by using the {'@type': 'and' | 'or', children: []} composite filter type.

Warning
Experimental Feature
External Auto Grid filtering is an experimental feature. The experimentalFilter property API may be changed or removed in future releases.

Filter Options

You can customize the filter behavior by using additional columnOptions properties like filterPlaceholder, filterDebounceTime, or filterMinLength:

<AutoGrid
  service={ProductService}
  model={ProductModel}
  columnOptions={{
    displayName: {
        filterPlaceholder: 'Filter by name',
        filterDebounceTime: 500,
        filterMinLength: 3,
    },
  }}
/>

Refreshing Data

Auto Grid supports refreshing the data by providing a reference to a refresh function that can be called from the parent component. The refresh function can be obtained by providing a reference of the type React.MutableRefObject<AutoGridRef> to the Auto Grid. The AutoGridRef type provides a refresh function that refreshes the data by calling the backend service. The following example shows how to use the refresh function to refresh the data in Auto Grid:

const autoGridRef = React.useRef<AutoGridRef>(null);

function refreshGrid() {
  autoGridRef.current?.refresh();
}

return (
  <VerticalLayout>
    <Button onClick={() => { refreshGrid() }}>Refresh Data</Button>
    <AutoGrid service={EmployeeService} model={EmployeeModel} ref={autoGridRef} />
  </VerticalLayout>
);

Grid Properties

You can also customize the underlying Grid component properties in Auto Grid.

The example here enables single-row selection in the Auto Grid by using a combination of the onActiveItemChanged event listener with the selectedItems property. Both are supported by the Grid component that Auto Grid uses internally.

Open in a
new tab
const selectedItems = useSignal<Product[]>([]);

return (
  <AutoGrid
    service={ProductService}
    model={ProductModel}
    selectedItems={selectedItems.value}
    onActiveItemChanged={(e) => {
      const item = e.detail.value;
      selectedItems.value = item ? [item] : [];
    }}
  />
);

You can read more about the properties, and use cases supported, on the Grid component documentation page.

Working with Plain Java Classes

Auto Grid supports working with plain, non-JPA Java classes. This can be useful, for example, if you want to use the Data Transfer Object (DTO) pattern to decouple your domain from your presentation logic, or you want to avoid serializing your JPA entities to the client due to performance concerns.

Implementing a Custom Service

To use plain Java classes, you need to provide a custom implementation of the ListService<T> interface, as the ListRepositoryService<T> base class only works with JPA entities. The List<T> list(Pageable pageable, Filter filter) method needs to be implemented. It returns a list of paginated, sorted and filtered items.

The following example shows a basic implementation of a custom ListService that wraps a JPA repository. The service only exposes DTO instances to the client and maps between the DTO instances and JPA entities internally. It also reuses the Hilla JpaFilterConverter for the filter implementation. In order for sorting and filtering to work, the DTO class and the JPA entity class must have the same structure, which means all properties that are sorted and filtered-by should have the same name and are at the same path in the data structure.

@BrowserCallable
@AnonymousAllowed
public class ProductDtoListService implements ListService<ProductDto> {
    private final ProductRepository productRepository;
    private final JpaFilterConverter jpaFilterConverter;

    public ProductDtoListService(ProductRepository productRepository, JpaFilterConverter jpaFilterConverter) {
        this.productRepository = productRepository;
        this.jpaFilterConverter = jpaFilterConverter;
    }

    @Override
    @Nonnull
    public List<@Nonnull ProductDto> list(Pageable pageable, @Nullable Filter filter) {
        // Use the Hilla JpaFilterConverter to create a JPA specification from the filter
        Specification<Product> spec = filter != null
                ? jpaFilterConverter.toSpec(filter, Product.class)
                : Specification.anyOf();
        // Query the JPA repository
        Page<Product> products = productRepository.findAll(spec, pageable);
        // Map entities to DTOs and return result
        return products.stream().map(ProductDto::fromEntity).toList();
    }
}

Advanced Implementations

The example above works for an ideal case, where the DTO class structure maps one-to-one to the JPA entity class structure. In practice, however, this is often not the case. For example, you might want to flatten nested properties, use different property names or create computed properties that are not present in the JPA entity. Alternatively, you might want to use a different data source than JPA.

For these cases you need to implement pagination, sorting and filtering manually using the parameters provided to the list method. The method receives a org.springframework.data.domain.Pageable and com.vaadin.hilla.crud.filter.Filter instances as parameters, which can be used to extract the following information:

  • Pageable.getOffset: The offset of the first item to fetch

  • Pageable.getPageSize: The maximum number of items to fetch

  • Pageable.getSort: The sort order of the items. Each sort order contains the property to sort by (Sort.Order.getProperty()), and the direction (Sort.Order.getDirection())

  • Filter: The filter to apply to the items. The filter is a tree structure of Filter instances, where each Filter instance is either an AndFilter, an OrFilter, or a PropertyStringFilter:

    • AndFilter contains a list of nested filters (AndFilter.getChildren()) that should all match.

    • OrFilter contains a list of nested filters (OrFilter.getChildren()) of which at least one should match.

    • PropertyStringFilter filters an individual property against a value. It contains the name of the property to filter (PropertyStringFilter.getPropertyId()), the value to filter against (PropertyStringFilter.getFilterValue()), and the matcher to use (PropertyStringFilter.getMatcher()). The filter value is always a string and needs to be converted into a respective type or format that works with the type of the property. The matcher specifies the type of filter operation, such as EQUALS, CONTAINS, GREATER_THAN, etc.

When using the default header filters, the filter is always an AndFilter with a list of PropertyStringFilter instances. The PropertyStringFilter instances are created based on the filter values entered by the user in the header filters. When using an external filter, the filter can be of any structure.

The following example shows a more complex use case:

  • The DTO class uses different property names than the JPA entity class and flattens nested properties. It also adds a computed property that is not present in the JPA entity.

  • The sort implementation maps the DTO property names to the JPA entity class structure.

  • The filter implementation creates JPA filter specifications based on the filter values entered by the user in the header filters. As part of that it demonstrates how to handle different matcher types, and how to filter for computed properties.

@BrowserCallable
@AnonymousAllowed
public class ProductAdvancedDtoListService implements ListService<ProductAdvancedDto> {
    private final ProductRepository productRepository;

    public ProductAdvancedDtoListService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Override
    public List<ProductAdvancedDto> list(Pageable pageable, @Nullable Filter filter) {
        // Create page request with mapped sort properties
        pageable = createPageRequest(pageable);
        // Create JPA specification from Hilla filter
        Specification<Product> specification = createSpecification(filter);
        // Fetch data from JPA repository
        return productRepository.findAll(specification, pageable)
                .map(ProductAdvancedDto::fromEntity)
                .toList();
    }

    private Pageable createPageRequest(Pageable pageable) {
        List<Sort.Order> sortOrders = pageable.getSort().stream()
                .map(order -> {
                    // Map DTO property names to JPA property names
                    // For the computed supplierInfo property, just use the supplier name
                    String mappedProperty = switch (order.getProperty()) {
                        case "productName" -> "name";
                        case "productCategory" -> "category";
                        case "productPrice" -> "price";
                        case "supplierInfo" -> "supplier.supplierName";
                        default -> throw new IllegalArgumentException("Unknown sort property " + order.getProperty());
                    };
                    return order.isAscending()
                            ? Sort.Order.asc(mappedProperty)
                            : Sort.Order.desc(mappedProperty);
                }).toList();

        return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(sortOrders));
    }

    private Specification<Product> createSpecification(Filter filter) {
        if (filter == null) {
            return Specification.anyOf();
        }
        if (filter instanceof AndFilter andFilter) {
            return Specification.allOf(andFilter.getChildren().stream()
                    .map(this::createSpecification).toList());
        } else if (filter instanceof OrFilter orFilter) {
            return Specification.anyOf(orFilter.getChildren().stream()
                    .map(this::createSpecification).toList());
        } else if (filter instanceof PropertyStringFilter propertyFilter) {
            return filterProperty(propertyFilter);
        } else {
            throw new IllegalArgumentException("Unknown filter type " + filter.getClass().getName());
        }
    }

    private static Specification<Product> filterProperty(PropertyStringFilter filter) {
        String filterValue = filter.getFilterValue();

        // Create filter criteria for each filterable property
        // For the price property, handle the different matchers used by the default header filters
        // For the computed supplier info property, search by supplier name or city
        return (root, query, criteriaBuilder) -> {
            return switch (filter.getPropertyId()) {
                case "productName" -> criteriaBuilder.like(root.get("name"), "%" + filterValue + "%");
                case "productCategory" -> criteriaBuilder.like(root.get("category"), "%" + filterValue + "%");
                case "productPrice" -> switch (filter.getMatcher()) {
                    case EQUALS -> criteriaBuilder.equal(root.get("price"), filterValue);
                    case GREATER_THAN -> criteriaBuilder.greaterThan(root.get("price"), filterValue);
                    case LESS_THAN -> criteriaBuilder.lessThan(root.get("price"), filterValue);
                    default -> throw new IllegalArgumentException("Unsupported matcher: " + filter.getMatcher());
                };
                case "supplierInfo" -> criteriaBuilder.or(
                        criteriaBuilder.like(root.get("supplier").get("supplierName"), "%" + filterValue + "%"),
                        criteriaBuilder.like(root.get("supplier").get("headquarterCity"), "%" + filterValue + "%")
                );
                default -> throw new IllegalArgumentException("Unknown filter property " + filter.getPropertyId());
            };
        };
    }
}