diff --git a/internal/render/row.go b/internal/render/row.go index 680a405013..8f42c09169 100644 --- a/internal/render/row.go +++ b/internal/render/row.go @@ -8,6 +8,8 @@ import ( "time" "github.com/fvbommel/sortorder" + "github.com/rs/zerolog/log" + "k8s.io/apimachinery/pkg/api/resource" ) // Fields represents a collection of row fields. @@ -162,10 +164,10 @@ func (rr Rows) Sort(col int, asc, isNum, isDur bool) { // RowSorter sorts rows. type RowSorter struct { - Rows Rows - Index int - IsNumber, IsDuration bool - Asc bool + Rows Rows + Index int + IsNumber, IsDuration, IsQuantity bool + Asc bool } func (s RowSorter) Len() int { @@ -177,7 +179,7 @@ func (s RowSorter) Swap(i, j int) { } func (s RowSorter) Less(i, j int) bool { - return Less(s.Asc, s.IsNumber, s.IsDuration, s.Rows[i].Fields[s.Index], s.Rows[j].Fields[s.Index]) + return Less(s.Asc, s.IsNumber, s.IsDuration, s.IsQuantity, s.Rows[i].Fields[s.Index], s.Rows[j].Fields[s.Index]) } // ---------------------------------------------------------------------------- @@ -193,7 +195,22 @@ func toAgeDuration(dur string) string { } // Less return true if c1 < c2. -func Less(asc, isNumber, isDuration bool, c1, c2 string) bool { +func Less(asc, isNumber, isDuration, isQuantity bool, c1, c2 string) bool { + if isQuantity { + q1, errQ1 := resource.ParseQuantity(c1) + q2, errQ2 := resource.ParseQuantity(c2) + if errQ1 == nil && errQ2 == nil { + cmp := q1.Cmp(q2) + b := cmp == -1 + if asc { + return b + } + return !b + } else { + log.Debug().Msgf("Failed to parse quantities: %s - %s", c1, c2) + // Use default comparison even if it might be incorrect + } + } if isNumber { c1, c2 = strings.Replace(c1, ",", "", -1), strings.Replace(c2, ",", "", -1) } diff --git a/internal/render/row_event.go b/internal/render/row_event.go index cc718a31f6..dc9804c02d 100644 --- a/internal/render/row_event.go +++ b/internal/render/row_event.go @@ -196,7 +196,7 @@ func (r RowEvents) FindIndex(id string) (int, bool) { } // Sort rows based on column index and order. -func (r RowEvents) Sort(ns string, sortCol int, ageCol, numCol, asc bool) { +func (r RowEvents) Sort(ns string, sortCol int, ageCol, numCol, qtyCol, asc bool) { if sortCol == -1 { return } @@ -208,6 +208,7 @@ func (r RowEvents) Sort(ns string, sortCol int, ageCol, numCol, asc bool) { Asc: asc, IsNumber: numCol, IsDuration: ageCol, + IsQuantity: qtyCol, } sort.Sort(t) @@ -239,6 +240,7 @@ type RowEventSorter struct { NS string IsNumber bool IsDuration bool + IsQuantity bool Asc bool } @@ -252,7 +254,7 @@ func (r RowEventSorter) Swap(i, j int) { func (r RowEventSorter) Less(i, j int) bool { f1, f2 := r.Events[i].Row.Fields, r.Events[j].Row.Fields - return Less(r.Asc, r.IsNumber, r.IsDuration, f1[r.Index], f2[r.Index]) + return Less(r.Asc, r.IsNumber, r.IsDuration, r.IsQuantity, f1[r.Index], f2[r.Index]) } // ---------------------------------------------------------------------------- diff --git a/internal/render/row_event_test.go b/internal/render/row_event_test.go index 80cf75a907..44931591c8 100644 --- a/internal/render/row_event_test.go +++ b/internal/render/row_event_test.go @@ -409,10 +409,10 @@ func TestRowEventsDelete(t *testing.T) { func TestRowEventsSort(t *testing.T) { uu := map[string]struct { - re render.RowEvents - col int - age, num, asc bool - e render.RowEvents + re render.RowEvents + col int + age, num, qty, asc bool + e render.RowEvents }{ "age_time": { re: render.RowEvents{ @@ -444,6 +444,38 @@ func TestRowEventsSort(t *testing.T) { {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "32d"}}}, }, }, + "qty": { + re: render.RowEvents{ + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "900Mi"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "100Mi"}}}, + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "8Gi"}}}, + }, + + col: 2, + asc: true, + qty: true, + e: render.RowEvents{ + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "100Mi"}}}, + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "900Mi"}}}, + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "8Gi"}}}, + }, + }, + "qty_desc": { + re: render.RowEvents{ + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "900Mi"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "100Mi"}}}, + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "8Gi"}}}, + }, + + col: 2, + asc: false, + qty: true, + e: render.RowEvents{ + {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "8Gi"}}}, + {Row: render.Row{ID: "B", Fields: render.Fields{"0", "2", "900Mi"}}}, + {Row: render.Row{ID: "C", Fields: render.Fields{"10", "2", "100Mi"}}}, + }, + }, "col0": { re: render.RowEvents{ {Row: render.Row{ID: "A", Fields: render.Fields{"1", "2", "3"}}}, @@ -483,7 +515,7 @@ func TestRowEventsSort(t *testing.T) { for k := range uu { u := uu[k] t.Run(k, func(t *testing.T) { - u.re.Sort("", u.col, u.age, u.num, u.asc) + u.re.Sort("", u.col, u.age, u.num, u.qty, u.asc) assert.Equal(t, u.e, u.re) }) } diff --git a/internal/ui/key.go b/internal/ui/key.go index eb51f81ca4..48446c0a07 100644 --- a/internal/ui/key.go +++ b/internal/ui/key.go @@ -10,6 +10,8 @@ func initKeys() { tcell.KeyNames[tcell.Key(KeyHelp)] = "?" tcell.KeyNames[tcell.Key(KeySlash)] = "/" tcell.KeyNames[tcell.Key(KeySpace)] = "space" + tcell.KeyNames[tcell.Key(KeyLess)] = "<" + tcell.KeyNames[tcell.Key(KeyGreater)] = ">" initNumbKeys() initStdKeys() @@ -73,10 +75,12 @@ const ( KeyX KeyY KeyZ - KeyHelp = 63 - KeySlash = 47 - KeyColon = 58 - KeySpace = 32 + KeyHelp = 63 + KeySlash = 47 + KeyColon = 58 + KeySpace = 32 + KeyLess = 60 + KeyGreater = 62 ) // Define Shift Keys. diff --git a/internal/ui/table.go b/internal/ui/table.go index d506078fb3..bd91928bda 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -177,6 +177,23 @@ func (t *Table) SetSortCol(name string, asc bool) { t.sortCol.name, t.sortCol.asc = name, asc } +func (t *Table) GetSortCol() (string, bool) { + return t.sortCol.name, t.sortCol.asc +} + +func (t *Table) isVisible(h render.HeaderColumn) bool { + if h.Name == "NAMESPACE" && !t.GetModel().ClusterWide() { + return false + } + if h.MX && !t.hasMetrics { + return false + } + if h.Wide && !t.wide { + return false + } + return true +} + // Update table content. func (t *Table) Update(data render.TableData, hasMetrics bool) { t.header = data.Header @@ -219,10 +236,7 @@ func (t *Table) doUpdate(data render.TableData) { var col int for _, h := range custData.Header { - if h.Name == "NAMESPACE" && !t.GetModel().ClusterWide() { - continue - } - if h.MX && !t.hasMetrics { + if !t.isVisible(h) { continue } t.AddHeaderCell(col, h) @@ -237,6 +251,7 @@ func (t *Table) doUpdate(data render.TableData) { colIndex, t.sortCol.name == "AGE", data.Header.IsMetricsCol(colIndex), + t.sortCol.name == "CAPACITY", t.sortCol.asc, ) @@ -263,10 +278,7 @@ func (t *Table) buildRow(r int, re, ore render.RowEvent, h render.Header, pads M continue } - if h[c].Name == "NAMESPACE" && !t.GetModel().ClusterWide() { - continue - } - if h[c].MX && !t.hasMetrics { + if !t.isVisible(h[c]) { continue } @@ -310,6 +322,46 @@ func (t *Table) SortColCmd(name string, asc bool) func(evt *tcell.EventKey) *tce } } +// SortColChange changes on which column to sort +func (t *Table) SortColChange(direction SortChange) func(evt *tcell.EventKey) *tcell.EventKey { + return func(evt *tcell.EventKey) *tcell.EventKey { + sortCol := t.sortCol.name + sortColIdx := -1 + newSortColIdx := -1 + for i := range t.header { + if direction == SortPrevCol { + i = len(t.header) - i - 1 + } + h := t.header[i] + + if !t.isVisible(h) { + continue + } + if newSortColIdx == -1 { + // This is the wrap around value, also default value if current sort col name is not found + newSortColIdx = i + } + if sortColIdx != -1 { + // Next match after finding the col name is the target sort column + newSortColIdx = i + break + } + if h.Name == sortCol { + sortColIdx = i + } + } + log.Debug().Msgf("Currently sorting on col %s with index %d", t.sortCol.name, sortColIdx) + // sortColIdx equals -1 is a possible outcome, don't check for it + if newSortColIdx != -1 { + t.sortCol.name = t.header[newSortColIdx].Name + log.Debug().Msgf("New sort col is %s with index %d", t.sortCol.name, newSortColIdx) + // Leave sort order as is + } + t.Refresh() + return nil + } +} + // SortInvertCmd reverses sorting order. func (t *Table) SortInvertCmd(evt *tcell.EventKey) *tcell.EventKey { t.sortCol.asc = !t.sortCol.asc diff --git a/internal/ui/types.go b/internal/ui/types.go index 578cb00c2b..601f74c8b1 100644 --- a/internal/ui/types.go +++ b/internal/ui/types.go @@ -18,6 +18,14 @@ type ( name string asc bool } + + // SortChange changes the column on which to sort + SortChange bool +) + +const ( + SortNextCol SortChange = true + SortPrevCol SortChange = false ) // Namespaceable represents a namespaceable model. diff --git a/internal/view/alias_test.go b/internal/view/alias_test.go index 88805fbdc9..2b26dbd1da 100644 --- a/internal/view/alias_test.go +++ b/internal/view/alias_test.go @@ -23,7 +23,7 @@ func TestAliasNew(t *testing.T) { assert.Nil(t, v.Init(makeContext())) assert.Equal(t, "Aliases", v.Name()) - assert.Equal(t, 6, len(v.Hints())) + assert.Equal(t, 9, len(v.Hints())) } func TestAliasSearch(t *testing.T) { diff --git a/internal/view/cm_test.go b/internal/view/cm_test.go index 461ff4ba00..5f8a563423 100644 --- a/internal/view/cm_test.go +++ b/internal/view/cm_test.go @@ -13,5 +13,5 @@ func TestConfigMapNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "ConfigMaps", s.Name()) - assert.Equal(t, 6, len(s.Hints())) + assert.Equal(t, 9, len(s.Hints())) } diff --git a/internal/view/container_test.go b/internal/view/container_test.go index 161f4cec9d..4d1a23f2f0 100644 --- a/internal/view/container_test.go +++ b/internal/view/container_test.go @@ -13,5 +13,5 @@ func TestContainerNew(t *testing.T) { assert.Nil(t, c.Init(makeCtx())) assert.Equal(t, "Containers", c.Name()) - assert.Equal(t, 18, len(c.Hints())) + assert.Equal(t, 21, len(c.Hints())) } diff --git a/internal/view/context_test.go b/internal/view/context_test.go index dc0f693ea0..696b8e7e3d 100644 --- a/internal/view/context_test.go +++ b/internal/view/context_test.go @@ -13,5 +13,5 @@ func TestContext(t *testing.T) { assert.Nil(t, ctx.Init(makeCtx())) assert.Equal(t, "Contexts", ctx.Name()) - assert.Equal(t, 4, len(ctx.Hints())) + assert.Equal(t, 7, len(ctx.Hints())) } diff --git a/internal/view/dir_test.go b/internal/view/dir_test.go index b47f12b12c..1a618198ec 100644 --- a/internal/view/dir_test.go +++ b/internal/view/dir_test.go @@ -12,5 +12,5 @@ func TestDir(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Directory", v.Name()) - assert.Equal(t, 7, len(v.Hints())) + assert.Equal(t, 10, len(v.Hints())) } diff --git a/internal/view/dp_test.go b/internal/view/dp_test.go index 9c006652e4..033708d1e2 100644 --- a/internal/view/dp_test.go +++ b/internal/view/dp_test.go @@ -13,5 +13,5 @@ func TestDeploy(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Deployments", v.Name()) - assert.Equal(t, 14, len(v.Hints())) + assert.Equal(t, 17, len(v.Hints())) } diff --git a/internal/view/ds_test.go b/internal/view/ds_test.go index d43fe84bff..4fefb67c37 100644 --- a/internal/view/ds_test.go +++ b/internal/view/ds_test.go @@ -13,5 +13,5 @@ func TestDaemonSet(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "DaemonSets", v.Name()) - assert.Equal(t, 15, len(v.Hints())) + assert.Equal(t, 18, len(v.Hints())) } diff --git a/internal/view/help_test.go b/internal/view/help_test.go index 0465b53c62..b6f1743b21 100644 --- a/internal/view/help_test.go +++ b/internal/view/help_test.go @@ -21,7 +21,7 @@ func TestHelp(t *testing.T) { v := view.NewHelp(app) assert.Nil(t, v.Init(ctx)) - assert.Equal(t, 25, v.GetRowCount()) + assert.Equal(t, 28, v.GetRowCount()) assert.Equal(t, 6, v.GetColumnCount()) assert.Equal(t, "", strings.TrimSpace(v.GetCell(1, 0).Text)) assert.Equal(t, "Attach", strings.TrimSpace(v.GetCell(1, 1).Text)) diff --git a/internal/view/ns_test.go b/internal/view/ns_test.go index 093e2e57ed..c114f57000 100644 --- a/internal/view/ns_test.go +++ b/internal/view/ns_test.go @@ -13,5 +13,5 @@ func TestNSCleanser(t *testing.T) { assert.Nil(t, ns.Init(makeCtx())) assert.Equal(t, "Namespaces", ns.Name()) - assert.Equal(t, 7, len(ns.Hints())) + assert.Equal(t, 10, len(ns.Hints())) } diff --git a/internal/view/pf_test.go b/internal/view/pf_test.go index 505cc60b69..5dbaee3045 100644 --- a/internal/view/pf_test.go +++ b/internal/view/pf_test.go @@ -13,5 +13,5 @@ func TestPortForwardNew(t *testing.T) { assert.Nil(t, pf.Init(makeCtx())) assert.Equal(t, "PortForwards", pf.Name()) - assert.Equal(t, 10, len(pf.Hints())) + assert.Equal(t, 13, len(pf.Hints())) } diff --git a/internal/view/pod_test.go b/internal/view/pod_test.go index bf1dd70709..1f942588d7 100644 --- a/internal/view/pod_test.go +++ b/internal/view/pod_test.go @@ -16,7 +16,7 @@ func TestPodNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "Pods", po.Name()) - assert.Equal(t, 24, len(po.Hints())) + assert.Equal(t, 27, len(po.Hints())) } // Helpers... diff --git a/internal/view/pvc_test.go b/internal/view/pvc_test.go index 1559549882..8c9ea1db02 100644 --- a/internal/view/pvc_test.go +++ b/internal/view/pvc_test.go @@ -13,5 +13,5 @@ func TestPVCNew(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "PersistentVolumeClaims", v.Name()) - assert.Equal(t, 10, len(v.Hints())) + assert.Equal(t, 13, len(v.Hints())) } diff --git a/internal/view/rbac_test.go b/internal/view/rbac_test.go index 2c0972c81d..a81a77507f 100644 --- a/internal/view/rbac_test.go +++ b/internal/view/rbac_test.go @@ -13,5 +13,5 @@ func TestRbacNew(t *testing.T) { assert.Nil(t, v.Init(makeCtx())) assert.Equal(t, "Rbac", v.Name()) - assert.Equal(t, 5, len(v.Hints())) + assert.Equal(t, 8, len(v.Hints())) } diff --git a/internal/view/reference_test.go b/internal/view/reference_test.go index c7e17f3719..f54c871f69 100644 --- a/internal/view/reference_test.go +++ b/internal/view/reference_test.go @@ -13,5 +13,5 @@ func TestReferenceNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "References", s.Name()) - assert.Equal(t, 4, len(s.Hints())) + assert.Equal(t, 7, len(s.Hints())) } diff --git a/internal/view/screen_dump_test.go b/internal/view/screen_dump_test.go index 0e191f1b54..7152f30449 100644 --- a/internal/view/screen_dump_test.go +++ b/internal/view/screen_dump_test.go @@ -13,5 +13,5 @@ func TestScreenDumpNew(t *testing.T) { assert.Nil(t, po.Init(makeCtx())) assert.Equal(t, "ScreenDumps", po.Name()) - assert.Equal(t, 5, len(po.Hints())) + assert.Equal(t, 8, len(po.Hints())) } diff --git a/internal/view/secret_test.go b/internal/view/secret_test.go index c2d9d4df4f..ad6606a402 100644 --- a/internal/view/secret_test.go +++ b/internal/view/secret_test.go @@ -13,5 +13,5 @@ func TestSecretNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "Secrets", s.Name()) - assert.Equal(t, 7, len(s.Hints())) + assert.Equal(t, 10, len(s.Hints())) } diff --git a/internal/view/sts_test.go b/internal/view/sts_test.go index 6b7374906c..4b7e969731 100644 --- a/internal/view/sts_test.go +++ b/internal/view/sts_test.go @@ -13,5 +13,5 @@ func TestStatefulSetNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "StatefulSets", s.Name()) - assert.Equal(t, 12, len(s.Hints())) + assert.Equal(t, 15, len(s.Hints())) } diff --git a/internal/view/svc_test.go b/internal/view/svc_test.go index 32fd6a2cc2..87aa7f3edb 100644 --- a/internal/view/svc_test.go +++ b/internal/view/svc_test.go @@ -162,5 +162,5 @@ func TestServiceNew(t *testing.T) { assert.Nil(t, s.Init(makeCtx())) assert.Equal(t, "Services", s.Name()) - assert.Equal(t, 10, len(s.Hints())) + assert.Equal(t, 13, len(s.Hints())) } diff --git a/internal/view/table.go b/internal/view/table.go index d941a07f43..cdffdd7e2a 100644 --- a/internal/view/table.go +++ b/internal/view/table.go @@ -181,6 +181,9 @@ func (t *Table) bindKeys() { tcell.KeyCtrlW: ui.NewKeyAction("Toggle Wide", t.toggleWideCmd, false), ui.KeyShiftN: ui.NewKeyAction("Sort Name", t.SortColCmd(nameCol, true), false), ui.KeyShiftA: ui.NewKeyAction("Sort Age", t.SortColCmd(ageCol, true), false), + ui.KeyQ: ui.NewKeyAction("Reverse sort order", t.SortInvertCmd, false), + ui.KeyLess: ui.NewKeyAction("Sort Previous Column", t.SortColChange(ui.SortPrevCol), false), + ui.KeyGreater: ui.NewKeyAction("Sort Next Column", t.SortColChange(ui.SortNextCol), false), }) } diff --git a/internal/view/table_int_test.go b/internal/view/table_int_test.go index 0ddab5fd31..8119ac0d19 100644 --- a/internal/view/table_int_test.go +++ b/internal/view/table_int_test.go @@ -4,6 +4,7 @@ import ( "context" "io/ioutil" "path/filepath" + "strings" "testing" "time" @@ -64,7 +65,7 @@ func TestTableNew(t *testing.T) { func TestTableViewFilter(t *testing.T) { v := NewTable(client.NewGVR("test")) v.Init(makeContext()) - v.SetModel(&mockTableModel{}) + v.SetModel(defaultModelMock()) v.Refresh() v.CmdBuff().SetActive(true) v.CmdBuff().SetText("blee") @@ -75,7 +76,7 @@ func TestTableViewFilter(t *testing.T) { func TestTableViewSort(t *testing.T) { v := NewTable(client.NewGVR("test")) v.Init(makeContext()) - v.SetModel(&mockTableModel{}) + v.SetModel(defaultModelMock()) v.SortColCmd("NAME", true)(nil) assert.Equal(t, 3, v.GetRowCount()) assert.Equal(t, "blee", v.GetCell(1, 0).Text) @@ -85,44 +86,190 @@ func TestTableViewSort(t *testing.T) { assert.Equal(t, "fred", v.GetCell(1, 0).Text) } -// ---------------------------------------------------------------------------- -// Helpers... +func TestTableViewSortChangeSmoke(t *testing.T) { + v := NewTable(client.NewGVR("test")) + v.Init(makeContext()) + v.SetModel(defaultModelMock()) + v.SortColCmd("NAME", true)(nil) + assert.Equal(t, 3, v.GetRowCount()) + assert.Equal(t, "blee", v.GetCell(1, 0).Text) + + v.SortColChange(ui.SortNextCol)(nil) + v.SortColChange(ui.SortNextCol)(nil) + sortCol, asc := v.GetSortCol() + assert.Equal(t, "AGE", sortCol) + assert.Equal(t, true, asc) + assert.Equal(t, "fred", v.GetCell(1, 0).Text) -type mockTableModel struct{} + v.SortInvertCmd(nil) + _, asc = v.GetSortCol() + assert.Equal(t, false, asc) + assert.Equal(t, "blee", v.GetCell(1, 0).Text) +} -var _ ui.Tabular = (*mockTableModel)(nil) +type SortSteps []struct { + change ui.SortChange + reverse bool -func (t *mockTableModel) SetInstance(string) {} -func (t *mockTableModel) SetLabelFilter(string) {} -func (t *mockTableModel) Empty() bool { return false } -func (t *mockTableModel) HasMetrics() bool { return true } -func (t *mockTableModel) Peek() render.TableData { return makeTableData() } -func (t *mockTableModel) Refresh(context.Context) error { return nil } -func (t *mockTableModel) ClusterWide() bool { return false } -func (t *mockTableModel) GetNamespace() string { return "blee" } -func (t *mockTableModel) SetNamespace(string) {} -func (t *mockTableModel) ToggleToast() {} -func (t *mockTableModel) AddListener(model.TableListener) {} -func (t *mockTableModel) RemoveListener(model.TableListener) {} -func (t *mockTableModel) Watch(context.Context) error { return nil } -func (t *mockTableModel) Get(context.Context, string) (runtime.Object, error) { - return nil, nil + sortCol string + asc bool + value string + col int } -func (t *mockTableModel) Delete(context.Context, string, bool, bool) error { - return nil +func TestTableViewSortWrapAround(t *testing.T) { + v := NewTable(client.NewGVR("test")) + v.Init(makeContext()) + v.SetModel(modelMockForSortingMinimal()) + v.SortColCmd("NAME", true)(nil) + assert.Equal(t, 3, v.GetRowCount()) + + steps := SortSteps{ + {change: ui.SortNextCol, sortCol: "FRED", asc: true, value: "fred1", col: 1}, + {change: ui.SortNextCol, sortCol: "AGE", asc: true, value: "90s", col: 2}, + {change: ui.SortNextCol, sortCol: "NAME", asc: true, value: "name1", col: 0}, + {change: ui.SortNextCol, reverse: true, sortCol: "FRED", asc: false, value: "fred2", col: 1}, + {change: ui.SortPrevCol, sortCol: "NAME", asc: false, value: "name2", col: 0}, + {change: ui.SortPrevCol, sortCol: "AGE", asc: false, value: "110s", col: 2}, + {change: ui.SortPrevCol, sortCol: "FRED", asc: false, value: "fred2", col: 1}, + } + + runSortSteps(t, v, steps) } -func (t *mockTableModel) Describe(context.Context, string) (string, error) { - return "", nil +func TestTableViewFullSortWrapAround(t *testing.T) { + v := NewTable(client.NewGVR("test")) + v.ToggleWide() // Enable wide display + v.Init(makeContext()) + v.SetModel(modelMockForSortingFull()) + v.Update(makeTableDataForSorting(), true) // Enable metrics + v.SortColCmd("NAME", true)(nil) + assert.Equal(t, 3, v.GetRowCount()) + + steps := SortSteps{ + {change: ui.SortNextCol, sortCol: "LABELS", asc: true, value: "k8s-app=kube-dns1", col: 2}, + {change: ui.SortNextCol, sortCol: "FRED", asc: true, value: "fred1", col: 3}, + {change: ui.SortNextCol, sortCol: "CPU", asc: true, value: "10", col: 4}, + {change: ui.SortNextCol, sortCol: "AGE", asc: true, value: "90s", col: 5}, + {change: ui.SortNextCol, sortCol: "NAMESPACE", asc: true, value: "ns1", col: 0}, + {change: ui.SortNextCol, reverse: true, sortCol: "NAME", asc: false, value: "name2", col: 1}, + {change: ui.SortPrevCol, sortCol: "NAMESPACE", asc: false, value: "ns1", col: 0}, + {change: ui.SortPrevCol, sortCol: "AGE", asc: false, value: "110s", col: 5}, + {change: ui.SortPrevCol, sortCol: "CPU", asc: false, value: "20", col: 4}, + {change: ui.SortPrevCol, sortCol: "FRED", asc: false, value: "fred2", col: 3}, + {change: ui.SortPrevCol, sortCol: "LABELS", asc: false, value: "k8s-app=kube-dns2", col: 2}, + } + + runSortSteps(t, v, steps) } -func (t *mockTableModel) ToYAML(ctx context.Context, path string) (string, error) { - return "", nil +func runSortSteps(t *testing.T, v *Table, steps SortSteps) { + for _, step := range steps { + v.SortColChange(step.change)(nil) + if step.reverse { + v.SortInvertCmd(nil) + } + sortCol, asc := v.GetSortCol() + assert.Equal(t, step.sortCol, sortCol) + assert.Equal(t, step.asc, asc) + assert.Equal(t, step.value, strings.TrimSpace(v.GetCell(1, step.col).Text)) + } } -func (t *mockTableModel) InNamespace(string) bool { return true } -func (t *mockTableModel) SetRefreshRate(time.Duration) {} +func TestTableViewSortCapacity(t *testing.T) { + v := NewTable(client.NewGVR("test")) + v.Init(makeContext()) + + data := render.NewTableData() + data.Header = render.Header{ + render.HeaderColumn{Name: "NAMESPACE"}, + render.HeaderColumn{Name: "NAME", Align: tview.AlignRight}, + render.HeaderColumn{Name: "CAPACITY"}, + render.HeaderColumn{Name: "AGE", Time: true, Decorator: render.AgeDecorator}, + } + data.RowEvents = render.RowEvents{ + render.RowEvent{ + Row: render.Row{ + Fields: render.Fields{"ns1", "a", "100Mi", "3m"}, + }, + }, + render.RowEvent{ + Row: render.Row{ + Fields: render.Fields{"ns1", "b", "900Mi", "1m"}, + }, + }, + render.RowEvent{ + Row: render.Row{ + Fields: render.Fields{"ns1", "c", "8Gi", "10m"}, + }, + }, + } + data.Namespace = "" + + v.SetSortCol("CAPACITY", true) + v.Update(*data, false) + assert.Equal(t, "100Mi", strings.TrimSpace(v.GetCell(1, 2).Text)) + assert.Equal(t, "900Mi", strings.TrimSpace(v.GetCell(2, 2).Text)) + assert.Equal(t, "8Gi", strings.TrimSpace(v.GetCell(3, 2).Text)) + v.SetSortCol("CAPACITY", false) + v.Update(*data, false) + assert.Equal(t, "8Gi", strings.TrimSpace(v.GetCell(1, 2).Text)) + assert.Equal(t, "900Mi", strings.TrimSpace(v.GetCell(2, 2).Text)) + assert.Equal(t, "100Mi", strings.TrimSpace(v.GetCell(3, 2).Text)) +} + +// ---------------------------------------------------------------------------- +// Helpers... + +type tableModelMock struct { + MockHasMetrics func() bool + MockPeek func() render.TableData + MockClusterWide func() bool +} + +func (fake *tableModelMock) HasMetrics() bool { return fake.MockHasMetrics() } +func (fake *tableModelMock) Peek() render.TableData { return fake.MockPeek() } +func (fake *tableModelMock) ClusterWide() bool { return fake.MockClusterWide() } +func (fake *tableModelMock) SetInstance(string) {} +func (fake *tableModelMock) SetLabelFilter(string) {} +func (fake *tableModelMock) Empty() bool { return false } +func (fake *tableModelMock) Refresh(context.Context) error { return nil } +func (fake *tableModelMock) GetNamespace() string { return "blee" } +func (fake *tableModelMock) SetNamespace(string) {} +func (fake *tableModelMock) ToggleToast() {} +func (fake *tableModelMock) AddListener(model.TableListener) {} +func (fake *tableModelMock) RemoveListener(model.TableListener) {} +func (fake *tableModelMock) Watch(context.Context) error { return nil } +func (fake *tableModelMock) Get(context.Context, string) (runtime.Object, error) { return nil, nil } +func (fake *tableModelMock) Delete(context.Context, string, bool, bool) error { return nil } +func (fake *tableModelMock) Describe(context.Context, string) (string, error) { return "", nil } +func (fake *tableModelMock) ToYAML(ctx context.Context, path string) (string, error) { return "", nil } +func (fake *tableModelMock) InNamespace(string) bool { return true } +func (fake *tableModelMock) SetRefreshRate(time.Duration) {} + +func defaultModelMock() *tableModelMock { + return &tableModelMock{ + MockHasMetrics: func() bool { return true }, + MockPeek: func() render.TableData { return makeTableData() }, + MockClusterWide: func() bool { return false }, + } +} + +func modelMockForSortingMinimal() *tableModelMock { + return &tableModelMock{ + MockHasMetrics: func() bool { return false }, + MockPeek: func() render.TableData { return makeTableDataForSorting() }, + MockClusterWide: func() bool { return false }, + } +} + +func modelMockForSortingFull() *tableModelMock { + return &tableModelMock{ + MockHasMetrics: func() bool { return true }, + MockPeek: func() render.TableData { return makeTableDataForSorting() }, + MockClusterWide: func() bool { return true }, + } +} func makeTableData() render.TableData { t := render.NewTableData() @@ -151,6 +298,34 @@ func makeTableData() render.TableData { return *t } +func makeTableDataForSorting() render.TableData { + t := render.NewTableData() + + t.Header = render.Header{ + render.HeaderColumn{Name: "NAMESPACE"}, + render.HeaderColumn{Name: "NAME", Align: tview.AlignRight}, + render.HeaderColumn{Name: "LABELS", Wide: true}, + render.HeaderColumn{Name: "FRED"}, + render.HeaderColumn{Name: "CPU", MX: true}, + render.HeaderColumn{Name: "AGE", Time: true, Decorator: render.AgeDecorator}, + } + t.RowEvents = render.RowEvents{ + render.RowEvent{ + Row: render.Row{ + Fields: render.Fields{"ns1", "name1", "k8s-app=kube-dns2", "fred2", "20", "90s"}, + }, + }, + render.RowEvent{ + Row: render.Row{ + Fields: render.Fields{"ns1", "name2", "k8s-app=kube-dns1", "fred1", "10", "110s"}, + }, + }, + } + t.Namespace = "" + + return *t +} + func makeContext() context.Context { a := NewApp(config.NewConfig(ks{})) ctx := context.WithValue(context.Background(), internal.KeyApp, a)