From 0968ef8815d55c88b714fef0be13f42dbcf5da5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Pitu=C5=82a?= Date: Tue, 20 Jan 2026 22:17:29 +0100 Subject: [PATCH 1/4] Add server data mode --- .../forms4s/datatable/LoadingState.scala | 9 + .../scala/forms4s/datatable/TableState.scala | 74 ++++++- .../forms4s/datatable/TableStateSpec.scala | 100 ++++++++++ .../components/DatatablePlayground.scala | 180 +++++++++++++++++- .../example/docs/DatatableExample.scala | 31 +++ .../datatable/BootstrapTableRenderer.scala | 47 ++++- .../tyrian/datatable/BulmaTableRenderer.scala | 35 +++- .../tyrian/datatable/RawTableRenderer.scala | 42 +++- website/docs/datatables/index.mdx | 49 ++++- 9 files changed, 552 insertions(+), 15 deletions(-) create mode 100644 forms4s-core/src/main/scala/forms4s/datatable/LoadingState.scala diff --git a/forms4s-core/src/main/scala/forms4s/datatable/LoadingState.scala b/forms4s-core/src/main/scala/forms4s/datatable/LoadingState.scala new file mode 100644 index 0000000..59994fd --- /dev/null +++ b/forms4s-core/src/main/scala/forms4s/datatable/LoadingState.scala @@ -0,0 +1,9 @@ +package forms4s.datatable + +/** Loading state for server-side data fetching. + */ +enum LoadingState { + case Idle + case Loading + case Failed(message: String) +} diff --git a/forms4s-core/src/main/scala/forms4s/datatable/TableState.scala b/forms4s-core/src/main/scala/forms4s/datatable/TableState.scala index 4fefcea..a93deb7 100644 --- a/forms4s-core/src/main/scala/forms4s/datatable/TableState.scala +++ b/forms4s-core/src/main/scala/forms4s/datatable/TableState.scala @@ -24,6 +24,11 @@ case class SortState( * * @tparam T * The row type + * @param serverMode + * When true, assumes data is already filtered/sorted/paged by server. Local filtering/sorting/pagination logic is bypassed and displayData returns + * data as-is. + * @param totalOverride + * Server-provided total count of filtered records. Used for pagination when serverMode is true. */ case class TableState[T]( definition: TableDef[T], @@ -32,6 +37,9 @@ case class TableState[T]( sort: Option[SortState] = None, page: PageState, selection: Set[Int] = Set.empty, + serverMode: Boolean = false, + totalOverride: Option[Int] = None, + loading: LoadingState = LoadingState.Idle, ) { private def rowPassesFilters(row: T): Boolean = { @@ -94,13 +102,17 @@ case class TableState[T]( def pagedData: Vector[T] = pagedDataWithIndices.map(_._1) /** Data to display with original indices (filtered, sorted, paged) */ - def displayDataWithIndices: Vector[(T, Int)] = pagedDataWithIndices + def displayDataWithIndices: Vector[(T, Int)] = + if serverMode then data.zipWithIndex else pagedDataWithIndices - /** Data to display (filtered, sorted, paged) */ - def displayData: Vector[T] = pagedData + /** Data to display (filtered, sorted, paged). In server mode, returns data as-is (server already did filtering/sorting/paging). + */ + def displayData: Vector[T] = + if serverMode then data else pagedData - /** Total items after filtering */ - def totalFilteredItems: Int = filteredData.size + /** Total items after filtering. In server mode, uses totalOverride if provided. + */ + def totalFilteredItems: Int = totalOverride.getOrElse(filteredData.size) /** Total pages */ def totalPages: Int = page.totalPages(totalFilteredItems) @@ -236,11 +248,34 @@ case class TableState[T]( val parsed = TableStateQueryParams.fromQueryParams(params) TableStateQueryParams.applyToState(this, parsed) } + + // === Server mode helpers === + + /** Set loading state to Loading. */ + def setLoading: TableState[T] = copy(loading = LoadingState.Loading) + + /** Set loading state to Failed with error message. */ + def setError(message: String): TableState[T] = copy(loading = LoadingState.Failed(message)) + + /** Apply server response data. + * + * @param newData + * The page of data returned by server + * @param totalCount + * Total count of filtered records (for pagination) + */ + def setServerData(newData: Vector[T], totalCount: Int): TableState[T] = + copy( + data = newData, + totalOverride = Some(totalCount), + loading = LoadingState.Idle, + selection = Set.empty, + ) } object TableState { - /** Create initial state from definition and data */ + /** Create initial state from definition and data (client-side mode) */ def apply[T](definition: TableDef[T], data: Seq[T]): TableState[T] = TableState( definition = definition, @@ -248,7 +283,32 @@ object TableState { page = PageState(0, definition.pageSize), ) - /** Create empty state from definition */ + /** Create empty state from definition (client-side mode) */ def empty[T](definition: TableDef[T]): TableState[T] = apply(definition, Vector.empty) + + /** Create state for server-side mode. + * + * In server mode, filtering/sorting/pagination is handled by the server. The table state tracks UI state (filters, sort, page) for sending to + * server and rendering, but displayData returns data as-is without local processing. + * + * Typical usage: + * {{{ + * val table = TableState.serverMode(tableDef) + * + * // On filter/sort/page change: + * val newTable = table.update(msg).setLoading + * fetchFromServer(newTable.toQueryParams).map { response => + * newTable.setServerData(response.data, response.totalCount) + * } + * }}} + */ + def serverMode[T](definition: TableDef[T]): TableState[T] = + TableState( + definition = definition, + data = Vector.empty, + page = PageState(0, definition.pageSize), + serverMode = true, + loading = LoadingState.Idle, + ) } diff --git a/forms4s-core/src/test/scala/forms4s/datatable/TableStateSpec.scala b/forms4s-core/src/test/scala/forms4s/datatable/TableStateSpec.scala index e30166a..dc78f9e 100644 --- a/forms4s-core/src/test/scala/forms4s/datatable/TableStateSpec.scala +++ b/forms4s-core/src/test/scala/forms4s/datatable/TableStateSpec.scala @@ -313,5 +313,105 @@ class TableStateSpec extends AnyFreeSpec { assert(state.uniqueValuesFor("age") == List("25", "30")) } } + + "server mode" - { + def serverState = TableState.serverMode(tableDef) + + "creates state with good deafaults" in { + assert(serverState.serverMode) + assert(serverState.data.isEmpty) + assert(serverState.loading == LoadingState.Idle) + } + + "setLoading sets loading to Loading" in { + val state = serverState.setLoading + assert(state.loading == LoadingState.Loading) + } + + "setError sets loading to Failed with message" in { + val state = serverState.setError("Network error") + assert(state.loading == LoadingState.Failed("Network error")) + } + + "setServerData sets data, totalOverride, and resets loading" in { + val serverData = Vector(Person("Test", 99, true)) + val state = serverState.setLoading.setServerData(serverData, 100) + + assert(state.data == serverData) + assert(state.totalOverride == Some(100)) + assert(state.loading == LoadingState.Idle) + assert(state.selection.isEmpty) + } + + "displayData returns data directly in server mode (no local filtering/paging)" in { + val serverData = Vector( + Person("Page1A", 1, true), + Person("Page1B", 2, true), + ) + val state = serverState.setServerData(serverData, 50) + + // In server mode, displayData should return exactly what server sent + assert(state.displayData == serverData) + } + + "totalFilteredItems uses totalOverride in server mode" in { + val serverData = Vector(Person("Test", 99, true)) + val state = serverState.setServerData(serverData, 150) + + // data.size is 1, but totalOverride is 150 + assert(state.totalFilteredItems == 150) + } + + "totalPages calculated from totalOverride" in { + val serverData = Vector(Person("Test", 99, true)) + val state = serverState.setServerData(serverData, 25) // 25 items, page size 2 = 13 pages + + assert(state.totalPages == 13) + } + + "client mode still uses local data for totalFilteredItems" in { + val state = initialState + // No totalOverride, so uses filteredData.size + assert(state.totalFilteredItems == 5) + assert(state.totalOverride.isEmpty) + } + + "update preserves serverMode flag" in { + val state = serverState + .update(TableUpdate.SetFilter("name", FilterState.TextValue("test"))) + .update(TableUpdate.ToggleSort("age")) + .update(TableUpdate.SetPage(2)) + + assert(state.serverMode) + } + + "filter/sort/page state tracked for query params in server mode" in { + val state = serverState + .update(TableUpdate.SetFilter("name", FilterState.TextValue("alice"))) + .update(TableUpdate.SetSort("age", SortDirection.Desc)) + .update(TableUpdate.SetPageSize(10)) + + assert(state.filters.contains("name")) + assert(state.sort == Some(SortState("age", SortDirection.Desc))) + assert(state.page.pageSize == 10) + + // Can generate query params to send to server + val queryParams = state.toQueryParams + assert(queryParams.contains(("f.name", "alice"))) + assert(queryParams.contains(("sort", "age:desc"))) + assert(queryParams.contains(("size", "10"))) + } + + "displayDataWithIndices returns data.zipWithIndex in server mode" in { + val serverData = Vector( + Person("A", 1, true), + Person("B", 2, false), + ) + val state = serverState.setServerData(serverData, 10) + + // In server mode, indices are 0-based for current page (not original data indices) + assert(state.displayDataWithIndices == Vector((Person("A", 1, true), 0), (Person("B", 2, false), 1))) + } + } } } diff --git a/forms4s-examples/src/main/scala/forms4s/example/components/DatatablePlayground.scala b/forms4s-examples/src/main/scala/forms4s/example/components/DatatablePlayground.scala index cb32957..4cf69be 100644 --- a/forms4s-examples/src/main/scala/forms4s/example/components/DatatablePlayground.scala +++ b/forms4s-examples/src/main/scala/forms4s/example/components/DatatablePlayground.scala @@ -9,6 +9,7 @@ import tyrian.Html.* import org.scalajs.dom import java.time.LocalDate +import scala.concurrent.duration.DurationInt import scala.scalajs.js // Data model for the example @@ -22,14 +23,23 @@ case class Employee( active: Boolean, ) +enum DataMode { + case Client + case Server +} + enum DatatableMsg { case TableMsg(msg: TableUpdate) case FrameworkSelected(framework: CssFramework) + case DataModeSelected(mode: DataMode) + case ServerDataReceived(data: Vector[Employee], totalCount: Int) + case ServerError(message: String) } case class DatatablePlayground( tableState: TableState[Employee], framework: CssFramework, + dataMode: DataMode, ) { private val renderer: TableRenderer = framework match { @@ -63,10 +73,72 @@ case class DatatablePlayground( (this, Cmd.None) case DatatableMsg.TableMsg(msg) => - (copy(tableState = tableState.update(msg)), Cmd.None) + dataMode match { + case DataMode.Client => + (copy(tableState = tableState.update(msg)), Cmd.None) + + case DataMode.Server => + val newState = tableState.update(msg) + // Check if this update requires fetching from server + if (needsServerFetch(msg)) { + val loadingState = newState.setLoading + (copy(tableState = loadingState), fetchFromServer(loadingState)) + } else { + (copy(tableState = newState), Cmd.None) + } + } case DatatableMsg.FrameworkSelected(newFramework) => (copy(framework = newFramework), Cmd.None) + + case DatatableMsg.DataModeSelected(mode) => + mode match { + case DataMode.Client => + // Switch to client mode with all data loaded locally + val clientState = TableState(DatatablePlayground.tableDef, DatatablePlayground.employees) + (copy(tableState = clientState, dataMode = DataMode.Client), Cmd.None) + + case DataMode.Server => + // Switch to server mode with empty data, trigger initial fetch + val serverState = TableState.serverMode(DatatablePlayground.tableDef).setLoading + (copy(tableState = serverState, dataMode = DataMode.Server), fetchFromServer(serverState)) + } + + case DatatableMsg.ServerDataReceived(data, totalCount) => + (copy(tableState = tableState.setServerData(data, totalCount)), Cmd.None) + + case DatatableMsg.ServerError(message) => + (copy(tableState = tableState.setError(message)), Cmd.None) + } + + // Determine if a TableUpdate requires a server fetch + private def needsServerFetch(msg: TableUpdate): Boolean = msg match { + case _: TableUpdate.SetFilter => true + case _: TableUpdate.ClearFilter => true + case TableUpdate.ClearAllFilters => true + case _: TableUpdate.SetSort => true + case _: TableUpdate.ToggleSort => true + case TableUpdate.ClearSort => true + case _: TableUpdate.SetPage => true + case TableUpdate.NextPage => true + case TableUpdate.PrevPage => true + case TableUpdate.FirstPage => true + case TableUpdate.LastPage => true + case _: TableUpdate.SetPageSize => true + case _: TableUpdate.SelectRow => false + case _: TableUpdate.DeselectRow => false + case _: TableUpdate.ToggleRowSelection => false + case TableUpdate.SelectAll => false + case TableUpdate.DeselectAll => false + case _: TableUpdate.SetData[?] => false + case TableUpdate.ExportCSV => false + } + + // Simulate fetching data from server with a delay + private def fetchFromServer(state: TableState[Employee]): Cmd[IO, DatatableMsg] = { + Cmd.Run( + DatatablePlayground.simulateServerFetch(state), + ) } def render: Html[DatatableMsg] = { @@ -84,6 +156,27 @@ case class DatatablePlayground( ), ), div(className := "level-right")( + // Data mode selector + div(className := "level-item")( + div(className := "field has-addons")( + div(className := "control")( + span(className := "button is-static is-small")("Data Mode"), + ), + div(className := "control")( + Html.div(`class` := "select is-small")( + Html.select( + id := "data-mode", + name := "data-mode", + onChange(value => DatatableMsg.DataModeSelected(DataMode.valueOf(value))), + )( + Html.option(value := DataMode.Client.toString, selected := (dataMode == DataMode.Client))("Client"), + Html.option(value := DataMode.Server.toString, selected := (dataMode == DataMode.Server))("Server"), + ), + ), + ), + ), + ), + // CSS framework selector div(className := "level-item")( Html.div(`class` := "select is-small")( Html.select( @@ -103,6 +196,13 @@ case class DatatablePlayground( ), ), ), + // Mode indicator + dataMode match { + case DataMode.Client => + div(className := "tag is-light mb-3")("Client-side filtering/sorting/pagination") + case DataMode.Server => + div(className := "tag is-warning mb-3")("Server-side filtering/sorting/pagination (simulated 300ms delay)") + }, hr(), tyrian.Tag( "css-separator", @@ -168,10 +268,88 @@ object DatatablePlayground { .withPageSize(10) .withSelection(multi = true) + /** Simulate server-side filtering, sorting, and pagination. This mimics what a real server would do - apply filters, sort, and return just the + * requested page. + */ + def simulateServerFetch(state: TableState[Employee]): IO[DatatableMsg] = { + IO.sleep(300.millis) *> IO { + // Start with all employees + var filtered = employees + + // Apply filters (server-side filtering simulation) + state.filters.foreach { case (columnId, filterState) => + columnId match { + case "name" => + filterState match { + case FilterState.TextValue(v) if v.nonEmpty => + filtered = filtered.filter(_.name.toLowerCase.contains(v.toLowerCase)) + case _ => () + } + case "email" => + filterState match { + case FilterState.TextValue(v) if v.nonEmpty => + filtered = filtered.filter(_.email.toLowerCase.contains(v.toLowerCase)) + case _ => () + } + case "department" => + filterState match { + case FilterState.SelectValue(Some(v)) => + filtered = filtered.filter(_.department == v) + case _ => () + } + case "salary" => + filterState match { + case FilterState.NumberRangeValue(min, max) => + min.foreach(m => filtered = filtered.filter(_.salary >= m)) + max.foreach(m => filtered = filtered.filter(_.salary <= m)) + case _ => () + } + case "hireDate" => + filterState match { + case FilterState.DateRangeValue(from, to) => + from.foreach(d => filtered = filtered.filter(!_.hireDate.isBefore(d))) + to.foreach(d => filtered = filtered.filter(!_.hireDate.isAfter(d))) + case _ => () + } + case "active" => + filterState match { + case FilterState.BooleanValue(Some(v)) => + filtered = filtered.filter(_.active == v) + case _ => () + } + case _ => () + } + } + + val totalFiltered = filtered.size + + // Apply sorting (server-side sorting simulation) + state.sort.foreach { case SortState(columnId, direction) => + val sorted = columnId match { + case "name" => filtered.sortBy(_.name) + case "email" => filtered.sortBy(_.email) + case "department" => filtered.sortBy(_.department) + case "salary" => filtered.sortBy(_.salary) + case "hireDate" => filtered.sortBy(_.hireDate) + case "active" => filtered.sortBy(_.active) + case _ => filtered + } + filtered = if (direction == SortDirection.Desc) sorted.reverse else sorted + } + + // Apply pagination (server-side pagination simulation) + val offset = state.page.currentPage * state.page.pageSize + val pageData = filtered.slice(offset, offset + state.page.pageSize) + + DatatableMsg.ServerDataReceived(pageData, totalFiltered) + }.handleError(e => DatatableMsg.ServerError(e.getMessage)) + } + def empty(): DatatablePlayground = { DatatablePlayground( tableState = TableState(tableDef, employees), framework = CssFramework.Bulma, + dataMode = DataMode.Client, ) } } diff --git a/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableExample.scala b/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableExample.scala index a545741..67ce801 100644 --- a/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableExample.scala +++ b/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableExample.scala @@ -65,3 +65,34 @@ val restoredState: TableState[Employee] = tableState.loadFromQueryString(querySt // Load state from params (e.g., from request object) val restoredState2: TableState[Employee] = tableState.loadFromQueryParams(queryParams) // end_query_params + +// start_server_mode +// 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") +// end_server_mode diff --git a/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/BootstrapTableRenderer.scala b/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/BootstrapTableRenderer.scala index 2bc5881..b7ae3e2 100644 --- a/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/BootstrapTableRenderer.scala +++ b/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/BootstrapTableRenderer.scala @@ -9,8 +9,43 @@ import tyrian.Html.* object BootstrapTableRenderer extends TableRenderer { override def renderTable[T](state: TableState[T]): Html[TableUpdate] = { - div(className := "card")( + val isLoading = state.loading == LoadingState.Loading + + val errorAlert = state.loading match { + case LoadingState.Failed(msg) => + div(className := "alert alert-danger alert-dismissible fade show", attribute("role", "alert"))( + text(s"Error: $msg"), + button(`type` := "button", className := "btn-close", attribute("data-bs-dismiss", "alert"))(), + ) + case _ => div()() + } + + val spinnerOverlay = + if (isLoading) + div( + styles( + "position" -> "absolute", + "top" -> "0", + "left" -> "0", + "right" -> "0", + "bottom" -> "0", + "background-color" -> "rgba(255, 255, 255, 0.7)", + "display" -> "flex", + "justify-content" -> "center", + "align-items" -> "center", + "z-index" -> "10", + ), + )( + div(className := "spinner-border text-primary", attribute("role", "status"))( + span(className := "visually-hidden")("Loading..."), + ), + ) + else div()() + + div(className := "card", styles("position" -> "relative"))( + spinnerOverlay, div(className := "card-body")( + errorAlert, div(className := "row mb-3")( div(className := "col-auto")(renderInfo(state)), div(className := "col-auto ms-auto")(renderPageSizeSelect(state)), @@ -18,7 +53,15 @@ object BootstrapTableRenderer extends TableRenderer { button( className := "btn btn-primary btn-sm", onClick(TableUpdate.ExportCSV), - )("Export CSV"), + disabled(isLoading), + )( + if (isLoading) + List( + span(className := "spinner-border spinner-border-sm me-1", attribute("role", "status"))(), + text("Export CSV"), + ) + else List(text("Export CSV")), + ), ), ), renderFilters(state), diff --git a/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/BulmaTableRenderer.scala b/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/BulmaTableRenderer.scala index 5d45487..48ed5b2 100644 --- a/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/BulmaTableRenderer.scala +++ b/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/BulmaTableRenderer.scala @@ -9,7 +9,19 @@ import tyrian.Html.* object BulmaTableRenderer extends TableRenderer { override def renderTable[T](state: TableState[T]): Html[TableUpdate] = { - div(className := "box")( + val isLoading = state.loading == LoadingState.Loading + + val errorNotification = state.loading match { + case LoadingState.Failed(msg) => + div(className := "notification is-danger is-light mb-4")( + button(className := "delete")(), + text(s"Error: $msg"), + ) + case _ => div()() + } + + val tableContent = div(className := "box")( + errorNotification, div(className := "level")( div(className := "level-left")( div(className := "level-item")(renderInfo(state)), @@ -18,8 +30,9 @@ object BulmaTableRenderer extends TableRenderer { div(className := "level-item")(renderPageSizeSelect(state)), div(className := "level-item")( button( - className := "button is-small is-primary", + className := s"button is-small is-primary${if (isLoading) " is-loading" else ""}", onClick(TableUpdate.ExportCSV), + disabled(isLoading), )("Export CSV"), ), ), @@ -33,6 +46,24 @@ object BulmaTableRenderer extends TableRenderer { ), renderPagination(state), ) + + if (isLoading) + div(styles("position" -> "relative"))( + tableContent, + div( + className := "is-overlay", + styles( + "background-color" -> "rgba(255, 255, 255, 0.7)", + "display" -> "flex", + "justify-content" -> "center", + "align-items" -> "center", + "z-index" -> "10", + ), + )( + div(className := "button is-loading is-large is-white")(), + ), + ) + else tableContent } override def renderFilters[T](state: TableState[T]): Html[TableUpdate] = { diff --git a/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/RawTableRenderer.scala b/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/RawTableRenderer.scala index 3f06b26..650fcdf 100644 --- a/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/RawTableRenderer.scala +++ b/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/RawTableRenderer.scala @@ -9,10 +9,27 @@ import tyrian.Html.* object RawTableRenderer extends TableRenderer { override def renderTable[T](state: TableState[T]): Html[TableUpdate] = { - div( + val isLoading = state.loading == LoadingState.Loading + + val errorMessage = state.loading match { + case LoadingState.Failed(msg) => + div(styles("color" -> "red", "padding" -> "10px", "margin-bottom" -> "10px", "border" -> "1px solid red"))( + s"Error: $msg", + ) + case _ => div()() + } + + val loadingIndicator = + if (isLoading) span(styles("margin-left" -> "10px"))("Loading...") else span()() + + val tableContent = div( + errorMessage, div( renderInfo(state), - button(onClick(TableUpdate.ExportCSV))("Export CSV"), + loadingIndicator, + button(onClick(TableUpdate.ExportCSV), disabled(isLoading))( + if (isLoading) "Exporting..." else "Export CSV", + ), ), renderFilters(state), Html.table( @@ -21,6 +38,27 @@ object RawTableRenderer extends TableRenderer { ), renderPagination(state), ) + + if (isLoading) + div(styles("position" -> "relative"))( + tableContent, + div( + styles( + "position" -> "absolute", + "top" -> "0", + "left" -> "0", + "right" -> "0", + "bottom" -> "0", + "background-color" -> "rgba(255, 255, 255, 0.7)", + "display" -> "flex", + "justify-content" -> "center", + "align-items" -> "center", + ), + )( + span(styles("font-size" -> "24px"))("Loading..."), + ), + ) + else tableContent } override def renderFilters[T](state: TableState[T]): Html[TableUpdate] = { diff --git a/website/docs/datatables/index.mdx b/website/docs/datatables/index.mdx index e16517c..2567ea3 100644 --- a/website/docs/datatables/index.mdx +++ b/website/docs/datatables/index.mdx @@ -76,4 +76,51 @@ Query parameter format: - `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. \ No newline at end of file +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 + +```scala file=./main/scala/forms4s/example/docs/DatatableExample.scala start=start_server_mode end=end_server_mode +``` + +### Tyrian Integration with Server Mode + +```scala +enum Msg: + case TableMsg(msg: TableUpdate) + case DataLoaded(data: Vector[Employee], totalCount: Int) + case DataFailed(error: String) + +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 (needsServerFetch(tableMsg)) { + 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) +} + +def fetchFromServer(state: TableState[Employee]): Cmd[IO, Msg] = { + val queryParams = state.toQueryParams + // Send queryParams to your API and return DataLoaded or DataFailed + Cmd.Run( + httpClient.get(s"/api/employees?${state.toQueryString}") + .map(response => Msg.DataLoaded(response.data, response.totalCount)) + .handleError(e => Msg.DataFailed(e.getMessage)) + ) +} +``` \ No newline at end of file From 3bdf595cddc8d7c7cdbc5fa93d59683d4665a30b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Pitu=C5=82a?= Date: Wed, 21 Jan 2026 08:29:00 +0100 Subject: [PATCH 2/4] review fixes --- .../example/docs/DatatableTyrianExample.scala | 34 +++++++++++++++++++ .../tyrian/datatable/RawTableRenderer.scala | 2 +- website/docs/datatables/index.mdx | 34 +------------------ 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala b/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala index 9f1d2a5..46fb784 100644 --- a/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala +++ b/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala @@ -33,3 +33,37 @@ object DatatableTyrianExample extends TyrianIOApp[TableMsg, TableModel] { def router: Location => TableMsg = _ => TableMsg.Update(TableUpdate.ClearAllFilters) } // end_tyrian + +// start_tyrian_server +enum Msg: + case TableMsg(msg: TableUpdate) + case DataLoaded(data: Vector[Employee], totalCount: Int) + case DataFailed(error: String) + +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 (needsServerFetch(tableMsg)) { + 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) +} + +def fetchFromServer(state: TableState[Employee]): Cmd[IO, Msg] = { + // Send queryParams to your API and return DataLoaded or DataFailed + Cmd.Run( + httpClient.get(s"/api/employees?${state.toQueryString}") + .map(response => Msg.DataLoaded(response.data, response.totalCount)) + .handleError(e => Msg.DataFailed(e.getMessage)) + ) +} +// end_tyrian_server diff --git a/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/RawTableRenderer.scala b/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/RawTableRenderer.scala index 650fcdf..cf28f47 100644 --- a/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/RawTableRenderer.scala +++ b/forms4s-tyrian/src/main/scala/forms4s/tyrian/datatable/RawTableRenderer.scala @@ -153,7 +153,7 @@ object RawTableRenderer extends TableRenderer { } override def renderInfo[T](state: TableState[T]): Html[TableUpdate] = { - val start = state.page.offset + 1 + val start = if (state.totalFilteredItems == 0) 0 else state.page.offset + 1 val end = math.min(state.page.offset + state.page.pageSize, state.totalFilteredItems) val total = state.totalFilteredItems val allTotal = state.data.size diff --git a/website/docs/datatables/index.mdx b/website/docs/datatables/index.mdx index 2567ea3..49fba67 100644 --- a/website/docs/datatables/index.mdx +++ b/website/docs/datatables/index.mdx @@ -90,37 +90,5 @@ For large datasets, you can delegate filtering, sorting, and pagination to the s ### Tyrian Integration with Server Mode -```scala -enum Msg: - case TableMsg(msg: TableUpdate) - case DataLoaded(data: Vector[Employee], totalCount: Int) - case DataFailed(error: String) - -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 (needsServerFetch(tableMsg)) { - 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) -} - -def fetchFromServer(state: TableState[Employee]): Cmd[IO, Msg] = { - val queryParams = state.toQueryParams - // Send queryParams to your API and return DataLoaded or DataFailed - Cmd.Run( - httpClient.get(s"/api/employees?${state.toQueryString}") - .map(response => Msg.DataLoaded(response.data, response.totalCount)) - .handleError(e => Msg.DataFailed(e.getMessage)) - ) -} +```scala file=./main/scala/forms4s/example/docs/DatatableTyrianExample.scala start=start_tyrian_server end=end_tyrian_server ``` \ No newline at end of file From 32fa01c9ab717c951b832d6a6ba9cf0e23a5527a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Pitu=C5=82a?= Date: Wed, 21 Jan 2026 09:01:06 +0100 Subject: [PATCH 3/4] fixed doc --- .../example/docs/DatatableTyrianExample.scala | 61 +++++++++---------- website/docs/datatables/index.mdx | 2 +- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala b/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala index 46fb784..e3a7ad6 100644 --- a/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala +++ b/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala @@ -34,36 +34,35 @@ object DatatableTyrianExample extends TyrianIOApp[TableMsg, TableModel] { } // end_tyrian -// start_tyrian_server -enum Msg: - case TableMsg(msg: TableUpdate) - case DataLoaded(data: Vector[Employee], totalCount: Int) - case DataFailed(error: String) - -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 (needsServerFetch(tableMsg)) { - 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) -} +// start_server_tyrian +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 needsServerFetch(msg: TableUpdate): Boolean = ??? + def fetchFromServer(state: TableState[Employee]): Cmd[IO, Msg] = ??? -def fetchFromServer(state: TableState[Employee]): Cmd[IO, Msg] = { - // Send queryParams to your API and return DataLoaded or DataFailed - Cmd.Run( - httpClient.get(s"/api/employees?${state.toQueryString}") - .map(response => Msg.DataLoaded(response.data, response.totalCount)) - .handleError(e => Msg.DataFailed(e.getMessage)) - ) + 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 (needsServerFetch(tableMsg)) { + 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) + } } -// end_tyrian_server +// end_server_tyrian diff --git a/website/docs/datatables/index.mdx b/website/docs/datatables/index.mdx index 49fba67..705bfe4 100644 --- a/website/docs/datatables/index.mdx +++ b/website/docs/datatables/index.mdx @@ -90,5 +90,5 @@ For large datasets, you can delegate filtering, sorting, and pagination to the s ### Tyrian Integration with Server Mode -```scala file=./main/scala/forms4s/example/docs/DatatableTyrianExample.scala start=start_tyrian_server end=end_tyrian_server +```scala file=./main/scala/forms4s/example/docs/DatatableTyrianExample.scala start=start_server_tyrian end=end_server_tyrian ``` \ No newline at end of file From a265eb5b2b81f3e18f2b9ed573ba64f57b151a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Pitu=C5=82a?= Date: Wed, 21 Jan 2026 09:08:27 +0100 Subject: [PATCH 4/4] include needsServerFetch --- .../scala/forms4s/datatable/TableUpdate.scala | 25 +++++++++++++++++++ .../components/DatatablePlayground.scala | 25 +------------------ .../example/docs/DatatableTyrianExample.scala | 3 +-- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/forms4s-core/src/main/scala/forms4s/datatable/TableUpdate.scala b/forms4s-core/src/main/scala/forms4s/datatable/TableUpdate.scala index f66e3e3..afaf7ba 100644 --- a/forms4s-core/src/main/scala/forms4s/datatable/TableUpdate.scala +++ b/forms4s-core/src/main/scala/forms4s/datatable/TableUpdate.scala @@ -33,4 +33,29 @@ enum TableUpdate { // Export case ExportCSV + + /** Returns true if this update affects server-side data (filtering, sorting, pagination) and thus requires fetching fresh data from the server in + * server-data mode. + */ + def needsServerFetch: Boolean = this match { + case _: SetFilter => true + case _: ClearFilter => true + case ClearAllFilters => true + case _: SetSort => true + case _: ToggleSort => true + case ClearSort => true + case _: SetPage => true + case NextPage => true + case PrevPage => true + case FirstPage => true + case LastPage => true + case _: SetPageSize => true + case _: SelectRow => false + case _: DeselectRow => false + case _: ToggleRowSelection => false + case SelectAll => false + case DeselectAll => false + case _: SetData[?] => false + case ExportCSV => false + } } diff --git a/forms4s-examples/src/main/scala/forms4s/example/components/DatatablePlayground.scala b/forms4s-examples/src/main/scala/forms4s/example/components/DatatablePlayground.scala index 4cf69be..1dda82d 100644 --- a/forms4s-examples/src/main/scala/forms4s/example/components/DatatablePlayground.scala +++ b/forms4s-examples/src/main/scala/forms4s/example/components/DatatablePlayground.scala @@ -80,7 +80,7 @@ case class DatatablePlayground( case DataMode.Server => val newState = tableState.update(msg) // Check if this update requires fetching from server - if (needsServerFetch(msg)) { + if (msg.needsServerFetch) { val loadingState = newState.setLoading (copy(tableState = loadingState), fetchFromServer(loadingState)) } else { @@ -111,29 +111,6 @@ case class DatatablePlayground( (copy(tableState = tableState.setError(message)), Cmd.None) } - // Determine if a TableUpdate requires a server fetch - private def needsServerFetch(msg: TableUpdate): Boolean = msg match { - case _: TableUpdate.SetFilter => true - case _: TableUpdate.ClearFilter => true - case TableUpdate.ClearAllFilters => true - case _: TableUpdate.SetSort => true - case _: TableUpdate.ToggleSort => true - case TableUpdate.ClearSort => true - case _: TableUpdate.SetPage => true - case TableUpdate.NextPage => true - case TableUpdate.PrevPage => true - case TableUpdate.FirstPage => true - case TableUpdate.LastPage => true - case _: TableUpdate.SetPageSize => true - case _: TableUpdate.SelectRow => false - case _: TableUpdate.DeselectRow => false - case _: TableUpdate.ToggleRowSelection => false - case TableUpdate.SelectAll => false - case TableUpdate.DeselectAll => false - case _: TableUpdate.SetData[?] => false - case TableUpdate.ExportCSV => false - } - // Simulate fetching data from server with a delay private def fetchFromServer(state: TableState[Employee]): Cmd[IO, DatatableMsg] = { Cmd.Run( diff --git a/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala b/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala index e3a7ad6..d296a21 100644 --- a/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala +++ b/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableTyrianExample.scala @@ -44,14 +44,13 @@ object ServerModeExample { case DataFailed(error: String) } - def needsServerFetch(msg: TableUpdate): Boolean = ??? 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 (needsServerFetch(tableMsg)) { + if (tableMsg.needsServerFetch) { val loadingState = newState.setLoading (model.copy(tableState = loadingState), fetchFromServer(loadingState)) } else {