Tables and Trees — Configuration-Driven Tables, Tree-Tables, and Views¶
Overview and Motivation¶
Tables are the workhorse of business applications: search results, pick lists, journal views,
editable detail grids. Plain JavaFX makes each of these a chore — every TableColumn needs a
cell-value factory, a cell factory, a formatter, an alignment, an editor, and none of it survives
a restart: column widths, ordering, visibility, and sorting are gone when the user closes the
window.
Tentackle replaces that per-column boilerplate with a declarative table configuration: a single
TableConfiguration object describes the
table by binding paths, and everything else —
cell rendering, editors, formats, preferences-backed layouts, totals, printing, spreadsheet export —
derives from it:
- One configuration, two view types — the same
TableConfigurationconfigures both anFxTableViewand anFxTreeTableView. - Columns are binding paths —
config.addColumn("orgUnit", "Org Unit")is all it takes; the column's type, format, alignment, and editor follow from the bound model member. - Rendering by value class — a pluggable
TableCellTypeknows how to display and edit one Java type, application-wide. - Autofilter in the header — an Excel-like filter popup per column, for tables and tree-tables alike (see below).
- Layouts that roam — column widths, order, visibility, and sorting are stored in the persisted preferences, per user or system-wide; with database-backed preferences the layout follows the user to any workstation.
- PDO awareness on top — the rich desktop client
derives default configurations from the PDO model, adds a column-chooser popup, printing, and
spreadsheet/XML export (
tentackle-fx-rdc-poiupgrades the export to real Excel workbooks).
The foundation lives in org.tentackle.fx.table (+ table.type and table.filter) in this module; the PDO layer in
org.tentackle.fx.rdc.table of tentackle-fx-rdc
is covered below.
Key Concepts¶
The view components¶
FxTableViewandFxTreeTableViewextend the JavaFXTableView/TreeTableViewand implementFxComponent, so they participate in the binding and delegate machinery like any other Tentackle FX control. Each view holds its (optional but recommended)TableConfiguration.FxTableColumn/FxTreeTableColumnand the matchingFxTableCell/FxTreeTableCellare created from the column configurations — application code rarely instantiates them directly.TotalsTableViewdisplays the totals of another table (see below).
TableConfiguration — the single source of truth¶
A TableConfiguration<S> describes a
table of row type S. Its name doubles as the key to the table's preferences. Columns are
added by binding path:
TableConfiguration<Invoice> config = FxFactory.getInstance().createTableConfiguration(Invoice.class, null);
config.addColumn("invoiceNumber", bundle.getString("invoiceNumber"));
config.addColumn("total", bundle.getString("total"));
config.setEditHandler(Fx.rowWiseTableEditHandler()); // optional: makes the table editable
config.configure(tableView); // creates the columns with their cells and editors
tableView.loadPreferences(null, false); // optional: restores the saved layout
configure(...) accepts either an FxTableView or an FxTreeTableView — the configuration is
view-type agnostic (for tree-tables, the value translators create the TreeItems via createTreeItem).
The BINDING enum controls whether the cells are bound to the row object's members: YES (the
default, binds all @Bindable members), INHERITED (includes bindables inherited from
superclasses) or NO.
A table is read-only unless its configuration provides an edit handler, which
controls everything that happens when a value is entered in a cell. With a handler, all columns
are editable, except those whose editable property is FALSE. Without a handler, a column is
editable only if its editable property is TRUE; committing a value then simply ends editing.
The configuration owns an FxTableBinder that
binds the cells to the row objects. Don't confuse it with the controller's binder, which binds a
data list to the table as a whole: binding properties needed by cell editors — the
DomainContext, for instance — must be applied to the table binder
(see binding.md).
TableColumnConfiguration — per-column tuning¶
Each column is described by a
TableColumnConfiguration<S,T>,
whose name is the binding path (and the column's preferences key). Everything a business grid
needs is a property, with null meaning "use the default derived from the column type or editor":
| Aspect | Properties |
|---|---|
| Display | displayedName, alignment, pattern (yields a DecimalFormat or DateTimeFormatter), blankZero (not evaluated by the built-in cell types) |
| Editing | editable, editor (an FxComponent overriding the cell type's default), validChars / invalidChars, unsigned, scale, maxColumns, caseConversion, autoSelect |
| Behavior | summable (defaults to true for numeric types — see totals), filterable (see column filters), cellType (overrides the type-derived cell type), binding |
The configuration creates and caches the actual TableColumn or TreeTableColumn on demand
(getTableColumn() / getTreeTableColumn()).
Table cell types — rendering per value class¶
A TableCellType<T> is a singleton that knows
how to display (updateItem) and edit (getEditor) values of one Java class in table and
tree-table cells. Cell types are produced on demand by the
TableCellTypeFactory and registered
with @TableCellTypeService, a
mapped service keyed by the value class —
so an application can add cell types for its own
datatypes or replace the provided ones.
The org.tentackle.fx.table.type package ships cell types for strings, numbers, booleans, enums,
BMoney,
I18NText, plain objects, and the full
date/time family (Date, Time, Timestamp, LocalDate, LocalTime, LocalDateTime,
Instant, OffsetTime, OffsetDateTime, ZonedDateTime), plus abstract bases
(AbstractTableCellType, AbstractDateTimeTableCellType) for custom types.
Table configuration providers¶
A TableConfigurationProvider
creates the TableConfiguration for one row class. Providers are registered via
@TableConfigurationProviderService
(again a @MappedService) and located through the
TableConfigurationProviderFactory,
which maps the row class leniently — a provider registered for a supertype or interface serves the
subclasses as well. This is how generic UI such as the RDC search dialog obtains the right columns
for any row type without knowing it in advance. In RDC applications the per-PDO
GuiProvider
is the table configuration provider (see below).
Preferences-backed layouts¶
TableConfiguration.savePreferences(...) / loadPreferences(...) persist and restore the visual
state of a table via the pluggable
persisted preferences:
- What is stored: per column the visibility, width, view position and sort type/order, plus the
table's preferred width and height. Restoring the sorting is optional (
setSortingIncluded, default off); restoring the view size can be turned off (setViewSizeIncluded, default on). - Where it is stored: under a node named after the configuration's name, an optional suffix (to distinguish several tables of the same type, e.g. per controller), and the effective locale — column headers translate, so layouts are locale-specific. Each column gets a child node keyed by its binding path.
- User vs. system scope: layouts save to the user's preferences by default; loading falls back to the system preferences when the user hasn't stored a layout yet. Saving to the system scope turns a layout into the default for all users.
In a Tentackle application the preferences are usually database-backed, so a user's table layouts follow them to any workstation — and system-wide defaults are rolled out by simply saving them once.
Column filters (autofilter)¶
JavaFX can filter rows only programmatically — a FilteredList and a Predicate — and offers
nothing at all for TreeTableView. Tentackle adds the autofilter known from spreadsheet
applications: a filter button in each column header opening a popup with a search field, a checkbox
list of the column's distinct values, and a type-aware condition editor.
Filtering is off by default and is switched on per table configuration:
config.setFilterEnabled(true); // filter buttons in the column headers
config.setFilterIncluded(true); // optional: the filter becomes part of the saved layout
config.getColumnConfiguration("serial").setFilterable(Boolean.FALSE); // opt out per column
configure(...) then installs a
TableFilterSupport on the view,
reachable through FxTableView.getFilterSupport() / getFilter() / isFilterActive() /
clearFilter() (and the same on FxTreeTableView).
Each column gets a TableColumnFilter
combining two optional parts with a logical and:
- the value set — the displayed texts the user checked in the popup, plus the pseudo entry (blank) for empty values;
- the condition — a
TableFilterOperatorwith up to two operands: equals, contains, starts/ends with, less/greater, between, is empty, … Only the operators applicable to the column type are offered.
Values are matched by their displayed text, not by equals, since the values of a column often
provide no useful equality (PDOs, for example) and since the user picks what is displayed. Only the
ordering operators compare the values naturally, parsing the operand according to the column's
format. As in spreadsheet applications, the value list of a column shows the values of the rows
passing the filters of all other columns; it is capped at
TableFilterSupport.setMaxDistinctValues(int) (1000 by default).
Table views are filtered by inserting a FilteredList into the view's item chain, which then
reads source → FilteredList → SortedList. The chain always ends in a SortedList bound to the
table's comparator, because the table's sort policy cannot sort anything else. As a consequence,
getItems() returns an immutable transformation list as soon as filtering is enabled — modify the
root source instead:
FxUtilities.getInstance().getRootSource(table.getItems()).add(pdo);
FxUtilities.getInstance().setSourceElement(table.getItems(), viewIndex, pdo); // maps the index down
Tree-table views are filtered level by level by a
TreeItemFilter: an item is hidden when
its own row does not pass the filter, together with its whole subtree, and the children of an item
are evaluated when the item is expanded and not before. Lazily loading tree items — such as the
RDC's PdoTreeItem, which queries the database in getChildren() — are thus never forced to
materialize the whole tree, and node loops cannot lead to an endless recursion. Two consequences
worth knowing:
- an item all of whose children are filtered out still shows its disclosure arrow, since being a leaf is decided by the tree item itself;
- if the application replaces the children of an item while a filter is active, tell the support
about it via
TableFilterSupport.invalidate(treeItem).
With setFilterIncluded(true) the active filter is stored per column in the same preferences node as
the width, visibility, and sorting, under the key filter. Unparsable or outdated entries clear the
column's filter instead of failing the load.
The popup renders each value with the text (and graphic) the table's cells would show. This comes
from TableCellType.getText(...) / getGraphic(...), which render a value without a cell —
the same methods the spreadsheet and XML exports use, so a date column exports as 31.12.2025
rather than 2025-12-31.
The filter UI can be styled with these style classes:
| Style class | Applies to |
|---|---|
tt-filter-column |
every filterable column |
tt-filter-button |
the filter button in a column header |
tt-filter-button-active |
additionally, the filter button of a filtered column |
tt-filter-popup |
the filter popup |
tt-filter-popup-pane |
the popup's content pane |
tt-filter-search |
the search field |
tt-filter-list |
the checkbox list of the distinct values |
tt-filter-condition |
the condition editor |
tt-filter-hint |
the hint shown if the value list was truncated |
Totals¶
TotalsTableView is a small companion table
displayed below (or wherever the layout puts it) a regular FxTableView. Bound via
setBoundTable, it mirrors the bound table's columns — widths and visibility are bound, the header
and horizontal scrollbar are hidden via its own CSS — and renders one row per aggregate the
application supplies as items (one row for totals; three for totals, minimum and maximum; …).
Which columns show a total is controlled by the column configuration's summable property,
defaulting to true for numeric column types.
Edit handlers¶
An editable grid is rarely just a grid of independent input fields. Entering a product in an order
line fills in the price, changing a quantity recalculates the row, leaving the last cell appends
the next line, and the cell the user continues in depends on what was entered. A
TableEditHandler<S> covers all of
that in one place. It is set on the table configuration, before the view is configured, and
works the same way for tables and tree-tables:
The handler is consulted at each step of editing a cell:
| Method | When | Purpose |
|---|---|---|
isCellEditable |
before editing starts, and during navigation | Editability per cell, on top of the column's — a price that is locked once the line is invoiced. |
isCommitOnFocusLost |
when editing starts | Whether the value is committed if the editor loses the focus. By default, it is not. |
verify |
ENTER / TAB, before the model is touched |
Veto: a returned message keeps the editor open and is shown as its error. |
committed |
after the value was applied to the row object | Modify other cells, add or remove rows, and return the cell to edit next (null ends editing). |
canceled |
after ESC, or when the edit was abandoned |
Clean up, for example, remove a row that was appended but never filled in. |
The entered value arrives as a
TableCellEdit: the cell, the old and
the new value, and the
TableEditTrigger telling whether
the user moved forward (NEXT), backward (PREVIOUS, with Shift) or away (FOCUS_LOST). Cells
are addressed by a TableCellRef — the
row object and the column — rather than by indexes, which change as soon as rows are added,
removed or sorted.
Values are modified directly in the row objects; the view is refreshed when the handler
returns. Everything that concerns the view goes through the
TableEditContext passed to each
method:
- rows —
getRows(),indexOf(row),addRow(row),insertRowAfter(sibling, row),insertRowBefore(sibling, row),removeRow(row). The rows are added to the root source of the table's items, so this works for sorted and filtered tables as well — where the sort order and the filter decide where, and whether, the new row is displayed. - navigation —
next(cell)/previous(cell)within the row,below(cell)/above(cell)within the column,firstEditable(row)/lastEditable(row),firstEditableBelow(row)/lastEditableAbove(row). All of them skip cells that are not editable. - actions —
cell(row, "columnName"),select(cell),edit(cell).
For tree-tables, the context is a
TreeTableEditContext, which
adds getParent(row), getChildren(row), addChild(parent, row) and setExpanded(row, expanded).
The rows are the displayed ones, i.e. those whose parents are expanded; edit(cell) expands the
parents of the row as necessary.
The context is also available from the view (tableView.getEditContext()), so that a controller
uses the very same operations, for example for an add line button:
TableEditContext<InvoiceLine> ctx = tableView.getEditContext();
InvoiceLine line = invoice.addLine();
ctx.addRow(line);
ctx.edit(ctx.firstEditable(line));
Provided handlers¶
FxFactory creates the handlers for the common
cases. Fx provides shortcuts, so the code reads
Fx.rowWiseTableEditHandler():
| Factory method | Handler | Behavior |
|---|---|---|
simpleTableEditHandler() |
SimpleEditHandler |
A commit ends editing. |
rowWiseTableEditHandler() |
RowWiseEditHandler |
Continues with the next/previous editable cell, wrapping to the next/previous row. |
columnWiseTableEditHandler() |
ColumnWiseEditHandler |
Continues with the editable cell below/above. |
ruleBasedTableEditHandler() |
RuleBasedEditHandler |
Rules per column, on top of the row-wise handler; its constructor takes any other handler to decorate. |
Because the handlers are created by the factory, an application can replace their implementations
by providing its own FxFactory (annotated with @Service(FxFactory.class)) that overrides the
factory methods.
At either end of the table, the row-wise and column-wise handlers keep the cell in edit mode. The
row-wise handler has two options: setWrapping(false) ends editing at the end of the row instead
of continuing in the next one, and setRowFactory(supplier) appends a new row when the last cell
of the last row is left — the classic way to enter order lines:
The RuleBasedEditHandler is
the shortest path to "entering X changes Y": it takes lambdas per column name and leaves the rest
to the handler it decorates.
config.setEditHandler(Fx.<InvoiceLine>ruleBasedTableEditHandler()
.editable("price", (ctx, cell) -> !cell.row().isInvoiced())
.commitOnFocusLost("quantity")
.verify("quantity", (ctx, edit) -> edit.newValue(Integer.class) < 0 ? "must not be negative" : null)
.onCommit("product", (ctx, edit) -> edit.row().applyProductDefaults())
.onCommit((ctx, edit) -> edit.row().recalculate()) // any column
.navigate("product", (ctx, edit) -> ctx.cell(edit.row(), "quantity"))); // skip the rest
Commit rules only do something, navigate rules decide where to continue. A column without a
navigate rule is navigated by the decorated handler. For anything beyond that, implement
TableEditHandler directly — only committed is mandatory — or extend one of the provided
handlers.
A few rules of the game:
- A value committed by a lost focus has left the cell for good:
committedis invoked, but the cell it returns is ignored, since the user already moved on. A value that fails toverifycannot be committed that way; the edit is canceled instead. - While another cell is already being edited — the user clicked into it — the view is not refreshed, because that would cancel the edit. Changed values of other cells show up as soon as that edit ends.
- The handler does not replace validation:
verifyis for the immediate feedback on a single input, the validators of the model still apply when the data is saved.
The RDC Layer: PDO-Aware Tables¶
The rich desktop client builds on this
foundation in org.tentackle.fx.rdc.table and makes tables of PDOs essentially free.
GuiProviders supply the configuration¶
Every PDO's GuiProvider
extends TableConfigurationProvider. The
DefaultGuiProvider
derives a default configuration from the PDO model via reflection: each @Persistent attribute
with a @Bindable getter becomes a column, displayed names come from the provider's resource
bundle. createTableView() then creates the table, applies the binding mode, and asserts that all
columns are bound. A typical override picks and orders the columns explicitly, using the
generated attribute-name constants:
@Override
public TableConfiguration<Message> createTableConfiguration() {
TableConfiguration<Message> config = createEmptyTableConfiguration();
config.addColumn(Message.AN_MESSAGENUMBER, getBundle().getString(Message.AN_MESSAGENUMBER));
config.addColumn(Message.AN_WHEN, getBundle().getString(Message.AN_WHEN));
config.addColumn(Message.RN_ORGUNIT, getBundle().getString(Message.RN_ORGUNIT));
return config;
}
This is what the RDC search dialog
uses for every result table: guiProvider.createTableView() plus a table popup — no per-entity
table code at all.
RdcTableConfiguration
and RdcTableColumnConfiguration
are the PDO-aware configuration implementations, and
PdoTableCellType
registers a cell type for the PersistentDomainObject interface itself, so PDO-valued columns
(relations, for example) render out of the box.
TablePopup — the user's table menu¶
TablePopup
(created via Rdc.createTablePopup(table, preferencesSuffix, title)) attaches a context menu to
any table or tree-table:
- a column chooser with per-column visibility checkboxes and an auto adjust width action;
- print with a preview, via the
TablePrintercontroller; - export to spreadsheet and export to XML, each for all or only the selected rows;
- save / load preferences — the user's own layout, plus loading the system-wide default; saving the system layout is offered when the application runs with system-only preferences (an administrative mode), and the save items disappear entirely when the preferences are read-only;
- expand / collapse all for tree-tables.
TableUtilities — replaceable table services¶
TableUtilities
is the @Service singleton behind the popup's actions: creating default configurations from PDO
classes, printing, and exporting. The stock implementation writes RFC-4180 CSV for the
spreadsheet export — dependency-free. Adding the
tentackle-fx-rdc-poi module to the
path replaces the service with one that writes styled, typed Excel workbooks via Apache POI,
without touching application code.
How It Fits Together¶
A controller displaying a list of invoices in an editable table:
- The FXML declares an
FxTableView(or the code creates one viaguiProvider.createTableView()in an RDC application). - The table configuration — from a
TableConfigurationProvider, the PDO'sGuiProvider, or built inline — is applied withconfig.configure(tableView): columns, cell types, editors, and formats appear. - The controller's binder binds the data list to the table by name; the table's own binder moves values between cells and row objects, translating and validating through the same machinery as any form field.
Rdc.createTablePopup(...)adds the column chooser, printing, export, and layout save/load; itsloadPreferences()restores the saved layout.- When the user rearranges columns and saves, the layout lands in the (database-backed) preferences and greets them on every workstation from then on.
Package Layout¶
| Package | Content | Module |
|---|---|---|
org.tentackle.fx.table |
Configurations, providers, cell/column classes, TotalsTableView |
tentackle-fx |
org.tentackle.fx.table.edit |
Edit handlers: handler, edit context, provided handlers | tentackle-fx |
org.tentackle.fx.table.filter |
Column filters (autofilter): filter model, support, popup | tentackle-fx |
org.tentackle.fx.table.type |
Built-in TableCellTypes (strings, numbers, money, date/time, …) |
tentackle-fx |
org.tentackle.fx.rdc.table |
PDO-aware configuration, TablePopup, TablePrinter, TableUtilities |
tentackle-fx-rdc |
org.tentackle.fx.rdc.poi |
Excel export replacing the CSV default | tentackle-fx-rdc-poi |
Related Documentation¶
- tentackle-fx — the extended JavaFX layer this builds on.
- Binding — the binding machinery behind columns and cells.
- tentackle-fx-rdc — the rich desktop client.
- Spreadsheet (XLS) Export — Excel export via Apache POI.
- Preferences — the persisted preferences backing table layouts.
- Embedded Entities vs. DataTypes — custom value types that may need their own cell type.