Users CRUD with lookups
Build a full CRUD for users, with role assignment via a lookup field, a custom editor page, and Spring dependency injection throughout.
This case adds several real-world patterns on top of the basic CRUD:
- Spring-managed beans for adapter and orchestrator
@Lookupfor selecting values from a backend service@Stereotypeto render a multi-select as checkboxes- A separate editor page with its own route
What this case teaches
Section titled “What this case teaches”- how to use Spring injection in CRUD classes
- how to define a lookup field backed by a backend service
- how to add a custom editor page with
@Route - how actions return messages and refresh form state
The pieces
Section titled “The pieces”This case has five pieces:
User— the modelUserRepository— the persistence layerUsersPage— the CRUD orchestratorUserEditorPage— a standalone editor bound to the CRUDRoleOptionsSupplier/RoleLabelSupplier— lookup support
1. The model
Section titled “1. The model”public record User( @NotEmpty String id, @NotEmpty String name, @NotEmpty @Email String email, @Stereotype(FieldStereotype.checkbox) @Lookup(search = RoleOptionsSupplier.class, label = RoleLabelSupplier.class) List<String> roles) implements Identifiable {
@Override public String toString() { return name != null ? name : "New user"; }}Key points:
@Lookuptells Mateu where to find available options and how to display selected values@Stereotype(checkbox)renders the field as a checkbox group, not a dropdownList<String>means the field holds multiple selected IDs
2. The repository
Section titled “2. The repository”@Servicepublic class UserRepository implements CrudRepository<User> {
private static final Map<String, User> db = new HashMap<>();
static { db.put("1", new User("1", "Miguel", "miguel@example.com", List.of("1", "2"))); db.put("2", new User("2", "Ana", "ana@example.com", List.of("2"))); }
@Override public Optional<User> findById(String id) { return Optional.ofNullable(db.get(id)); }
@Override public String save(User entity) { db.put(entity.id(), entity); return entity.id(); }
@Override public List<User> findAll() { return db.values().stream().toList(); }
@Override public void deleteAllById(List<String> selectedIds) { selectedIds.forEach(db::remove); }}The repository is a @Service bean. This allows it to be injected wherever needed.
3. The orchestrator
Section titled “3. The orchestrator”@Service@UI("/users")public class UsersPage extends AutoCrud<User> {
final UserRepository userRepository;
public UsersPage(UserRepository userRepository) { this.userRepository = userRepository; }
@Override public CrudRepository<User> repository() { return userRepository; }}UsersPage is a @Service bean. The repository is injected via constructor, and repository() is the only method that needs to be overridden.
Compare this to the Products example, where the repository was a plain class with no injection. In this case, @Service is required because the class holds an injected dependency.
4. The lookup support
Section titled “4. The lookup support”Options supplier
Section titled “Options supplier”@Servicepublic class RoleOptionsSupplier implements LookupOptionsSupplier {
@Override public ListingData<Option> search( String fieldName, String searchText, Pageable pageable, HttpRequest httpRequest) { return ListingData.of(List.of( new Option("1", "Admin"), new Option("2", "Editor"), new Option("3", "Viewer") )); }}LookupOptionsSupplier provides the options shown when editing the field. In a real application, you would query a database here.
Label supplier
Section titled “Label supplier”@Servicepublic class RoleLabelSupplier implements LabelSupplier {
@Override public String label(String fieldName, Object id, HttpRequest httpRequest) { return switch (String.valueOf(id)) { case "1" -> "Admin"; case "2" -> "Editor"; case "3" -> "Viewer"; default -> "?"; }; }}LabelSupplier resolves a stored ID to a human-readable label. Mateu calls this when displaying a list row or a read-only form.
5. The custom editor page
Section titled “5. The custom editor page”@Service@Route(value = "/:id/edit", uis = {"/users"})@Style(StyleConstants.CONTAINER)@FormLayout(columns = 1)public class UserEditorPage {
final UserRepository userRepository;
public UserEditorPage(UserRepository userRepository) { this.userRepository = userRepository; }
String id;
@NotEmpty String name;
@NotEmpty @Email String email;
@Lookup(search = RoleOptionsSupplier.class, label = RoleLabelSupplier.class) @Stereotype(FieldStereotype.checkbox) List<String> roles;
@Button Object save() { userRepository.save(new User(id, name, email, roles)); return List.of( new Message("User saved"), new State(this) ); }}Key points:
@Route(value = "/:id/edit", uis = {"/users"})binds this page to the edit action of the/usersCRUD- The
:idsegment is populated automatically by Mateu save()returns both aMessage(toast notification) and aState(this)(refresh the form state)- The page is a
@Servicebean, soUserRepositoryis injected via constructor
How the pieces connect
Section titled “How the pieces connect”UsersPage (@UI("/users")) └── UserRepository └── User (model)
UserEditorPage (@Route("/:id/edit", uis="/users")) └── UserRepository └── User (model) └── RoleOptionsSupplier (lookup) └── RoleLabelSupplier (labels)Mental model
Section titled “Mental model”- Use
@Servicewhenever a class needs dependency injection @Lookup= where to find options + how to display them@Stereotype= how to render the field (checkbox, select, etc.)- Custom editor pages bind to a CRUD via
@Route(uis = "/route") save()returnsList.of(new Message(...), new State(this))to give feedback and refresh