diff --git a/build.sbt b/build.sbt index c4a21e8..307ef83 100644 --- a/build.sbt +++ b/build.sbt @@ -27,6 +27,9 @@ lazy val `forms4s-core` = crossProject(JSPlatform, JVMPlatform) "org.scalatest" %%% "scalatest" % "3.2.19" % "test", ), ) + .jsSettings( + libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % "2.6.0", + ) .settings(commonSettings) lazy val `forms4s-jsonschema` = crossProject(JSPlatform, JVMPlatform) diff --git a/forms4s-core/src/main/scala/forms4s/datatable/TableState.scala b/forms4s-core/src/main/scala/forms4s/datatable/TableState.scala index f7241f7..4fefcea 100644 --- a/forms4s-core/src/main/scala/forms4s/datatable/TableState.scala +++ b/forms4s-core/src/main/scala/forms4s/datatable/TableState.scala @@ -198,6 +198,44 @@ case class TableState[T]( def selectedItems: Vector[T] = { selection.toVector.sorted.flatMap(i => data.lift(i)) } + + /** Convert current state to URL query string. + * + * @return + * URL query string (without leading '?') + */ + def toQueryString: String = TableStateQueryParams.toQueryString(this) + + /** Convert current state to query parameters. + * + * @return + * Sequence of key-value pairs (supports repeated keys for multi-select) + */ + def toQueryParams: Seq[(String, String)] = TableStateQueryParams.toQueryParams(this) + + /** Load state from URL query string. + * + * @param queryString + * URL query string (with or without leading '?') + * @return + * Updated table state with applied parameters + */ + def loadFromQueryString(queryString: String): TableState[T] = { + val params = TableStateQueryParams.fromQueryString(queryString) + TableStateQueryParams.applyToState(this, params) + } + + /** Load state from query parameters. + * + * @param params + * Sequence of key-value pairs + * @return + * Updated table state with applied parameters + */ + def loadFromQueryParams(params: Seq[(String, String)]): TableState[T] = { + val parsed = TableStateQueryParams.fromQueryParams(params) + TableStateQueryParams.applyToState(this, parsed) + } } object TableState { diff --git a/forms4s-core/src/main/scala/forms4s/datatable/TableStateQueryParams.scala b/forms4s-core/src/main/scala/forms4s/datatable/TableStateQueryParams.scala new file mode 100644 index 0000000..55e676a --- /dev/null +++ b/forms4s-core/src/main/scala/forms4s/datatable/TableStateQueryParams.scala @@ -0,0 +1,348 @@ +package forms4s.datatable + +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.time.LocalDate + +import scala.util.Try + +/** Intermediate representation of parsed filter values from query parameters. + * + * These are raw parsed values before being converted to proper FilterState types. + */ +sealed trait ParsedFilterValue +object ParsedFilterValue { + case class SimpleValues(values: Seq[String]) extends ParsedFilterValue + case class RangeValue(min: Option[String], max: Option[String]) extends ParsedFilterValue +} + +/** Parsed table state from query parameters. + * + * This is an intermediate representation that can be applied to any TableState. + */ +case class TableStateParams( + filters: Map[String, ParsedFilterValue] = Map.empty, + sort: Option[SortState] = None, + page: Option[Int] = None, + pageSize: Option[Int] = None, +) + +/** Utilities for converting table state to/from URL query parameters. + * + * Query parameter format: + * - `sort={columnId}:{asc|desc}` - Sorting + * - `page={0-indexed}` - Current page + * - `size={number}` - Page size + * - `f.{columnId}={value}` - Text, select, or boolean filter + * - `f.{columnId}={value}` (repeated) - Multi-select filter + * - `f.{columnId}.min={value}` / `f.{columnId}.max={value}` - Range filter (number or date) + */ +object TableStateQueryParams { + + private val FilterPrefix = "f." + private val SortKey = "sort" + private val PageKey = "page" + private val SizeKey = "size" + private val MinSuffix = ".min" + private val MaxSuffix = ".max" + + /** Convert table state to a URL query string. + * + * @param state + * The table state to serialize + * @return + * URL query string (without leading '?'), URL-encoded + */ + def toQueryString[T](state: TableState[T]): String = { + val params = toQueryParams(state) + if (params.isEmpty) "" + else params.map { case (k, v) => s"${encode(k)}=${encode(v)}" }.mkString("&") + } + + /** Convert table state to query parameters. + * + * @param state + * The table state to serialize + * @return + * Sequence of key-value pairs (supports repeated keys for multi-select) + */ + def toQueryParams[T](state: TableState[T]): Seq[(String, String)] = { + val filterParams = state.filters.toSeq.flatMap { case (columnId, filterState) => + filterStateToParams(columnId, filterState) + } + + val sortParams = state.sort.toSeq.map { s => + SortKey -> s"${s.columnId}:${s.direction.toString.toLowerCase}" + } + + val pageParams = + if (state.page.currentPage > 0) Seq(PageKey -> state.page.currentPage.toString) + else Seq.empty + + val sizeParams = state.definition.pageSize match { + case defaultSize if state.page.pageSize != defaultSize => + Seq(SizeKey -> state.page.pageSize.toString) + case _ => Seq.empty + } + + sortParams ++ pageParams ++ sizeParams ++ filterParams + } + + /** Parse query string into table state parameters. + * + * @param queryString + * URL query string (with or without leading '?') + * @return + * Parsed parameters + */ + def fromQueryString(queryString: String): TableStateParams = { + val qs = queryString.stripPrefix("?") + if (qs.isEmpty) return TableStateParams() + + val params = qs + .split("&") + .filter(_.nonEmpty) + .flatMap { pair => + pair.split("=", 2) match { + case Array(k, v) => Some(decode(k) -> decode(v)) + case Array(k) => Some(decode(k) -> "") + case _ => None + } + } + .toSeq + + fromQueryParams(params) + } + + /** Parse query parameters into table state parameters. + * + * @param params + * Sequence of key-value pairs + * @return + * Parsed parameters + */ + def fromQueryParams(params: Seq[(String, String)]): TableStateParams = { + val grouped = params.groupBy(_._1).view.mapValues(_.map(_._2)).toMap + + val sort = grouped.get(SortKey).flatMap(_.headOption).flatMap(parseSortValue) + + val page = grouped.get(PageKey).flatMap(_.headOption).flatMap(_.toIntOption).filter(_ >= 0) + val pageSize = grouped.get(SizeKey).flatMap(_.headOption).flatMap(_.toIntOption).filter(_ > 0) + + val filters = parseFilters(grouped) + + TableStateParams(filters, sort, page, pageSize) + } + + /** Apply parsed parameters to an existing table state. + * + * Uses the TableDef to determine the correct FilterState type for each column. + * + * @param state + * The table state to update + * @param params + * The parsed parameters + * @return + * Updated table state + */ + def applyToState[T](state: TableState[T], params: TableStateParams): TableState[T] = { + var updated = state + + params.filters.foreach { case (columnId, parsedValue) => + val filterState = toFilterState(parsedValue, state.definition, columnId) + filterState.foreach { fs => + updated = updated.update(TableUpdate.SetFilter(columnId, fs)) + } + } + + params.sort.foreach { s => + updated = updated.update(TableUpdate.SetSort(s.columnId, s.direction)) + } + + params.pageSize.foreach { size => + updated = updated.update(TableUpdate.SetPageSize(size)) + } + + params.page.foreach { p => + updated = updated.update(TableUpdate.SetPage(p)) + } + + updated + } + + /** Convert a parsed filter value to the appropriate FilterState based on the column's filter type. + * + * Returns None if: + * - The column doesn't exist in the TableDef + * - The column has no filter defined + * - The parsed value type doesn't match what the filter expects + */ + private def toFilterState[T]( + parsed: ParsedFilterValue, + tableDef: TableDef[T], + columnId: String, + ): Option[FilterState] = { + val column = tableDef.columns.find(_.id == columnId) + val filterType = column.flatMap(_.filter) + def unexpectedValues = { + // We might want exception or log line here, but for now just ignore the values + None + } + + filterType match { + case Some(_: ColumnFilter.TextFilter[_]) => + parsed match { + case ParsedFilterValue.SimpleValues(values) => + values.headOption.map(FilterState.TextValue.apply) + case _: ParsedFilterValue.RangeValue => unexpectedValues + } + + case Some(_: ColumnFilter.SelectFilter[_]) => + parsed match { + case ParsedFilterValue.SimpleValues(values) => + values.headOption.map(v => FilterState.SelectValue(Some(v))) + case _: ParsedFilterValue.RangeValue => unexpectedValues + } + + case Some(_: ColumnFilter.MultiSelectFilter[_]) => + parsed match { + case ParsedFilterValue.SimpleValues(values) => + Some(FilterState.MultiSelectValue(values.toSet)) + case _: ParsedFilterValue.RangeValue => unexpectedValues + } + + case Some(_: ColumnFilter.BooleanFilter[_]) => + parsed match { + case ParsedFilterValue.SimpleValues(values) => + values.headOption.flatMap { v => + v.toLowerCase match { + case "true" => Some(FilterState.BooleanValue(Some(true))) + case "false" => Some(FilterState.BooleanValue(Some(false))) + case _ => unexpectedValues + } + } + case _: ParsedFilterValue.RangeValue => unexpectedValues + } + + case Some(_: ColumnFilter.NumberRangeFilter[_]) => + parsed match { + case ParsedFilterValue.RangeValue(min, max) => + Some(FilterState.NumberRangeValue(min.flatMap(_.toDoubleOption), max.flatMap(_.toDoubleOption))) + case _: ParsedFilterValue.SimpleValues => unexpectedValues + } + + case Some(_: ColumnFilter.DateRangeFilter[_]) => + parsed match { + case ParsedFilterValue.RangeValue(min, max) => + Some(FilterState.DateRangeValue(min.flatMap(parseDate), max.flatMap(parseDate))) + case _: ParsedFilterValue.SimpleValues => unexpectedValues + } + + case None => unexpectedValues // column not found or has no filter + } + } + + private def filterStateToParams(columnId: String, state: FilterState): Seq[(String, String)] = { + state match { + case FilterState.TextValue(v) if v.nonEmpty => + Seq(s"$FilterPrefix$columnId" -> v) + + case FilterState.SelectValue(Some(v)) => + Seq(s"$FilterPrefix$columnId" -> v) + + case FilterState.MultiSelectValue(selected) if selected.nonEmpty => + selected.toSeq.sorted.map(v => s"$FilterPrefix$columnId" -> v) + + case FilterState.BooleanValue(Some(b)) => + Seq(s"$FilterPrefix$columnId" -> b.toString) + + case FilterState.NumberRangeValue(min, max) => + val minParam = min.map(n => s"$FilterPrefix$columnId$MinSuffix" -> formatNumber(n)) + val maxParam = max.map(n => s"$FilterPrefix$columnId$MaxSuffix" -> formatNumber(n)) + (minParam.toSeq ++ maxParam.toSeq) + + case FilterState.DateRangeValue(from, to) => + val fromParam = from.map(d => s"$FilterPrefix$columnId$MinSuffix" -> d.toString) + val toParam = to.map(d => s"$FilterPrefix$columnId$MaxSuffix" -> d.toString) + (fromParam.toSeq ++ toParam.toSeq) + + case _ => Seq.empty + } + } + + private def parseSortValue(value: String): Option[SortState] = { + value.split(":", 2) match { + case Array(columnId, dir) => + val direction = dir.toLowerCase match { + case "asc" => Some(SortDirection.Asc) + case "desc" => Some(SortDirection.Desc) + case _ => None + } + direction.map(d => SortState(columnId, d)) + case _ => None + } + } + + private def parseFilters(grouped: Map[String, Seq[String]]): Map[String, ParsedFilterValue] = { + val filterParams = grouped.filter(_._1.startsWith(FilterPrefix)) + + val simpleValues = scala.collection.mutable.Map[String, Seq[String]]().withDefaultValue(Seq.empty) + val rangeMin = scala.collection.mutable.Map[String, String]() + val rangeMax = scala.collection.mutable.Map[String, String]() + + filterParams.foreach { case (key, values) => + val filterKey = key.stripPrefix(FilterPrefix) + + if (filterKey.endsWith(MinSuffix)) { + val columnId = filterKey.stripSuffix(MinSuffix) + values.headOption.foreach { v => + rangeMin(columnId) = v + } + } else if (filterKey.endsWith(MaxSuffix)) { + val columnId = filterKey.stripSuffix(MaxSuffix) + values.headOption.foreach { v => + rangeMax(columnId) = v + } + } else { + // Simple value (text, select, multi-select, or boolean - type determined later) + val columnId = filterKey + simpleValues(columnId) = simpleValues(columnId) ++ values + } + } + + val result = scala.collection.mutable.Map[String, ParsedFilterValue]() + + // Add simple values + simpleValues.foreach { case (columnId, values) => + if (values.nonEmpty) { + result(columnId) = ParsedFilterValue.SimpleValues(values) + } + } + + // Add ranges (type will be determined from TableDef) + val rangeColumns = rangeMin.keySet ++ rangeMax.keySet + rangeColumns.foreach { columnId => + result(columnId) = ParsedFilterValue.RangeValue( + rangeMin.get(columnId), + rangeMax.get(columnId), + ) + } + + result.toMap + } + + private def parseDate(s: String): Option[LocalDate] = + Try(LocalDate.parse(s)).toOption + + private def formatNumber(n: Double): String = { + if (n == n.toLong) n.toLong.toString + else n.toString + } + + private def encode(s: String): String = + URLEncoder.encode(s, StandardCharsets.UTF_8) + + private def decode(s: String): String = + URLDecoder.decode(s, StandardCharsets.UTF_8) +} diff --git a/forms4s-core/src/test/scala/forms4s/datatable/TableStateQueryParamsSpec.scala b/forms4s-core/src/test/scala/forms4s/datatable/TableStateQueryParamsSpec.scala new file mode 100644 index 0000000..b4d40fd --- /dev/null +++ b/forms4s-core/src/test/scala/forms4s/datatable/TableStateQueryParamsSpec.scala @@ -0,0 +1,386 @@ +package forms4s.datatable + +import org.scalatest.freespec.AnyFreeSpec + +import java.time.LocalDate + +class TableStateQueryParamsSpec extends AnyFreeSpec { + + case class Person(name: String, age: Int, department: String, active: Boolean, tags: Set[String], hireDate: LocalDate) + + val testData: Vector[Person] = Vector( + Person("Alice", 30, "Engineering", true, Set("dev", "lead"), LocalDate.of(2020, 3, 15)), + Person("Bob", 25, "Marketing", false, Set("marketing"), LocalDate.of(2019, 7, 22)), + Person("Carol", 35, "Engineering", true, Set("dev"), LocalDate.of(2018, 1, 10)), + Person("David", 28, "Sales", true, Set("sales", "lead"), LocalDate.of(2021, 5, 1)), + Person("Eve", 32, "Marketing", false, Set("marketing", "design"), LocalDate.of(2020, 11, 30)), + ) + + val tableDef: TableDef[Person] = TableDef( + id = "people", + columns = List( + Column[Person, String]("name", "Name", _.name).withFilter(ColumnFilter.text), + Column[Person, Int]("age", "Age", _.age, _.toString).withFilter(ColumnFilter.numberRange(a => Some(a.toDouble))), + Column[Person, String]("department", "Department", _.department).withFilter(ColumnFilter.select), + Column[Person, Boolean]("active", "Active", _.active, b => if (b) "Yes" else "No") + .withFilter(ColumnFilter.boolean(identity)), + Column[Person, Set[String]]("tags", "Tags", _.tags, _.mkString(", ")).withFilter(ColumnFilter.multiSelect(_.mkString(", "))), + Column[Person, LocalDate]("hireDate", "Hire Date", _.hireDate, _.toString).withFilter(ColumnFilter.dateRange(d => Some(d))), + ), + pageSize = 2, // Small page size to allow pagination tests + ) + + def initialState: TableState[Person] = TableState(tableDef, testData) + + "TableStateQueryParams" - { + "toQueryString" - { + "empty state produces empty string" in { + assert(initialState.toQueryString == "") + } + + "serializes sort" in { + val state = initialState.update(TableUpdate.SetSort("name", SortDirection.Asc)) + assert(state.toQueryString == "sort=name%3Aasc") + } + + "serializes sort descending" in { + val state = initialState.update(TableUpdate.SetSort("age", SortDirection.Desc)) + assert(state.toQueryString == "sort=age%3Adesc") + } + + "serializes page when not 0" in { + val state = initialState.update(TableUpdate.SetPage(2)) + assert(state.toQueryString == "page=2") + } + + "does not serialize page when 0" in { + val state = initialState.update(TableUpdate.SetPage(0)) + assert(state.toQueryString == "") + } + + "serializes page size when different from default" in { + val state = initialState.update(TableUpdate.SetPageSize(25)) + assert(state.toQueryString == "size=25") + } + + "does not serialize page size when same as default" in { + val state = initialState.update(TableUpdate.SetPageSize(2)) + assert(state.toQueryString == "") + } + + "serializes text filter" in { + val state = initialState.update(TableUpdate.SetFilter("name", FilterState.TextValue("alice"))) + assert(state.toQueryString == "f.name=alice") + } + + "serializes text filter with special characters" in { + val state = initialState.update(TableUpdate.SetFilter("name", FilterState.TextValue("John Doe"))) + assert(state.toQueryString == "f.name=John+Doe") + } + + "serializes select filter" in { + val state = initialState.update(TableUpdate.SetFilter("department", FilterState.SelectValue(Some("Engineering")))) + assert(state.toQueryString == "f.department=Engineering") + } + + "serializes multi-select filter" in { + val state = + initialState.update(TableUpdate.SetFilter("department", FilterState.MultiSelectValue(Set("Engineering", "Marketing")))) + val qs = state.toQueryString + assert(qs.contains("f.department=Engineering")) + assert(qs.contains("f.department=Marketing")) + } + + "serializes boolean filter true" in { + val state = initialState.update(TableUpdate.SetFilter("active", FilterState.BooleanValue(Some(true)))) + assert(state.toQueryString == "f.active=true") + } + + "serializes boolean filter false" in { + val state = initialState.update(TableUpdate.SetFilter("active", FilterState.BooleanValue(Some(false)))) + assert(state.toQueryString == "f.active=false") + } + + "serializes number range filter - min only" in { + val state = initialState.update(TableUpdate.SetFilter("age", FilterState.NumberRangeValue(Some(25), None))) + assert(state.toQueryString == "f.age.min=25") + } + + "serializes number range filter - max only" in { + val state = initialState.update(TableUpdate.SetFilter("age", FilterState.NumberRangeValue(None, Some(30)))) + assert(state.toQueryString == "f.age.max=30") + } + + "serializes number range filter - both" in { + val state = initialState.update(TableUpdate.SetFilter("age", FilterState.NumberRangeValue(Some(25), Some(30)))) + val qs = state.toQueryString + assert(qs.contains("f.age.min=25")) + assert(qs.contains("f.age.max=30")) + } + + "serializes date range filter - min only" in { + val state = initialState.update(TableUpdate.SetFilter("hireDate", FilterState.DateRangeValue(Some(LocalDate.of(2020, 1, 1)), None))) + assert(state.toQueryString == "f.hireDate.min=2020-01-01") + } + + "serializes date range filter - max only" in { + val state = initialState.update(TableUpdate.SetFilter("hireDate", FilterState.DateRangeValue(None, Some(LocalDate.of(2021, 12, 31))))) + assert(state.toQueryString == "f.hireDate.max=2021-12-31") + } + + "serializes date range filter - both" in { + val state = initialState.update( + TableUpdate.SetFilter("hireDate", FilterState.DateRangeValue(Some(LocalDate.of(2020, 1, 1)), Some(LocalDate.of(2021, 12, 31)))), + ) + val qs = state.toQueryString + assert(qs.contains("f.hireDate.min=2020-01-01")) + assert(qs.contains("f.hireDate.max=2021-12-31")) + } + + "serializes complex state" in { + val state = initialState + .update(TableUpdate.SetSort("name", SortDirection.Desc)) + .update(TableUpdate.SetPageSize(3)) // 5 items / 3 = 2 pages + .update(TableUpdate.SetPage(1)) + val qs = state.toQueryString + assert(qs.contains("sort=name%3Adesc")) + assert(qs.contains("page=1")) + assert(qs.contains("size=3")) + } + + "serializes filters combined with other state" in { + val state = initialState + .update(TableUpdate.SetSort("name", SortDirection.Desc)) + .update(TableUpdate.SetFilter("name", FilterState.TextValue("a"))) + .update(TableUpdate.SetFilter("active", FilterState.BooleanValue(Some(true)))) + val qs = state.toQueryString + assert(qs.contains("sort=name%3Adesc")) + assert(qs.contains("f.name=a")) + assert(qs.contains("f.active=true")) + } + } + + "fromQueryString" - { + "empty string produces empty params" in { + val params = TableStateQueryParams.fromQueryString("") + assert(params == TableStateParams()) + } + + "handles leading question mark" in { + val params = TableStateQueryParams.fromQueryString("?page=1") + assert(params.page == Some(1)) + } + + "parses sort" in { + val params = TableStateQueryParams.fromQueryString("sort=name:asc") + assert(params.sort == Some(SortState("name", SortDirection.Asc))) + } + + "parses sort descending" in { + val params = TableStateQueryParams.fromQueryString("sort=age:desc") + assert(params.sort == Some(SortState("age", SortDirection.Desc))) + } + + "parses page" in { + val params = TableStateQueryParams.fromQueryString("page=2") + assert(params.page == Some(2)) + } + + "parses page size" in { + val params = TableStateQueryParams.fromQueryString("size=25") + assert(params.pageSize == Some(25)) + } + + "parses simple filter value" in { + val params = TableStateQueryParams.fromQueryString("f.name=alice") + assert(params.filters("name") == ParsedFilterValue.SimpleValues(Seq("alice"))) + } + + "parses multi-value filter from repeated params" in { + val params = TableStateQueryParams.fromQueryString("f.department=Engineering&f.department=Marketing") + assert(params.filters("department") == ParsedFilterValue.SimpleValues(Seq("Engineering", "Marketing"))) + } + + "parses range filter" in { + val params = TableStateQueryParams.fromQueryString("f.age.min=25&f.age.max=30") + assert(params.filters("age") == ParsedFilterValue.RangeValue(Some("25"), Some("30"))) + } + + "parses date range filter" in { + val params = TableStateQueryParams.fromQueryString("f.hireDate.min=2020-01-01&f.hireDate.max=2021-12-31") + assert(params.filters("hireDate") == ParsedFilterValue.RangeValue(Some("2020-01-01"), Some("2021-12-31"))) + } + + "handles URL-encoded values" in { + val params = TableStateQueryParams.fromQueryString("f.name=John%20Doe") + assert(params.filters("name") == ParsedFilterValue.SimpleValues(Seq("John Doe"))) + } + + "handles invalid sort gracefully" in { + val params = TableStateQueryParams.fromQueryString("sort=invalid") + assert(params.sort.isEmpty) + } + + "handles invalid page gracefully" in { + val params = TableStateQueryParams.fromQueryString("page=abc") + assert(params.page.isEmpty) + } + + "handles negative page gracefully" in { + val params = TableStateQueryParams.fromQueryString("page=-1") + assert(params.page.isEmpty) + } + } + + "roundtrip" - { + "text filter roundtrip" in { + val state = initialState.update(TableUpdate.SetFilter("name", FilterState.TextValue("alice"))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("name") == FilterState.TextValue("alice")) + } + + "text filter with special characters roundtrip" in { + val state = initialState.update(TableUpdate.SetFilter("name", FilterState.TextValue("O'Brien & Co."))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("name") == FilterState.TextValue("O'Brien & Co.")) + } + + "boolean filter roundtrip" in { + val state = initialState.update(TableUpdate.SetFilter("active", FilterState.BooleanValue(Some(true)))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("active") == FilterState.BooleanValue(Some(true))) + } + + "number range filter roundtrip" in { + val state = initialState.update(TableUpdate.SetFilter("age", FilterState.NumberRangeValue(Some(25), Some(30)))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("age") == FilterState.NumberRangeValue(Some(25.0), Some(30.0))) + } + + "date range filter roundtrip" in { + val from = LocalDate.of(2020, 1, 1) + val to = LocalDate.of(2021, 12, 31) + val state = initialState.update(TableUpdate.SetFilter("hireDate", FilterState.DateRangeValue(Some(from), Some(to)))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("hireDate") == FilterState.DateRangeValue(Some(from), Some(to))) + } + + "sort roundtrip" in { + val state = initialState.update(TableUpdate.SetSort("name", SortDirection.Desc)) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.sort == Some(SortState("name", SortDirection.Desc))) + } + + "pagination roundtrip" in { + // Default page size is 2, so with 5 items we have 3 pages (0, 1, 2) + val state = initialState.update(TableUpdate.SetPage(2)) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.page.currentPage == 2) + assert(newState.page.pageSize == 2) + } + + "complex state roundtrip" in { + val state = initialState + .update(TableUpdate.SetSort("name", SortDirection.Desc)) + .update(TableUpdate.SetPageSize(3)) // 5 items / 3 = 2 pages + .update(TableUpdate.SetPage(1)) + .update(TableUpdate.SetFilter("name", FilterState.TextValue("a"))) + .update(TableUpdate.SetFilter("active", FilterState.BooleanValue(Some(true)))) + .update(TableUpdate.SetFilter("age", FilterState.NumberRangeValue(Some(20), Some(40)))) + + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + + assert(newState.sort == state.sort) + assert(newState.page.currentPage == state.page.currentPage) + assert(newState.page.pageSize == state.page.pageSize) + assert(newState.filters("name") == state.filters("name")) + assert(newState.filters("active") == state.filters("active")) + assert(newState.filters("age") == FilterState.NumberRangeValue(Some(20.0), Some(40.0))) + } + } + + "toQueryParams and fromQueryParams" - { + "multi-select filter with toQueryParams" in { + val state = initialState.update(TableUpdate.SetFilter("department", FilterState.MultiSelectValue(Set("A", "B", "C")))) + val params = state.toQueryParams + val deptParams = params.filter(_._1 == "f.department").map(_._2).toSet + assert(deptParams == Set("A", "B", "C")) + } + + "fromQueryParams handles repeated keys" in { + val params = Seq( + "f.department" -> "Engineering", + "f.department" -> "Marketing", + "f.department" -> "Sales", + ) + val parsed = TableStateQueryParams.fromQueryParams(params) + assert(parsed.filters("department") == ParsedFilterValue.SimpleValues(Seq("Engineering", "Marketing", "Sales"))) + } + } + + "loadFromQueryParams extension" - { + "applies params to state using column filter type" in { + val params = Seq("sort" -> "name:asc", "page" -> "1", "f.active" -> "true") + val newState = initialState.loadFromQueryParams(params) + assert(newState.sort == Some(SortState("name", SortDirection.Asc))) + assert(newState.page.currentPage == 1) + // The BooleanValue is determined by the column's filter type in TableDef + assert(newState.filters("active") == FilterState.BooleanValue(Some(true))) + } + } + + "filter type inference from TableDef" - { + "text filter roundtrip preserves type" in { + val state = initialState.update(TableUpdate.SetFilter("name", FilterState.TextValue("alice"))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("name") == FilterState.TextValue("alice")) + } + + "select filter roundtrip preserves type" in { + val state = initialState.update(TableUpdate.SetFilter("department", FilterState.SelectValue(Some("Engineering")))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("department") == FilterState.SelectValue(Some("Engineering"))) + } + + "multi-select filter roundtrip preserves type" in { + val state = initialState.update(TableUpdate.SetFilter("tags", FilterState.MultiSelectValue(Set("dev", "lead")))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("tags") == FilterState.MultiSelectValue(Set("dev", "lead"))) + } + + "boolean filter roundtrip preserves type" in { + val state = initialState.update(TableUpdate.SetFilter("active", FilterState.BooleanValue(Some(true)))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("active") == FilterState.BooleanValue(Some(true))) + } + + "number range filter roundtrip preserves type" in { + val state = initialState.update(TableUpdate.SetFilter("age", FilterState.NumberRangeValue(Some(25), Some(35)))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("age") == FilterState.NumberRangeValue(Some(25.0), Some(35.0))) + } + + "date range filter roundtrip preserves type" in { + val from = LocalDate.of(2019, 1, 1) + val to = LocalDate.of(2022, 12, 31) + val state = initialState.update(TableUpdate.SetFilter("hireDate", FilterState.DateRangeValue(Some(from), Some(to)))) + val qs = state.toQueryString + val newState = initialState.loadFromQueryString(qs) + assert(newState.filters("hireDate") == FilterState.DateRangeValue(Some(from), Some(to))) + } + } + } +} 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 9481e9d..a545741 100644 --- a/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableExample.scala +++ b/forms4s-examples/src/main/scala/forms4s/example/docs/DatatableExample.scala @@ -48,3 +48,20 @@ val tableState: TableState[Employee] = TableState(tableDef, employees) // start_export val csv: String = TableExport.toCSV(tableState) // end_export + +// start_query_params +// 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) +// end_query_params diff --git a/website/docs/datatables/index.mdx b/website/docs/datatables/index.mdx index cee463f..e16517c 100644 --- a/website/docs/datatables/index.mdx +++ b/website/docs/datatables/index.mdx @@ -60,3 +60,20 @@ Available renderers: ``` Export respects current filters, sorting, and selection. + +## URL Query Parameters + +Persist and share table state via URL query parameters: + +```scala file=./main/scala/forms4s/example/docs/DatatableExample.scala start=start_query_params end=end_query_params +``` + +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. \ No newline at end of file