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
41 changes: 41 additions & 0 deletions forms4s-core/src/main/scala/forms4s/datatable/Column.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package forms4s.datatable

/** Column definition with type-safe value extraction.
*
* @tparam T
* Row type
* @tparam V
* Value type for this column
*/
case class Column[T, V](
id: String,
label: String,
extract: T => V,
render: V => String = (v: V) => String.valueOf(v),
sortable: Boolean = true,
filter: Option[ColumnFilter[V]] = None,
sortBy: Option[Ordering[V]] = None,
) {

/** Create a copy with a different filter */
def withFilter(f: ColumnFilter[V]): Column[T, V] = copy(filter = Some(f))

/** Create a copy with custom rendering */
def withRender(r: V => String): Column[T, V] = copy(render = r)

/** Create a copy with custom sorting */
def withSort(ord: Ordering[V]): Column[T, V] = copy(sortBy = Some(ord))

/** Disable sorting */
def noSort: Column[T, V] = copy(sortable = false)

/** Change the label */
def withLabel(newLabel: String): Column[T, V] = copy(label = newLabel)
}

object Column {

/** Create a column with default rendering */
def apply[T, V](id: String, label: String, extract: T => V): Column[T, V] =
new Column(id, label, extract)
}
156 changes: 156 additions & 0 deletions forms4s-core/src/main/scala/forms4s/datatable/ColumnFilter.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package forms4s.datatable

import java.time.LocalDate

/** Filter type enumeration for rendering purposes */
enum FilterType {
case Text
case Select
case MultiSelect
case DateRange
case NumberRange
case Boolean
}

/** Filter types for columns. Each filter type defines matching logic.
*/
sealed trait ColumnFilter[V] {

/** The filter type identifier for rendering */
def filterType: FilterType

/** Test if a value matches this filter's current state */
def matches(value: V, state: FilterState): Boolean

/** Get the empty state for this filter type */
def emptyState: FilterState
}

object ColumnFilter {

/** Free text filter - matches if rendered value contains the search string (case-insensitive).
*/
case class TextFilter[V](
render: V => String = (v: V) => String.valueOf(v),
caseSensitive: Boolean = false,
) extends ColumnFilter[V] {
def filterType: FilterType = FilterType.Text
def emptyState: FilterState = FilterState.emptyText

def matches(value: V, state: FilterState): Boolean = state match {
case FilterState.TextValue(search) if search.nonEmpty =>
val rendered = render(value)
if (caseSensitive) rendered.contains(search)
else rendered.toLowerCase.contains(search.toLowerCase)
case FilterState.TextValue(_) => true
case other =>
throw new IllegalArgumentException(s"TextFilter received invalid state type: ${other.getClass.getSimpleName}")
}
}

/** Select filter - dropdown with all unique values from the data. Options are computed dynamically from data.
*/
case class SelectFilter[V](
render: V => String = (v: V) => String.valueOf(v),
) extends ColumnFilter[V] {
def filterType: FilterType = FilterType.Select
def emptyState: FilterState = FilterState.emptySelect

def matches(value: V, state: FilterState): Boolean = state match {
case FilterState.SelectValue(Some(selected)) => render(value) == selected
case FilterState.SelectValue(None) => true
case other =>
throw new IllegalArgumentException(s"SelectFilter received invalid state type: ${other.getClass.getSimpleName}")
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Multi-select filter - allows selecting multiple values.
*/
case class MultiSelectFilter[V](
render: V => String = (v: V) => String.valueOf(v),
) extends ColumnFilter[V] {
def filterType: FilterType = FilterType.MultiSelect
def emptyState: FilterState = FilterState.emptyMultiSelect

def matches(value: V, state: FilterState): Boolean = state match {
case FilterState.MultiSelectValue(selected) if selected.nonEmpty =>
selected.contains(render(value))
case FilterState.MultiSelectValue(_) => true
case other =>
throw new IllegalArgumentException(s"MultiSelectFilter received invalid state type: ${other.getClass.getSimpleName}")
}
}

/** Date range filter - filters values between from and to dates.
*/
case class DateRangeFilter[V](
extract: V => Option[LocalDate],
) extends ColumnFilter[V] {
def filterType: FilterType = FilterType.DateRange
def emptyState: FilterState = FilterState.emptyDateRange

def matches(value: V, state: FilterState): Boolean = state match {
case FilterState.DateRangeValue(from, to) if from.isDefined || to.isDefined =>
extract(value) match {
case None => true // No date = no filter
case Some(date) =>
val afterFrom = from.forall(f => !date.isBefore(f))
val beforeTo = to.forall(t => !date.isAfter(t))
afterFrom && beforeTo
}
case FilterState.DateRangeValue(_, _) => true
case other =>
throw new IllegalArgumentException(s"DateRangeFilter received invalid state type: ${other.getClass.getSimpleName}")
}
}

/** Number range filter - filters numeric values between min and max.
*/
case class NumberRangeFilter[V](
extract: V => Option[Double],
) extends ColumnFilter[V] {
def filterType: FilterType = FilterType.NumberRange
def emptyState: FilterState = FilterState.emptyNumberRange

def matches(value: V, state: FilterState): Boolean = state match {
case FilterState.NumberRangeValue(min, max) if min.isDefined || max.isDefined =>
extract(value) match {
case None => true
case Some(num) =>
val aboveMin = min.forall(m => num >= m)
val belowMax = max.forall(m => num <= m)
aboveMin && belowMax
}
case FilterState.NumberRangeValue(_, _) => true
case other =>
throw new IllegalArgumentException(s"NumberRangeFilter received invalid state type: ${other.getClass.getSimpleName}")
}
}

/** Boolean filter - filters true/false/all.
*/
case class BooleanFilter[V](
extract: V => Boolean,
) extends ColumnFilter[V] {
def filterType: FilterType = FilterType.Boolean
def emptyState: FilterState = FilterState.emptyBoolean

def matches(value: V, state: FilterState): Boolean = state match {
case FilterState.BooleanValue(Some(expected)) => extract(value) == expected
case FilterState.BooleanValue(None) => true
case other =>
throw new IllegalArgumentException(s"BooleanFilter received invalid state type: ${other.getClass.getSimpleName}")
}
}

// Convenience constructors
def text[V]: TextFilter[V] = TextFilter[V]()
def text[V](render: V => String): TextFilter[V] = TextFilter[V](render)
def select[V]: SelectFilter[V] = SelectFilter[V]()
def select[V](render: V => String): SelectFilter[V] = SelectFilter[V](render)
def multiSelect[V]: MultiSelectFilter[V] = MultiSelectFilter[V]()
def multiSelect[V](render: V => String): MultiSelectFilter[V] = MultiSelectFilter[V](render)
def dateRange[V](extract: V => Option[LocalDate]): DateRangeFilter[V] = DateRangeFilter(extract)
def numberRange[V](extract: V => Option[Double]): NumberRangeFilter[V] = NumberRangeFilter(extract)
def boolean[V](extract: V => Boolean): BooleanFilter[V] = BooleanFilter(extract)
}
41 changes: 41 additions & 0 deletions forms4s-core/src/main/scala/forms4s/datatable/FilterState.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package forms4s.datatable

import java.time.LocalDate

/** Filter state - the actual runtime value of a filter */
sealed trait FilterState {
def isEmpty: Boolean
}

object FilterState {
case class TextValue(value: String) extends FilterState {
def isEmpty: Boolean = value.isEmpty
}

case class SelectValue(selected: Option[String]) extends FilterState {
def isEmpty: Boolean = selected.isEmpty
}

case class MultiSelectValue(selected: Set[String]) extends FilterState {
def isEmpty: Boolean = selected.isEmpty
}

case class DateRangeValue(from: Option[LocalDate], to: Option[LocalDate]) extends FilterState {
def isEmpty: Boolean = from.isEmpty && to.isEmpty
}

case class NumberRangeValue(min: Option[Double], max: Option[Double]) extends FilterState {
def isEmpty: Boolean = min.isEmpty && max.isEmpty
}

case class BooleanValue(value: Option[Boolean]) extends FilterState {
def isEmpty: Boolean = value.isEmpty
}

def emptyText: TextValue = TextValue("")
def emptySelect: SelectValue = SelectValue(None)
def emptyMultiSelect: MultiSelectValue = MultiSelectValue(Set.empty)
def emptyDateRange: DateRangeValue = DateRangeValue(None, None)
def emptyNumberRange: NumberRangeValue = NumberRangeValue(None, None)
def emptyBoolean: BooleanValue = BooleanValue(None)
}
15 changes: 15 additions & 0 deletions forms4s-core/src/main/scala/forms4s/datatable/SortDirection.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package forms4s.datatable

enum SortDirection {
case Asc, Desc

def toggle: SortDirection = this match {
case Asc => Desc
case Desc => Asc
}

def symbol: String = this match {
case Asc => "▲"
case Desc => "▼"
}
}
41 changes: 41 additions & 0 deletions forms4s-core/src/main/scala/forms4s/datatable/TableDef.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package forms4s.datatable

/** Static table definition - the structure of a datatable.
*
* @tparam T
* The row type
*/
case class TableDef[T](
id: String,
columns: List[Column[T, ?]],
pageSize: Int = 10,
pageSizeOptions: List[Int] = List(10, 25, 50, 100),
selectable: Boolean = false,
multiSelect: Boolean = false,
) {

/** Builder-style methods */
def withPageSize(size: Int): TableDef[T] = copy(pageSize = size)
def withPageSizeOptions(opts: List[Int]): TableDef[T] = copy(pageSizeOptions = opts)
def withSelection(multi: Boolean = false): TableDef[T] = copy(selectable = true, multiSelect = multi)

/** Add a column */
def addColumn[V](col: Column[T, V]): TableDef[T] = copy(columns = columns :+ col)

/** Remove a column by id */
def removeColumn(id: String): TableDef[T] = copy(columns = columns.filterNot(_.id == id))

/** Modify a column by id */
def modifyColumn(id: String)(f: Column[T, ?] => Column[T, ?]): TableDef[T] =
copy(columns = columns.map(c => if (c.id == id) f(c) else c))
}

object TableDef {

/** Create an empty table definition */
def apply[T](id: String): TableDef[T] = TableDef(id, columns = Nil)

/** Create a table definition with columns */
def apply[T](id: String, columns: List[Column[T, ?]]): TableDef[T] =
new TableDef(id, columns)
}
75 changes: 75 additions & 0 deletions forms4s-core/src/main/scala/forms4s/datatable/TableExport.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package forms4s.datatable

/** Export functionality for tables.
*/
object TableExport {

/** Export table data to CSV format.
*
* @param state
* The table state to export
* @param includeHeaders
* Whether to include column headers
* @param exportFiltered
* Whether to export only filtered data or all data
* @param delimiter
* CSV delimiter (default comma)
*/
def toCSV[T](
state: TableState[T],
includeHeaders: Boolean = true,
exportFiltered: Boolean = true,
delimiter: String = ",",
): String = {
val columns = state.definition.columns
val data = if (exportFiltered) state.filteredData else state.data

val sb = new StringBuilder

// Headers
if (includeHeaders) {
sb.append(columns.map(c => escapeCSV(c.label, delimiter)).mkString(delimiter))
sb.append("\n")
}

// Data rows
data.foreach { row =>
val values = columns.map { col =>
val value = col.extract(row)
escapeCSV(col.render(value), delimiter)
}
sb.append(values.mkString(delimiter))
sb.append("\n")
}

sb.toString
}

/** Export selected rows only.
*/
def selectedToCSV[T](state: TableState[T], delimiter: String = ","): String = {
val columns = state.definition.columns
val data = state.selectedItems

val sb = new StringBuilder
sb.append(columns.map(c => escapeCSV(c.label, delimiter)).mkString(delimiter))
sb.append("\n")

data.foreach { row =>
val values = columns.map { col =>
val value = col.extract(row)
escapeCSV(col.render(value), delimiter)
}
sb.append(values.mkString(delimiter))
sb.append("\n")
}

sb.toString
}

private def escapeCSV(value: String, delimiter: String): String = {
if (value.contains(delimiter) || value.contains("\"") || value.contains("\n"))
"\"" + value.replace("\"", "\"\"") + "\""
else value
}
}
Loading