Repository: Evertras/bubble-table
Pull request #27 (2022-02-23 07:20:26)

Programmatic Sorting
Adds sorting options.

Sorts by numbers if they're numbers, otherwise sorts by string values.

Allows sorting using multiple columns chained together in either ascending or descending order.

Evertras: Closes #2 

Patch:
diff --git a/Makefile b/Makefile
index e6cf6b2..593885a 100644
--- a/Makefile
+++ b/Makefile
@@ -18,6 +18,10 @@ example-pagination:
 example-simplest:
 	@go run ./examples/simplest/*.go
 
+.PHONY: example-simplest
+example-sorting:
+	@go run ./examples/sorting/*.go
+
 .PHONY: test
 test:
 	@go test -race -cover ./table
diff --git a/README.md b/README.md
index 3fc01a8..516ec58 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,15 @@ Can make rows selectable, and fetch the current selections.
 Pagination can be set with a given page size, which automatically generates a
 simple footer to show the current page and total pages.
 
+Columns can be sorted in either ascending or descending order.  Multiple columns
+can be specified in a row.  If multiple columns are specified, first the table
+is sorted by the first specified column, then each group within that column is
+sorted in smaller and smaller groups.  [See the sorting example](examples/sorting/main.go)
+for more information.
+
+If a feature is confusing to use or could use a better example, please feel free
+to open an issue.
+
 ## Defining table data
 
 A table is defined by a list of `Column` values that define the columns in the
diff --git a/examples/features/main.go b/examples/features/main.go
index 16436ff..6e9333e 100644
--- a/examples/features/main.go
+++ b/examples/features/main.go
@@ -47,7 +47,11 @@ type Model struct {
 
 func NewModel() Model {
 	columns := []table.Column{
-		table.NewColumn(columnKeyID, "ID", 5).WithStyle(lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("#88f"))),
+		table.NewColumn(columnKeyID, "ID", 5).WithStyle(
+			lipgloss.NewStyle().
+				Faint(true).
+				Foreground(lipgloss.Color("#88f")).
+				Align(lipgloss.Left)),
 		table.NewColumn(columnKeyName, "Name", 10),
 		table.NewColumn(columnKeyDescription, "Description", 30),
 		table.NewColumn(columnKeyCount, "#", 5),
@@ -74,13 +78,13 @@ func NewModel() Model {
 			columnKeyCount:       table.NewStyledCell(0, lipgloss.NewStyle().Faint(true)),
 		}),
 		table.NewRow(table.RowData{
-			columnKeyID:          "2pg",
+			columnKeyID:          "spg",
 			columnKeyName:        "Page 2",
 			columnKeyDescription: "Second page",
 			columnKeyCount:       2,
 		}),
 		table.NewRow(table.RowData{
-			columnKeyID:          "2pg2",
+			columnKeyID:          "spg2",
 			columnKeyName:        "Page 2.1",
 			columnKeyDescription: "Second page again",
 			columnKeyCount:       4,
@@ -103,7 +107,8 @@ func NewModel() Model {
 			WithKeyMap(keys).
 			WithStaticFooter("Footer!").
 			WithPageSize(3).
-			WithSelectedText(" ", "✓"),
+			WithSelectedText(" ", "✓").
+			SortByAsc(columnKeyID),
 	}
 
 	model.updateFooter()
diff --git a/examples/sorting/main.go b/examples/sorting/main.go
new file mode 100644
index 0000000..d115455
--- /dev/null
+++ b/examples/sorting/main.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+	"log"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/evertras/bubble-table/table"
+)
+
+const (
+	columnKeyName = "name"
+	columnKeyType = "type"
+	columnKeyWins = "element"
+)
+
+type Model struct {
+	simpleTable table.Model
+
+	columnSortKey string
+	sortDirection string
+}
+
+func NewModel() Model {
+	return Model{
+		simpleTable: table.New([]table.Column{
+			table.NewColumn(columnKeyName, "Name", 13),
+			table.NewColumn(columnKeyType, "Type", 13),
+			table.NewColumn(columnKeyWins, "Wins", 5),
+		}).WithRows([]table.Row{
+			table.NewRow(table.RowData{
+				columnKeyName: "ピカピカ",
+				columnKeyType: "Pikachu",
+				columnKeyWins: 4,
+			}),
+			table.NewRow(table.RowData{
+				columnKeyName: "Alphonse",
+				columnKeyType: "Pikachu",
+				columnKeyWins: 13,
+			}),
+			table.NewRow(table.RowData{
+				columnKeyName: "Burninator",
+				columnKeyType: "Charmander",
+				columnKeyWins: 8,
+			}),
+			table.NewRow(table.RowData{
+				columnKeyName: "Dihydrogen Monoxide",
+				columnKeyType: "Squirtle",
+				columnKeyWins: 31,
+			}),
+		}),
+	}
+}
+
+func (m Model) Init() tea.Cmd {
+	return nil
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var (
+		cmd  tea.Cmd
+		cmds []tea.Cmd
+	)
+
+	m.simpleTable, cmd = m.simpleTable.Update(msg)
+	cmds = append(cmds, cmd)
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "ctrl+c", "esc", "q":
+			cmds = append(cmds, tea.Quit)
+
+		case "n":
+			m.columnSortKey = columnKeyName
+			m.simpleTable = m.simpleTable.SortByAsc(m.columnSortKey)
+
+		case "t":
+			m.columnSortKey = columnKeyType
+			// Within the same type, order each by wins
+			m.simpleTable = m.simpleTable.SortByAsc(m.columnSortKey).ThenSortByDesc(columnKeyWins)
+
+		case "w":
+			m.columnSortKey = columnKeyWins
+			m.simpleTable = m.simpleTable.SortByDesc(m.columnSortKey)
+		}
+	}
+
+	return m, tea.Batch(cmds...)
+}
+
+func (m Model) View() string {
+	body := strings.Builder{}
+
+	body.WriteString("A sorted simple default table\nSort by (n)ame, (t)ype, or (w)ins\nCurrently sorting by: " + m.columnSortKey + "\nPress q or ctrl+c to quit\n\n")
+
+	body.WriteString(m.simpleTable.View())
+
+	return body.String()
+}
+
+func main() {
+	p := tea.NewProgram(NewModel())
+
+	if err := p.Start(); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/table/data.go b/table/data.go
new file mode 100644
index 0000000..44dc613
--- /dev/null
+++ b/table/data.go
@@ -0,0 +1,64 @@
+package table
+
+import "time"
+
+// This is just a bunch of data type checks, so... no linting here
+// nolint: cyclop
+func asInt(data interface{}) (int64, bool) {
+	switch val := data.(type) {
+	case int:
+		return int64(val), true
+
+	case int8:
+		return int64(val), true
+
+	case int16:
+		return int64(val), true
+
+	case int32:
+		return int64(val), true
+
+	case int64:
+		return val, true
+
+	case uint:
+		return int64(val), true
+
+	case uint8:
+		return int64(val), true
+
+	case uint16:
+		return int64(val), true
+
+	case uint32:
+		return int64(val), true
+
+	case uint64:
+		return int64(val), true
+
+	case time.Duration:
+		return int64(val), true
+
+	case StyledCell:
+		return asInt(val.Data)
+	}
+
+	return 0, false
+}
+
+func asNumber(data interface{}) (float64, bool) {
+	switch val := data.(type) {
+	case float32:
+		return float64(val), true
+
+	case float64:
+		return val, true
+
+	case StyledCell:
+		return asNumber(val.Data)
+	}
+
+	intVal, isInt := asInt(data)
+
+	return float64(intVal), isInt
+}
diff --git a/table/model.go b/table/model.go
index 17da4b6..b4ea53e 100644
--- a/table/model.go
+++ b/table/model.go
@@ -40,6 +40,10 @@ type Model struct {
 	pageSize    int
 	currentPage int
 
+	// Sorting
+	sortOrder  []sortColumn
+	sortedRows []Row
+
 	// Internal cached calculations for reference
 	totalWidth int
 }
diff --git a/table/options.go b/table/options.go
index 99e5f0a..455bd7c 100644
--- a/table/options.go
+++ b/table/options.go
@@ -14,6 +14,7 @@ func (m Model) HeaderStyle(style lipgloss.Style) Model {
 // WithRows sets the rows to show as data in the table.
 func (m Model) WithRows(rows []Row) Model {
 	m.rows = rows
+	m.updateSortedRows()
 
 	return m
 }
diff --git a/table/row.go b/table/row.go
index eb59e13..5c7b31d 100644
--- a/table/row.go
+++ b/table/row.go
@@ -44,7 +44,7 @@ func (r Row) WithStyle(style lipgloss.Style) Row {
 // nolint: cyclop
 func (m Model) renderRow(rowIndex int, last bool) string {
 	numColumns := len(m.columns)
-	row := m.rows[rowIndex]
+	row := m.sortedRows[rowIndex]
 	highlighted := rowIndex == m.rowCursorIndex
 
 	columnStrings := []string{}
diff --git a/table/sort.go b/table/sort.go
new file mode 100644
index 0000000..4ffebae
--- /dev/null
+++ b/table/sort.go
@@ -0,0 +1,158 @@
+package table
+
+import (
+	"fmt"
+	"sort"
+)
+
+type sortDirection int
+
+const (
+	sortDirectionAsc sortDirection = iota
+	sortDirectionDesc
+)
+
+type sortColumn struct {
+	columnKey string
+	direction sortDirection
+}
+
+func (m Model) SortByAsc(columnKey string) Model {
+	m.sortOrder = []sortColumn{
+		{
+			columnKey: columnKey,
+			direction: sortDirectionAsc,
+		},
+	}
+
+	m.updateSortedRows()
+
+	return m
+}
+
+func (m Model) SortByDesc(columnKey string) Model {
+	m.sortOrder = []sortColumn{
+		{
+			columnKey: columnKey,
+			direction: sortDirectionDesc,
+		},
+	}
+
+	m.updateSortedRows()
+
+	return m
+}
+
+func (m Model) ThenSortByAsc(columnKey string) Model {
+	m.sortOrder = append([]sortColumn{
+		{
+			columnKey: columnKey,
+			direction: sortDirectionAsc,
+		},
+	}, m.sortOrder...)
+
+	m.updateSortedRows()
+
+	return m
+}
+
+func (m Model) ThenSortByDesc(columnKey string) Model {
+	m.sortOrder = append([]sortColumn{
+		{
+			columnKey: columnKey,
+			direction: sortDirectionDesc,
+		},
+	}, m.sortOrder...)
+
+	m.updateSortedRows()
+
+	return m
+}
+
+type sortableTable struct {
+	rows     []Row
+	byColumn sortColumn
+}
+
+func (s *sortableTable) Len() int {
+	return len(s.rows)
+}
+
+func (s *sortableTable) Swap(i, j int) {
+	old := s.rows[i]
+	s.rows[i] = s.rows[j]
+	s.rows[j] = old
+}
+
+func (s *sortableTable) extractString(i int, column string) string {
+	iData, exists := s.rows[i].Data[column]
+
+	if !exists {
+		return ""
+	}
+
+	switch iData := iData.(type) {
+	case StyledCell:
+		return fmt.Sprintf("%v", iData.Data)
+
+	case string:
+		return iData
+
+	default:
+		return fmt.Sprintf("%v", iData)
+	}
+}
+
+func (s *sortableTable) extractNumber(i int, column string) (float64, bool) {
+	iData, exists := s.rows[i].Data[column]
+
+	if !exists {
+		return 0, false
+	}
+
+	return asNumber(iData)
+}
+
+func (s *sortableTable) Less(first, second int) bool {
+	firstNum, firstNumIsValid := s.extractNumber(first, s.byColumn.columnKey)
+	secondNum, secondNumIsValid := s.extractNumber(second, s.byColumn.columnKey)
+
+	if firstNumIsValid && secondNumIsValid {
+		if s.byColumn.direction == sortDirectionAsc {
+			return firstNum < secondNum
+		}
+
+		return firstNum > secondNum
+	}
+
+	firstVal := s.extractString(first, s.byColumn.columnKey)
+	secondVal := s.extractString(second, s.byColumn.columnKey)
+
+	if s.byColumn.direction == sortDirectionAsc {
+		return firstVal < secondVal
+	}
+
+	return firstVal > secondVal
+}
+
+func (m *Model) updateSortedRows() {
+	if len(m.sortOrder) == 0 {
+		m.sortedRows = m.rows
+
+		return
+	}
+
+	m.sortedRows = make([]Row, len(m.rows))
+	copy(m.sortedRows, m.rows)
+
+	for _, byColumn := range m.sortOrder {
+		sorted := &sortableTable{
+			rows:     m.sortedRows,
+			byColumn: byColumn,
+		}
+
+		sort.Stable(sorted)
+
+		m.sortedRows = sorted.rows
+	}
+}


Test patch:
diff --git a/table/data_test.go b/table/data_test.go
new file mode 100644
index 0000000..5b78776
--- /dev/null
+++ b/table/data_test.go
@@ -0,0 +1,43 @@
+package table
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAsInt(t *testing.T) {
+	check := func(data interface{}, isInt bool, expectedValue int64) {
+		val, ok := asInt(data)
+		assert.Equal(t, isInt, ok)
+		assert.Equal(t, expectedValue, val)
+	}
+
+	check(3, true, 3)
+	check(3.3, false, 0)
+	check(int8(3), true, 3)
+	check(int16(3), true, 3)
+	check(int32(3), true, 3)
+	check(int64(3), true, 3)
+	check(uint(3), true, 3)
+	check(uint8(3), true, 3)
+	check(uint16(3), true, 3)
+	check(uint32(3), true, 3)
+	check(uint64(3), true, 3)
+	check(StyledCell{Data: 3}, true, 3)
+	check(time.Duration(3), true, 3)
+}
+
+func TestAsNumber(t *testing.T) {
+	check := func(data interface{}, isFloat bool, expectedValue float64) {
+		val, ok := asNumber(data)
+		assert.Equal(t, isFloat, ok)
+		assert.InDelta(t, expectedValue, val, 0.001)
+	}
+
+	check(uint32(3), true, 3)
+	check(3.3, true, 3.3)
+	check(float32(3.3), true, 3.3)
+	check(StyledCell{Data: 3.3}, true, 3.3)
+}
diff --git a/table/sort_test.go b/table/sort_test.go
new file mode 100644
index 0000000..3631951
--- /dev/null
+++ b/table/sort_test.go
@@ -0,0 +1,141 @@
+package table
+
+import (
+	"testing"
+
+	"github.com/charmbracelet/lipgloss"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSortSingleColumnAscAndDesc(t *testing.T) {
+	const idColKey = "id"
+
+	rows := []Row{
+		NewRow(RowData{idColKey: "b"}),
+		NewRow(RowData{idColKey: NewStyledCell("c", lipgloss.NewStyle().Bold(true))}),
+		NewRow(RowData{idColKey: "a"}),
+		// Missing data
+		NewRow(RowData{}),
+	}
+
+	model := New([]Column{
+		NewColumn(idColKey, "ID", 3),
+	}).WithRows(rows).SortByAsc(idColKey)
+
+	assertOrder := func(expectedList []string) {
+		for index, expected := range expectedList {
+			idVal, ok := model.sortedRows[index].Data[idColKey]
+
+			if expected != "" {
+				assert.True(t, ok)
+			} else {
+				assert.False(t, ok)
+
+				continue
+			}
+
+			switch idVal := idVal.(type) {
+			case string:
+				assert.Equal(t, expected, idVal)
+
+			case StyledCell:
+				assert.Equal(t, expected, idVal.Data)
+
+			default:
+				assert.Fail(t, "Unknown type")
+			}
+		}
+	}
+
+	assert.Len(t, model.sortedRows, len(rows))
+	assertOrder([]string{"", "a", "b", "c"})
+
+	model = model.SortByDesc(idColKey)
+
+	assertOrder([]string{"c", "b", "a", ""})
+}
+
+func TestSortSingleColumnIntsAsc(t *testing.T) {
+	const idColKey = "id"
+
+	rows := []Row{
+		NewRow(RowData{idColKey: 13}),
+		NewRow(RowData{idColKey: NewStyledCell(1, lipgloss.NewStyle().Bold(true))}),
+		NewRow(RowData{idColKey: 2}),
+	}
+
+	model := New([]Column{
+		NewColumn(idColKey, "ID", 3),
+	}).WithRows(rows).SortByAsc(idColKey)
+
+	assertOrder := func(expectedList []int) {
+		for index, expected := range expectedList {
+			idVal, ok := model.sortedRows[index].Data[idColKey]
+
+			assert.True(t, ok)
+
+			switch idVal := idVal.(type) {
+			case int:
+				assert.Equal(t, expected, idVal)
+
+			case StyledCell:
+				assert.Equal(t, expected, idVal.Data)
+
+			default:
+				assert.Fail(t, "Unknown type")
+			}
+		}
+	}
+
+	assert.Len(t, model.sortedRows, len(rows))
+	assertOrder([]int{1, 2, 13})
+}
+
+func TestSortTwoColumnsAscDescMix(t *testing.T) {
+	const (
+		nameKey  = "name"
+		scoreKey = "score"
+	)
+
+	makeRow := func(name string, score int) Row {
+		return NewRow(RowData{
+			nameKey:  name,
+			scoreKey: score,
+		})
+	}
+
+	model := New([]Column{
+		NewColumn(nameKey, "Name", 8),
+		NewColumn(scoreKey, "Score", 8),
+	}).WithRows([]Row{
+		makeRow("c", 50),
+		makeRow("a", 75),
+		makeRow("b", 101),
+		makeRow("a", 100),
+	}).SortByAsc(nameKey).ThenSortByDesc(scoreKey)
+
+	assertVals := func(index int, name string, score int) {
+		actualName, ok := model.sortedRows[index].Data[nameKey].(string)
+		assert.True(t, ok)
+
+		actualScore, ok := model.sortedRows[index].Data[scoreKey].(int)
+		assert.True(t, ok)
+
+		assert.Equal(t, name, actualName)
+		assert.Equal(t, score, actualScore)
+	}
+
+	assert.Len(t, model.sortedRows, 4)
+
+	assertVals(0, "a", 100)
+	assertVals(1, "a", 75)
+	assertVals(2, "b", 101)
+	assertVals(3, "c", 50)
+
+	model = model.SortByDesc(nameKey).ThenSortByAsc(scoreKey)
+
+	assertVals(0, "c", 50)
+	assertVals(1, "b", 101)
+	assertVals(2, "a", 75)
+	assertVals(3, "a", 100)
+}
diff --git a/table/view_test.go b/table/view_test.go
index 3f71af8..1aff0b7 100644
--- a/table/view_test.go
+++ b/table/view_test.go
@@ -101,6 +101,26 @@ func TestSingleColumnView(t *testing.T) {
 	assert.Equal(t, expectedTable, rendered)
 }
 
+func TestSingleColumnViewSorted(t *testing.T) {
+	model := New([]Column{
+		NewColumn("id", "ID", 4),
+	}).WithRows([]Row{
+		NewRow(RowData{"id": "1"}),
+		NewRow(RowData{"id": "2"}),
+	}).SortByDesc("id")
+
+	const expectedTable = `┏━━━━┓
+┃  ID┃
+┣━━━━┫
+┃   2┃
+┃   1┃
+┗━━━━┛`
+
+	rendered := model.View()
+
+	assert.Equal(t, expectedTable, rendered)
+}
+
 func TestSingleRowView(t *testing.T) {
 	model := New([]Column{
 		NewColumn("1", "1", 4),