Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package forms4s.datatable

/** Loading state for server-side data fetching.
*/
enum LoadingState {
case Idle
case Loading
case Failed(message: String)
}
74 changes: 67 additions & 7 deletions forms4s-core/src/main/scala/forms4s/datatable/TableState.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -236,19 +248,67 @@ 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,
data = data.toVector,
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,
)
}
25 changes: 25 additions & 0 deletions forms4s-core/src/main/scala/forms4s/datatable/TableUpdate.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
100 changes: 100 additions & 0 deletions forms4s-core/src/test/scala/forms4s/datatable/TableStateSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
}
}
}
Loading