Skip to content

Custom listing

Build a listing screen with custom row types, row actions, toolbar buttons, and access to the HTTP request.

This case shows the Listing<Filters, Row> pattern, which gives you full control over what is shown and how data is fetched.


  • how to use Listing<Filters, Row> for custom data sources
  • how to define row types with actions and status badges
  • how to add toolbar buttons that open forms
  • how to access HTTP headers from an action (e.g., to extract a JWT)
  • how to auto-trigger search on page load
  • how to validate a form action with @Action(validationRequired = true)

  • ChangeRow — the row type shown in the list
  • Changes — the listing screen
  • CreateReleaseForm — a form opened from the toolbar
  • ChangeQueryService — the backend data source

public record ChangeRow(
@Hidden String id,
String page,
String country,
String language,
Status status,
ColumnAction action
) implements Identifiable {}

Key points:

  • @Hidden hides id from the table but makes it available for action routing
  • Status renders as a colored badge, not plain text
  • ColumnAction renders as an action button next to the row
  • implements Identifiable is required for row selection and actions to work

@Title("Changes")
@Service
@Scope("prototype")
@Trigger(type = TriggerType.OnLoad, actionId = "search")
@Style("max-width:900px;margin: auto;")
public class Changes extends Listing<NoFilters, ChangeRow> {
final ChangeQueryService queryService;
final CreateReleaseForm createReleaseForm;
@Override
public ListingData<ChangeRow> search(
String searchText,
NoFilters filters,
Pageable pageable,
HttpRequest httpRequest) {
var found = queryService.findAll(searchText, filters, pageable);
return ListingData.<ChangeRow>builder()
.page(Page.<ChangeRow>builder()
.searchSignature(found.page().searchSignature())
.totalElements(found.page().totalElements())
.pageSize(found.page().pageSize())
.pageNumber(found.page().pageNumber())
.content(found.page().content().stream()
.map(dto -> new ChangeRow(
dto.pageId(),
dto.page(),
dto.country(),
dto.language(),
new Status(mapStatus(dto.status()), dto.status().name()),
new ColumnAction("compare", "Compare")))
.toList())
.build())
.build();
}
public Object compare(ChangeRow row) {
var result = new ComparisonResult("x", "x", "x", "x", 1);
return new ComparisonResultPage(result);
}
private StatusType mapStatus(ChangeStatus status) {
if (status == ChangeStatus.Released) return StatusType.SUCCESS;
return StatusType.DANGER;
}
@Toolbar
public CreateReleaseForm createRelease(
List<ChangeRow> selectedRows,
HttpRequest httpRequest) {
var auth = httpRequest.getHeaderValue("Authorization");
var jwt = auth.split(" ")[1];
String[] chunks = jwt.split("\\.");
var payload = fromJson(new String(Base64.getUrlDecoder().decode(chunks[1])));
var user = payload.get("preferred_username").toString();
return createReleaseForm.withUser(user);
}
}

Key points:

  • extends Listing<NoFilters, ChangeRow> gives full control: you define search() and Mateu calls it
  • NoFilters means no filter form above the list; replace it with a custom record to add filters
  • @Scope("prototype") is required because the class holds state per request
  • @Trigger(type = TriggerType.OnLoad, actionId = "search") automatically triggers search() when the page loads, so the list is not empty on first visit
  • compare(ChangeRow row) handles the ColumnAction("compare", ...) defined in the row
  • @Toolbar on createRelease() adds a button to the top toolbar of the listing
  • HttpRequest httpRequest in @Toolbar methods gives access to HTTP headers
@Toolbar
public CreateReleaseForm createRelease(
List<ChangeRow> selectedRows,
HttpRequest httpRequest) {
var auth = httpRequest.getHeaderValue("Authorization");
var jwt = auth.split(" ")[1];
String[] chunks = jwt.split("\\.");
var payload = fromJson(new String(Base64.getUrlDecoder().decode(chunks[1])));
var user = payload.get("preferred_username").toString();
return createReleaseForm.withUser(user);
}

This pattern lets you access the logged-in user (or any other header value) inside a toolbar action.

The first selected rows are passed as List<ChangeRow> selectedRows, making it straightforward to act on selected items.


@Service
@Title("Create release")
@Style("max-width:900px;margin: auto;")
public class CreateReleaseForm {
@ReadOnly
String user;
@NotNull
String site;
@NotEmpty
String name;
@Toolbar
@Action(validationRequired = true)
Object create() {
var businessKey = UUID.randomUUID().toString();
return URI.create("/workflow/processes/" + businessKey + "?returnTo=/controlPlane/releases");
}
public CreateReleaseForm withUser(String user) {
this.user = user;
return this;
}
}

Key points:

  • @ReadOnly on user makes the field visible but not editable; it is populated from the JWT before the form is shown
  • @Action(validationRequired = true) ensures validation runs before create() is called
  • @Toolbar places the create button in the form toolbar (as opposed to a regular @Button)
  • Returning a URI navigates the user to that URL

Changes (Listing<NoFilters, ChangeRow>)
└── search() → ChangeQueryService → maps DTOs to ChangeRow
└── compare(ChangeRow) → opens ComparisonResultPage
└── createRelease() [@Toolbar] → opens CreateReleaseForm (pre-filled from JWT)
└── create() [@Toolbar, @Action] → navigates to workflow URL

When to use Listing vs AutoCrudOrchestrator

Section titled “When to use Listing vs AutoCrudOrchestrator”
PatternWhen to use
AutoCrudOrchestrator<T>Data fits a single model; standard CRUD operations
Listing<Filters, Row>Custom data source, rows differ from domain model, no standard CRUD

  • Listing<Filters, Row> = custom list, you control the data
  • NoFilters = no filter form; replace with a record for custom filters
  • @Trigger(OnLoad, "search") = auto-run search on page load
  • @Toolbar on a method = button in the list toolbar (receives selected rows)
  • Row action methods are matched by the actionId in ColumnAction
  • HttpRequest in action parameters gives access to headers