Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: implementing custom services for auto grid / form / crud #3008

Merged
merged 12 commits into from
Dec 6, 2023
51 changes: 51 additions & 0 deletions articles/react/components/auto-crud/index.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ include::{root}/frontend/demo/component/auto-crud/react/auto-crud-basic.tsx[rend

As you can see in the previous example, Auto CRUD renders a grid with columns for all properties of the `Employee` entity, next to a form with fields for editing those properties. Selecting an entity in the grid populates the form with data from that entity. The new button allows one to create a new entity. When submitting the form, the data is automatically submitted to the `save` method of the configured `CrudService`, and the row in the grid is updated. When an existing entity is selected, the form also shows a delete button. Clicking the button calls the `delete` method of the configured `CrudService`, clears the form and removes the row in the grid.

Auto CRUD 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, Working with Plain Java Classes>> section.

== Component Composition

Auto CRUD is a composition of the following components:
Expand Down Expand Up @@ -132,3 +134,52 @@ include::{root}/frontend/demo/component/auto-crud/react/auto-crud-customized-for
When using Auto CRUD, 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 `visibleFields` does not 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 could do this, for example, by using a `@JsonIgnore` annotation on the property.

For more detailed examples of customizing the underlying form, please refer to the <<../auto-form/index.asciidoc#,Auto Form>> component documentation.

== Working with Plain Java Classes

Auto CRUD 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

// tag::custom-crud-service[]
To use plain Java classes, you need to provide a custom implementation of the `CrudService<T>` interface, as the `CrudRepositoryService<T>` base class only works with JPA entities. The following methods need to be implemented:

* `List<T> list(Pageable pageable, Filter filter)`: Returns a list of paginated, sorted and filtered items.
* `T save(T value)`: Either creates a new item or updates an existing one, depending on whether the item has an ID or not. Returns the saved item.
* `void delete(ID id)`: Deletes the item with the given ID.

ifdef::auto-form[]
[NOTE]
`CrudService` is a common interface shared with other components such as <<../auto-crud#,Auto CRUD>> and as such requires implementing a `list` method for fetching items. If you intend to use the service only with the Auto Form component, you can create a fake implementation of that method, for example by throwing an exception.
sissbruecker marked this conversation as resolved.
Show resolved Hide resolved

endif::[]

The following example shows a basic implementation of a custom `CrudService` that wraps a JPA repository. The service only exposes DTO instances to the client and maps between the DTO instances and JPA entities internally.

[.example]
--
.ProductDtoCrudService.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/ProductDtoCrudService.java[tags=snippet,indent=0]
----
.ProductDto.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/ProductDto.java[tags=snippet,indent=0]
----
.Product.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/Product.java[tags=snippet,indent=0]
----
.ProductRepository.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/ProductRepository.java[tags=snippet,indent=0]
----
--

// end::custom-crud-service[]

The example above only has a very basic implementation of the `list` method that does not support sorting and filtering. For a proper implementation of that method, see the corresponding section in the <<../auto-grid#implementing-a-custom-service,Auto Grid>> documentation. It shows how to implement a `ListService`, in which the `list` method has the same signature as for `CrudService`.
11 changes: 11 additions & 0 deletions articles/react/components/auto-form/index.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ include::{root}/frontend/demo/component/auto-form/react/auto-form-basic.tsx[rend

As you can see, Auto Form component renders a form with fields for all properties of the `Employee` entity. When submitting the form, the data is automatically submitted to the `save` method of the configured `CrudService`.

Auto Form 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, Working with Plain Java Classes>> section.

.Default Responsive Breakpoints
[NOTE]
Auto Form uses the <<../form-layout#,Form Layout component>>'s default responsive breakpoints. To customize them, see the <<customizing-form-layout,Customizing Form Layout>> section.
Expand Down Expand Up @@ -227,3 +229,12 @@ include::{root}/frontend/demo/component/auto-form/react/auto-form-on-errors.tsx[
.On Submit Error Not Called
[NOTE]
Note that `onSubmitError` callback is not be called for form validation errors, which are handled automatically.

== Working with Plain Java Classes

Auto Form 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

:auto-form:
include::../auto-crud/index.asciidoc[tag=custom-crud-service]
82 changes: 82 additions & 0 deletions articles/react/components/auto-grid/index.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ include::{root}/frontend/demo/component/auto-grid/react/auto-grid-basic.tsx[rend

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. If you want to show them, though, you can use the `visibleColumns` property to specify which columns should be displayed. See <<#customizing-columns,Customizing Columns>> for more information.

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, Working with Plain Java Classes>> section.

== Customizing Columns

Expand Down Expand Up @@ -163,6 +164,87 @@ include::{root}/frontend/demo/component/auto-grid/react/auto-grid-selection.tsx[
You can read more on the properties, and use cases supported, on the <<../grid#,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 following method needs to be implemented:

* `List<T> list(Pageable pageable, Filter filter)`: 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.

[.example]
--
.ProductDtoListService.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/ProductDtoListService.java[tags=snippet,indent=0]
----
.ProductDto.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/ProductDto.java[tags=snippet,indent=0]
----
.Product.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/Product.java[tags=snippet,indent=0]
----
.ProductRepository.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/ProductRepository.java[tags=snippet,indent=0]
----
--

==== 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 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.

[.example]
--
.ProductAdvancedDtoListService.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/ProductAdvancedDtoListService.java[tags=snippet,indent=0]
----
.ProductAdvancedDto.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/ProductAdvancedDto.java[tags=snippet,indent=0]
----
.Product.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/Product.java[tags=snippet,indent=0]
----
.ProductRepository.java
[source,java]
----
include::{root}/src/main/java/com/vaadin/demo/fusion/crud/ProductRepository.java[tags=snippet,indent=0]
----
--

++++
<style>
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/com/vaadin/demo/fusion/crud/ProductAdvancedDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.vaadin.demo.fusion.crud;

//tag::snippet[]
public record ProductAdvancedDto(Long productId,
String productName,
String productCategory,
double productPrice,
Long supplierId,
String supplierInfo) {
public static ProductAdvancedDto fromEntity(Product product) {
// Compute a custom property that includes the supplier name and city
String supplierInfo = product.getSupplier() != null
? String.format("%s (%s)", product.getSupplier().getSupplierName(), product.getSupplier().getHeadquarterCity())
: "";

return new ProductAdvancedDto(
product.getId(),
product.getName(),
product.getCategory(),
product.getPrice(),
product.getSupplier().getId(),
supplierInfo
);
}
}
//end::snippet[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.vaadin.demo.fusion.crud;

import com.vaadin.flow.server.auth.AnonymousAllowed;
import dev.hilla.BrowserCallable;
import dev.hilla.Nullable;
import dev.hilla.crud.ListService;
import dev.hilla.crud.filter.AndFilter;
import dev.hilla.crud.filter.Filter;
import dev.hilla.crud.filter.OrFilter;
import dev.hilla.crud.filter.PropertyStringFilter;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;

import java.util.List;

//tag::snippet[]
@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());
};
};
}
}
//end::snippet[]
9 changes: 9 additions & 0 deletions src/main/java/com/vaadin/demo/fusion/crud/ProductDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.vaadin.demo.fusion.crud;

//tag::snippet[]
public record ProductDto(Long id, String name, String category, double price) {
public static ProductDto fromEntity(Product product) {
return new ProductDto(product.getId(), product.getName(), product.getCategory(), product.getPrice());
}
}
//end::snippet[]
Loading