Skip to main content

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 search
  • ColumnFilter.select - Dropdown with unique values
  • ColumnFilter.multiSelect - Multiple selection
  • ColumnFilter.numberRange - Min/max numeric filter
  • ColumnFilter.dateRange - Date range filter
  • ColumnFilter.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 framework
  • BootstrapTableRenderer - Bootstrap 5
  • RawTableRenderer - 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:asc or sort=column:desc - Sorting
  • page=0 - Current page (0-indexed)
  • size=25 - Page size
  • f.column=value - Text, select, or boolean filter
  • f.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:

  • displayData returns data as-is (no local processing)
  • totalFilteredItems uses 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)
}
}