Add Deep Linking
- Add a Route Parameter
- Select on Navigation
- Add Navigation Methods
- Navigate on Selection
- Complete Code for This Step
- Next Steps
In the previous step, you added support for creating new products in the catalog.
In this step, you’ll add support for deep linking, making it possible to share and bookmark links to individual products.
Add a Route Parameter
For deep linking to work, the application must be able to determine the selected item from the URL alone. You’ll achieve this by including the product ID as a route parameter.
|
Note
|
Using internal database keys in public URLs
In production applications, avoid exposing database primary keys directly in URLs. Sequential or predictable IDs can be guessed, which may lead to unauthorized data access if authorization has not been implemented correctly. Instead, consider using a randomly generated, public-facing identifier (for example, a UUID or NanoID). This approach improves security and makes the application more future-proof, as changes to the database schema or key strategy won’t break existing links.
|
Start by making ProductCatalogView implement HasUrlParameter<Long>. While you’re at it, change the service and grid variables to fields, as you’ll need them later when you implement the setParameter() method. The updated code looks like this:
Source code
ProductCatalogView.java
package com.example.product;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.grid.ColumnTextAlign;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.masterdetaillayout.MasterDetailLayout;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasUrlParameter;
import com.vaadin.flow.router.OptionalParameter;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.OptimisticLockingFailureException;
@Route("")
@PageTitle("Product Catalog")
class ProductCatalogView extends MasterDetailLayout implements HasUrlParameter<Long> { 1
private final ProductCatalogService service;
private final Grid<ProductCatalogItem> grid;
ProductCatalogView(ProductCatalogService service) {
this.service = service;
// Create components
var searchField = new TextField();
searchField.setPlaceholder("Search");
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
searchField.setValueChangeMode(ValueChangeMode.LAZY);
grid = new Grid<>();
grid.addColumn(ProductCatalogItem::name)
.setHeader("Name")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_NAME);
grid.addColumn(ProductCatalogItem::price)
.setHeader("Price")
.setTextAlign(ColumnTextAlign.END)
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_PRICE);
grid.addColumn(ProductCatalogItem::description)
.setHeader("Description")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_DESCRIPTION);
grid.addColumn(ProductCatalogItem::category)
.setHeader("Category")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_CATEGORY);
grid.addColumn(ProductCatalogItem::brand)
.setHeader("Brand")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_BRAND);
grid.setItemsPageable(pageable -> service
.findItems(searchField.getValue(), pageable)
);
var drawer = new ProductFormDrawer(productDetails -> {
var saved = service.save(productDetails);
grid.getDataProvider().refreshAll();
return saved;
}, this::handleException);
searchField.addValueChangeListener(e ->
grid.getDataProvider().refreshAll());
grid.addSelectionListener(e -> {
var productDetails = e.getFirstSelectedItem()
.flatMap(item -> service.findDetailsById(item.productId()));
productDetails.ifPresent(drawer::setProductDetails);
setDetail(productDetails.isPresent() ? drawer : null);
});
addBackdropClickListener(e -> grid.deselectAll());
addDetailEscapePressListener(e -> grid.deselectAll());
var addButton = new Button("Add Product", e ->
new AddProductDialog(
productDetails -> {
var saved = service.save(productDetails);
grid.getDataProvider().refreshAll();
service.findItemById(saved.getProductId())
.ifPresent(grid::select);
},
this::handleException
).open()
);
// Layout view
setSizeFull();
setDetailSize("300px");
setExpandMaster(true);
var toolbar = new HorizontalLayout();
toolbar.setWidthFull();
toolbar.addToStart(searchField);
toolbar.addToEnd(addButton);
var listLayout = new VerticalLayout(toolbar, grid);
listLayout.setSizeFull();
grid.setSizeFull();
setMaster(listLayout);
}
private void handleException(RuntimeException exception) {
if (exception instanceof OptimisticLockingFailureException) {
var notification = new Notification(
"Another user has edited the same product. "
+ "Please refresh and try again.");
notification.setPosition(Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.WARNING);
notification.setDuration(3000);
notification.open();
} else if (exception instanceof DuplicateKeyException) {
var notification = new Notification(
"The SKU is already in use. Please enter another one.");
notification.setPosition(Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.WARNING);
notification.setDuration(3000);
notification.open();
} else {
// Delegate to Vaadin's default error handler
throw exception;
}
}
@Override
public void setParameter(BeforeEvent event, @OptionalParameter Long productId) { 2
// To be implemented
}
}-
Implement
HasUrlParameter<Long>to add a single route parameter of typeLongto the view. -
Make the route parameter optional with the
@OptionalParameterannotation.
You can now navigate to http://localhost:8080/12, where 12 is the route parameter value. The view won’t look any different yet — you’ll fix that next.
Select on Navigation
Now you’ll implement setParameter() to select the corresponding product in the grid. When there’s no parameter, it should clear the selection.
You don’t have to show the drawer here. Selecting an item from code fires the same selection event as a click, so the selection listener you added in the Show Details step shows the drawer for you.
Change the code of ProductCatalogView as follows:
Source code
ProductCatalogView.java
package com.example.product;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.grid.ColumnTextAlign;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.masterdetaillayout.MasterDetailLayout;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasUrlParameter;
import com.vaadin.flow.router.OptionalParameter;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.OptimisticLockingFailureException;
import java.util.Optional;
@Route("")
@PageTitle("Product Catalog")
class ProductCatalogView extends MasterDetailLayout implements HasUrlParameter<Long> {
private final ProductCatalogService service;
private final Grid<ProductCatalogItem> grid;
ProductCatalogView(ProductCatalogService service) {
this.service = service;
// Create components
var searchField = new TextField();
searchField.setPlaceholder("Search");
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
searchField.setValueChangeMode(ValueChangeMode.LAZY);
grid = new Grid<>();
grid.addColumn(ProductCatalogItem::name)
.setHeader("Name")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_NAME);
grid.addColumn(ProductCatalogItem::price)
.setHeader("Price")
.setTextAlign(ColumnTextAlign.END)
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_PRICE);
grid.addColumn(ProductCatalogItem::description)
.setHeader("Description")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_DESCRIPTION);
grid.addColumn(ProductCatalogItem::category)
.setHeader("Category")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_CATEGORY);
grid.addColumn(ProductCatalogItem::brand)
.setHeader("Brand")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_BRAND);
grid.setItemsPageable(pageable -> service
.findItems(searchField.getValue(), pageable)
);
var drawer = new ProductFormDrawer(productDetails -> {
var saved = service.save(productDetails);
grid.getDataProvider().refreshAll();
return saved;
}, this::handleException);
searchField.addValueChangeListener(e ->
grid.getDataProvider().refreshAll());
grid.addSelectionListener(e -> {
var productDetails = e.getFirstSelectedItem()
.flatMap(item -> service.findDetailsById(item.productId()));
productDetails.ifPresent(drawer::setProductDetails);
setDetail(productDetails.isPresent() ? drawer : null);
});
addBackdropClickListener(e -> grid.deselectAll());
addDetailEscapePressListener(e -> grid.deselectAll());
var addButton = new Button("Add Product", e ->
new AddProductDialog(
productDetails -> {
var saved = service.save(productDetails);
grid.getDataProvider().refreshAll();
service.findItemById(saved.getProductId())
.ifPresent(grid::select);
},
this::handleException
).open()
);
// Layout view
setSizeFull();
setDetailSize("300px");
setExpandMaster(true);
var toolbar = new HorizontalLayout();
toolbar.setWidthFull();
toolbar.addToStart(searchField);
toolbar.addToEnd(addButton);
var listLayout = new VerticalLayout(toolbar, grid);
listLayout.setSizeFull();
grid.setSizeFull();
setMaster(listLayout);
}
private void handleException(RuntimeException exception) {
if (exception instanceof OptimisticLockingFailureException) {
var notification = new Notification(
"Another user has edited the same product. "
+ "Please refresh and try again.");
notification.setPosition(Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.WARNING);
notification.setDuration(3000);
notification.open();
} else if (exception instanceof DuplicateKeyException) {
var notification = new Notification(
"The SKU is already in use. Please enter another one.");
notification.setPosition(Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.WARNING);
notification.setDuration(3000);
notification.open();
} else {
// Delegate to Vaadin's default error handler
throw exception;
}
}
@Override
public void setParameter(BeforeEvent event, @OptionalParameter Long productId) {
Optional.ofNullable(productId)
.flatMap(service::findItemById) 1
.ifPresentOrElse(grid::select, grid::deselectAll); 2
}
}-
Fetch the
ProductCatalogItemthat has the ID from the URL. -
Select the item in the grid. If there’s no parameter, or no product with that ID, clear the selection instead. The selection listener then shows or hides the drawer.
If you now navigate to http://localhost:8080/12, you should see an item selected in the grid and its details visible in the drawer. Try different product IDs — the test data contains products from 1 to 50. Navigating to http://localhost:8080 should clear the selection and hide the drawer.
Add Navigation Methods
Now that the URL controls selection, you can navigate to specific products from anywhere in the application using UI.navigate(). To improve readability, you’ll create two helper methods: one to open a specific product, and one to open the catalog without selection.
Update the code as follows:
Source code
ProductCatalogView.java
package com.example.product;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.grid.ColumnTextAlign;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.masterdetaillayout.MasterDetailLayout;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasUrlParameter;
import com.vaadin.flow.router.OptionalParameter;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.OptimisticLockingFailureException;
import java.util.Optional;
@Route("")
@PageTitle("Product Catalog")
class ProductCatalogView extends MasterDetailLayout implements HasUrlParameter<Long> {
private final ProductCatalogService service;
private final Grid<ProductCatalogItem> grid;
ProductCatalogView(ProductCatalogService service) {
this.service = service;
// Create components
var searchField = new TextField();
searchField.setPlaceholder("Search");
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
searchField.setValueChangeMode(ValueChangeMode.LAZY);
grid = new Grid<>();
grid.addColumn(ProductCatalogItem::name)
.setHeader("Name")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_NAME);
grid.addColumn(ProductCatalogItem::price)
.setHeader("Price")
.setTextAlign(ColumnTextAlign.END)
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_PRICE);
grid.addColumn(ProductCatalogItem::description)
.setHeader("Description")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_DESCRIPTION);
grid.addColumn(ProductCatalogItem::category)
.setHeader("Category")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_CATEGORY);
grid.addColumn(ProductCatalogItem::brand)
.setHeader("Brand")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_BRAND);
grid.setItemsPageable(pageable -> service
.findItems(searchField.getValue(), pageable)
);
var drawer = new ProductFormDrawer(productDetails -> {
var saved = service.save(productDetails);
grid.getDataProvider().refreshAll();
return saved;
}, this::handleException);
searchField.addValueChangeListener(e ->
grid.getDataProvider().refreshAll());
grid.addSelectionListener(e -> {
var productDetails = e.getFirstSelectedItem()
.flatMap(item -> service.findDetailsById(item.productId()));
productDetails.ifPresent(drawer::setProductDetails);
setDetail(productDetails.isPresent() ? drawer : null);
});
addBackdropClickListener(e -> grid.deselectAll());
addDetailEscapePressListener(e -> grid.deselectAll());
var addButton = new Button("Add Product", e ->
new AddProductDialog(
productDetails -> {
var saved = service.save(productDetails);
grid.getDataProvider().refreshAll();
service.findItemById(saved.getProductId())
.ifPresent(grid::select);
},
this::handleException
).open()
);
// Layout view
setSizeFull();
setDetailSize("300px");
setExpandMaster(true);
var toolbar = new HorizontalLayout();
toolbar.setWidthFull();
toolbar.addToStart(searchField);
toolbar.addToEnd(addButton);
var listLayout = new VerticalLayout(toolbar, grid);
listLayout.setSizeFull();
grid.setSizeFull();
setMaster(listLayout);
}
private void handleException(RuntimeException exception) {
if (exception instanceof OptimisticLockingFailureException) {
var notification = new Notification(
"Another user has edited the same product. "
+ "Please refresh and try again.");
notification.setPosition(Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.WARNING);
notification.setDuration(3000);
notification.open();
} else if (exception instanceof DuplicateKeyException) {
var notification = new Notification(
"The SKU is already in use. Please enter another one.");
notification.setPosition(Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.WARNING);
notification.setDuration(3000);
notification.open();
} else {
// Delegate to Vaadin's default error handler
throw exception;
}
}
@Override
public void setParameter(BeforeEvent event, @OptionalParameter Long productId) {
Optional.ofNullable(productId)
.flatMap(service::findItemById)
.ifPresentOrElse(grid::select, grid::deselectAll);
}
public static void showProductDetails(long productId) {
UI.getCurrentOrThrow().navigate(ProductCatalogView.class, productId);
}
public static void showProductCatalog() {
UI.getCurrentOrThrow().navigate(ProductCatalogView.class);
}
}From other views, you’ll be able to call ProductCatalogView.showProductCatalog() to open the catalog, or ProductCatalogView.showProductDetails(productId) to jump to a specific product. For now, you’ll use these methods internally.
Navigate on Selection
If you select an item in the grid now, the drawer opens as before — but the URL doesn’t change. You’ll fix that next.
The grid selection still decides what the drawer shows. The URL only has to follow it: whenever the selection changes, the view should navigate to the matching URL. The URL sets the selection when the user opens a link, and follows the selection after that. The Add a Master-Detail View guide describes the same pattern.
Update the grid selection listener and setParameter() as follows:
Source code
ProductCatalogView.java
package com.example.product;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.grid.ColumnTextAlign;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.masterdetaillayout.MasterDetailLayout;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasUrlParameter;
import com.vaadin.flow.router.OptionalParameter;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.OptimisticLockingFailureException;
import java.util.Objects;
import java.util.Optional;
@Route("")
@PageTitle("Product Catalog")
class ProductCatalogView extends MasterDetailLayout implements HasUrlParameter<Long> {
private final ProductCatalogService service;
private final Grid<ProductCatalogItem> grid;
ProductCatalogView(ProductCatalogService service) {
this.service = service;
// Create components
var searchField = new TextField();
searchField.setPlaceholder("Search");
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
searchField.setValueChangeMode(ValueChangeMode.LAZY);
grid = new Grid<>();
grid.addColumn(ProductCatalogItem::name)
.setHeader("Name")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_NAME);
grid.addColumn(ProductCatalogItem::price)
.setHeader("Price")
.setTextAlign(ColumnTextAlign.END)
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_PRICE);
grid.addColumn(ProductCatalogItem::description)
.setHeader("Description")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_DESCRIPTION);
grid.addColumn(ProductCatalogItem::category)
.setHeader("Category")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_CATEGORY);
grid.addColumn(ProductCatalogItem::brand)
.setHeader("Brand")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_BRAND);
grid.setItemsPageable(pageable -> service
.findItems(searchField.getValue(), pageable)
);
var drawer = new ProductFormDrawer(productDetails -> {
var saved = service.save(productDetails);
grid.getDataProvider().refreshAll();
return saved;
}, this::handleException);
searchField.addValueChangeListener(e ->
grid.getDataProvider().refreshAll());
grid.addSelectionListener(e -> {
var productDetails = e.getFirstSelectedItem()
.flatMap(item -> service.findDetailsById(item.productId()));
productDetails.ifPresent(drawer::setProductDetails);
setDetail(productDetails.isPresent() ? drawer : null);
e.getFirstSelectedItem()
.map(ProductCatalogItem::productId)
.ifPresentOrElse(
ProductCatalogView::showProductDetails, 1
ProductCatalogView::showProductCatalog 2
);
});
addBackdropClickListener(e -> grid.deselectAll());
addDetailEscapePressListener(e -> grid.deselectAll());
var addButton = new Button("Add Product", e ->
new AddProductDialog(
productDetails -> {
var saved = service.save(productDetails);
grid.getDataProvider().refreshAll();
service.findItemById(saved.getProductId())
.ifPresent(grid::select);
},
this::handleException
).open()
);
// Layout view
setSizeFull();
setDetailSize("300px");
setExpandMaster(true);
var toolbar = new HorizontalLayout();
toolbar.setWidthFull();
toolbar.addToStart(searchField);
toolbar.addToEnd(addButton);
var listLayout = new VerticalLayout(toolbar, grid);
listLayout.setSizeFull();
grid.setSizeFull();
setMaster(listLayout);
}
private void handleException(RuntimeException exception) {
if (exception instanceof OptimisticLockingFailureException) {
var notification = new Notification(
"Another user has edited the same product. "
+ "Please refresh and try again.");
notification.setPosition(Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.WARNING);
notification.setDuration(3000);
notification.open();
} else if (exception instanceof DuplicateKeyException) {
var notification = new Notification(
"The SKU is already in use. Please enter another one.");
notification.setPosition(Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.WARNING);
notification.setDuration(3000);
notification.open();
} else {
// Delegate to Vaadin's default error handler
throw exception;
}
}
@Override
public void setParameter(BeforeEvent event, @OptionalParameter Long productId) {
var selectedId = grid.asSingleSelect().getOptionalValue()
.map(ProductCatalogItem::productId)
.orElse(null);
if (Objects.equals(productId, selectedId)) {
return; 3
}
Optional.ofNullable(productId)
.flatMap(service::findItemById)
.ifPresentOrElse(grid::select, grid::deselectAll);
}
public static void showProductDetails(long productId) {
UI.getCurrentOrThrow().navigate(ProductCatalogView.class, productId);
}
public static void showProductCatalog() {
UI.getCurrentOrThrow().navigate(ProductCatalogView.class);
}
}-
Set the route parameter if there is a selected item.
-
Clear the route parameter if there is no selection.
-
If the grid already has this product selected, there’s nothing to do. This is the case when the navigation comes from the selection listener, so
setParameter()doesn’t fetch an item that the grid already has.
If you now select an item in the grid, you should see the URL change. Clearing the selection should also remove the URL parameter. Since adding a product selects it, the URL follows when you add products, too. Even though you are calling UI.navigate(), the page is not reloading and the ProductCatalogView instance remains the same. Vaadin notices you are already on the view that you are trying to navigate to, and only calls setParameter() again.
You may have noticed two potential loops:
-
When the user selects a product, the selection listener navigates, which calls
setParameter(). Since the product is already selected,setParameter()returns without doing anything. -
When the user opens a link,
setParameter()selects the product, and the selection listener navigates to the URL the user is already on. Vaadin compares the URL parameters before navigating, and does nothing if you’re already on the same view with the same parameter.
Each call to UI.navigate() also adds an entry to the browser history. Because the view navigates whenever the selection changes, each product the user selects becomes a history entry, and so does clearing the selection. The browser’s Back and Forward buttons then step through these entries. Each step calls setParameter(), which updates the selection and the drawer, so the view always matches the URL. Try it: select three products one after another, and then press the Back button three times.
The downside is that a user who has looked at many products has to press Back many times to leave the catalog. Avoiding this means changing the URL without adding a history entry. UI.navigate() can’t do that, and the lower-level History API that can bypasses the router, so this tutorial keeps one entry per selection.
Complete Code for This Step
If your application doesn’t behave as described, compare your files with these listings. They show every class in the application as it should look at the end of this step.
ProductCatalogView.java
Source code
Java
package com.example.product;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.grid.ColumnTextAlign;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.masterdetaillayout.MasterDetailLayout;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasUrlParameter;
import com.vaadin.flow.router.OptionalParameter;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.OptimisticLockingFailureException;
import java.util.Objects;
import java.util.Optional;
@Route("")
@PageTitle("Product Catalog")
class ProductCatalogView extends MasterDetailLayout implements HasUrlParameter<Long> {
private final ProductCatalogService service;
private final Grid<ProductCatalogItem> grid;
ProductCatalogView(ProductCatalogService service) {
this.service = service;
// Create components
var searchField = new TextField();
searchField.setPlaceholder("Search");
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
searchField.setValueChangeMode(ValueChangeMode.LAZY);
grid = new Grid<>();
grid.addColumn(ProductCatalogItem::name)
.setHeader("Name")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_NAME);
grid.addColumn(ProductCatalogItem::price)
.setHeader("Price")
.setTextAlign(ColumnTextAlign.END)
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_PRICE);
grid.addColumn(ProductCatalogItem::description)
.setHeader("Description")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_DESCRIPTION);
grid.addColumn(ProductCatalogItem::category)
.setHeader("Category")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_CATEGORY);
grid.addColumn(ProductCatalogItem::brand)
.setHeader("Brand")
.setSortProperty(ProductCatalogItem.SORT_PROPERTY_BRAND);
grid.setItemsPageable(pageable -> service
.findItems(searchField.getValue(), pageable)
);
var drawer = new ProductFormDrawer(productDetails -> {
var saved = service.save(productDetails);
grid.getDataProvider().refreshAll();
return saved;
}, this::handleException);
searchField.addValueChangeListener(e ->
grid.getDataProvider().refreshAll());
grid.addSelectionListener(e -> {
var productDetails = e.getFirstSelectedItem()
.flatMap(item -> service.findDetailsById(item.productId()));
productDetails.ifPresent(drawer::setProductDetails);
setDetail(productDetails.isPresent() ? drawer : null);
e.getFirstSelectedItem()
.map(ProductCatalogItem::productId)
.ifPresentOrElse(
ProductCatalogView::showProductDetails,
ProductCatalogView::showProductCatalog
);
});
addBackdropClickListener(e -> grid.deselectAll());
addDetailEscapePressListener(e -> grid.deselectAll());
var addButton = new Button("Add Product", e ->
new AddProductDialog(
productDetails -> {
var saved = service.save(productDetails);
grid.getDataProvider().refreshAll();
service.findItemById(saved.getProductId())
.ifPresent(grid::select);
},
this::handleException
).open()
);
// Layout view
setSizeFull();
setDetailSize("300px");
setExpandMaster(true);
var toolbar = new HorizontalLayout();
toolbar.setWidthFull();
toolbar.addToStart(searchField);
toolbar.addToEnd(addButton);
var listLayout = new VerticalLayout(toolbar, grid);
listLayout.setSizeFull();
grid.setSizeFull();
setMaster(listLayout);
}
private void handleException(RuntimeException exception) {
if (exception instanceof OptimisticLockingFailureException) {
var notification = new Notification(
"Another user has edited the same product. "
+ "Please refresh and try again.");
notification.setPosition(Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.WARNING);
notification.setDuration(3000);
notification.open();
} else if (exception instanceof DuplicateKeyException) {
var notification = new Notification(
"The SKU is already in use. Please enter another one.");
notification.setPosition(Notification.Position.MIDDLE);
notification.addThemeVariants(NotificationVariant.WARNING);
notification.setDuration(3000);
notification.open();
} else {
// Delegate to Vaadin's default error handler
throw exception;
}
}
@Override
public void setParameter(BeforeEvent event, @OptionalParameter Long productId) {
var selectedId = grid.asSingleSelect().getOptionalValue()
.map(ProductCatalogItem::productId)
.orElse(null);
if (Objects.equals(productId, selectedId)) {
return;
}
Optional.ofNullable(productId)
.flatMap(service::findItemById)
.ifPresentOrElse(grid::select, grid::deselectAll);
}
public static void showProductDetails(long productId) {
UI.getCurrentOrThrow().navigate(ProductCatalogView.class, productId);
}
public static void showProductCatalog() {
UI.getCurrentOrThrow().navigate(ProductCatalogView.class);
}
}ProductCatalogItem.java
Source code
Java
package com.example.product;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal;
import java.util.Objects;
@Table("PRODUCTS")
record ProductCatalogItem(
@Id long productId,
String name,
String description,
String category,
String brand,
BigDecimal price
) {
static final String SORT_PROPERTY_NAME = "name";
static final String SORT_PROPERTY_PRICE = "price";
static final String SORT_PROPERTY_DESCRIPTION = "description";
static final String SORT_PROPERTY_CATEGORY = "category";
static final String SORT_PROPERTY_BRAND = "brand";
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
ProductCatalogItem that = (ProductCatalogItem) o;
return productId == that.productId;
}
@Override
public int hashCode() {
return Objects.hashCode(productId);
}
}ProductCatalogItemRepository.java
Source code
Java
package com.example.product;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.Optional;
interface ProductCatalogItemRepository
extends PagingAndSortingRepository<ProductCatalogItem, Long> {
Optional<ProductCatalogItem> findById(Long id);
Slice<ProductCatalogItem> findByNameContainingIgnoreCase(String name,
Pageable pageable);
}ProductCatalogService.java
Source code
Java
package com.example.product;
import jakarta.validation.Valid;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.List;
import java.util.Optional;
@Service
@Validated
@Transactional(readOnly = true)
class ProductCatalogService {
private final ProductCatalogItemRepository productCatalogItemRepository;
private final ProductDetailsRepository productDetailsRepository;
public ProductCatalogService(
ProductCatalogItemRepository productCatalogItemRepository,
ProductDetailsRepository productDetailsRepository) {
this.productCatalogItemRepository = productCatalogItemRepository;
this.productDetailsRepository = productDetailsRepository;
}
public List<ProductCatalogItem> findItems(String searchTerm,
Pageable pageable) {
return productCatalogItemRepository
.findByNameContainingIgnoreCase(searchTerm, pageable).getContent();
}
public Optional<ProductDetails> findDetailsById(Long id) {
return productDetailsRepository.findById(id);
}
public Optional<ProductCatalogItem> findItemById(Long id) {
return productCatalogItemRepository.findById(id);
}
@Transactional
public ProductDetails save(@Valid ProductDetails productDetails) {
return productDetailsRepository.save(new ProductDetails(productDetails));
}
}ProductDetails.java
Source code
Java
package com.example.product;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.relational.core.mapping.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
@Table("PRODUCTS")
class ProductDetails {
@Id
private Long productId;
@Version
private Long version;
@NotBlank @Size(max = 100)
private String name;
@NotBlank @Size(max = 500)
private String description;
@NotBlank @Size(max = 100)
private String category;
@Size(max = 100)
private String brand;
@NotBlank @Size(max = 50)
private String sku;
private LocalDate releaseDate;
@NotNull @PositiveOrZero
private BigDecimal price;
@DecimalMin("0") @DecimalMax("100")
private BigDecimal discount;
public ProductDetails() {
}
public ProductDetails(ProductDetails original) {
this.productId = original.productId;
this.version = original.version;
this.name = original.name;
this.description = original.description;
this.category = original.category;
this.brand = original.brand;
this.sku = original.sku;
this.releaseDate = original.releaseDate;
this.price = original.price;
this.discount = original.discount;
}
public Long getProductId() {
return productId;
}
public Long getVersion() {
return version;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public String getSku() {
return sku;
}
public void setSku(String sku) {
this.sku = sku;
}
public LocalDate getReleaseDate() {
return releaseDate;
}
public void setReleaseDate(LocalDate releaseDate) {
this.releaseDate = releaseDate;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public BigDecimal getDiscount() {
return discount;
}
public void setDiscount(BigDecimal discount) {
this.discount = discount;
}
}ProductDetailsRepository.java
Source code
Java
package com.example.product;
import org.springframework.data.repository.CrudRepository;
interface ProductDetailsRepository
extends CrudRepository<ProductDetails, Long> {
}ProductForm.java
Source code
Java
package com.example.product;
import com.vaadin.flow.component.Composite;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.textfield.BigDecimalField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.validator.BigDecimalRangeValidator;
import org.jspecify.annotations.Nullable;
import java.math.BigDecimal;
import java.util.Optional;
class ProductForm extends Composite<FormLayout> {
private final Binder<ProductDetails> binder;
ProductForm() {
// Create components
var nameField = new TextField("Name");
nameField.setMaxLength(100);
var descriptionField = new TextArea("Description");
descriptionField.setMaxLength(500);
var categoryField = new TextField("Category");
categoryField.setMaxLength(100);
var brandField = new TextField("Brand");
brandField.setMaxLength(100);
var skuField = new TextField("SKU");
skuField.setMaxLength(50);
var releaseDateField = new DatePicker("Release Date");
var priceField = new BigDecimalField("Price");
var discountField = new BigDecimalField("Discount");
discountField.setSuffixComponent(new Span("%"));
// Layout form
var layout = getContent();
layout.add(nameField);
layout.add(descriptionField);
layout.add(categoryField);
layout.add(brandField);
layout.add(skuField);
layout.add(releaseDateField);
layout.add(priceField);
layout.add(discountField);
// Bind fields
binder = new Binder<>();
binder.forField(nameField)
.asRequired("Enter name")
.bind(ProductDetails::getName,
ProductDetails::setName);
binder.forField(descriptionField)
.asRequired("Enter description")
.bind(ProductDetails::getDescription,
ProductDetails::setDescription);
binder.forField(categoryField)
.asRequired("Enter category")
.bind(ProductDetails::getCategory,
ProductDetails::setCategory);
binder.forField(brandField)
.bind(ProductDetails::getBrand,
ProductDetails::setBrand);
binder.forField(skuField)
.asRequired("Enter SKU")
.bind(ProductDetails::getSku,
ProductDetails::setSku);
binder.forField(releaseDateField)
.bind(ProductDetails::getReleaseDate,
ProductDetails::setReleaseDate);
binder.forField(priceField)
.asRequired("Enter price")
.withValidator(new BigDecimalRangeValidator(
"Enter a price of 0 or more", BigDecimal.ZERO, null))
.bind(ProductDetails::getPrice,
ProductDetails::setPrice);
binder.forField(discountField)
.withValidator(new BigDecimalRangeValidator(
"Enter a discount between 0 and 100",
BigDecimal.ZERO, BigDecimal.valueOf(100)))
.bind(ProductDetails::getDiscount,
ProductDetails::setDiscount);
}
public void setFormDataObject(@Nullable ProductDetails productDetails) {
binder.setBean(productDetails);
}
public Optional<ProductDetails> getFormDataObject() {
if (binder.getBean() == null) {
throw new IllegalStateException("No form data object");
}
if (binder.validate().isOk()) {
return Optional.of(binder.getBean());
} else {
return Optional.empty();
}
}
}ProductFormDrawer.java
Source code
Java
package com.example.product;
import com.vaadin.flow.component.Composite;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.orderedlayout.Scroller;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
class ProductFormDrawer extends Composite<VerticalLayout> {
@FunctionalInterface
interface SaveCallback {
ProductDetails save(ProductDetails productDetails);
}
@FunctionalInterface
interface ErrorCallback {
void handleException(RuntimeException e);
}
private final SaveCallback saveCallback;
private final ErrorCallback errorCallback;
private final ProductForm form;
ProductFormDrawer(SaveCallback saveCallback, ErrorCallback errorCallback) {
this.saveCallback = saveCallback;
this.errorCallback = errorCallback;
var header = new H2("Product Details");
form = new ProductForm();
var saveButton = new Button("Save", e -> save());
saveButton.addThemeVariants(ButtonVariant.PRIMARY);
var layout = getContent();
layout.add(header);
layout.add(new Scroller(form));
layout.add(saveButton);
layout.setHeightFull();
}
public void setProductDetails(ProductDetails productDetails) {
form.setFormDataObject(productDetails);
}
private void save() {
form.getFormDataObject().ifPresent(productDetails -> {
try {
var saved = saveCallback.save(productDetails);
form.setFormDataObject(saved);
} catch (RuntimeException e) {
errorCallback.handleException(e);
}
});
}
}AddProductDialog.java
Source code
Java
package com.example.product;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
class AddProductDialog extends Dialog {
@FunctionalInterface
interface SaveCallback {
void save(ProductDetails productDetails);
}
@FunctionalInterface
interface ErrorCallback {
void handleException(RuntimeException e);
}
private final SaveCallback saveCallback;
private final ErrorCallback errorCallback;
private final ProductForm form;
AddProductDialog(SaveCallback saveCallback, ErrorCallback errorCallback) {
this.saveCallback = saveCallback;
this.errorCallback = errorCallback;
// Create components
form = new ProductForm();
form.setFormDataObject(new ProductDetails());
var saveButton = new Button("Save", e -> save());
saveButton.addThemeVariants(ButtonVariant.PRIMARY);
var cancelButton = new Button("Cancel", e -> close());
// Layout dialog
setHeaderTitle("Add Product");
add(form);
getFooter().add(cancelButton, saveButton);
}
private void save() {
form.getFormDataObject().ifPresent(productDetails -> {
try {
saveCallback.save(productDetails);
close();
} catch (RuntimeException e) {
errorCallback.handleException(e);
}
});
}
}Next Steps
You’ve now added support for linking to and bookmarking individual product items. As an extra exercise, you could read the Query Parameters guide and try to store the search field value in a query parameter.
Next, you’ll write automated tests for the service and the view. Proceed to the Add Tests step to continue the tutorial.