Skip to content

Commit 6ea6191

Browse files
Ppitokeidarcy
andauthored
feat: preserve sort/filter state across view refreshes (#477)
* chore: keep sort/filter state * fix: preserve table state across refresh --------- Co-authored-by: Xing Yahao <48758247+keidarcy@users.noreply.github.com>
1 parent fb209d1 commit 6ea6191

6 files changed

Lines changed: 240 additions & 7 deletions

File tree

internal/view/app.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ type Option struct {
6767
Splash bool
6868
}
6969

70+
// viewState holds sort/filter state per page so it can be restored after a reload.
71+
type viewState struct {
72+
sortColumn int // -1 = no active sort
73+
sortOrder string // "asc" or "desc"
74+
filterText string
75+
}
76+
7077
// tview App
7178
type App struct {
7279
// tview Application
@@ -95,6 +102,8 @@ type App struct {
95102
rowIndex int
96103
// Specify in tview app suspend or not
97104
isSuspended bool
105+
// True while the filter input is open; auto refresh should not replace it.
106+
filterInputActive bool
98107
// Show selected status tasks
99108
taskStatus types.DesiredStatus
100109
// Show resources from cluster
@@ -104,6 +113,8 @@ type App struct {
104113
bootstrapServices []types.Service
105114
// Set when splash bootstrap fails before Run() returns; read after Run().
106115
splashStartupErr error
116+
// Persists sort/filter state per page across page reloads.
117+
viewStates map[string]viewState
107118
}
108119

109120
func newApp(option Option) (*App, error) {
@@ -151,9 +162,18 @@ func newApp(option Option) (*App, error) {
151162
container: &types.Container{},
152163
taskDefinition: &types.TaskDefinition{},
153164
},
165+
viewStates: make(map[string]viewState),
154166
}, nil
155167
}
156168

169+
func (app *App) viewStateKey() string {
170+
return app.kind.getAppPageName(app.getPageHandle())
171+
}
172+
173+
func (app *App) canAutoRefresh() bool {
174+
return app.secondaryKind == EmptyKind && !app.isSuspended && !app.filterInputActive
175+
}
176+
157177
// Entry point of the app
158178
func Start(option Option) error {
159179
file := utils.GetLogger(option.LogFile, option.JSON, option.Debug)
@@ -304,6 +324,10 @@ func (app *App) start() error {
304324
if app.secondaryKind == EmptyKind && !app.isSuspended {
305325
// tview is not thread-safe: UI updates must run on the main loop
306326
app.QueueUpdateDraw(func() {
327+
if !app.canAutoRefresh() {
328+
slog.Debug("Auto refresh skipped")
329+
return
330+
}
307331
if err := app.showPrimaryKindPage(app.kind, true); err != nil {
308332
// showPrimaryKindPage already shows error in Notice
309333
}

internal/view/filter.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func (v *view) initFilterInput() {
3434
if v.filterApplyTimer != nil {
3535
v.filterApplyTimer.Stop()
3636
}
37+
v.saveCurrentViewState()
3738
v.filterApplyTimer = time.AfterFunc(1*time.Second, func() {
3839
v.app.QueueUpdateDraw(func() {
3940
if v.filterActive {
@@ -76,7 +77,8 @@ func (v *view) applyFilter() {
7677
if v.filterInput == nil {
7778
return
7879
}
79-
filterText := v.filterInput.GetText()
80+
filterText := v.currentFilterText()
81+
v.saveCurrentViewState()
8082
filteredData := [][]string{}
8183
filteredReferences := []Entity{}
8284
for i, row := range v.originalRowData {
@@ -115,6 +117,7 @@ func (v *view) showFilterInput() error {
115117
return nil
116118
}
117119
v.filterActive = true
120+
v.app.filterInputActive = true
118121
if v.mainFlex != nil {
119122
v.mainFlex.RemoveItem(v.tablePages)
120123
v.mainFlex.RemoveItem(v.footer.footerFlex)
@@ -131,6 +134,7 @@ func (v *view) hideFilterInput() {
131134
return
132135
}
133136
v.filterActive = false
137+
v.app.filterInputActive = false
134138
if v.filterApplyTimer != nil {
135139
v.filterApplyTimer.Stop()
136140
v.filterApplyTimer = nil

internal/view/resource_view.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ func buildResourcePage[T any](
6161

6262
v.mainFlex = page
6363
v.initFilterInput()
64+
v.restoreViewState()
65+
6466
v.app.addAppPage(page)
6567
v.table.Select(v.app.rowIndex, 0)
6668
return nil

internal/view/resource_view_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,167 @@ func TestBuildResourcePage(t *testing.T) {
172172
}
173173
})
174174
}
175+
176+
func TestViewStateKeyIncludesPageHandle(t *testing.T) {
177+
app, _ := newApp(Option{})
178+
app.kind = ServiceKind
179+
app.cluster.ClusterArn = aws.String(clusterArn1)
180+
cluster1Key := app.viewStateKey()
181+
182+
app.cluster.ClusterArn = aws.String(clusterArn2)
183+
cluster2Key := app.viewStateKey()
184+
185+
if cluster1Key == cluster2Key {
186+
t.Errorf("view state keys should differ between clusters, got %q", cluster1Key)
187+
}
188+
}
189+
190+
func TestCanAutoRefreshSkipsWhileFilterInputActive(t *testing.T) {
191+
app, _ := newApp(Option{})
192+
if !app.canAutoRefresh() {
193+
t.Errorf("canAutoRefresh should allow refresh by default")
194+
}
195+
196+
app.filterInputActive = true
197+
if app.canAutoRefresh() {
198+
t.Errorf("canAutoRefresh should skip while filter input is active")
199+
}
200+
201+
app.filterInputActive = false
202+
app.isSuspended = true
203+
if app.canAutoRefresh() {
204+
t.Errorf("canAutoRefresh should skip while app is suspended")
205+
}
206+
207+
app.isSuspended = false
208+
app.secondaryKind = DescriptionKind
209+
if app.canAutoRefresh() {
210+
t.Errorf("canAutoRefresh should skip while a secondary view is active")
211+
}
212+
}
213+
214+
func TestShowAndHideFilterInputTogglesAutoRefreshGuard(t *testing.T) {
215+
app, _ := newApp(Option{})
216+
v := newView(app, basicKeyInputs, nil)
217+
v.initFilterInput()
218+
219+
err := v.showFilterInput()
220+
if err != nil {
221+
t.Errorf("Got: %v, Want: %v\n", err, nil)
222+
}
223+
if !app.filterInputActive {
224+
t.Errorf("filterInputActive should be true after showing the filter input")
225+
}
226+
if app.canAutoRefresh() {
227+
t.Errorf("canAutoRefresh should skip while the filter input is shown")
228+
}
229+
230+
v.hideFilterInput()
231+
if app.filterInputActive {
232+
t.Errorf("filterInputActive should be false after hiding the filter input")
233+
}
234+
}
235+
236+
func TestFilterInputChangeSavesViewStateBeforeApply(t *testing.T) {
237+
app, _ := newApp(Option{})
238+
app.kind = ClusterKind
239+
v := newView(app, basicKeyInputs, nil)
240+
v.initFilterInput()
241+
242+
v.filterInput.SetText("bravo")
243+
244+
state, ok := app.viewStates[app.viewStateKey()]
245+
if !ok {
246+
t.Fatalf("view state should be saved when filter text changes")
247+
}
248+
if state.filterText != "bravo" {
249+
t.Errorf("filterText Got: %q, Want: %q", state.filterText, "bravo")
250+
}
251+
if state.sortColumn != -1 {
252+
t.Errorf("sortColumn Got: %d, Want: %d", state.sortColumn, -1)
253+
}
254+
}
255+
256+
func TestApplyFilterSavesNoSortState(t *testing.T) {
257+
app, _ := newApp(Option{})
258+
app.kind = ClusterKind
259+
v := newView(app, basicKeyInputs, nil)
260+
v.headers = []string{"Name"}
261+
v.originalRowData = [][]string{
262+
{"alpha"},
263+
{"bravo"},
264+
}
265+
v.originalRowReferences = []Entity{
266+
{entityName: "alpha"},
267+
{entityName: "bravo"},
268+
}
269+
v.initFilterInput()
270+
v.filterInput.SetText("a")
271+
272+
v.applyFilter()
273+
274+
state, ok := app.viewStates[app.viewStateKey()]
275+
if !ok {
276+
t.Fatalf("view state should be saved")
277+
}
278+
if state.filterText != "a" {
279+
t.Errorf("filterText Got: %q, Want: %q", state.filterText, "a")
280+
}
281+
if state.sortColumn != -1 {
282+
t.Errorf("sortColumn Got: %d, Want: %d", state.sortColumn, -1)
283+
}
284+
if state.sortOrder != "desc" {
285+
t.Errorf("sortOrder Got: %q, Want: %q", state.sortOrder, "desc")
286+
}
287+
}
288+
289+
func TestBuildResourcePageRestoresFilterOnlyStateWithoutSorting(t *testing.T) {
290+
app, _ := newApp(Option{})
291+
app.kind = ClusterKind
292+
app.viewStates[app.viewStateKey()] = viewState{
293+
sortColumn: -1,
294+
sortOrder: "desc",
295+
filterText: "br",
296+
}
297+
298+
v := newView(app, basicKeyInputs, nil)
299+
v.originalRowReferences = []Entity{
300+
{entityName: "alpha"},
301+
{entityName: "bravo"},
302+
{entityName: "charlie"},
303+
}
304+
footer := tview.NewTextView().SetDynamicColors(true)
305+
builder := &testResourceViewBuilder{
306+
v: v,
307+
footer: footer,
308+
title: "clusters",
309+
headers: []string{
310+
"Name",
311+
},
312+
rows: [][]string{
313+
{"alpha"},
314+
{"bravo"},
315+
{"charlie"},
316+
},
317+
}
318+
319+
err := buildResourcePage([]string{"resource"}, app, nil, func() resourceViewBuilder {
320+
return builder
321+
})
322+
if err != nil {
323+
t.Errorf("Got: %v, Want: %v\n", err, nil)
324+
}
325+
if v.filterInput.GetText() != "br" {
326+
t.Errorf("filter input Got: %q, Want: %q", v.filterInput.GetText(), "br")
327+
}
328+
if v.table.GetRowCount() != 2 {
329+
t.Errorf("RowCount Got: %d, Want: %d", v.table.GetRowCount(), 2)
330+
}
331+
firstRow := v.table.GetCell(1, 0).Text
332+
if firstRow != "bravo" {
333+
t.Errorf("first row Got: %q, Want filtered row %q", firstRow, "bravo")
334+
}
335+
if v.sortColumn != -1 {
336+
t.Errorf("sortColumn Got: %d, Want: %d", v.sortColumn, -1)
337+
}
338+
}

internal/view/table.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,6 @@ func (v *view) handleInputCapture(event *tcell.EventKey) *tcell.EventKey {
226226
return event
227227
}
228228
case 'r':
229-
v.sortColumn = 0
230-
v.sortOrder = "desc"
231229
v.reloadResource(true)
232230
case 'R':
233231
if v.app.kind == ServiceDeploymentKind {

internal/view/table_sort.go

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,58 @@ func (v *view) sortByColumn(column int) {
3535

3636
slog.Info("sort column", "column", column, "header", v.headers[column], "order", v.sortOrder)
3737

38-
v.table.Clear()
38+
v.saveCurrentViewState()
3939

4040
sortedOriginalIndex := v.getSortedOriginalIndexWithFilterText(column)
41+
v.rebuildTableFromOriginalIndexes(sortedOriginalIndex)
42+
}
43+
44+
func (v *view) currentFilterText() string {
45+
if v.filterInput == nil {
46+
return ""
47+
}
48+
return v.filterInput.GetText()
49+
}
50+
51+
func (v *view) saveCurrentViewState() {
52+
v.app.viewStates[v.app.viewStateKey()] = viewState{
53+
sortColumn: v.sortColumn,
54+
sortOrder: v.sortOrder,
55+
filterText: v.currentFilterText(),
56+
}
57+
}
58+
59+
func (v *view) restoreViewState() {
60+
state, ok := v.app.viewStates[v.app.viewStateKey()]
61+
if !ok {
62+
return
63+
}
64+
if state.filterText != "" && v.filterInput != nil {
65+
v.filterInput.SetText(state.filterText)
66+
}
67+
if state.sortColumn >= 0 && state.sortColumn < len(v.headers) && len(v.originalRowData) > 0 {
68+
v.sortColumn = state.sortColumn
69+
if state.sortOrder != "" {
70+
v.sortOrder = state.sortOrder
71+
}
72+
sortedOriginalIndex := v.getSortedOriginalIndexWithFilterText(v.sortColumn)
73+
v.rebuildTableFromOriginalIndexes(sortedOriginalIndex)
74+
v.updateFilterTitle()
75+
return
76+
}
77+
if state.filterText != "" {
78+
v.applyFilter()
79+
}
80+
}
4181

42-
// sortIndex is sorted
43-
sortedRowData := [][]string{}
44-
sortedReference := []Entity{}
82+
func (v *view) rebuildTableFromOriginalIndexes(sortedOriginalIndex []int) {
83+
sortedRowData := make([][]string, 0, len(sortedOriginalIndex))
84+
sortedReference := make([]Entity, 0, len(sortedOriginalIndex))
4585
for _, oldIdx := range sortedOriginalIndex {
4686
sortedRowData = append(sortedRowData, v.originalRowData[oldIdx])
4787
sortedReference = append(sortedReference, v.originalRowReferences[oldIdx])
4888
}
89+
v.table.Clear()
4990
v.buildTableContent(sortedRowData, sortedReference)
5091

5192
// need to change selected values after sort for enter to work

0 commit comments

Comments
 (0)