Auto Grid
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 this example, 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 '@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:
new tab
import { AutoGrid } from '@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, which 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:
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
.
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 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:
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:
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 and Filtering
Auto Grid has sorting and filtering enabled by default for all columns with a supported Java type. That includes all primitive types, like strings, numbers, booleans, as well as some complex types like enums, java.time.LocalDate
and java.time.LocalTime
. It automatically 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 sortable
and filterable
properties of the column in the columnOptions
object. This can be useful, for example for columns that show a computed property for which no sorting and filtering is implemented in the ListService
, or if there simply isn’t any meaningful way to sort or filter.
<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
/>
As an alternative to 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:
new tab
const [categoryFilterValue, setCategoryFilterValue] = useState(categories[0].value!);
const [nameFilterValue, setNameFilterValue] = useState('');
const filter = useMemo(() => {
const categoryFilter: PropertyStringFilter = {
propertyId: 'category',
filterValue: categoryFilterValue,
matcher: Matcher.EQUALS,
'@type': 'propertyString',
};
const nameFilter: PropertyStringFilter = {
propertyId: 'name',
filterValue: nameFilterValue,
matcher: Matcher.CONTAINS,
'@type': 'propertyString',
};
const andFilter: AndFilter = {
'@type': 'and',
children: [nameFilter, categoryFilter],
};
return categoryFilterValue == 'All' ? nameFilter : andFilter;
}, [categoryFilterValue, nameFilterValue]);
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}
onValueChanged={(e) => setCategoryFilterValue(e.detail.value)}
/>
<TextField
label="Filter by name"
value={nameFilterValue}
onValueChanged={(e) => setNameFilterValue(e.detail.value)}
/>
</div>
<AutoGrid
service={ProductService}
model={ProductModel}
experimentalFilter={filter}
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.
new tab
const [selectedItems, setSelectedItems] = useState<Product[]>([]);
return (
<AutoGrid
service={ProductService}
model={ProductModel}
selectedItems={selectedItems}
onActiveItemChanged={(e) => {
const item = e.detail.value;
setSelectedItems(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 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 dev.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 ofFilter
instances, where eachFilter
instance is either anAndFilter
, anOrFilter
, or aPropertyStringFilter
:-
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 asEQUALS
,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());
};
};
}
}