Skip to content

Customizing CRUD and listings

Mateu generates a full CRUD UI from your model.

Most real applications need to refine that default UI:

  • hide fields
  • adjust layouts
  • customize lists
  • add actions
  • restrict what users can do

This section shows how to do that progressively, without breaking the model-driven approach.


Customize the model first. Customize the orchestrator only when the model is not enough.


@UI("/products")
public class Products extends AutoCrud<Product> {
final ProductStore repository;
public Products(ProductStore repository) {
this.repository = repository;
}
@Override
public CrudStore<Product> store() {
return repository;
}
}
public record Product(
String id,
String name,
BigDecimal price,
ProductStatus status
) implements Identifiable {}

Identifiable requires a single method String id(). Java records satisfy this automatically — the compiler generates id() from the record component. Regular classes with Lombok’s @Data do not: Lombok generates getId(), not id(). If you use a class, add the override explicitly:

// ✅ record — id() generated automatically
public record Product(String id, String name) implements Identifiable {}
// ✅ class — must add id() manually
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Product implements Identifiable {
String id;
String name;
@Override
public String id() { return id; }
}

Prefer records for row/entity types when they are immutable. Use classes when you need Lombok’s @Builder combined with @NoArgsConstructor (e.g. for JPA or in-memory stores that mutate the entity after construction).


This already gives you:

  • list
  • view
  • edit
  • create

Add class-level annotations to AutoCrud (or any Crud subclass) to remove capabilities:

AnnotationEffect
@ReadOnlyShorthand for @NotCreatable @NotEditable @NotDeletable — removes all write operations
@NotCreatableHides the New button
@NotEditableHides the Edit button in the detail view
@NotDeletableHides the Delete button
@NotNavigableHides the View button column — list rows are not clickable

Common combinations:

IntentAnnotations
Read-only catalogue with detail view@ReadOnly
Simple read-only list (no detail)@ReadOnly @NotNavigable
List you can add to but not click into@NotNavigable
List you can edit but not create@NotCreatable
@UI("/products")
@ReadOnly
public class ProductCatalogue extends AutoCrud<Product> { ... }

Hide fields depending on context.

public record Product(
@HiddenInList
String id,
String name,
BigDecimal price,
@HiddenInEditor
ProductStatus status
) implements Identifiable {}
  • @HiddenInList
  • @HiddenInView
  • @HiddenInEditor
  • @ReadOnly — on a field: makes that field read-only in forms

@EditableOnlyWhenCreating
String id;
@NotNull
BigDecimal price;

Mateu uses standard validation annotations:

  • @NotNull
  • @NotEmpty
  • @Email
  • etc.

@Status(defaultStatus = StatusType.NONE, mappings = {
@StatusMapping(from = "ACTIVE", to = StatusType.SUCCESS),
@StatusMapping(from = "DISCONTINUED", to = StatusType.DANGER)
})
ProductStatus status;

This renders the field as a visual badge instead of plain text.


@Override
public String toString() {
return name;
}

Used in:

  • lists
  • lookups
  • references

Every built-in CRUD button label can be replaced by overriding the matching method on your AutoCrud (or any Crud) subclass:

MethodDefault label
newLabel()New
saveLabel()Save
cancelLabel()Cancel
deleteLabel()Delete
editLabel()Edit
addAnotherLabel()Add another — offered by the toast confirming a create (not on the view of an existing record)
backToListLabel()Back to list
importLabel()Import
historyLabel()History
@UI("/products")
public class Products extends AutoCrud<Product> {
@Override
public String newLabel() { return "Add product"; }
@Override
public String deleteLabel() { return "Remove"; }
private final ProductStore store;
public Products(ProductStore store) { this.store = store; }
@Override
public CrudStore<Product> store() { return store; }
}

This is the simplest way to localise or rephrase the CRUD chrome without touching the generated screens.


@FormLayout(columns = 2)
public record Product(
String id,
String name,
BigDecimal price,
ProductStatus status
) implements Identifiable {}

@Section("General")
String name;
@Section("Pricing")
BigDecimal price;

Consecutive fields sharing the same @Section name are grouped into one titled card.


@Style("max-width:600px;margin:auto;")
public class Products {}

A record’s page (/{id}) is the read-only form of the view class — for an AutoCrud<T>, T. @FoldoutDetail turns it into an overview plus folding panels. For any other content, the view class implements DetailContentSupplier and returns the component to show instead — for instance an item overview, with the key information pinned beside tabs:

public class Student implements Identifiable, DetailContentSupplier {
String id;
String name;
String email;
@Override
public String id() {
return id;
}
@Override
public Component detailContent(HttpRequest httpRequest) {
return new StudentOverview(this);
}
}
class StudentOverview extends ItemOverview {
Component keyInfo;
@Panel(title = "Personal data")
Component personal;
StudentOverview(Student student) {
keyInfo = new Text("Email: " + student.email);
personal = new Text("Name: " + student.name);
}
}

Only the content changes. The header (title, subtitle, Status badges, KPIs) and the toolbar (Back to list, Edit, @Toolbar and @ViewToolbarButton buttons) are still the CRUD’s, and the editor and the creation form keep their regular forms although they are the same class. A button inside the content reaches the CRUD like the toolbar’s do: "edit", "cancel-view", or "action-on-view-<method>" for a @ViewToolbarButton method. Return null to keep the default form for a given record. The record is what store().findById (or an overridden view(id, httpRequest)) returns, so load there whatever else the content needs. The same hook applies to an editable view in view mode.


@Button
public Object publish() {
return new Message("Published");
}

@Button
public Object discount() {
price = price.multiply(BigDecimal.valueOf(0.9));
return List.of(new Message("Discount applied"), new State(this));
}

Override fetchRows() directly on your AutoCrud subclass to control how the listing is populated — push filtering to the database, sort server-side, or apply pagination:

@UI("/products")
public class Products extends AutoCrud<Product> {
final ProductStore repository;
final ProductJpaRepository jpa;
public Products(ProductStore repository, ProductJpaRepository jpa) {
this.repository = repository;
this.jpa = jpa;
}
@Override
public CrudStore<Product> store() {
return repository;
}
@Override
public ListingData<Product> fetchRows(
String searchText, Product filters,
Pageable pageable, HttpRequest httpRequest) {
return ListingData.of(jpa.findByNameContainingIgnoreCase(
searchText != null ? searchText : ""));
}
}

Typical customizations:

  • filtering
  • sorting
  • search behavior
  • pagination

Use:

  • Callable<?> → dynamic UI
  • routes.yaml entries → custom pages
  • embedded orchestrators → master-detail

Example:

Callable<?> stats = () -> new HorizontalLayout(
new KPI("Revenue", "10k"),
new KPI("Orders", "120")
);

Do not create custom pages too early. Start with the model and the CRUD. Add custom pages only when the model-driven approach cannot express what you need.


When your Java backend serves the UI, logic lives on the server: keep logic, validation, and state in your Java model. The UI is a projection of that model, not an independent stateful application.


Customization in Mateu follows this progression:

  1. Model (annotations)
  2. Capability restrictions (@ReadOnly, @NotNavigable, @NotCreatable, @NotEditable, @NotDeletable)
  3. Validation
  4. Layout
  5. Actions
  6. Adapter
  7. Custom UI (Callable / Route)

Stay in the model as long as possible.

Move to more advanced techniques only when needed.