Lab 2: Using Spring ViewComponent

This lab aims to build the same application as Lab 1. But this time we will use Spring ViewComponent and htmx-spring-boot to delegate rendering responsibility to the ViewComponent.

An Introduction to Spring ViewComponent

A ViewComponent is a Spring-managed bean that defines a rendering context for a corresponding template, this context is called ViewContext.

We can create a ViewComponent by annotating a class with the @ViewComponent annotation and defining a public nested record that implements the ViewContext interface.

SimpleViewComponent.java
@ViewComponent
public class SimpleViewComponent {
    public record SimpleViewContext(String helloWorld) implements ViewContext {
    }

    public SimpleView render() {
        return new SimpleView("Hello World");
    }
}

A ViewComponent needs to have a template with the same name defined in the same package. In the template, we can access the properties of the record.

SimpleViewComponent.jte
@param SimpleViewComponent.SimpleViewContext simpleViewContext
<div>${simpleViewContext.helloWorld()}</div>

Spring ViewComponent wraps the underlying MVC model using Spring AOP and enables us to create the frontend in a similar way to the component-oriented JavaScript frameworks

Creating the UserMangement with Spring ViewComponent

To start we need to add three dependencies to the build.gradle.kts file.

build.gradle.kts
implementation("de.tschuehly:spring-view-component-jte:0.8.1")
implementation("io.github.wimdeblauwe:htmx-spring-boot:3.3.0")

We can enable live-reload for Spring ViewComponent with these properties in application.yaml.

Also, remove the gg.jte properties and uncomment the spring.view-component properties

application.yaml
spring:
  view-component:
    local-development: true
    viewComponentRoot: lab-2/src/main/java
#gg:
 # jte:
  #  developmentMode: true
   # templateLocation: lab-2/src/main/jte

We start by creating a UserManagementComponent.java file in the de.tschuehly.easy.spring.auth.user.management package.

UserManagementComponent.java
@ViewComponent
public class UserManagementComponent {
  public static final String MODAL_CONTAINER_ID = "modalContainer";
  public static final String CLOSE_MODAL_EVENT = "close-modal";
  
  public record UserManagementContext() 
         implements ViewContext{}

  public ViewContext render(){
    return new UserManagementContext();
  }
}

We then create a UserManagementComponent.jte template in the de.tschuehly.easy.spring.auth.user.management package:

UserManagementComponent.jte
@import static de.tschuehly.easy.spring.auth.user.management.UserManagementComponent.*
@import de.tschuehly.easy.spring.auth.user.management.UserManagementComponent.UserManagementContext
@param UserManagementContext userManagementContext
<html lang="en">

<head>
    <title>Easy Spring Auth</title>
    <link rel="stylesheet" href="/css/sakura.css" type="text/css">
    <script src="/htmx_1.9.11.js"></script>
    <script src="/htmx_debug.js"></script>
    <script src="http://localhost:35729"></script>
</head>
<body hx-ext="debug">
<nav>
    <h1>
        Easy Spring Auth
    </h1>
</nav>
<main>
 
</main>
</body>
<div id="${MODAL_CONTAINER_ID}" hx-on:$unsafe{CLOSE_MODAL_EVENT}="this.innerHTML = null">
</div>
</html>

We add a static import to the UserManagementComponent class. The param is a UserManagementContext as we put all variables into this record.

Now we will create a separate component for the table.

UserTableComponent

Create a UserTableComponent.java in de.tschuehly.easy.spring.auth.user.management.table

UserTableComponent.java
@ViewComponent
public class UserTableComponent {
  private final UserService userService;

  public UserTableComponent(UserService userService) {
    this.userService = userService;
  }

  public record UserTableContext() implements ViewContext{

  }
  public static final String USER_TABLE_BODY_ID = "userTableBody";

  public ViewContext render(){
    return new UserTableContext();
  }
}

In the last lab, we defined the USER_TABLE_BODY_ID in the UserController.java. Now define it in the UserTableComponent.java .

Now we will create a UserManagementComponent.jte template in the same package as the UserTableComponent.java:

UserTableComponent.jte
@import de.tschuehly.easy.spring.auth.user.management.table.UserTableComponent.UserTableContext
@import static de.tschuehly.easy.spring.auth.user.UserController.GET_CREATE_USER_MODAL
@import static de.tschuehly.easy.spring.auth.user.management.UserManagementComponent.MODAL_CONTAINER_ID
@import static de.tschuehly.easy.spring.auth.user.management.table.UserTableComponent.USER_TABLE_BODY_ID
@param UserTableContext userTableContext
<table>
    <thead>
    <tr>
        <th>
            uuid
        </th>
        <th>
            username
        </th>
        <th>
            password
        </th>
    </tr>
    </thead>
    <tbody id="${USER_TABLE_BODY_ID}">
    
    </tbody>
    <tfoot>
    <tr>
        <td colspan="4">
            <button hx-get="${GET_CREATE_USER_MODAL}" 
                    hx-target="#${MODAL_CONTAINER_ID}">
                Create new User
            </button>
        </td>
    </tr>
    </tfoot>
</table>

We then create a UserRowComponent.java class in de.tschuehly.easy.spring.auth.user.management.table.row package:

UserRowComponent.java
@ViewComponent
public class UserRowComponent {

  public record UserRowContext(EasyUser easyUser) implements ViewContext {
    public static String htmlUserId(UUID uuid) {
      return "user-" + uuid;
    }
  }

  public ViewContext render(EasyUser easyUser) {
    return new UserRowContext(easyUser);
  }
}

We then create a UserRowComponent.jte template in the auth.user.management.table.row package.

@import static de.tschuehly.easy.spring.auth.htmx.HtmxUtil.URI
@import static de.tschuehly.easy.spring.auth.user.UserController.*
@import static de.tschuehly.easy.spring.auth.user.management.UserManagementComponent.MODAL_CONTAINER_ID
@import de.tschuehly.easy.spring.auth.user.management.table.row.UserRowComponent.UserRowContext
@param UserRowContext userRowContext

!{var uuid = userRowContext.easyUser().uuid;} <%-- (1) --%>
<tr id="${UserRowContext.htmlUserId(uuid)}"> <%-- (2) --%>
    <td>
        ${uuid.toString()}
    </td>
    <td>
        ${userRowContext.easyUser().username} <%-- (1) --%>
    </td>
    <td>
        ${userRowContext.easyUser().password} <%-- (1) --%>
    </td>
    <td>
        <button hx-get="${URI(GET_EDIT_USER_MODAL,uuid)}"
                hx-target="#${MODAL_CONTAINER_ID}">
            <img src="/edit.svg">
        </button>
    </td>
</tr>

(1): We use the userRowContext.easyUser() attribute to access the user we want to render.

(2): We set the id of the table row using the UserRowContext.htmlUserId(uuid) function

UserTableComponent

We now change the UserTableComponent.java:

UserTableComponent.java
@ViewComponent
public class UserTableComponent {
  private final UserService userService;
  private final UserRowComponent userRowComponent; // (1)

  public UserTableComponent(UserService userService, UserRowComponent userRowComponent) {
    this.userService = userService;
    this.userRowComponent = userRowComponent; // (1)
  }

  public record UserTableContext(List<ViewContext> userTableRowList) // (2)
    implements ViewContext{

  }
  public static final String USER_TABLE_BODY_ID = "userTableBody";

  public ViewContext render(){
    List<ViewContext> rowList = userService.findAll() // (3)
        .stream().map(userRowComponent::render).toList(); // (4)
    return new UserTableContext(rowList);
  }
}

(1): We autowire the userRowComponent.

(2): We add a List<ViewContext> property to the UserTableContext

(3): We call the userService.findAll() method

(4): Then we call the autowired userRowComponent::render method in the .stream().map() function.

Now we will render the userTableRowList in the UserTableComponent.jte:

UserTableComponent.jte
@import de.tschuehly.easy.spring.auth.user.management.table.UserTableComponent.UserTableContext
@import static de.tschuehly.easy.spring.auth.user.UserController.GET_CREATE_USER_MODAL
@import static de.tschuehly.easy.spring.auth.user.management.UserManagementComponent.MODAL_CONTAINER_ID
@import static de.tschuehly.easy.spring.auth.user.management.table.UserTableComponent.USER_TABLE_BODY_ID
@param UserTableContext userTableContext
<table>
    <thead>
    <tr>
        <th>
            uuid
        </th>
        <th>
            username
        </th>
        <th>
            password
        </th>
    </tr>
    </thead>
    <tbody id="${USER_TABLE_BODY_ID}">
    @for(var row: userTableContext.userTableRowList()) <%-- (1) --%>
        ${row} <%-- (2) --%>
    @endfor
    </tbody>
    <tfoot>
    <tr>
        <td colspan="4">
            <button hx-get="${GET_CREATE_USER_MODAL}" hx-target="#${MODAL_CONTAINER_ID}">
                Create new User
            </button>
        </td>
    </tr>
    </tfoot>
</table>

(1): We loop through the userTableRowList and create a row loop variable.

(2): We render the row ViewContext in the <tbody>

Now we can render the table using Spring ViewComponent in the UserMangement.java:

UserMangement.java
@ViewComponent
public class UserManagementComponent {
  public static final String MODAL_CONTAINER_ID = "modalContainer";
  public static final String CLOSE_MODAL_EVENT = "close-modal";
  private final UserTableComponent userTableComponent; // (1)

  public UserManagementComponent(UserTableComponent userTableComponent) {// (1)
    this.userTableComponent = userTableComponent; 
  }

  public record UserManagementContext(ViewContext userTable)
      implements ViewContext {}

  public ViewContext render(){
    return new UserManagementContext(userTableComponent.render());
  }
}

(1): We autowire the UserTableComponent

(2): We add a userTable ViewContext field to the UserManagementContext

(3): In the UserManagementComponent.render method we call the userTableComponent.render and pass it into the UserManagementContext constructor.

In the UserManagementComponent.jte template, we can insert the rendered table:

UserManagementComponent.jte
@import static de.tschuehly.easy.spring.auth.user.management.UserManagementComponent.*
@import de.tschuehly.easy.spring.auth.user.management.UserManagementComponent.UserManagementContext
@param UserManagementContext userManagementContext
<html lang="en">

<head>
    <title>Easy Spring Auth</title>
    <link rel="stylesheet" href="/css/sakura.css" type="text/css">
    <script src="/htmx_1.9.11.js"></script>
    <script src="/htmx_debug.js"></script>
    <script src="http://localhost:35729"></script>
</head>
<body hx-ext="debug">
<nav>
    <h1>
        Easy Spring Auth
    </h1>
</nav>
<main>
    ${userManagementContext.userTable()}
</main>
</body>
<div id="${MODAL_CONTAINER_ID}" hx-on:$unsafe{CLOSE_MODAL_EVENT}="this.innerHTML = null">

</div>

</html>

In the UserController.java we can autowire the UserManagementComponent ViewComponent and render it in the index method:

UserController.java
@Controller
public class UserController {

  private final UserService userService;
  private final UserManagementComponent userManagementComponent;

  public UserController(UserService userService, UserManagementComponent userManagementComponent) {
    this.userService = userService;
    this.userManagementComponent = userManagementComponent;
  }

  @GetMapping("/")
  public ViewContext index() {
    return userManagementComponent.render();
  }
}

We can restart the application, navigate to http://localhost:8080/ and see the table rendered.

Lab 2 Checkpoint 1

If you are stuck you can resume at this checkpoint with:

git checkout tags/lab-2-checkpoint-1 -b lab-2-c1

Edit User

We now create the edit user functionality with Spring ViewComponent.

We create an EditUserComponent.java in the de.tschuehly.easy.spring.auth.user.management.edit package:

EditUserComponent.java
@ViewComponent
public class EditUserComponent {

  private final UserService userService;

  public EditUserComponent(UserService userService) { // (1)
    this.userService = userService;
  }

  public ViewContext render(UUID uuid) { // (2)
    EasyUser user = userService.findById(uuid); // (3)
    return new EditUserContext(user.uuid, user.username, user.password); // (4)
  }
  
  public record EditUserContext(UUID uuid, String username, String password) 
    implements ViewContext {

  }
}

(1): We first autowire the userService in the constructor

(2): Then we create a render method with a uuid parameter.

(3): We get the user with the userService.findById(uuid) method

(4): We add the uuid, username and password of the user to the EditUserContext ViewContext


We then create the EditUserComponent.jte template in the same package as the EditUserComponent.java

EditUserComponent.jte
@import de.tschuehly.easy.spring.auth.user.management.edit.EditUserComponent.EditUserContext
@import static de.tschuehly.easy.spring.auth.user.UserController.POST_SAVE_USER
@param EditUserContext editUserContext

<div style="width: 100dvw; height: 100dvh; position: fixed; top: 0;left: 0; background-color: rgba(128,128,128,0.69); display: flex; justify-content: center; align-items: center;">
    <form style="background-color: whitesmoke; padding: 2rem;">
        <label>
            UUID
            <input type="text" readonly name="uuid" value="${editUserContext.uuid().toString()}">
        </label>
        <label>
            Username
            <input type="text" name="username" value="${editUserContext.username()}">
        </label>
        <label>
            Password
            <input type="text" name="password" value="${editUserContext.password()}">
        </label>
        <button type="submit" hx-post="${POST_SAVE_USER}">
            Save User
        </button>
    </form>
</div>

In the UserController.java we remove the UserForm record, autowire the EditUserComponent and then change the editUserModal method to call the editUserComponent.render method.

UserController.java
public static final String GET_EDIT_USER_MODAL = "/save-user/modal/{uuid}";

@GetMapping(GET_EDIT_USER_MODAL)
public ViewContext editUserModal(@PathVariable UUID uuid) {
  return editUserComponent.render(uuid);
}

We can restart the application navigate to http://localhost:8080/ and the edit user modal works again.

Lab 2 Checkpoint 2

If you are stuck you can resume at this checkpoint with:

git checkout tags/lab-2-checkpoint-2 -b lab-2-c2

Fix the Save User functionality

In Lab1 we used HX Response headers to set the swapping functionality directly in the UserController.java:

UserController.java
@PostMapping(POST_SAVE_USER)
public String saveUser(UUID uuid, String username, String password, Model model, HttpServletResponse response) {
  EasyUser user = userService.saveUser(uuid, username, password);
  model.addAttribute("easyUser", user);
  response.addHeader("HX-Retarget", "#user-" + user.uuid);
  response.addHeader("HX-Reswap", "outerHTML");
  response.addHeader("HX-Trigger", CLOSE_MODAL_EVENT);
  return "UserRow";
}

We now want to move this functionality to the UserRowComponent.

HtmxUtil

I have already created a HtmxUtil class in the de.tschuehly.easy.spring.auth.htmx package that helps us set the HX Response Headers.

We are using Wim Deblauwes htmx-spring-boot library: github.com/wimdeblauwe/htmx-spring-boot. It offers a HtmxResponseHeader enum with all possible values and a HxSwapType enum.

We will add these convenience methods to the HtmxUtil.java class:

HtmxUtil.java
public static String target(String id){
  return "#" + id;
}

public static void retarget(String cssSelector) {
  setHeader(HtmxResponseHeader.HX_RETARGET.getValue(), cssSelector);
}

public static void reswap(HxSwapType hxSwapType){
  setHeader(HtmxResponseHeader.HX_RESWAP.getValue(), hxSwapType.getValue());
}

public static void trigger(String event) {
  setHeader(HtmxResponseHeader.HX_TRIGGER.getValue(), event);
}

Back to the UserRowComponent we create a rerender function where we use these utility functions:

UserRowComponent.java
public ViewContext rerender(EasyUser easyUser) {
  String target = HtmxUtil.target(UserRowContext.htmlUserId(easyUser.uuid)); // (1)
  HtmxUtil.retarget(target); 
  HtmxUtil.reswap(HxSwapType.OUTER_HTML); // (2)
  HtmxUtil.trigger(UserManagementComponent.CLOSE_MODAL_EVENT); // (3)
  return new UserRowContext(easyUser); // (4)
}

(1): We retarget to the id of the <tr> element we created with the UserRowContext.htmlUserId() function.

(2): We swap the outerHTML of the target element

(3). And we trigger the CLOSE_MODAL_EVENT

(4): Finally, we return the UserRowContext with the easyUser


In the UserController.saveUser method we can call the userRowComponent.rerender method

UserController.java
@PostMapping(POST_SAVE_USER)
public ViewContext saveUser(UUID uuid, String username, String password) {
  EasyUser user = userService.saveUser(uuid, username, password);
  return userRowComponent.rerender(user);
}

We can restart the application and navigate to http://localhost:8080/ and the save user function works again!

Lab 2 Checkpoint 3

If you are stuck you can resume at this checkpoint with:

git checkout tags/lab-2-checkpoint-3 -b lab-2-c3

We have the advantage that the Controller doesn't need to know how the UserRowComponent template looks and what needs to be swapped. The UserRowComponent offers an API to rerender a row.

Create User

Finally, we need to migrate the create user functionality to Spring ViewComponent. We start by creating a CreateUserComponent in the de.tschuehly.easy.spring.auth.user.management.create package:

CreateUserComponent.java
@ViewComponent
public class CreateUserComponent {

  public record CreateUserContext() implements ViewContext{}

  public ViewContext render(){
    return new CreateUserContext();
  }
}

We now need to create a CreateUserComponent.jte in the same package as the CreateUserComponent.java

CreateUserComponent.jte
@import static de.tschuehly.easy.spring.auth.user.UserController.POST_CREATE_USER
<div style="width: 100dvw; height: 100dvh; position: fixed; top: 0;left: 0; background-color: rgba(128,128,128,0.69); display: flex; justify-content: center; align-items: center;">
    <form style="background-color: whitesmoke; padding: 2rem;">
        <label>
            Username
            <input type="text" name="username">
        </label>
        <label>
            Password
            <input type="text" name="password">
        </label>
        <button type="submit" hx-post="${POST_CREATE_USER}">
            Save User
        </button>
    </form>
</div>

We can now call the createUserComponent.render method in the UserController.getCreateUserModal method:

UserController.java
public static final String GET_CREATE_USER_MODAL = "/create-user/modal";

@GetMapping(GET_CREATE_USER_MODAL)
public ViewContext getCreateUserModal() {
  return createUserComponent.render();
}

We can restart the application and navigate to http://localhost:8080/ and the create user modal is shown when we click on Create User

Finally, we need to migrate the UserController.createUser method. Currently, it looks like this:

UserController.java
@PostMapping(POST_CREATE_USER)
public String createUser(String username, String password, Model model, HttpServletResponse response) {
  EasyUser user = userService.createUser(username, password);
  model.addAttribute("easyUser", user);

  response.addHeader("HX-Retarget", "#" + USER_TABLE_BODY_ID);
  response.addHeader("HX-Reswap", "afterbegin");
  response.addHeader("HX-Trigger", CLOSE_MODAL_EVENT);
  return "UserRow";
}

As before we want to move this code into the UserRowComponent.java, by creating a new renderNewRow function:

UserRowComponent.java
public ViewContext renderNewRow(EasyUser user) {
  String target = HtmxUtil.target(UserTableComponent.USER_TABLE_BODY_ID);
  HtmxUtil.retarget(target);
  HtmxUtil.reswap(HxSwapType.AFTER_BEGIN);
  HtmxUtil.trigger(UserManagementComponent.CLOSE_MODAL_EVENT);
  return new UserRowContext(user);
}

We can now simplify the UserController.createUser function:

UserController.java
@PostMapping(POST_CREATE_USER)
public ViewContext createUser(String username, String password) {
  EasyUser user = userService.createUser(username, password);
  return userRowComponent.renderNewRow(user);
}

Now if we restart the application we can save a new user and they are inserted at the start of the table.

Lab 2 Checkpoint 4

If you are stuck you can resume at this checkpoint with:

git checkout tags/lab-2-checkpoint-4 -b lab-2-c4

Last updated