-
Notifications
You must be signed in to change notification settings - Fork 4
Datatables #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Datatables #11
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
41 changes: 41 additions & 0 deletions
41
forms4s-core/src/main/scala/forms4s/datatable/Column.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
156
forms4s-core/src/main/scala/forms4s/datatable/ColumnFilter.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}") | ||
| } | ||
| } | ||
|
|
||
| /** 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
41
forms4s-core/src/main/scala/forms4s/datatable/FilterState.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
15
forms4s-core/src/main/scala/forms4s/datatable/SortDirection.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
41
forms4s-core/src/main/scala/forms4s/datatable/TableDef.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
75
forms4s-core/src/main/scala/forms4s/datatable/TableExport.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.