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)
}
}