Datatables
forms4s provides a datatable component for displaying, filtering, sorting, and exporting tabular data.
Dependencies
"org.business4s" %% "forms4s-core" % "0.2.0"
For Tyrian rendering:
"org.business4s" %% "forms4s-tyrian" % "0.2.0"
Defining a Table
Define your data model:
case class Employee(
name: String,
department: String,
salary: Double,
hireDate: LocalDate,
active: Boolean,
)
Create a table definition using TableDefBuilder:
val tableDef: TableDef[Employee] = TableDefBuilder[Employee]
.modify(_.name)(_.withFilter(ColumnFilter.text))
.modify(_.department)(_.withFilter(ColumnFilter.select))
.modify(_.salary)(
_.withFilter(ColumnFilter.numberRange(s => Some(s)))
.withRender(s => f"$$$s%,.0f")
.withSort(Ordering.Double.TotalOrdering),
)
.modify(_.hireDate)(_.withFilter(ColumnFilter.dateRange(d => Some(d))))
.modify(_.active)(
_.withFilter(ColumnFilter.boolean(identity))
.withRender(b => if (b) "Yes" else "No"),
)
.rename(_.hireDate, "Hire Date")
.build("employees")
.withPageSize(10)
.withSelection(multi = true)
Table State
Create runtime state from your definition and data:
val employees: Vector[Employee] = Vector(
Employee("Alice", "Engineering", 95000, LocalDate.of(2020, 3, 15), true),
Employee("Bob", "Marketing", 75000, LocalDate.of(2019, 7, 22), true),
// ...
)
val tableState: TableState[Employee] = TableState(tableDef, employees)
Filters
Available filter types:
ColumnFilter.text- Free text searchColumnFilter.select- Dropdown with unique valuesColumnFilter.multiSelect- Multiple selectionColumnFilter.numberRange- Min/max numeric filterColumnFilter.dateRange- Date range filterColumnFilter.boolean- Yes/No/All filter
Tyrian Integration
case class TableModel(tableState: TableState[Employee])
enum TableMsg {
case Update(msg: TableUpdate)
}
object DatatableTyrianExample extends TyrianIOApp[TableMsg, TableModel] {
val tableDef: TableDef[Employee] = ???
val data: Vector[Employee] = ???
def init(flags: Map[String, String]): (TableModel, Cmd[IO, TableMsg]) =
(TableModel(TableState(tableDef, data)), Cmd.None)
def update(model: TableModel): TableMsg => (TableModel, Cmd[IO, TableMsg]) = { case TableMsg.Update(msg) =>
(model.copy(tableState = model.tableState.update(msg)), Cmd.None)
}
val renderer: TableRenderer = BulmaTableRenderer
def view(model: TableModel): Html[TableMsg] =
renderer.renderTable(model.tableState).map(TableMsg.Update.apply)
def subscriptions(model: TableModel): Sub[IO, TableMsg] = Sub.None
def router: Location => TableMsg = _ => TableMsg.Update(TableUpdate.ClearAllFilters)
}
Available renderers:
BulmaTableRenderer- Bulma CSS frameworkBootstrapTableRenderer- Bootstrap 5RawTableRenderer- Semantic HTML (works with Pico CSS)
CSV Export
val csv: String = TableExport.toCSV(tableState)
Export respects current filters, sorting, and selection.
URL Query Parameters
Persist and share table state via URL query parameters:
// Convert table state to URL query string
val queryString: String = tableState.toQueryString
// => "sort=name:asc&page=1&f.department=Engineering&f.salary.min=50000"
// Convert to individual params (useful for framework URL builders)
val queryParams: Seq[(String, String)] = tableState.toQueryParams
// => Seq("sort" -> "name:asc", "page" -> "1", ...)
// Load state from URL query string (e.g., from browser URL)
// Filter types are inferred from the TableDef
val restoredState: TableState[Employee] = tableState.loadFromQueryString(queryString)
// Load state from params (e.g., from request object)
val restoredState2: TableState[Employee] = tableState.loadFromQueryParams(queryParams)
Query parameter format:
sort=column:ascorsort=column:desc- Sortingpage=0- Current page (0-indexed)size=25- Page sizef.column=value- Text, select, or boolean filterf.column=a&f.column=b- Multi-select filter (repeated params)f.column.min=10&f.column.max=100- Range filter (number or date)
The correct FilterState type is determined automatically from the column's filter definition in the TableDef.
Server Mode
For large datasets, you can delegate filtering, sorting, and pagination to the server. In server mode:
displayDatareturns data as-is (no local processing)totalFilteredItemsuses the server-provided count (for pagination)- Loading and error states are tracked for UI feedback
// Create table in server mode - filtering/sorting/pagination handled by server
val serverTableState: TableState[Employee] = TableState.serverMode(tableDef)
// Set loading state before fetching
val loadingState: TableState[Employee] = serverTableState.setLoading
// After receiving server response, apply the data
// totalCount is the total number of filtered records (for pagination)
val withData: TableState[Employee] = loadingState.setServerData(
newData = Vector(Employee("Alice", "Engineering", 95000, LocalDate.of(2020, 3, 15), true)),
totalCount = 150,
)
// On error, set error state
val withError: TableState[Employee] = loadingState.setError("Network error")
// In server mode, displayData returns data as-is (no local processing)
val displayedData: Vector[Employee] = withData.displayData
// totalFilteredItems uses server-provided totalCount
val total: Int = withData.totalFilteredItems // => 150
// Query params still work for sending to server
val serverQueryParams: Seq[(String, String)] = serverTableState
.update(TableUpdate.SetFilter("name", FilterState.TextValue("alice")))
.update(TableUpdate.SetSort("salary", SortDirection.Desc))
.toQueryParams
// => Seq("sort" -> "salary:desc", "page" -> "0", "size" -> "10", "f.name" -> "alice")
Tyrian Integration with Server Mode
object ServerModeExample {
case class Model(tableState: TableState[Employee])
enum Msg {
case TableMsg(msg: TableUpdate)
case DataLoaded(data: Vector[Employee], totalCount: Int)
case DataFailed(error: String)
}
def fetchFromServer(state: TableState[Employee]): Cmd[IO, Msg] = ???
def update(model: Model, msg: Msg): (Model, Cmd[IO, Msg]) = msg match {
case Msg.TableMsg(tableMsg) =>
val newState = model.tableState.update(tableMsg)
// Check if this message requires server fetch
if (tableMsg.needsServerFetch) {
val loadingState = newState.setLoading
(model.copy(tableState = loadingState), fetchFromServer(loadingState))
} else {
(model.copy(tableState = newState), Cmd.None)
}
case Msg.DataLoaded(data, totalCount) =>
(model.copy(tableState = model.tableState.setServerData(data, totalCount)), Cmd.None)
case Msg.DataFailed(error) =>
(model.copy(tableState = model.tableState.setError(error)), Cmd.None)
}
}