Routing and Composites
Up until now, this tutorial has only been a single-page app. This step changes that. You'll move the UI you created in Working with Data to its own page and create another page for adding new customers. Then, you'll connect these pages so your app is able to navigate between them by applying these concepts:
- Routing
- Composite components
- The
ColumnsLayoutcomponent
Completing this step creates a version of 3-routing-and-composites.
Running the app
As you develop your app, you can use 3-routing-and-composites as a comparison. To see the app in action:
-
Navigate to the top-level directory containing the
pom.xmlfile; this is3-routing-and-compositesif you're following along with the version on GitHub. -
Use the following Maven command to run the Spring Boot app locally:
mvn
Running the app automatically opens a new browser at http://localhost:8080.
Routable apps
Previously, your app had a single function: displaying a table of existing customer data. In this step, your app will also be able to modify the customer data by adding new customers. Separating the UIs for display and modification is beneficial for long-term maintenance and testing, so you'll add this feature as a separate page. You’ll make your app routable so webforJ can access and load the two UIs individually.
A routable app renders the UI based on the URL. Annotating the class that extends the App class with @Routify enables routing, and the packages element tells webforJ which packages contain UI components.
When you add the @Routify annotation to Application, remove the run() method. You'll move the components from that method to a class that you'll make in the com.webforj.tutorial.views package. Your updated Application.java file should look like this:
@SpringBootApplication
@StyleSheet("ws://css/card.css")
@AppTheme("system")
//Added @Routify annotation
@Routify(packages = "com.webforj.tutorial.views")
@AppProfile(name = "CustomerApplication", shortName = "CustomerApplication")
public class Application extends App {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
// Removed overridden App.run() method
}
Keeping the @StyleSheet annotation in Application applies that CSS globally.
Creating routes
Adding the @Routify annotation makes your app routable. Once it's routable, your app will look in the com.webforj.tutorial.views package for routes.
You'll need to create the routes for your UIs and also specify their Route Types. The route type determines how to map the UI content to the URL.
The first route type is View. These kinds of routes map directly to a specific URL segment in your app. The UIs for the table and the new customer form will both be View routes.
The second route type is Layout, which contains UI that appears on multiple pages, such as a header or sidebar. Layout routes also wrap child views without contributing to the URL.
To specify the route type of a class, append the route type to the end of the class name as a suffix.
For example, MainView is a View route type.
To keep the app's two functions separate, your app needs to map the UIs to two unique View routes: one for the table and one for the customer form. In /src/main/java/com/webforj/tutorial/views, create two classes with a View suffix:
MainView: This view will have theTablepreviously in theApplicationclass.FormView: This view will have a form for adding new customers.
Mapping URLs to components
Your app is routable and knows to look for two View routes, MainView and FormView, but it doesn't have a specific URL to load them at. Using the @Route annotation on a view class, you can tell webforJ where to load it based on a given URL segment. For example, using @Route("about") in a view locally maps the class to http://localhost:8080/about.
As the name implies, MainView is the class you want to initially load when the app runs. To achieve this, add a @Route annotation that maps MainView to the root URL of your app:
@Route("/")
public class MainView {
public MainView() {
}
}
For the FormView, map the view so it loads when a user goes to http://localhost:8080/customer:
@Route("customer")
public class FormView {
public FormView() {
}
}
If you don’t explicitly assign a value for the @Route annotation, the URL segment is the class name converted to lowercase, with the View suffix removed.
MainViewwould map to/mainFormViewwould map to/form
Shared characteristics
Besides both being view routes, MainView and FormView share additional characteristics. Some of these shared traits, like using Composite components, are fundamental to using webforJ apps, while others just make it easier to manage your app.
Using Composite components
When the app was single-paged, you stored the components inside a Frame. Moving forward, with an app with multiple views, you'll need to wrap those UI components inside Composite components.
Composite components are wrappers that make it easy to create reusable components.
To create a Composite component, extend the Composite class with a specified bound component that serves as the foundation of the class, e.g., Composite<FlexLayout>.
This tutorial uses Div elements as the bound components, but they can be any component, such as FlexLayout or AppLayout. Using the getBoundComponent() method, you can reference the bound component and have access to its methods. This lets you set the sizing, add a CSS class name, add components you want displayed in the Composite component, and access component-specific methods.
For MainView and FormView, extend Composite with Div as the bound component. Then, reference that bound component so you can add in the UIs later. Both views should look similar to the following structure:
// Extend Composite with a bound component
public class MainView extends Composite<Div> {
// Access the bound component
private Div self = getBoundComponent();
// Create a component UI
private Button submit = new Button("Submit");
public MainView() {
// Add the UI component to the bound component
self.add(submit);
}
}
Setting the frame title
When a user has multiple tabs in their browser, a unique frame title helps them quickly identify which part of the app they have opened.
The @FrameTitle annotation defines what appears in the browser's title or page's tab. For both views, add a frame title using the @FrameTitle annotation:
- MainView
- FormView
@Route("/")
@FrameTitle("Customer Table")
public class MainView extends Composite<Div> {
private Div self = getBoundComponent();
public MainView(CustomerService customerService) {
}
}
@Route("customer")
@FrameTitle("Customer Form")
public class FormView extends Composite<Div> {
private Div self = getBoundComponent();
public FormView(CustomerService customerService) {
}
}
Shared CSS
With a bound component you can reference in MainView and FormView, you can style it with CSS.
You can use the CSS from the first step, Creating a Basic App, to give both views identical UI container styles.
Add the CSS class name card to the bound component in each view:
- MainView
- FormView
@Route("/")
@FrameTitle("Customer Table")
public class MainView extends Composite<Div> {
private Div self = getBoundComponent();
public MainView() {
self.addClassName("card");
}
}
@Route("customer")
@FrameTitle("Customer Form")
public class FormView extends Composite<Div> {
private Div self = getBoundComponent();
public FormView() {
self.addClassName("card");
}
}
Using CustomerService
The last shared trait for the views is using the CustomerService class.
The Table in MainView displays each customer, while FormView adds new customers. Since both views interact with customer data, they need access to the app's business logic.
The views get access through the Spring service created in Working with Data, CustomerService. To use the Spring service in each view, make CustomerService a constructor parameter:
- MainView
- FormView
@Route("/")
@FrameTitle("Customer Table")
public class MainView extends Composite<Div> {
private Div self = getBoundComponent();
public MainView(CustomerService customerService) {
this.customerService = customerService;
self.addClassName("card");
}
}
@Route("customer")
@FrameTitle("Customer Form")
public class FormView extends Composite<Div> {
private Div self = getBoundComponent();
public FormView(CustomerService customerService) {
this.customerService = customerService;
self.addClassName("card");
}
}
Creating MainView
After making your app routable, giving the views Composite component wrappers, and including the CustomerService, you’re ready to build the UIs unique to each view. As mentioned previously, MainView contains the UI components initially in Application. This class also needs a way to navigate to FormView.
Grouping the Table methods
As you're moving the components from Application to MainView, it's a good idea to start sectioning parts of your app, so one custom method can make changes to the Table at once. Sectioning your code now makes it more manageable as the app grows more complex.
Now, your MainView constructor should only call one buildTable() method that adds the columns, sets the sizing, and references the repository:
private void buildTable() {
table.setSize("1000px", "294px");
table.setMaxWidth("90vw");
table.addColumn("firstName", Customer::getFirstName).setLabel("First Name");
table.addColumn("lastName", Customer::getLastName).setLabel("Last Name");
table.addColumn("company", Customer::getCompany).setLabel("Company");
table.addColumn("country", Customer::getCountry).setLabel("Country");
table.setColumnsToAutoFit();
table.getColumns().forEach(column -> column.setSortable(true));
table.setRepository(customerService.getRepositoryAdapter());
}
Navigating to FormView
Users need a way to navigate from MainView to FormView using the UI.
In webforJ, you can directly navigate to a new view by using the view's class. Routing via a class instead of a URL segment guarantees webforJ will take the correct path to load the view.
To navigate to a different view, use the Router class to get the current location with getCurrent(), then use the navigate() method with the view's class as a parameter:
Router.getCurrent().navigate(FormView.class);
This code will programmatically send users to the new customer form, but the navigation needs to be connected to a user action.
To allow users to add a new customer, you can either modify or replace the info button from Application. Instead of opening a message dialog, the button can navigate to the FormView class:
private Button addCustomer = new Button("Add Customer", ButtonTheme.PRIMARY,
e -> Router.getCurrent().navigate(FormView.class));
Completed MainView
With the navigation to FormView and grouped table methods, here's what MainView should look like before moving on to creating FormView:
Creating FormView
FormView will display a form to add new customers. For each customer property, FormView will have an editable component for users to interact with. Additionally, it will have a button for users to submit the data and a cancel button to discard it.
Creating a Customer instance
When a user is editing data for a new customer, changes should only be applied to the repository when they're ready to submit the form. Using an instance of the Customer object is a convenient way to edit and maintain the new data without editing the repository directly. Create a new Customer inside FormView to use for the form:
private Customer customer = new Customer();
To make the Customer instance editable, each property, except for the id, should be associated with an editable component. The changes a user makes in the UI should be reflected in the Customer instance.
Adding TextField components
The first three editable properties in Customer (firstName, lastName, and company) are all String values, and should be represented with a single-line text editor. TextField components are a great choice to represent these properties.
With the TextField component, you can add a label and an event listener that fires whenever the field value changes. Each event listener should update the Customer instance for the corresponding property.
Add three TextField components that update the Customer instance:
public class FormView extends Composite<Div> {
private final CustomerService customerService;
private Customer customer = new Customer();
private Div self = getBoundComponent();
private TextField firstName = new TextField("First Name", e -> customer.setFirstName(e.getValue()));
private TextField lastName = new TextField("Last Name", e -> customer.setLastName(e.getValue()));
private TextField company = new TextField("Company", e -> customer.setCompany(e.getValue()));
public FormView(CustomerService customerService) {
this.customerService = customerService;
self.addClassName("card");
}
}
Naming the components the same as the properties they're representing for the Customer entity makes it easier to bind data in a future step, Validating and Binding Data.
Adding a ChoiceBox component
Using a TextField for the country property wouldn’t be ideal, because the property can only be one of five enum values: UNKNOWN, GERMANY, ENGLAND, ITALY, and USA.
A better component for selecting from a predefined list of options is the ChoiceBox.
Each option for a ChoiceBox component is represented as a ListItem. Each ListItem has two values, an Object key and a String text to display in the UI. Having two values for each option allows you to handle the Object internally while simultaneously presenting a more readable option for users in the UI.
For example, the Object key could be an International Standard Book Number (ISBN), while the String text is the book title, which is more human-readable.
new ListItem(isbn, bookTitle);
However, this app deals with a list of country names, not books. For each ListItem, you want the Object to be the Customer.Country enum, while the text can be its String representation.
To add all the country options into a ChoiceBox, you can use an iterator to create a ListItem for each Customer.Country enum, and put them into an ArrayList<ListItem>. Then, you can insert that ArrayList<ListItem> into a ChoiceBox component:
//Create the ChoiceBox component
private ChoiceBox country = new ChoiceBox("Country");
//Create an ArrayList of ListItem objects
ArrayList<ListItem> listCountries = new ArrayList<>();
//Add an iterator that creates a ListItem for each Customer.Country option
for (Country countryItem : Customer.Country.values()) {
listCountries.add(new ListItem(countryItem, countryItem.toString()));
}
//Insert the filled ArrayList into the ChoiceBox
country.insert(listCountries);
//Makes the first `ListItem` the default when the form loads
country.selectIndex(0);
Then, when the user selects an option in the ChoiceBox, the Customer instance should update with the key of the selected item, which is a Customer.Country value.
private ChoiceBox country = new ChoiceBox("Country",
e -> customer.setCountry((Customer.Country) e.getSelectedItem().getKey()));
To keep the code clean, the iterator that creates the ArrayList<ListItem> and adds it to the ChoiceBox should be in a separate method.
After you add a ChoiceBox that allows the user to choose the country property, FormView should look like this:
public class FormView extends Composite<Div> {
private final CustomerService customerService;
private Customer customer = new Customer();
private Div self = getBoundComponent();
private TextField firstName = new TextField("First Name", e -> customer.setFirstName(e.getValue()));
private TextField lastName = new TextField("Last Name", e -> customer.setLastName(e.getValue()));
private TextField company = new TextField("Company", e -> customer.setCompany(e.getValue()));
private ChoiceBox country = new ChoiceBox("Country",
e -> customer.setCountry((Customer.Country) e.getSelectedItem().getKey()));
public FormView(CustomerService customerService) {
this.customerService = customerService;
self.addClassName("card");
fillCountries();
}
private void fillCountries() {
ArrayList<ListItem> listCountries = new ArrayList<>();
for (Country countryItem : Customer.Country.values()) {
listCountries.add(new ListItem(countryItem, countryItem.toString()));
}
country.insert(listCountries);
country.selectIndex(0);
}
}
Adding Button components
When using the new customer form, users should be able to either save or discard their changes.
Create two Button components to implement this feature:
private Button submit = new Button("Submit");
private Button cancel = new Button("Cancel");
Both the submit and cancel buttons should return the user to MainView.
This allows the user to immediately see the results of their action, whether they see a new customer in the table or it remains unchanged.
Since multiple inputs in FormView take users to MainView, the navigation should be put into a recallable method:
private void navigateToMain(){
Router.getCurrent().navigate(MainView.class);
}
Cancel button
Discarding the changes on the form doesn’t require any additional code for the event beyond returning to MainView. However, since canceling isn't a primary action, setting the theme of the button to an outline gives the submit button more prominence.
The Themes section of the Button component page lists all available themes.
private Button cancel = new Button("Cancel", ButtonTheme.OUTLINED_PRIMARY,
e -> navigateToMain());
Submit button
When a user presses the submit button, the values in the Customer instance should be used to create a new entry in the repository.
Using the CustomerService, you can take the Customer instance to update the H2 database. When this happens, a new and unique id is assigned to that Customer. After updating the repository, you can redirect users to MainView, where they can see the new customer in the table.
private Button submit = new Button("Submit", ButtonTheme.PRIMARY,
e -> submitCustomer());
//...
private void submitCustomer() {
customerService.createCustomer(customer);
navigateToMain();
}
Using a ColumnsLayout
By adding the TextField, ChoiceBox, and Button components, you now have all the interactive parts of the form. The last improvement to FormView in this step is to visually organize the six components.
This form can use a ColumnsLayout to separate the components into two columns without having to set the width of any interactive components.
To create a ColumnsLayout, specify each component that should be inside the layout:
private ColumnsLayout layout = new ColumnsLayout(
firstName, lastName,
company, country,
submit, cancel);
To set the number of columns for a ColumnsLayout, use a List of Breakpoint objects. Each Breakpoint tells the ColumnsLayout the minimum width it must have to apply a specified number of columns. By using the ColumnsLayout, you can make a form with two columns, but only if the screen is wide enough to display two columns. On smaller screens, the components are displayed in a single column.
The Breakpoints section in the ColumnsLayout article explains breakpoints in more detail.
To keep the code maintainable, set the breakpoints in a separate method. In that method, you can also control the horizontal and vertical spacing between the components inside the ColumnsLayout with the setSpacing() method.
private void setColumnsLayout() {
//Have two columns in the ColumnsLayout if it's wider than 600px
List<Breakpoint> breakpoints = List.of(
new Breakpoint(600, 2));
//Add the List of breakpoints
layout.setBreakpoints(breakpoints);
//Set the spacing between components using a DWC CSS variable
layout.setSpacing("var(--dwc-space-l)")
}
Finally, you can add the newly created ColumnsLayout to the bound component of FormView, while also setting the max width, and adding the class name from earlier:
self.setMaxWidth(600)
.addClassName("card")
.add(layout);
Completed FormView
After adding a Customer instance, the interactive components, and the ColumnsLayout, your FormView should look like the following:
Next step
Since users can now add customers, your app should be able to edit existing customers using the same form. In the next step, Observers and Route Parameters, you’ll allow the customer id to be an initial parameter for FormView, so it can fill in the form with that customer's data and allow users to change the properties.