waveterm/tsunami/ui/table.go
Mike Sawka e7cd584659
tsunami framework (waveapps v2) (#2315)
Huge PR.  135 commits here to rebuild waveapps into the "Tsunami" framework.

* Simplified API
* Updated system.md prompt
* Basic applications building and running
* /api/config and /api/data support
* tailwind styling
* no need for async updates
* goroutine/timer primitives for async routing handling
* POC for integrating 3rd party react frameworks (recharts)
* POC for server side components (table.go)
* POC for interacting with apps via /api/config (tsunamiconfig)

Checkpoint.  Still needs to be tightly integrated with Wave (lifecycle, AI interaction, etc.) but looking very promising 🚀
2025-09-11 14:25:07 -07:00

545 lines
15 KiB
Go

package ui
import (
"fmt"
"reflect"
"sort"
"strconv"
"github.com/wavetermdev/waveterm/tsunami/app"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// Core table types
type CellContext[T any] struct {
Data T `json:"row"`
Value any `json:"value"`
RowIdx int `json:"rowIdx"`
ColIdx int `json:"colIdx"`
Column string `json:"column"`
}
type RowContext[T any] struct {
Data T `json:"row"`
RowIdx int `json:"rowIdx"`
}
type HeaderContext struct {
Column string `json:"column"`
IsSorted bool `json:"isSorted"`
SortDirection string `json:"sortDirection"`
}
// Column definition - similar to TanStack Table
type TableColumn[T any] struct {
AccessorKey string `json:"accessorKey"` // Field name in the data
AccessorFn func(rowCtx RowContext[T]) any `json:"-"` // Function to extract value from row
Header string `json:"header"` // Display name
Width string `json:"width,omitempty"`
Sortable bool `json:"sortable"`
CellClassName string
HeaderClassName string
CellRender func(ctx CellContext[T]) any `json:"-"` // Custom cell renderer
HeaderRender func(ctx HeaderContext) any `json:"-"` // Custom header renderer
}
// Table props
type TableProps[T any] struct {
Data []T `json:"data"`
Columns []TableColumn[T] `json:"columns"`
RowRender func(ctx RowContext[T]) any `json:"-"` // Custom row renderer (overrides columns)
RowClassName func(ctx RowContext[T]) string `json:"-"` // Custom row class names
OnRowClick func(row T, idx int) `json:"-"`
OnSort func(column string, direction string) `json:"-"`
DefaultSort string `json:"defaultSort,omitempty"`
Pagination *PaginationConfig `json:"pagination,omitempty"`
Selectable bool `json:"selectable"`
SelectedRows []int `json:"selectedRows,omitempty"`
OnSelectionChange func(selectedRows []int) `json:"-"`
}
type PaginationConfig struct {
PageSize int `json:"pageSize"`
CurrentPage int `json:"currentPage"`
ShowSizes []int `json:"showSizes,omitempty"` // [10, 25, 50, 100]
}
// Helper to extract field value from struct/map using reflection
func getFieldValueWithReflection(item any, key string) any {
if item == nil {
return nil
}
// Handle map[string]any
if m, ok := item.(map[string]any); ok {
return m[key]
}
// Handle struct via reflection
v := reflect.ValueOf(item)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return nil
}
field := v.FieldByName(key)
if !field.IsValid() {
return nil
}
return field.Interface()
}
// Generic helper to extract field value using either AccessorFn or reflection
func getFieldValue[T any](row T, rowIdx int, col TableColumn[T]) any {
if col.AccessorFn != nil {
return col.AccessorFn(RowContext[T]{
Data: row,
RowIdx: rowIdx,
})
}
return getFieldValueWithReflection(row, col.AccessorKey)
}
// Helper to find column by accessor key
func findColumnByKey[T any](columns []TableColumn[T], key string) *TableColumn[T] {
for _, col := range columns {
if col.AccessorKey == key {
return &col
}
}
return nil
}
// Sort data by column
func sortData[T any](data []T, col TableColumn[T], direction string) []T {
if len(data) == 0 || (col.AccessorKey == "" && col.AccessorFn == nil) {
return data
}
sorted := make([]T, len(data))
copy(sorted, data)
sort.Slice(sorted, func(i, j int) bool {
valI := getFieldValue(sorted[i], i, col)
valJ := getFieldValue(sorted[j], j, col)
// Handle nil values
if valI == nil && valJ == nil {
return false
}
if valI == nil {
return direction == "asc"
}
if valJ == nil {
return direction == "desc"
}
// Convert to strings for comparison (could be enhanced for numbers/dates)
strI := fmt.Sprintf("%v", valI)
strJ := fmt.Sprintf("%v", valJ)
if direction == "asc" {
return strI < strJ
}
return strI > strJ
})
return sorted
}
// Paginate data
func paginateData[T any](data []T, config *PaginationConfig) []T {
if config == nil || len(data) == 0 {
return data
}
start := config.CurrentPage * config.PageSize
end := start + config.PageSize
if start >= len(data) {
return []T{}
}
if end > len(data) {
end = len(data)
}
return data[start:end]
}
// Default cell renderer
func defaultCellRenderer[T any](ctx CellContext[T]) any {
if ctx.Value == nil {
return vdom.H("span", map[string]any{
"className": "text-gray-500",
}, "-")
}
return vdom.H("span", nil, fmt.Sprintf("%v", ctx.Value))
}
// Default header renderer
func defaultHeaderRenderer(ctx HeaderContext) any {
return vdom.H("div", map[string]any{
"className": "flex items-center gap-2",
},
vdom.H("span", nil, ctx.Column),
vdom.If(ctx.IsSorted,
vdom.H("span", map[string]any{
"className": "text-blue-400",
}, vdom.IfElse(ctx.SortDirection == "asc", "↑", "↓")),
),
)
}
// Helper function to safely call RowClassName function
func makeRowClassName[T any](rowClassNameFunc func(ctx RowContext[T]) string, rowCtx RowContext[T]) string {
if rowClassNameFunc == nil {
return ""
}
return rowClassNameFunc(rowCtx)
}
func MakeTableComponent[T any](componentName string) vdom.Component[TableProps[T]] {
return app.DefineComponent(componentName, genTableRenderFunc[T])
}
func genTableRenderFunc[T any](props TableProps[T]) any {
// State for sorting
sortColumnAtom := app.UseLocal(props.DefaultSort)
sortDirectionAtom := app.UseLocal("asc")
// State for pagination - initialize with prop values
initialPage := 0
initialPageSize := 25
if props.Pagination != nil {
initialPage = props.Pagination.CurrentPage
initialPageSize = props.Pagination.PageSize
}
currentPageAtom := app.UseLocal(initialPage)
pageSizeAtom := app.UseLocal(initialPageSize)
// State for selection - initialize with empty slice if nil
initialSelection := props.SelectedRows
if initialSelection == nil {
initialSelection = []int{}
}
selectedRowsAtom := app.UseLocal(initialSelection)
// Handle sorting
handleSort := func(column string) {
currentSort := sortColumnAtom.Get()
currentDir := sortDirectionAtom.Get()
if currentSort == column {
// Toggle direction
newDir := vdom.IfElse(currentDir == "asc", "desc", "asc").(string)
sortDirectionAtom.Set(newDir)
if props.OnSort != nil {
props.OnSort(column, newDir)
}
} else {
// New column
sortColumnAtom.Set(column)
sortDirectionAtom.Set("asc")
if props.OnSort != nil {
props.OnSort(column, "asc")
}
}
}
// Handle row selection
handleRowSelect := func(rowIdx int) {
if !props.Selectable {
return
}
selectedRowsAtom.SetFn(func(current []int) []int {
// Toggle selection
for i, idx := range current {
if idx == rowIdx {
// Remove
return append(current[:i], current[i+1:]...)
}
}
// Add
return append(current, rowIdx)
})
if props.OnSelectionChange != nil {
props.OnSelectionChange(selectedRowsAtom.Get())
}
}
// Handle pagination
handlePageChange := func(page int) {
currentPageAtom.Set(page)
}
handlePageSizeChange := func(size int) {
pageSizeAtom.Set(size)
currentPageAtom.Set(0) // Reset to first page
}
// Process data
processedData := props.Data
if sortColumnAtom.Get() != "" {
if sortCol := findColumnByKey(props.Columns, sortColumnAtom.Get()); sortCol != nil {
processedData = sortData(processedData, *sortCol, sortDirectionAtom.Get())
}
}
totalRows := len(processedData)
// Apply pagination
paginationConfig := &PaginationConfig{
PageSize: pageSizeAtom.Get(),
CurrentPage: currentPageAtom.Get(),
}
paginatedData := paginateData(processedData, paginationConfig)
// Get current state values
currentSort := sortColumnAtom.Get()
currentDir := sortDirectionAtom.Get()
currentSelected := selectedRowsAtom.Get()
return vdom.H("div", map[string]any{
"className": "w-full",
},
// Table
vdom.H("div", map[string]any{
"className": "overflow-auto border border-gray-600 rounded-lg",
},
vdom.H("table", map[string]any{
"className": "w-full bg-gray-900 text-gray-200",
},
// Header
vdom.H("thead", map[string]any{
"className": "bg-gray-800 border-b border-gray-600 text-white",
},
vdom.H("tr", nil,
vdom.If(props.Selectable,
vdom.H("th", map[string]any{
"className": "p-3 text-left",
"style": map[string]any{"width": "40px"},
},
vdom.H("input", map[string]any{
"type": "checkbox",
"checked": len(currentSelected) == len(paginatedData) && len(paginatedData) > 0,
"onChange": func() {
if len(currentSelected) == len(paginatedData) {
selectedRowsAtom.Set([]int{})
} else {
allSelected := make([]int, len(paginatedData))
for i := range paginatedData {
allSelected[i] = i
}
selectedRowsAtom.Set(allSelected)
}
},
}),
),
),
vdom.ForEach(props.Columns, func(col TableColumn[T], colIdx int) any {
isSorted := currentSort == col.AccessorKey
headerCtx := HeaderContext{
Column: col.Header,
IsSorted: isSorted,
SortDirection: currentDir,
}
headerContent := defaultHeaderRenderer(headerCtx)
if col.HeaderRender != nil {
headerContent = col.HeaderRender(headerCtx)
}
return vdom.H("th", map[string]any{
"key": col.AccessorKey,
"className": vdom.Classes(
"p-3 text-left font-semibold",
vdom.If(col.Sortable, "cursor-pointer hover:bg-gray-700"),
col.HeaderClassName,
),
"style": vdom.If(col.Width != "", map[string]any{"width": col.Width}),
"onClick": vdom.If(col.Sortable, func() { handleSort(col.AccessorKey) }),
}, headerContent)
}),
),
),
// Body
vdom.H("tbody", map[string]any{
"className": "divide-y divide-gray-700",
},
vdom.ForEach(paginatedData, func(row T, rowIdx int) any {
isSelected := func() bool {
for _, idx := range currentSelected {
if idx == rowIdx {
return true
}
}
return false
}()
// Custom row renderer
if props.RowRender != nil {
return props.RowRender(RowContext[T]{
Data: row,
RowIdx: rowIdx,
})
}
// Default row rendering with columns
rowCtx := RowContext[T]{
Data: row,
RowIdx: rowIdx,
}
return vdom.H("tr", map[string]any{
"key": rowIdx,
"className": vdom.Classes(
"hover:bg-gray-800 transition-colors",
vdom.If(isSelected, "bg-blue-900"),
vdom.If(props.OnRowClick != nil, "cursor-pointer"),
makeRowClassName(props.RowClassName, rowCtx),
),
"onClick": func() {
if props.OnRowClick != nil {
props.OnRowClick(row, rowIdx)
}
},
},
vdom.If(props.Selectable,
vdom.H("td", map[string]any{
"className": "p-3",
},
vdom.H("input", map[string]any{
"type": "checkbox",
"checked": isSelected,
"onChange": func() { handleRowSelect(rowIdx) },
}),
),
),
vdom.ForEach(props.Columns, func(col TableColumn[T], colIdx int) any {
var value any
value = getFieldValue(row, rowIdx, col)
cellCtx := CellContext[T]{
Data: row,
Value: value,
RowIdx: rowIdx,
ColIdx: colIdx,
Column: col.AccessorKey,
}
cellContent := defaultCellRenderer(cellCtx)
if col.CellRender != nil {
cellContent = col.CellRender(cellCtx)
}
return vdom.H("td", map[string]any{
"key": col.AccessorKey,
"className": vdom.Classes("p-3", col.CellClassName),
}, cellContent)
}),
)
}),
),
),
),
// Pagination
vdom.If(props.Pagination != nil,
renderPagination(totalRows, paginationConfig, handlePageChange, handlePageSizeChange),
),
)
}
// Pagination component
func renderPagination(totalRows int, config *PaginationConfig, onPageChange func(int), onPageSizeChange func(int)) any {
totalPages := (totalRows + config.PageSize - 1) / config.PageSize
currentPage := config.CurrentPage
return vdom.H("div", map[string]any{
"className": "flex items-center justify-between mt-4 px-4 py-3 bg-gray-800 rounded-lg",
},
// Page size selector
vdom.H("div", map[string]any{
"className": "flex items-center gap-2",
},
vdom.H("span", map[string]any{
"className": "text-sm text-gray-400",
}, "Show"),
vdom.H("select", map[string]any{
"className": "bg-gray-700 text-white rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:bg-gray-600 mx-1",
"value": strconv.Itoa(config.PageSize),
"onChange": func(e vdom.VDomEvent) {
if size, err := strconv.Atoi(e.TargetValue); err == nil {
onPageSizeChange(size)
}
},
},
vdom.H("option", map[string]any{"value": "10"}, "10"),
vdom.H("option", map[string]any{"value": "25"}, "25"),
vdom.H("option", map[string]any{"value": "50"}, "50"),
vdom.H("option", map[string]any{"value": "100"}, "100"),
),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-400",
}, "entries"),
),
// Page info
vdom.H("span", map[string]any{
"className": "text-sm text-gray-400",
}, fmt.Sprintf("Showing %d-%d of %d",
currentPage*config.PageSize+1,
vdom.Ternary(currentPage*config.PageSize+config.PageSize > totalRows,
totalRows,
currentPage*config.PageSize+config.PageSize),
totalRows,
)),
// Page controls
vdom.H("div", map[string]any{
"className": "flex items-center gap-3",
},
vdom.H("button", map[string]any{
"className": vdom.Classes(
"px-3 py-1.5 rounded text-sm transition-colors",
vdom.IfElse(currentPage > 0,
"bg-blue-600 hover:bg-blue-700 text-white cursor-pointer",
"bg-gray-600 text-gray-500"),
),
"disabled": currentPage <= 0,
"onClick": func() {
if currentPage > 0 {
onPageChange(currentPage - 1)
}
},
}, "Previous"),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-400 px-2",
}, fmt.Sprintf("Page %d of %d", currentPage+1, totalPages)),
vdom.H("button", map[string]any{
"className": vdom.Classes(
"px-3 py-1.5 rounded text-sm transition-colors",
vdom.IfElse(currentPage < totalPages-1,
"bg-blue-600 hover:bg-blue-700 text-white cursor-pointer",
"bg-gray-600 text-gray-500"),
),
"disabled": currentPage >= totalPages-1,
"onClick": func() {
if currentPage < totalPages-1 {
onPageChange(currentPage + 1)
}
},
}, "Next"),
),
)
}