-
Notifications
You must be signed in to change notification settings - Fork 35
LCTable Library User Documentation
Author: Giuseppe Del Castillo (independent consultant, working for ReliaTec GmbH on LibreClinica)
Created: 2026-07-09
Last update: 2026-07-15
LCTable is a declarative, type-safe Java library for building dynamic, server-side rendered HTML data tables. By combining HtmlFlow (for fast, fluent HTML generation) with HTMX (for seamless AJAX-based table updates), the library allows developers to construct data tables with pagination, sorting, and filtering using entirely server-side Java.
The library uses Java Generics to ensure type safety. The primary type parameter, commonly denoted as <T>, represents the underlying domain object or record type that generally corresponds to a single row in the table.
All data extractors (functions for extracting a field from a record of type <T> corresponding to a row) and renderers (functions for converting data to a string to be displayed in a cell of the table) are strictly typed, preventing runtime casting errors.
-
Declarative column configuration. For each column, its:
- (internal) name,
- display name,
- width,
- renderer (value-to-string conversion function, see above),
- (optional) filter, and
- sortability (
SORTABLEorNOT_SORTABLE)
are defined in a single
LCTableColumnDefobject. -
HTMX-based interactivity. The library's sorting, filtering, and pagination code automatically generates HTMX attributes (
hx-get,hx-push-url, etc.) to update the table smoothly without full page reloads nor custom JavaScript. -
Type safety. Generic type parameters guarantee the type-safe handling of row data.
-
Built-in null-safety. Extractor pipelines automatically handle
null, rendering a fallback character (—) instead of throwing aNullPointerException. -
HTML5 validation. Support for regex-based HTML5 validation on (text) filter inputs. Requests to the server are prevented if the content of a text filter is not in the valid format.
The following diagram illustrates the structural relationships between the core classes of the LCTable library:
classDiagram
class LCTable~T~ {
+String tableName
+String panelId
+Function<TableParams, LCTableData<T>> fetchData
+render(String, LCTableParams, String) String
}
class LCTableColumnDef~T~ {
+String columnName
+String columnDisplayName
+double columnWidth
+Sortability sortability
+BiConsumer cellRenderer
}
class LCTableFilterDef {
<<abstract>>
+renderFilter()
}
class LCTableParams {
+int page
+int maxRows
+String sortProp
+String sortDir
+Map filters
}
class LCTableData~T~ {
+List~T~ pageItems
+int totalCountWithFilter
}
LCTable *-- "*" LCTableColumnDef : defines columns
LCTableColumnDef o-- "0..1" LCTableFilterDef : defines filter via
LCTable ..> LCTableParams : uses
LCTable ..> LCTableData~T~ : consumes via fetchData
During the rendering cycle, the query string of an incoming HTTP request is parsed into LCTableParams. The LCTable invokes the provided fetchData function with these parameters, retrieving an LCTableData instance containing the current page of records. The LCTable code then iterates over the records, using the configuration specified in each LCTableColumnDef to generate the final HTML structure.
This is the core class responsible for orchestrating data fetching and rendering the HTML table. The type parameter <T> represents the domain object type displayed in the table rows.
Main constructor:
public LCTable(
String tableName,
List<LCTableColumnDef<T>> columns,
Function<LCTableParams, LCTableData<T>> fetchData
)-
tableName: A unique identifier for the table, utilized to generate stable IDs for HTMX targeting and other purposes (e.g., thepanelIdis derived fromtableNameaspanelId = tableName + "-panel"). -
columns: The list of column definitions (of typeLCTableColumnDef<T>) detailing how each attribute of<T>should be rendered and filtered. -
fetchData: A function that receives pagination, sorting, and filtering parameters (LCTableParams) and returns a paginated slice of data (LCTableData<T>).
Key methods:
-
render(String entityPath, LCTableParams params, String resourcePath): Evaluates the table parameters, executes the data fetcher (fetchData), and generates the HTML string.-
entityPathis the base URI without the query string (e.g.http://localhost:8080/LibreClinica/AuditUserActivity). -
paramsare the previously extracted URL parameters. -
resourcePathis the base path where static assets, such as images, are stored on the server.
-
Defines a single column's metadata, filtering capability, and HTML rendering logic.
Main constructor:
public LCTableColumnDef(
String columnName,
String columnDisplayName,
double columnWidth,
Sortability sortability,
LCTableFilterDef filterDef,
BiConsumer<Tr<?>, T> cellRenderer
)-
columnName: The internal name used in HTTP query parameters for sorting and filtering. -
columnDisplayName: The human-readable text displayed in the table header. -
columnWidth: The width of the column, specified in CSSremunits. -
sortability: An enum (SORTABLEorNOT_SORTABLE) determining whether the column header should render as a clickable sorting link. -
filterDef: An optionalLCTableFilterDefspecifying the input type for the filter row (nullif filtering is disabled for this column). -
cellRenderer: A closure that accepts an HtmlFlowTrelement (corresponding to<tr>in HTML) and the current record instance, writing the cell's<td>contents directly into the HTML stream.
Convenience factory methods
Because creating columns via the full constructor requires verbosely handling HTML generation and null-safety, LCTableColumnDef provides several static factory methods. These functions act as abbreviations that automatically construct a column configuration (of type LCTableColumnDef<T>) with a null-safe cellRenderer closure (of type BiConsumer<Tr<?>, T>).
The factory methods wrap the provided data extractor and/or renderer inside a nullSafeColText helper to ensure null-safety (see below for more details). If any step in the data extraction and rendering pipeline yields null, the cell safely renders a fallback character (—) instead of throwing a NullPointerException.
Currently, the following factory methods are available:
-
textCol(...): Constructs a simple text-based column. By default, it sets the column toSORTABLEand uses anLCTableFilterDef.Textfilter. -
enumCol(...): Specifies a column with values from an enumerated type (e.g., a Javaenum) or other finite set (e.g., a fixed set of string values). A column constructed with this factory method automatically includes anLCTableFilterDef.Selectfilter (see documentation ofLCTableFilterDefbelow). It requires the developer to specify the list of possible values alongside two conversion-to-string functions for displaying those values in the table and for converting them to URL parameter values, respectively. -
customTdCol(...): Designed for complex HTML content like action buttons, links, or images. Instead of extracting text, it passes the HtmlFlowTdelement builder directly to the provided closure (BiConsumer<Td<?>, F>), giving the developer full control over the cell's inner HTML while retaining the standard table structure.
Kinds of factory methods and null-safety
There are two general variants of factory methods:
-
The first kind, which is more general, takes a
Function<T, String> rendererparameter. Here, the renderer converts the content of a whole row to a string. Example of a factory method of the first kind:textCol(..., Function<T, String> renderer)`
A factory method of this kind checks whether the row is
nullbefore applying the renderer (if the row isnull,—is displayed).This kind of factory method is useful if the text to be displayed in the table's column depends on more than one field of the
<T>record. However, it can also be used when the column value is a string that does not need further formatting (see theuserNameanddetailscolumns in theAuditUserLoginTableexample in Section 4 below). -
The second kind, which is less general but safer, takes two distinct parameters: a
Function<T, F> extractorand aFunction<F, String> renderer. First, the extractor extracts a field (of type<F>) from the record (of type<T>); then, the renderer converts the value of the extracted field to a string. Example of a factory method of the second kind:textCol(..., Function<T, F> extractor, Function<F, String> renderer)`
A factory method of this kind performs two null-checks. In particular, it checks:
- whether the row is
nullbefore applying the extractor to extract the field (if the row isnull,—is displayed), and - whether the extracted field is
nullbefore applying the renderer (if the extracted field isnull,—is displayed).
In view of these two levels of null-safety, it is preferable to use the second kind of factory method whenever the text to be displayed in the column only depends on the respective field value (and not on the value of any other fields in the row).
- whether the row is
An abstract base class defining how a column can be filtered. It requires the implementation of a renderFilter(...) method to inject HTML input fields into the table header.
The class hierarchy for filters is as follows:
classDiagram
class LCTableFilterDef {
<<abstract>>
+renderFilter()
}
class LCFilterDef.Text {
+String pattern
+String message
}
class LCFilterDef.Select~F~ {
+List~F~ values
+Function~F, String~ valueToString
+Function~F, String~ valueToUrlParam
}
class LCFilterDef.ClearFilter {
}
LCTableFilterDef <|-- LCFilterDef.Text
LCTableFilterDef <|-- LCFilterDef.Select
LCTableFilterDef <|-- LCFilterDef.ClearFilter
Concrete Implementations
-
Text: Renders a standard<input type="text">. It can optionally hold an HTML5 regexpatternand a validationmessage. Its HTMX trigger explicitly verifies validation state before sending a request to the server: no filtering request is sent if the filter value is invalid (i.e., not matching the regex pattern). -
Select<F>: Renders a dropdown<select>element. This filter component iterates overvalues(a list of values of type<F>), mapping each value to a display label using thevalueToStringfunction to generate the dropdown list shown in the UI. When a value is selected from the dropdown list, it converts the selected value to a query parameter using thevalueToUrlParamfunction. -
ClearFilter: This filter is a bit special because it is not directly related to a column. Instead, it renders a "Clear Filter" button that resets all filters by sending out an HTMX request with no filter parameters. In other words, it causes all data to be displayed in the table without any filtering.
Convenience Factory Methods
Similar to columns, filters should generally be instantiated via the static factory methods provided in LCTableFilterDef:
-
textFilter(): Abbreviation fornew Text(). -
textFilter(String pattern, String message): Abbreviation for a validated text filter. -
selectFilter(List<T> values, Function<T, String> valueToString): Abbreviation fornew Select<T>(...). -
clearFilter(): Abbreviation fornew ClearFilter().
Encapsulates the state (URL parameters) extracted from an HTTP request, in particular from its query string.
-
Constructors:
-
LCTableParams(MultiValueMap<String, String> params, LCTable<?> table): Constructs anLCTableParamsobject by extracting the URL parameters from aMultiValueMap<String, String>(as usually built by Spring via annotations). -
LCTableParams(String queryString, LCTable<?> table): Constructs anLCTableParamsobject by parsing the raw query string and extracting the URL parameters from it. This constructor is the one that is currently used in practice (as the request processing is performed by plain servlets, not by Spring-based controllers).
-
-
Fields:
-
page: the currently displayed data page (0-indexed internally); -
maxRows: the maximum number of rows per page; -
sortProp: the sort property (column name by which the table is sorted); -
sortDir: the sort direction (ascfor ascending,descfor descending, or empty for "no sorting"); - a
Map<String, String>of activefilters(extracted from parameters prefixed withq.).
-
A simple object wrapping the retrieved database results.
-
Constructor:
LCTableData(List<T> pageItems, int totalCountWithFilter)
-
Fields:
-
pageItemsholds the rows for the current view; -
totalCountWithFilterrepresents the total count of matching records across the entire dataset (necessary for pagination calculations).
-
Contains helper methods to facilitate UI generation, standardizing HTMX attribute injection and common components.
-
utcTimestampToString(...): A thread-safe utility mappingjava.util.Dateobjects to strings in UTC representation. Mostly used for timestamps found in log files (which should not be adapted to the local time zone). -
escapeSqlLikeWildcards(...): Escapes%and_characters to prevent unintended wildcard matching when dealing with free-text database queries (which are mapped to SQL LIKE queries). -
linkIcon(...): Generates standard clickable icons enclosed in<a>tags (e.g., View/Edit actions).
The following section demonstrates how to implement a fully functional table with the LCTable library using AuditUserLoginTable as an example.
Functionally, AuditUserLoginTable configures the data table and corresponds to the AuditUserLoginTableFactory of the old jmesa-based table. The AuditUserLoginTableFactory class was carefully reviewed in order to understand what the environment expects from and provides to the table, and to implement the new AuditUserLoginTable accordingly.
The table class encapsulates the underlying LCTable instance and injects the data access dependencies through its constructor.
public class AuditUserLoginTable {
private final AuditUserLoginDao auditUserLoginDao;
private final LCTable<AuditUserLoginBean> table;
// constructor (takes DAO as parameter and initializes the LCTable with column definitions and fetchData method)
public AuditUserLoginTable(AuditUserLoginDao auditUserLoginDao) {
this.auditUserLoginDao = auditUserLoginDao;
this.table = new LCTable<>("userLogins", COLUMNS, this::fetchData);
}Using static imports from LCTableColumnDef and LCTableFilterDef with various convenience factory methods (textCol, enumCol, customTdCol, textFilter), the structure of the table is defined declaratively.
The class AuditUserLoginBean used as the type parameter defines the data content of a row of the table. Accessor methods of AuditUserLoginBean can then be used as extractors for the corresponding column of the table. For example, in the first column (the userName column), the method getUserName is directly used as the extractor to retrieve the value.
// defines the configuration of columns for the AuditUserLogin table
private static final List<LCTableColumnDef<AuditUserLoginBean>> COLUMNS = Arrays.asList(
// 1. A basic sortable text column.
// It extracts the username directly via a method reference. By default,
// textCol creates a standard text input filter.
textCol("userName", "User Name", 5, AuditUserLoginBean::getUserName),
// 2. A formatted text column with a regex-validated filter.
// It enforces a specific timestamp format before HTMX triggers a server request.
textCol("loginAttemptDate", "Attempt Date", 7,
textFilter(TIMESTAMP_FILTER_FOR_HTML_VALIDATION, TIMESTAMP_FILTER_MESSAGE),
AuditUserLoginBean::getLoginAttemptDate, LCTableUtil::utcTimestampToString
),
// 3. A column for enum (here: 'LoginStatus') values with a dropdown filter.
// The factory method requires, besides the usual name, displayName and width:
// - the extractor: AuditUserLoginBean::getLoginStatus
// - the list of the possible enum values: Arrays.asList(LoginStatus.values())
// - renderer method to display the enum value: LoginStatus::toString
// - converter method to build URL param value: LoginStatus::name
enumCol("loginStatus", "Status", 7,
AuditUserLoginBean::getLoginStatus,
Arrays.asList(LoginStatus.values()),
LoginStatus::toString,
LoginStatus::name
),
// 4. A straightforward, sortable text column with default filtering.
textCol("details", "Details", 3, AuditUserLoginBean::getDetails),
// 5. A custom column containing action links.
// Notice the use of `NOT_SORTABLE` and `clearFilter()`. This configures the
// column header to skip sorting links and places a "Clear Filter" button
// in the filter row. `customTdCol` provides raw access to the `Td` element.
customTdCol("actions", "Actions", 4, NOT_SORTABLE, clearFilter(),
AuditUserLoginBean::getUserAccountId,
(td, userAccountId) ->
td.of(linkIcon("View", "ViewUserAccount?userId=" + userAccountId + "&viewFull=yes", "images/bt_View.gif", "View"))
)
);The LCTable requires a function to bridge the table to the underlying data store (i.e., retrieve data depending on the parameters). Concretely, the fetchData closure translates LCTableParams into DAO queries and returns an LCTableData instance.
In general, nothing substantially new should be implemented here. Rather, this function provides "glue code" for data binding by connecting the table to core data management functionality that already exists in LibreClinica (in the example below: AuditUserLoginDao, AuditUserLoginSort, and AuditUserLoginFilter).
// fetches a page of AuditUserLoginBean records from the DAO based on the provided LCTableParams and returns them as LCTableData
private LCTableData<AuditUserLoginBean> fetchData(LCTableParams p) {
AuditUserLoginFilter filter = new AuditUserLoginFilter();
// Here we need to escape SQL LIKE wildcards as a workaround for a bug in AuditUserLoginFilter, which does
// not do it. Without this workaround, the following would just be: 'p.filters.forEach(filter::addFilter);'
final Set<String> freeTextColumns = Set.of("userName", "details");
p.filters.forEach((property, value) ->
filter.addFilter(property, freeTextColumns.contains(property) ? escapeSqlLikeWildcards(value) : value)
);
// Build sort: default to loginAttemptDate desc if no sort provided
boolean noSort = p.sortProp == null || p.sortProp.isEmpty();
final var sortProp = noSort ? "loginAttemptDate" : p.sortProp;
final var sortDir = noSort ? "desc" : p.sortDir;
AuditUserLoginSort sort = new AuditUserLoginSort();
sort.addSort(sortProp, sortDir);
// Fetch the page of data from the DAO
int rowStart = p.page * p.maxRows;
int rowEnd = rowStart + p.maxRows;
final var pageItems = auditUserLoginDao.getWithFilterAndSort(filter, sort, rowStart, rowEnd);
int total = auditUserLoginDao.getCountWithFilter(filter);
return new LCTableData<>(pageItems, total);
}To output the final HTML, the render method of the "AuditUserLogin" table takes the request as an argument, extracts the URL parameters from the request's query string, stores them in an LCTableParams object, and delegates the rendering of the table to the core LCTable.render method, supplying the entityPath and the resourcePath (as needed by LCTable.render):
// rendering method putting all pieces together
public String render(HttpServletRequest request) {
final LCTableParams params = new LCTableParams(request.getQueryString(), this.table);
return this.table.render(request.getRequestURI(), params, request.getContextPath());
}
}By returning the output of render(...) directly to the HTTP response or injecting it into the page template, the integration is complete. Interaction with the table UI will immediately trigger localized HTMX swaps using the established routes.
LibreClinica uses servlets to process requests and JSP templates to render pages in response to those requests.
To become effective, a data table provided by the LCTable library must be integrated into the servlet processing and JSP rendering lifecycles of the page in which the table is to be incorporated.
In practice, this requires modifications to both the page's servlet class and the JSP template.
Detailed modification instructions are omitted for the moment. However, you can review the following files to see how the example table AuditUserLoginTable was integrated:
-
web/src/main/java/org/akaza/openclinica/control/admin/AuditUserActivityServlet.java(servlet class); -
web/src/main/webapp/WEB-INF/jsp/admin/auditUserActivity.jsp(JSP template).
As we integrate more tables, we expect patterns to emerge that we can later document or abstract into code.