javaspringthymeleafmodelattribute

Spring web mvc + Thymeleaf ModelAttribute editing the list in the object


I have a form for editing a user, the user has roles, which are a list of objects of type Authority, I want to be able to use checkboxes (optional) to set the roles that the user will have, but I have no idea how to implement the form in thymeleaf and how to pass the user object with the given roles to the controller.

It's my user

import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.util.HashSet;
import java.util.Set;

@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Column(name="username", nullable = false, unique = true)
    @NotBlank @Size(min=5, message = "Не менeе 5 знаков")
    private String username;

    @NotBlank @Size(min=5, message = "Не менeе 5 знаков")
    @Column(name = "password")
    private String password;

    @Column(name = "enabled")
    private boolean enabled;

    @Column(name = "name")
    private String name;

    @Column(name = "surname")
    private String surname;

    @Column(name = "email", nullable = false, unique = true)
    private String email;

    @ManyToMany(cascade = {
            CascadeType.PERSIST,
            CascadeType.MERGE,
            CascadeType.DETACH,
            CascadeType.REFRESH
    }, fetch = FetchType.EAGER)
    @JoinTable(
            name = "users_authorities",
            joinColumns = @JoinColumn(name = "user_id"),
            inverseJoinColumns = @JoinColumn(name = "authority_id")
    )
    private Set<Authority> authorities = new HashSet<>();

    public User(String username, String password, boolean enabled,
                String name, String surname, String email) {
        this.username = username;
        this.password = password;
        this.enabled = enabled;
        this.name = name;
        this.surname = surname;
        this.email = email;
    }

    public void addAuthority(Authority authority) {
        this.authorities.add(authority);
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

}



It's my edit user form contoller

@GetMapping("/users/{id}/edit")
public String editUser(@PathVariable("id") Long id, Model model) {

    model.addAttribute("user", userService.findById(id));
    model.addAttribute("allAuthorities", authorityService.findAll());

    return "users/edit-user";
}



It's my edit user form view

<body>
<form th:method="PUT" th:action="@{/admin/users/{id}(id=${user.getId()})}" th:object="${user}">
    <input type="hidden" th:field="*{id}" id="id">

    <label for="username">Username: </label>
    <input type="text" th:field="*{username}" id="username" placeholder="username">
    <br><br>

    <div sec:authorize="hasRole('ROLE_ADMIN')">
        <label for="enabled">Enabled </label>
        <input type="checkbox" name="enabled" th:field="*{enabled}" id="enabled">
        <br><br>
    </div>

    <label for="name">Name: </label>
    <input type="text" th:field="*{name}" id="name" placeholder="Name">
    <br><br>

    <label for="surname">Surname: </label>
    <input type="text" th:field="*{surname}" id="surname" placeholder="Surname">
    <br><br>

    <label for="email">Email: </label>
    <input type="text" th:field="*{email}" id="email" placeholder="Email">
    <br><br>

    <div th:each="auth:${allAuthorities}">
        <label>
            <span th:text="${auth.authority}"></span>
            <input type="checkbox" name="authorities" th:checked="${user.authorities.contains(auth)}">
        </label>
    </div>

    <input type="submit" value="Edit">
</form>
</body>



It's put contoller, it getting the data from my form

@PutMapping("/users/{id}")
public String editUser(@PathVariable("id") Long id,
                       @ModelAttribute("user") User user,
                       @RequestParam("authorities") List<Authority> authorities) {
    user.setId(id);
    userService.update(user);

    return "redirect:/admin/users";
}



And it's my Authority class if you need

@Entity
@Table(name = "authorities")
@Data
@NoArgsConstructor
public class Authority implements GrantedAuthority {
    @Id
    private Long id;

    @Column(name = "authority")
    private String authority;

    @Transient
    @ManyToMany(mappedBy = "authorities")
    private Set<User> users;

    public Authority(Long id, String authority) {
        this.id = id;
        this.authority = authority;
    }
}

I'm try to pass the list of roles separately from the user object, but this also doesn't work and gives a bad request error.


Solution

  • To solve the problem, I added a new checked field to Entity authority

    @Entity
    @Table(name = "authorities")
    @Data
    @NoArgsConstructor
    public class Authority implements GrantedAuthority {
        @Id
        private Long id;
    
        @Column(name = "authority", nullable = false, unique = true)
        private String authority;
    
        @Transient
        private boolean checked;
    
        public Authority(Long id, String authority) {
            this.id = id;
            this.authority = authority;
        }
    }
    

    Before sending the view to it, I changed the authorites field of the user object.

    @GetMapping("/users/{id}/edit")
    public String editUser(@PathVariable("id") Long id, Model model) {
        User user = userService.findById(id);
        List<Authority> allAuthorities = authorityService.findAll();
    
        for(Authority auth : allAuthorities) {
            if(user.getAuthorities().contains(auth)) {
                auth.setChecked(true);
            }
        }
    
        user.setAuthorities(allAuthorities);
    
        model.addAttribute("user", user);
        return "users/edit-user";
    }
    

    I also changed the thymeleaf template
    <form th:method="PUT" th:action="@{/admin/users/{id}(id=${user.getId()})}" th:object="${user}">
    
        <label for="username">Username: </label>
        <input type="text" th:field="*{username}" id="username" placeholder="username">
        <br><br>
    
        <div sec:authorize="hasRole('ROLE_ADMIN')">
            <label for="enabled">Enabled </label>
            <input type="checkbox" name="enabled" th:field="*{enabled}" id="enabled">
            <br><br>
        </div>
    
        <label for="name">Name: </label>
        <input type="text" th:field="*{name}" id="name" placeholder="Name">
        <br><br>
    
        <label for="surname">Surname: </label>
        <input type="text" th:field="*{surname}" id="surname" placeholder="Surname">
        <br><br>
    
        <label for="email">Email: </label>
        <input type="text" th:field="*{email}" id="email" placeholder="Email">
        <br><br>
    
        <div th:each="auth, itemStat: ${user.authorities}">
            <label>
                <span th:text="${auth.authority}"></span>
                <input type="hidden"
                       th:field="*{authorities[__${itemStat.index}__].id}">
                <input type="hidden"
                       th:field="*{authorities[__${itemStat.index}__].authority}">
                <input type="checkbox" th:checked="${auth.checked}"
                       th:field="*{authorities[__${itemStat.index}__].checked}">
            </label>
        </div>
    
        <input type="submit" value="Edit">
    </form>
    

    My put controller looks like this

    @PutMapping("/users/{id}")
    public String editUser(@PathVariable("id") Long id,
                           @ModelAttribute("user") User user) {
        List<Authority> userAuthorities = user.getAuthorities();
        userAuthorities.removeIf(auth -> !auth.isChecked());
    
        user.setId(id);
        userService.update(user);
    
        return "redirect:/admin/users";
    }
    

    My save and delete methods from UserService look like this(If that would be useful)
    @Override
    @Transactional
    public User save(User user) {
        Optional<User> userFromDB = userRepository.findByUsername(user.getUsername());
        Optional<Authority> userRole;
        
        if(userFromDB.isPresent()) {
            return null;
        }
    
        userRole = authorityRepository.findByAuthority("ROLE_USER");
        user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
    
        userRole.ifPresent(user::addAuthority);
    
        return userRepository.save(user);
    }
    
    @Override
    @Transactional
    public User update(User user) {
        Optional<User> userFromDB = userRepository.findById(user.getId());
    
        if(userFromDB.isPresent()){
            //Adding the password for successfully update user in DB
            user.setPassword(userFromDB.get().getPassword());
            return userRepository.save(user);
        }
    
        return null;
    }
    

    This may not be the most elegant solution but I haven't found another one yet