Skip to content
Gx
GitHub

Data Table

A server-driven table with typed columns, sort headers and paging.

{datatable.DataTable(datatable.DataTableProps[datatable.FixtureRow]{
  Columns: datatable.FixtureColumns,
  Rows:    []datatable.FixtureRow{{Name: "Alpha", Total: 10}, {Name: "Beta", Total: 20}, {Name: "Gamma", Total: 30}},
  Page:    datatable.Page{Number: 1, Size: 10, Total: 3},
  Href:    datatable.FixtureHref,
})}

Installation

Run the command in the app module.

Terminal
gx add data-table

The command also installs table and pagination.

The command writes these files.

File Path in the app
DataTable.go ui/data-table/DataTable.go
DataTable.fixtures.go ui/data-table/DataTable.fixtures.go

Install table and pagination first.

Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.

ui/data-table/DataTable.go
// Package datatable renders a server-driven table (REQ-REG-10). Sort,
// filter and paging state live in typed GET routes; the component only
// renders one page and links the next one.
package datatable

import (
	"strconv"

	"github.com/alternayte/gx"
	"github.com/alternayte/gx/registry/pagination"
	"github.com/alternayte/gx/registry/table"
)

// Column is one typed column of a data table.
type Column[T any] struct {
	// Key identifies the column in the row data.
	Key string
	// Label is the visible header text.
	Label string
	// Cell renders one cell.
	Cell func(T) gx.Node
	// Sort is the query value of the column when the header is a sort
	// link. Empty makes the column unsortable.
	Sort string
}

// Page is the paging state of one table render.
type Page struct {
	Number int
	Size   int
	Total  int
}

// Href builds the typed GET URL of one table state. The app returns its
// own route value, so the query stays typed.
type Href func(sort, dir string, page int) gx.URL

// DataTableProps holds one rendered table.
type DataTableProps[T any] struct {
	// Columns are the typed columns, in display order.
	Columns []Column[T]
	// Rows are the rows of the current page.
	Rows []T
	// Page is the paging state: the page number, the page size and the
	// total row count.
	Page Page
	// Sort is the Sort value of the column the rows are sorted by. Empty
	// means no sort.
	Sort string
	// Dir is the sort direction: "asc" or "desc".
	Dir string
	// Href builds the URL of a sort link and of a paging link.
	Href Href
	// Empty renders in place of the rows when the page holds none.
	Empty gx.Node
}

// DataTable renders one page of a server-driven table with sortable
// headers and paging links (REQ-REG-10).
func DataTable[T any](p DataTableProps[T]) gx.Node {
	head := make([]gx.Node, 0, len(p.Columns))
	for _, col := range p.Columns {
		head = append(head, header(p, col))
	}
	body := make([]gx.Node, 0, len(p.Rows))
	for _, row := range p.Rows {
		cells := make([]gx.Node, 0, len(p.Columns))
		for _, col := range p.Columns {
			cells = append(cells, table.TableCell(table.TableCellProps{Children: col.Cell(row)}))
		}
		body = append(body, table.TableRow(table.TableRowProps{Children: gx.Frag(cells...)}))
	}
	var rows gx.Node
	switch {
	case len(p.Rows) > 0:
		rows = gx.Frag(body...)
	case p.Empty != nil:
		rows = table.TableRow(table.TableRowProps{
			Children: table.TableCell(table.TableCellProps{
				Class:    "h-24 text-center text-muted-foreground",
				Children: p.Empty,
			}),
		})
	default:
		rows = table.TableRow(table.TableRowProps{
			Children: table.TableCell(table.TableCellProps{
				Class:    "h-24 text-center text-muted-foreground",
				Children: gx.Text("No results."),
			}),
		})
	}
	parts := []gx.Node{
		table.Table(table.TableProps{Children: gx.Frag(
			table.TableHeader(table.TableHeaderProps{Children: table.TableRow(table.TableRowProps{Children: gx.Frag(head...)})}),
			table.TableBody(table.TableBodyProps{Children: rows}),
		)}),
	}
	if p.Page.Size > 0 {
		parts = append(parts, paging(p))
	}
	return gx.Frag(parts...)
}

// header renders one header cell, with a sort link when the column sorts.
func header[T any](p DataTableProps[T], col Column[T]) gx.Node {
	if col.Sort == "" {
		return table.TableHead(table.TableHeadProps{Children: gx.Text(col.Label)})
	}
	dir := "asc"
	arrow := "↕"
	attrs := gx.Attrs{}
	if p.Sort == col.Sort {
		switch p.Dir {
		case "desc":
			dir = "asc"
			arrow = "↓"
			attrs = append(attrs, gx.Attr{Key: "aria-sort", Value: "descending"})
		default:
			dir = "desc"
			arrow = "↑"
			attrs = append(attrs, gx.Attr{Key: "aria-sort", Value: "ascending"})
		}
	}
	link := gx.El("a", gx.Attrs{
		{Key: "href", Value: string(p.Href(col.Sort, dir, 1)), Kind: gx.AttrURL},
		{Key: "class", Value: "inline-flex items-center gap-1 no-underline hover:text-foreground"},
	}, gx.Text(col.Label), gx.El("span", gx.Attrs{{Key: "class", Value: "text-muted-foreground"}}, gx.Text(arrow)))
	return table.TableHead(table.TableHeadProps{Attrs: attrs, Children: link})
}

// paging renders the footer with the row range and the page links.
func paging[T any](p DataTableProps[T]) gx.Node {
	pages := (p.Page.Total + p.Page.Size - 1) / p.Page.Size
	if pages < 1 {
		pages = 1
	}
	number := p.Page.Number
	if number < 1 {
		number = 1
	}
	if number > pages {
		number = pages
	}
	from := (number-1)*p.Page.Size + 1
	to := from + len(p.Rows) - 1
	if p.Page.Total == 0 {
		from, to = 0, 0
	}
	items := []gx.Node{
		pagination.PaginationItem(pagination.PaginationItemProps{Children: pagination.PaginationPrevious(pagination.PaginationPreviousProps{
			Href:  p.Href(p.Sort, p.Dir, number-1),
			Class: previousClass(number),
		})}),
	}
	for _, page := range pageWindow(number, pages) {
		page := page
		if page == 0 {
			items = append(items, pagination.PaginationItem(pagination.PaginationItemProps{
				Children: pagination.PaginationEllipsis(pagination.PaginationEllipsisProps{}),
			}))
			continue
		}
		items = append(items, pagination.PaginationItem(pagination.PaginationItemProps{
			Children: pagination.PaginationLink(pagination.PaginationLinkProps{
				Href:     p.Href(p.Sort, p.Dir, page),
				Active:   page == number,
				Children: gx.Text(strconv.Itoa(page)),
			}),
		}))
	}
	items = append(items, pagination.PaginationItem(pagination.PaginationItemProps{Children: pagination.PaginationNext(pagination.PaginationNextProps{
		Href:  p.Href(p.Sort, p.Dir, number+1),
		Class: nextClass(number, pages),
	})}))
	return gx.El("div", gx.Attrs{{Key: "class", Value: "flex items-center justify-between gap-4 px-2 pt-4"}},
		gx.El("p", gx.Attrs{{Key: "class", Value: "text-sm text-muted-foreground"}},
			gx.Text(strconv.Itoa(from)+"–"+strconv.Itoa(to)+" of "+strconv.Itoa(p.Page.Total))),
		pagination.Pagination(pagination.PaginationProps{Children: pagination.PaginationContent(pagination.PaginationContentProps{Children: gx.Frag(items...)})}),
	)
}

// previousClass hides the previous link on the first page.
func previousClass(number int) string {
	if number <= 1 {
		return "pointer-events-none opacity-50"
	}
	return ""
}

// nextClass hides the next link on the last page.
func nextClass(number, pages int) string {
	if number >= pages {
		return "pointer-events-none opacity-50"
	}
	return ""
}

// pageWindow returns the page numbers to show, with 0 for an ellipsis.
func pageWindow(number, pages int) []int {
	if pages <= 7 {
		out := make([]int, 0, pages)
		for i := 1; i <= pages; i++ {
			out = append(out, i)
		}
		return out
	}
	out := []int{1}
	start, end := number-1, number+1
	if start < 2 {
		start, end = 2, 4
	}
	if end > pages-1 {
		start, end = pages-4, pages-1
	}
	if start > 2 {
		out = append(out, 0)
	}
	for i := start; i <= end; i++ {
		out = append(out, i)
	}
	if end < pages-1 {
		out = append(out, 0)
	}
	return append(out, pages)
}
ui/data-table/DataTable.fixtures.go
package datatable

import (
	"strconv"

	"github.com/alternayte/gx"
)

// FixtureRow is the row type of the data table fixtures.
type FixtureRow struct {
	Name  string
	Total int
}

// FixtureColumns are the columns of the data table fixtures. The name column
// is a sort link.
var FixtureColumns = []Column[FixtureRow]{
	{Key: "name", Label: "Name", Sort: "name", Cell: func(r FixtureRow) gx.Node { return gx.Text(r.Name) }},
	{Key: "total", Label: "Total", Cell: func(r FixtureRow) gx.Node { return gx.Text(strconv.Itoa(r.Total)) }},
}

// FixtureHref is the Href of the data table fixtures. An app returns its own
// typed route here.
func FixtureHref(sort, dir string, page int) gx.URL { return gx.URL("/") }

var DataTableFixtures = gx.Fixtures[DataTableProps[FixtureRow]]{
	"Three": {
		Columns: FixtureColumns,
		Rows:    []FixtureRow{{Name: "Alpha", Total: 10}, {Name: "Beta", Total: 20}, {Name: "Gamma", Total: 30}},
		Page:    Page{Number: 1, Size: 10, Total: 3},
		Href:    FixtureHref,
	},
	"Empty": {
		Columns: FixtureColumns,
		Page:    Page{Number: 1, Size: 10, Total: 0},
		Href:    FixtureHref,
	},
}

The theme must define these tokens: --border, --muted-foreground, --accent, --accent-foreground, --ring.

Usage

type Row struct {
  Name  string
  Total int
}

var columns = []datatable.Column[Row]{
  {Key: "name", Label: "Name", Sort: "name", Cell: func(r Row) gx.Node { return gx.Text(r.Name) }},
  {Key: "total", Label: "Total", Cell: func(r Row) gx.Node { return gx.Text(strconv.Itoa(r.Total)) }},
}

// In the view:
datatable.DataTable(datatable.DataTableProps[Row]{
  Columns: columns,
  Rows:    p.Rows,
  Page:    datatable.Page{Number: p.Page, Size: 25, Total: 10000},
  Sort:    p.Sort,
  Dir:     p.Dir,
  Href: func(sort, dir string, page int) gx.URL {
    return invoices.List{Sort: sort, Dir: dir, Page: page}.URL()
  },
})

Examples

Three

{datatable.DataTable(datatable.DataTableProps[datatable.FixtureRow]{
  Columns: datatable.FixtureColumns,
  Rows:    []datatable.FixtureRow{{Name: "Alpha", Total: 10}, {Name: "Beta", Total: 20}, {Name: "Gamma", Total: 30}},
  Page:    datatable.Page{Number: 1, Size: 10, Total: 3},
  Href:    datatable.FixtureHref,
})}

Empty

{datatable.DataTable(datatable.DataTableProps[datatable.FixtureRow]{
  Columns: datatable.FixtureColumns,
  Page:    datatable.Page{Number: 1, Size: 10, Total: 0},
  Href:    datatable.FixtureHref,
})}

API reference

datatable.DataTable

The component is a Go function. Its props are the fields of DataTableProps.

Prop Type Default Description
Columns []Column[T] Zero value Columns are the typed columns, in display order.
Rows []T Zero value Rows are the rows of the current page.
Page Page Zero value Page is the paging state: the page number, the page size and the total row count.
Sort string Zero value Sort is the Sort value of the column the rows are sorted by. Empty means no sort.
Dir string Zero value Dir is the sort direction: "asc" or "desc".
Href Href Zero value Href builds the URL of a sort link and of a paging link.
Empty gx.Node Zero value Empty renders in place of the rows when the page holds none.

Do and do not

Do

  • Sort, filter and page on the server through a typed GET route.
  • Pass the current Sort and Dir back into the props.

Don't

  • Do not page on the client.
  • Do not render more rows than the page size.

Keyboard

Key Action
Tab Moves between the sort headers and the page links.
Enter Sorts by the focused header or follows a page link.