Repository: Evertras/bubble-table
Pull request #35 (2022-03-04 12:05:12)

Increase filter tests

Evertras: Should finish off #5 , adjusted the behavior slightly after finding some edge cases in tests.

Patch:
diff --git a/Makefile b/Makefile
index 593885a..e6e01e8 100644
--- a/Makefile
+++ b/Makefile
@@ -10,6 +10,10 @@ example-dimensions:
 example-updates:
 	@go run ./examples/updates/*.go
 
+.PHONY: example-filter
+example-filter:
+	@go run ./examples/filter/*.go
+
 .PHONY: example-pagination
 example-pagination:
 	@go run ./examples/pagination/*.go
@@ -18,7 +22,7 @@ example-pagination:
 example-simplest:
 	@go run ./examples/simplest/*.go
 
-.PHONY: example-simplest
+.PHONY: example-sorting
 example-sorting:
 	@go run ./examples/sorting/*.go
 
diff --git a/table/column.go b/table/column.go
index 73acd58..fa04c2d 100644
--- a/table/column.go
+++ b/table/column.go
@@ -20,7 +20,9 @@ func NewColumn(key, title string, width int) Column {
 		Key:   key,
 		Title: title,
 		Width: width,
-		style: lipgloss.NewStyle().Width(width),
+
+		filterable: false,
+		style:      lipgloss.NewStyle().Width(width),
 	}
 }
 
diff --git a/table/filter.go b/table/filter.go
index 240e26b..032ab7c 100644
--- a/table/filter.go
+++ b/table/filter.go
@@ -26,30 +26,33 @@ func isRowMatched(columns []Column, row Row, filter string) bool {
 		return true
 	}
 
+	checkedAny := false
+
 	for _, column := range columns {
 		if !column.filterable {
 			continue
 		}
+
+		checkedAny = true
+
 		data, ok := row.Data[column.Key]
+
 		if !ok {
 			continue
 		}
+
 		switch dataV := data.(type) {
 		case string:
 			if strings.Contains(strings.ToLower(dataV), strings.ToLower(filter)) {
 				return true
 			}
+
 		case fmt.Stringer:
 			if strings.Contains(strings.ToLower(dataV.String()), strings.ToLower(filter)) {
 				return true
 			}
-
-			return false
-		default:
-
-			return false
 		}
 	}
 
-	return false
+	return !checkedAny
 }
diff --git a/table/footer.go b/table/footer.go
index 1e939d8..cebade3 100644
--- a/table/footer.go
+++ b/table/footer.go
@@ -1,36 +1,35 @@
 package table
 
-import "fmt"
+import (
+	"fmt"
+	"strings"
+)
 
 func (m Model) hasFooter() bool {
-	return m.staticFooter != "" || m.pageSize != 0
+	return m.staticFooter != "" || m.pageSize != 0 || m.filtered
 }
 
 func (m Model) renderFooter() string {
 	if !m.hasFooter() {
 		return ""
 	}
+
 	if m.staticFooter != "" {
 		return m.border.styleFooter.Width(m.totalWidth).Render(m.staticFooter)
 	}
-	var footerText string
-	var pagination string
-	var filter string
+
+	sections := []string{}
+
+	if m.filtered && (m.filterTextInput.Focused() || m.filterTextInput.Value() != "") {
+		sections = append(sections, fmt.Sprintf("/%s", m.filterTextInput.View()))
+	}
+
 	// paged feature enabled
 	if m.pageSize != 0 {
-		pagination = fmt.Sprintf("%d/%d", m.CurrentPage(), m.MaxPages())
-	}
-	if m.filtered {
-		// filter pressing
-		if m.filterTextInput.Focused() {
-			filter = fmt.Sprintf("/%s", m.filterTextInput.View())
-		} else {
-			if m.filterTextInput.Value() != "" {
-				filter = fmt.Sprintf("/%s", m.filterTextInput.Value())
-			}
-		}
+		sections = append(sections, fmt.Sprintf("%d/%d", m.CurrentPage(), m.MaxPages()))
 	}
-	footerText = fmt.Sprintf("%s %s", filter, pagination)
+
+	footerText := strings.Join(sections, " ")
 
 	styleFooter := m.baseStyle.Copy().Inherit(m.border.styleFooter).Width(m.totalWidth)
 
diff --git a/table/options.go b/table/options.go
index 230ca30..4820624 100644
--- a/table/options.go
+++ b/table/options.go
@@ -90,7 +90,8 @@ func (m Model) Filtered(filtered bool) Model {
 	return m
 }
 
-func (m Model) FilterMode() Model {
+// StartFilterTyping focuses the text input to allow user typing to filter.
+func (m Model) StartFilterTyping() Model {
 	m.filterTextInput.Focus()
 
 	return m


Test patch:
diff --git a/table/filter_test.go b/table/filter_test.go
index 6f1c81b..e44b82f 100644
--- a/table/filter_test.go
+++ b/table/filter_test.go
@@ -2,8 +2,8 @@ package table
 
 import (
 	"testing"
+	"time"
 
-	"github.com/charmbracelet/bubbles/textinput"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -13,84 +13,119 @@ func TestIsRowMatched(t *testing.T) {
 		NewColumn("description", "description", 10)}
 
 	assert.True(t, isRowMatched(columns,
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "AAA",
 			"description": "",
 		}), ""))
 
 	assert.True(t, isRowMatched(columns,
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "AAA",
 			"description": "",
 		}), "AA"))
 
 	assert.True(t, isRowMatched(columns,
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "AAA",
 			"description": "",
 		}), "A"))
 
 	assert.True(t, isRowMatched(columns,
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "AAA",
 			"description": "",
 		}), "a"))
 
 	assert.False(t, isRowMatched(columns,
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "AAA",
 			"description": "",
 		}), "B"))
 
 	assert.False(t, isRowMatched(columns,
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "AAA",
 			"description": "BBB",
 		}), "BBB"))
+
+	timeFrom2020 := time.Date(2020, time.July, 1, 1, 1, 1, 1, time.UTC)
+
+	assert.True(t, isRowMatched(columns,
+		NewRow(RowData{
+			"title": timeFrom2020,
+		}),
+		"2020",
+	))
+
+	assert.False(t, isRowMatched(columns,
+		NewRow(RowData{
+			"title": timeFrom2020,
+		}),
+		"2021",
+	))
 }
 
-func TestGetFilteredRows(t *testing.T) {
-	input := textinput.Model{}
-	input.SetValue("AAA")
+func TestGetFilteredRowsNoColumnFiltered(t *testing.T) {
 	columns := []Column{NewColumn("title", "title", 10)}
-	model := Model{filtered: true, filterTextInput: input, columns: columns}
 	rows := []Row{
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "AAA",
 			"description": "",
 		}),
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "BBB",
 			"description": "",
 		}),
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "CCC",
 			"description": "",
 		}),
 	}
+
+	model := New(columns).WithRows(rows).Filtered(true)
+	model.filterTextInput.SetValue("AAA")
+
+	filteredRows := model.getFilteredRows(rows)
+
+	assert.Len(t, filteredRows, len(rows))
+}
+
+func TestGetFilteredRowsUnfiltered(t *testing.T) {
+	columns := []Column{NewColumn("title", "title", 10)}
+	rows := []Row{
+		NewRow(RowData{
+			"title": "AAA",
+		}),
+		NewRow(RowData{
+			"title": "BBB",
+		}),
+	}
+
+	model := New(columns).WithRows(rows)
+
 	filteredRows := model.getFilteredRows(rows)
-	assert.Equal(t, 0, len(filteredRows))
+
+	assert.Len(t, filteredRows, len(rows))
 }
 
 func TestGetFilteredRowsFiltered(t *testing.T) {
-	input := textinput.Model{}
-	input.SetValue("AAA")
 	columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)}
-	model := Model{filtered: true, filterTextInput: input, columns: columns}
 	rows := []Row{
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "AAA",
 			"description": "",
 		}),
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "BBB",
 			"description": "",
 		}),
-		NewRow(map[string]interface{}{
-			"title":       "CCC",
-			"description": "",
-		}),
+		// Empty
+		NewRow(RowData{}),
 	}
+	model := New(columns).WithRows(rows).Filtered(true)
+	model.filterTextInput.SetValue("AaA")
+
 	filteredRows := model.getFilteredRows(rows)
-	assert.Equal(t, 1, len(filteredRows))
+
+	assert.Len(t, filteredRows, 1)
 }
diff --git a/table/model_test.go b/table/model_test.go
index 8fc982f..1864f22 100644
--- a/table/model_test.go
+++ b/table/model_test.go
@@ -20,15 +20,15 @@ func TestGetVisibleRows(t *testing.T) {
 	input.SetValue("AAA")
 	columns := []Column{NewColumn("title", "title", 10).WithFiltered(true)}
 	rows := []Row{
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "AAA",
 			"description": "",
 		}),
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "BBB",
 			"description": "",
 		}),
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"title":       "CCC",
 			"description": "",
 		}),
diff --git a/table/sort_test.go b/table/sort_test.go
index ac39f83..27d65a9 100644
--- a/table/sort_test.go
+++ b/table/sort_test.go
@@ -152,19 +152,19 @@ func TestGetSortedRows(t *testing.T) {
 		},
 	}
 	rows := getSortedRows(sortColumns, []Row{
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"ca": "2",
 			"cb": "t-1",
 		}),
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"ca": "1",
 			"cb": "t-2",
 		}),
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"ca": "3",
 			"cb": "t-3",
 		}),
-		NewRow(map[string]interface{}{
+		NewRow(RowData{
 			"ca": "3",
 			"cb": "t-2",
 		}),
diff --git a/table/update_test.go b/table/update_test.go
index ea4ffe6..133d976 100644
--- a/table/update_test.go
+++ b/table/update_test.go
@@ -193,14 +193,58 @@ func TestSelectingRowToggles(t *testing.T) {
 	keyDown := tea.KeyMsg{Type: tea.KeyDown}
 
 	assert.False(t, model.GetVisibleRows()[0].selected, "Row shouldn't be selected to start")
+	assert.Len(t, model.SelectedRows(), 0)
 
 	model, _ = model.Update(keyEnter)
 	assert.True(t, model.GetVisibleRows()[0].selected, "Row should be selected after first toggle")
+	assert.Len(t, model.SelectedRows(), 1)
 
 	model, _ = model.Update(keyEnter)
 	assert.False(t, model.GetVisibleRows()[0].selected, "Row should not be selected after second toggle")
+	assert.Len(t, model.SelectedRows(), 0)
 
 	model, _ = model.Update(keyDown)
 	model, _ = model.Update(keyEnter)
 	assert.True(t, model.GetVisibleRows()[1].selected, "Second row should be selected after moving and toggling")
 }
+
+func TestFilterWithKeypresses(t *testing.T) {
+	cols := []Column{
+		NewColumn("name", "Name", 10).WithFiltered(true),
+	}
+
+	model := New(cols).WithRows([]Row{
+		NewRow(RowData{"name": "Pikachu"}),
+		NewRow(RowData{"name": "Charmander"}),
+	}).Focused(true).Filtered(true)
+
+	hitKey := func(key rune) {
+		model, _ = model.Update(tea.KeyMsg{
+			Type:  tea.KeyRunes,
+			Runes: []rune{key},
+		})
+	}
+
+	hitEnter := func() {
+		model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
+	}
+
+	visible := model.GetVisibleRows()
+
+	assert.Len(t, visible, 2)
+	hitKey(rune(model.KeyMap().Filter.Keys()[0][0]))
+	assert.Len(t, visible, 2)
+	hitKey('p')
+	hitKey('i')
+	hitKey('k')
+
+	visible = model.GetVisibleRows()
+
+	assert.Len(t, visible, 1)
+
+	hitEnter()
+
+	hitKey('x')
+
+	assert.Len(t, visible, 1)
+}
diff --git a/table/view_test.go b/table/view_test.go
index 322a4ca..a34bceb 100644
--- a/table/view_test.go
+++ b/table/view_test.go
@@ -5,6 +5,7 @@ import (
 	"strings"
 	"testing"
 
+	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/stretchr/testify/assert"
 )
@@ -381,3 +382,75 @@ func TestSimple3x3StyleOverridesAsBaseColumnRowCell(t *testing.T) {
 
 	assert.Equal(t, expectedTable, rendered)
 }
+
+// This is a long test due to typing and multiple big table strings, that's okay
+// nolint: funlen
+func Test3x3WithFilterFooter(t *testing.T) {
+	model := New([]Column{
+		NewColumn("1", "1", 4).WithFiltered(true),
+		NewColumn("2", "2", 4),
+		NewColumn("3", "3", 4),
+	})
+
+	rows := []Row{}
+
+	for rowIndex := 1; rowIndex <= 3; rowIndex++ {
+		rowData := RowData{}
+
+		for columnIndex := 1; columnIndex <= 3; columnIndex++ {
+			id := fmt.Sprintf("%d", columnIndex)
+
+			rowData[id] = fmt.Sprintf("%d,%d", columnIndex, rowIndex)
+		}
+
+		rows = append(rows, NewRow(rowData))
+	}
+
+	model = model.WithRows(rows).Filtered(true).Focused(true)
+
+	const expectedTable = `┏━━━━┳━━━━┳━━━━┓
+┃   1┃   2┃   3┃
+┣━━━━╋━━━━╋━━━━┫
+┃ 1,1┃ 2,1┃ 3,1┃
+┃ 1,2┃ 2,2┃ 3,2┃
+┃ 1,3┃ 2,3┃ 3,3┃
+┣━━━━┻━━━━┻━━━━┫
+┃              ┃
+┗━━━━━━━━━━━━━━┛`
+
+	assert.Equal(t, expectedTable, model.View())
+
+	hitKey := func(key rune) {
+		model, _ = model.Update(
+			tea.KeyMsg{
+				Type:  tea.KeyRunes,
+				Runes: []rune{key},
+			})
+	}
+
+	hitKey('/')
+	hitKey('3')
+
+	// The byte code near the bottom is a block cursor from the text box
+	const expectedFilteredTypingTable = `┏━━━━┳━━━━┳━━━━┓
+┃   1┃   2┃   3┃
+┣━━━━╋━━━━╋━━━━┫
+┃ 1,3┃ 2,3┃ 3,3┃
+┣━━━━┻━━━━┻━━━━┫
+┃           /3` + "\x1b[7m \x1b[0m" + `┃
+┗━━━━━━━━━━━━━━┛`
+
+	assert.Equal(t, expectedFilteredTypingTable, model.View())
+
+	const expectedFilteredDoneTable = `┏━━━━┳━━━━┳━━━━┓
+┃   1┃   2┃   3┃
+┣━━━━╋━━━━╋━━━━┫
+┃ 1,3┃ 2,3┃ 3,3┃
+┣━━━━┻━━━━┻━━━━┫
+┃            /3┃
+┗━━━━━━━━━━━━━━┛`
+
+	model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+	assert.Equal(t, expectedFilteredDoneTable, model.View())
+}