tangled
alpha
login
or
join now
desertthunder.dev
/
noteleaf
29
fork
atom
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐
charm
leaflet
readability
golang
29
fork
atom
overview
issues
2
pulls
pipelines
feat: generic data list & tables
desertthunder.dev
6 months ago
3fed73b3
c32e8191
+2866
4 changed files
expand all
collapse all
unified
split
internal
ui
data_list.go
data_list_test.go
data_table.go
data_table_test.go
+473
internal/ui/data_list.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
"io"
7
7
+
"os"
8
8
+
"strings"
9
9
+
10
10
+
"github.com/charmbracelet/bubbles/help"
11
11
+
"github.com/charmbracelet/bubbles/key"
12
12
+
tea "github.com/charmbracelet/bubbletea"
13
13
+
"github.com/charmbracelet/lipgloss"
14
14
+
"github.com/stormlightlabs/noteleaf/internal/models"
15
15
+
)
16
16
+
17
17
+
// ListItem represents a single item in a list
18
18
+
type ListItem interface {
19
19
+
models.Model
20
20
+
GetTitle() string
21
21
+
GetDescription() string
22
22
+
GetFilterValue() string
23
23
+
}
24
24
+
25
25
+
// ListSource provides data for the list
26
26
+
type ListSource interface {
27
27
+
Load(ctx context.Context, opts ListOptions) ([]ListItem, error)
28
28
+
Count(ctx context.Context, opts ListOptions) (int, error)
29
29
+
Search(ctx context.Context, query string, opts ListOptions) ([]ListItem, error)
30
30
+
}
31
31
+
32
32
+
// ListOptions configures data loading for lists
33
33
+
type ListOptions struct {
34
34
+
Filters map[string]any
35
35
+
SortBy string
36
36
+
SortOrder string
37
37
+
Limit int
38
38
+
Offset int
39
39
+
Search string
40
40
+
}
41
41
+
42
42
+
// ListAction defines an action that can be performed on a list item
43
43
+
type ListAction struct {
44
44
+
Key string
45
45
+
Description string
46
46
+
Handler func(item ListItem) tea.Cmd
47
47
+
}
48
48
+
49
49
+
// DataListKeyMap defines key bindings for list navigation
50
50
+
type DataListKeyMap struct {
51
51
+
Up key.Binding
52
52
+
Down key.Binding
53
53
+
Enter key.Binding
54
54
+
View key.Binding
55
55
+
Search key.Binding
56
56
+
Refresh key.Binding
57
57
+
Quit key.Binding
58
58
+
Back key.Binding
59
59
+
Help key.Binding
60
60
+
Numbers []key.Binding
61
61
+
Actions map[string]key.Binding
62
62
+
}
63
63
+
64
64
+
func (k DataListKeyMap) ShortHelp() []key.Binding {
65
65
+
return []key.Binding{k.Up, k.Down, k.Enter, k.Search, k.Help, k.Quit}
66
66
+
}
67
67
+
68
68
+
func (k DataListKeyMap) FullHelp() [][]key.Binding {
69
69
+
bindings := [][]key.Binding{
70
70
+
{k.Up, k.Down, k.Enter, k.View},
71
71
+
{k.Search, k.Refresh, k.Help, k.Quit, k.Back},
72
72
+
}
73
73
+
74
74
+
if len(k.Actions) > 0 {
75
75
+
actionBindings := make([]key.Binding, 0, len(k.Actions))
76
76
+
for _, binding := range k.Actions {
77
77
+
actionBindings = append(actionBindings, binding)
78
78
+
}
79
79
+
bindings = append(bindings, actionBindings)
80
80
+
}
81
81
+
82
82
+
return bindings
83
83
+
}
84
84
+
85
85
+
// DefaultDataListKeys returns the default key bindings for lists
86
86
+
func DefaultDataListKeys() DataListKeyMap {
87
87
+
return DataListKeyMap{
88
88
+
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ/k", "move up")),
89
89
+
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ/j", "move down")),
90
90
+
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
91
91
+
View: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view")),
92
92
+
Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
93
93
+
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
94
94
+
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
95
95
+
Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")),
96
96
+
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
97
97
+
Numbers: []key.Binding{
98
98
+
key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to 1")),
99
99
+
key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to 2")),
100
100
+
key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to 3")),
101
101
+
key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to 4")),
102
102
+
key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to 5")),
103
103
+
key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to 6")),
104
104
+
key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to 7")),
105
105
+
key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to 8")),
106
106
+
key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to 9")),
107
107
+
},
108
108
+
Actions: make(map[string]key.Binding),
109
109
+
}
110
110
+
}
111
111
+
112
112
+
// DataListOptions configures list behavior
113
113
+
type DataListOptions struct {
114
114
+
Output io.Writer
115
115
+
Input io.Reader
116
116
+
Static bool
117
117
+
Title string
118
118
+
Actions []ListAction
119
119
+
ViewHandler func(item ListItem) string
120
120
+
ItemRenderer func(item ListItem, selected bool) string
121
121
+
ShowSearch bool
122
122
+
Searchable bool
123
123
+
}
124
124
+
125
125
+
// DataList handles list display and interaction
126
126
+
type DataList struct {
127
127
+
source ListSource
128
128
+
opts DataListOptions
129
129
+
}
130
130
+
131
131
+
// NewDataList creates a new data list
132
132
+
func NewDataList(source ListSource, opts DataListOptions) *DataList {
133
133
+
if opts.Output == nil {
134
134
+
opts.Output = os.Stdout
135
135
+
}
136
136
+
if opts.Input == nil {
137
137
+
opts.Input = os.Stdin
138
138
+
}
139
139
+
if opts.Title == "" {
140
140
+
opts.Title = "Items"
141
141
+
}
142
142
+
if opts.ItemRenderer == nil {
143
143
+
opts.ItemRenderer = defaultItemRenderer
144
144
+
}
145
145
+
146
146
+
return &DataList{
147
147
+
source: source,
148
148
+
opts: opts,
149
149
+
}
150
150
+
}
151
151
+
152
152
+
type (
153
153
+
listLoadedMsg []ListItem
154
154
+
listViewMsg string
155
155
+
listErrorMsg error
156
156
+
listCountMsg int
157
157
+
searchModeMsg bool
158
158
+
)
159
159
+
160
160
+
type dataListModel struct {
161
161
+
items []ListItem
162
162
+
selected int
163
163
+
viewing bool
164
164
+
viewContent string
165
165
+
searching bool
166
166
+
searchQuery string
167
167
+
err error
168
168
+
loading bool
169
169
+
source ListSource
170
170
+
opts DataListOptions
171
171
+
keys DataListKeyMap
172
172
+
help help.Model
173
173
+
showingHelp bool
174
174
+
totalCount int
175
175
+
currentPage int
176
176
+
listOpts ListOptions
177
177
+
}
178
178
+
179
179
+
func (m dataListModel) Init() tea.Cmd {
180
180
+
return tea.Batch(m.loadItems(), m.loadCount())
181
181
+
}
182
182
+
183
183
+
func (m dataListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
184
184
+
switch msg := msg.(type) {
185
185
+
case tea.KeyMsg:
186
186
+
if m.showingHelp {
187
187
+
switch {
188
188
+
case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help):
189
189
+
m.showingHelp = false
190
190
+
return m, nil
191
191
+
}
192
192
+
return m, nil
193
193
+
}
194
194
+
195
195
+
if m.viewing {
196
196
+
switch {
197
197
+
case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit):
198
198
+
m.viewing = false
199
199
+
m.viewContent = ""
200
200
+
return m, nil
201
201
+
case key.Matches(msg, m.keys.Help):
202
202
+
m.showingHelp = true
203
203
+
return m, nil
204
204
+
}
205
205
+
return m, nil
206
206
+
}
207
207
+
208
208
+
if m.searching {
209
209
+
switch msg.String() {
210
210
+
case "esc", "enter":
211
211
+
m.searching = false
212
212
+
if msg.String() == "enter" && m.opts.Searchable {
213
213
+
m.loading = true
214
214
+
return m, m.searchItems(m.searchQuery)
215
215
+
}
216
216
+
return m, nil
217
217
+
case "backspace", "ctrl+h":
218
218
+
if len(m.searchQuery) > 0 {
219
219
+
m.searchQuery = m.searchQuery[:len(m.searchQuery)-1]
220
220
+
}
221
221
+
return m, nil
222
222
+
default:
223
223
+
if len(msg.Runes) > 0 && msg.Runes[0] >= 32 {
224
224
+
m.searchQuery += string(msg.Runes)
225
225
+
}
226
226
+
return m, nil
227
227
+
}
228
228
+
}
229
229
+
230
230
+
switch {
231
231
+
case key.Matches(msg, m.keys.Quit):
232
232
+
return m, tea.Quit
233
233
+
case key.Matches(msg, m.keys.Up):
234
234
+
if m.selected > 0 {
235
235
+
m.selected--
236
236
+
}
237
237
+
case key.Matches(msg, m.keys.Down):
238
238
+
if m.selected < len(m.items)-1 {
239
239
+
m.selected++
240
240
+
}
241
241
+
case key.Matches(msg, m.keys.Enter) || key.Matches(msg, m.keys.View):
242
242
+
if len(m.items) > 0 && m.selected < len(m.items) && m.opts.ViewHandler != nil {
243
243
+
return m, m.viewItem(m.items[m.selected])
244
244
+
}
245
245
+
case key.Matches(msg, m.keys.Search):
246
246
+
if m.opts.ShowSearch {
247
247
+
m.searching = true
248
248
+
m.searchQuery = ""
249
249
+
return m, nil
250
250
+
}
251
251
+
case key.Matches(msg, m.keys.Refresh):
252
252
+
m.loading = true
253
253
+
return m, tea.Batch(m.loadItems(), m.loadCount())
254
254
+
case key.Matches(msg, m.keys.Help):
255
255
+
m.showingHelp = true
256
256
+
return m, nil
257
257
+
default:
258
258
+
for i, numKey := range m.keys.Numbers {
259
259
+
if key.Matches(msg, numKey) && i < len(m.items) {
260
260
+
m.selected = i
261
261
+
break
262
262
+
}
263
263
+
}
264
264
+
265
265
+
for actionKey, binding := range m.keys.Actions {
266
266
+
if key.Matches(msg, binding) && len(m.items) > 0 && m.selected < len(m.items) {
267
267
+
for _, action := range m.opts.Actions {
268
268
+
if action.Key == actionKey {
269
269
+
return m, action.Handler(m.items[m.selected])
270
270
+
}
271
271
+
}
272
272
+
}
273
273
+
}
274
274
+
}
275
275
+
case listLoadedMsg:
276
276
+
m.items = []ListItem(msg)
277
277
+
m.loading = false
278
278
+
if m.selected >= len(m.items) && len(m.items) > 0 {
279
279
+
m.selected = len(m.items) - 1
280
280
+
}
281
281
+
case listViewMsg:
282
282
+
m.viewContent = string(msg)
283
283
+
m.viewing = true
284
284
+
case listErrorMsg:
285
285
+
m.err = error(msg)
286
286
+
m.loading = false
287
287
+
case listCountMsg:
288
288
+
m.totalCount = int(msg)
289
289
+
}
290
290
+
return m, nil
291
291
+
}
292
292
+
293
293
+
func (m dataListModel) View() string {
294
294
+
var s strings.Builder
295
295
+
296
296
+
style := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex()))
297
297
+
298
298
+
if m.showingHelp {
299
299
+
return m.help.View(m.keys)
300
300
+
}
301
301
+
302
302
+
if m.viewing {
303
303
+
s.WriteString(m.viewContent)
304
304
+
s.WriteString("\n\n")
305
305
+
s.WriteString(style.Render("Press q/esc/backspace to return to list, ? for help"))
306
306
+
return s.String()
307
307
+
}
308
308
+
309
309
+
s.WriteString(TitleColorStyle.Render(m.opts.Title))
310
310
+
if m.totalCount > 0 {
311
311
+
s.WriteString(fmt.Sprintf(" (%d total)", m.totalCount))
312
312
+
}
313
313
+
if m.searchQuery != "" {
314
314
+
s.WriteString(fmt.Sprintf(" - Search: %s", m.searchQuery))
315
315
+
}
316
316
+
s.WriteString("\n\n")
317
317
+
318
318
+
if m.searching {
319
319
+
s.WriteString("Search: " + m.searchQuery + "โ")
320
320
+
s.WriteString("\n")
321
321
+
s.WriteString(style.Render("Press Enter to search, Esc to cancel"))
322
322
+
return s.String()
323
323
+
}
324
324
+
325
325
+
if m.loading {
326
326
+
s.WriteString("Loading...")
327
327
+
return s.String()
328
328
+
}
329
329
+
330
330
+
if m.err != nil {
331
331
+
s.WriteString(fmt.Sprintf("Error: %s", m.err))
332
332
+
return s.String()
333
333
+
}
334
334
+
335
335
+
if len(m.items) == 0 {
336
336
+
message := "No items found"
337
337
+
if m.searchQuery != "" {
338
338
+
message = "No items found for search: " + m.searchQuery
339
339
+
}
340
340
+
s.WriteString(message)
341
341
+
s.WriteString("\n\n")
342
342
+
s.WriteString(style.Render("Press r to refresh, q to quit"))
343
343
+
if m.opts.ShowSearch {
344
344
+
s.WriteString(style.Render(", / to search"))
345
345
+
}
346
346
+
return s.String()
347
347
+
}
348
348
+
349
349
+
for i, item := range m.items {
350
350
+
selected := i == m.selected
351
351
+
itemView := m.opts.ItemRenderer(item, selected)
352
352
+
s.WriteString(itemView)
353
353
+
s.WriteString("\n")
354
354
+
}
355
355
+
356
356
+
s.WriteString("\n")
357
357
+
s.WriteString(m.help.View(m.keys))
358
358
+
359
359
+
return s.String()
360
360
+
}
361
361
+
362
362
+
func (m dataListModel) loadItems() tea.Cmd {
363
363
+
return func() tea.Msg {
364
364
+
items, err := m.source.Load(context.Background(), m.listOpts)
365
365
+
if err != nil {
366
366
+
return listErrorMsg(err)
367
367
+
}
368
368
+
return listLoadedMsg(items)
369
369
+
}
370
370
+
}
371
371
+
372
372
+
func (m dataListModel) loadCount() tea.Cmd {
373
373
+
return func() tea.Msg {
374
374
+
count, err := m.source.Count(context.Background(), m.listOpts)
375
375
+
if err != nil {
376
376
+
return listCountMsg(0)
377
377
+
}
378
378
+
return listCountMsg(count)
379
379
+
}
380
380
+
}
381
381
+
382
382
+
func (m dataListModel) searchItems(query string) tea.Cmd {
383
383
+
return func() tea.Msg {
384
384
+
items, err := m.source.Search(context.Background(), query, m.listOpts)
385
385
+
if err != nil {
386
386
+
return listErrorMsg(err)
387
387
+
}
388
388
+
return listLoadedMsg(items)
389
389
+
}
390
390
+
}
391
391
+
392
392
+
func (m dataListModel) viewItem(item ListItem) tea.Cmd {
393
393
+
return func() tea.Msg {
394
394
+
content := m.opts.ViewHandler(item)
395
395
+
return listViewMsg(content)
396
396
+
}
397
397
+
}
398
398
+
399
399
+
// defaultItemRenderer provides a default rendering for list items
400
400
+
func defaultItemRenderer(item ListItem, selected bool) string {
401
401
+
prefix := " "
402
402
+
if selected {
403
403
+
prefix = "> "
404
404
+
}
405
405
+
406
406
+
title := item.GetTitle()
407
407
+
description := item.GetDescription()
408
408
+
409
409
+
line := fmt.Sprintf("%s%s", prefix, title)
410
410
+
if description != "" {
411
411
+
line += fmt.Sprintf(" - %s", description)
412
412
+
}
413
413
+
414
414
+
if selected {
415
415
+
return SelectedColorStyle.Render(line)
416
416
+
}
417
417
+
return line
418
418
+
}
419
419
+
420
420
+
// Browse opens an interactive list interface
421
421
+
func (dl *DataList) Browse(ctx context.Context) error {
422
422
+
return dl.BrowseWithOptions(ctx, ListOptions{})
423
423
+
}
424
424
+
425
425
+
// BrowseWithOptions opens an interactive list with custom options
426
426
+
func (dl *DataList) BrowseWithOptions(ctx context.Context, listOpts ListOptions) error {
427
427
+
if dl.opts.Static {
428
428
+
return dl.staticDisplay(ctx, listOpts)
429
429
+
}
430
430
+
431
431
+
keys := DefaultDataListKeys()
432
432
+
for _, action := range dl.opts.Actions {
433
433
+
keys.Actions[action.Key] = key.NewBinding(
434
434
+
key.WithKeys(action.Key),
435
435
+
key.WithHelp(action.Key, action.Description),
436
436
+
)
437
437
+
}
438
438
+
439
439
+
model := dataListModel{
440
440
+
source: dl.source,
441
441
+
opts: dl.opts,
442
442
+
keys: keys,
443
443
+
help: help.New(),
444
444
+
listOpts: listOpts,
445
445
+
loading: true,
446
446
+
}
447
447
+
448
448
+
program := tea.NewProgram(model, tea.WithInput(dl.opts.Input), tea.WithOutput(dl.opts.Output))
449
449
+
_, err := program.Run()
450
450
+
return err
451
451
+
}
452
452
+
453
453
+
func (dl *DataList) staticDisplay(ctx context.Context, listOpts ListOptions) error {
454
454
+
items, err := dl.source.Load(ctx, listOpts)
455
455
+
if err != nil {
456
456
+
fmt.Fprintf(dl.opts.Output, "Error: %s\n", err)
457
457
+
return err
458
458
+
}
459
459
+
460
460
+
fmt.Fprintf(dl.opts.Output, "%s\n\n", dl.opts.Title)
461
461
+
462
462
+
if len(items) == 0 {
463
463
+
fmt.Fprintf(dl.opts.Output, "No items found\n")
464
464
+
return nil
465
465
+
}
466
466
+
467
467
+
for _, item := range items {
468
468
+
itemView := dl.opts.ItemRenderer(item, false)
469
469
+
fmt.Fprintf(dl.opts.Output, "%s\n", itemView)
470
470
+
}
471
471
+
472
472
+
return nil
473
473
+
}
+978
internal/ui/data_list_test.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"bytes"
5
5
+
"context"
6
6
+
"errors"
7
7
+
"fmt"
8
8
+
"strings"
9
9
+
"testing"
10
10
+
"time"
11
11
+
12
12
+
"github.com/charmbracelet/bubbles/help"
13
13
+
"github.com/charmbracelet/bubbles/key"
14
14
+
tea "github.com/charmbracelet/bubbletea"
15
15
+
)
16
16
+
17
17
+
type MockListItem struct {
18
18
+
id int64
19
19
+
title string
20
20
+
description string
21
21
+
filterValue string
22
22
+
created time.Time
23
23
+
modified time.Time
24
24
+
}
25
25
+
26
26
+
func (m MockListItem) GetID() int64 {
27
27
+
return m.id
28
28
+
}
29
29
+
30
30
+
func (m MockListItem) SetID(id int64) {
31
31
+
m.id = id
32
32
+
}
33
33
+
34
34
+
func (m MockListItem) GetTableName() string {
35
35
+
return "mock_items"
36
36
+
}
37
37
+
38
38
+
func (m MockListItem) GetCreatedAt() time.Time {
39
39
+
return m.created
40
40
+
}
41
41
+
42
42
+
func (m MockListItem) SetCreatedAt(t time.Time) {
43
43
+
m.created = t
44
44
+
}
45
45
+
46
46
+
func (m MockListItem) GetUpdatedAt() time.Time {
47
47
+
return m.modified
48
48
+
}
49
49
+
50
50
+
func (m MockListItem) SetUpdatedAt(t time.Time) {
51
51
+
m.modified = t
52
52
+
}
53
53
+
54
54
+
func (m MockListItem) GetTitle() string {
55
55
+
return m.title
56
56
+
}
57
57
+
58
58
+
func (m MockListItem) GetDescription() string {
59
59
+
return m.description
60
60
+
}
61
61
+
62
62
+
func (m MockListItem) GetFilterValue() string {
63
63
+
return m.filterValue
64
64
+
}
65
65
+
66
66
+
func NewMockItem(id int64, title, description, filterValue string) MockListItem {
67
67
+
now := time.Now()
68
68
+
return MockListItem{
69
69
+
id: id,
70
70
+
title: title,
71
71
+
description: description,
72
72
+
filterValue: filterValue,
73
73
+
created: now,
74
74
+
modified: now,
75
75
+
}
76
76
+
}
77
77
+
78
78
+
type MockListSource struct {
79
79
+
items []ListItem
80
80
+
loadError error
81
81
+
countError error
82
82
+
searchError error
83
83
+
}
84
84
+
85
85
+
func (m *MockListSource) Load(ctx context.Context, opts ListOptions) ([]ListItem, error) {
86
86
+
if m.loadError != nil {
87
87
+
return nil, m.loadError
88
88
+
}
89
89
+
90
90
+
filtered := make([]ListItem, 0)
91
91
+
for _, item := range m.items {
92
92
+
include := true
93
93
+
for filterField, filterValue := range opts.Filters {
94
94
+
if filterField == "title" && item.GetTitle() != filterValue {
95
95
+
include = false
96
96
+
break
97
97
+
}
98
98
+
}
99
99
+
if include {
100
100
+
filtered = append(filtered, item)
101
101
+
}
102
102
+
}
103
103
+
104
104
+
if opts.Limit > 0 && len(filtered) > opts.Limit {
105
105
+
filtered = filtered[:opts.Limit]
106
106
+
}
107
107
+
108
108
+
return filtered, nil
109
109
+
}
110
110
+
111
111
+
func (m *MockListSource) Count(ctx context.Context, opts ListOptions) (int, error) {
112
112
+
if m.countError != nil {
113
113
+
return 0, m.countError
114
114
+
}
115
115
+
116
116
+
count := 0
117
117
+
for _, item := range m.items {
118
118
+
include := true
119
119
+
for filterField, filterValue := range opts.Filters {
120
120
+
if filterField == "title" && item.GetTitle() != filterValue {
121
121
+
include = false
122
122
+
break
123
123
+
}
124
124
+
}
125
125
+
if include {
126
126
+
count++
127
127
+
}
128
128
+
}
129
129
+
130
130
+
return count, nil
131
131
+
}
132
132
+
133
133
+
func (m *MockListSource) Search(ctx context.Context, query string, opts ListOptions) ([]ListItem, error) {
134
134
+
if m.searchError != nil {
135
135
+
return nil, m.searchError
136
136
+
}
137
137
+
138
138
+
results := make([]ListItem, 0)
139
139
+
for _, item := range m.items {
140
140
+
if strings.Contains(strings.ToLower(item.GetTitle()), strings.ToLower(query)) ||
141
141
+
strings.Contains(strings.ToLower(item.GetDescription()), strings.ToLower(query)) ||
142
142
+
strings.Contains(strings.ToLower(item.GetFilterValue()), strings.ToLower(query)) {
143
143
+
results = append(results, item)
144
144
+
}
145
145
+
}
146
146
+
147
147
+
return results, nil
148
148
+
}
149
149
+
150
150
+
func createMockItems() []ListItem {
151
151
+
return []ListItem{
152
152
+
NewMockItem(1, "First Item", "Description of first item", "item1 tag1"),
153
153
+
NewMockItem(2, "Second Item", "Description of second item", "item2 tag2"),
154
154
+
NewMockItem(3, "Third Item", "Description of third item", "item3 tag1"),
155
155
+
}
156
156
+
}
157
157
+
158
158
+
func TestDataList(t *testing.T) {
159
159
+
t.Run("Options", func(t *testing.T) {
160
160
+
t.Run("default options", func(t *testing.T) {
161
161
+
source := &MockListSource{items: createMockItems()}
162
162
+
opts := DataListOptions{}
163
163
+
164
164
+
list := NewDataList(source, opts)
165
165
+
if list.opts.Output == nil {
166
166
+
t.Error("Output should default to os.Stdout")
167
167
+
}
168
168
+
if list.opts.Input == nil {
169
169
+
t.Error("Input should default to os.Stdin")
170
170
+
}
171
171
+
if list.opts.Title != "Items" {
172
172
+
t.Error("Title should default to 'Items'")
173
173
+
}
174
174
+
if list.opts.ItemRenderer == nil {
175
175
+
t.Error("ItemRenderer should have a default")
176
176
+
}
177
177
+
})
178
178
+
179
179
+
t.Run("custom options", func(t *testing.T) {
180
180
+
var buf bytes.Buffer
181
181
+
source := &MockListSource{items: createMockItems()}
182
182
+
opts := DataListOptions{
183
183
+
Output: &buf,
184
184
+
Static: true,
185
185
+
Title: "Test List",
186
186
+
ShowSearch: true,
187
187
+
Searchable: true,
188
188
+
ViewHandler: func(item ListItem) string {
189
189
+
return fmt.Sprintf("Viewing: %s", item.GetTitle())
190
190
+
},
191
191
+
ItemRenderer: func(item ListItem, selected bool) string {
192
192
+
prefix := " "
193
193
+
if selected {
194
194
+
prefix = "> "
195
195
+
}
196
196
+
return fmt.Sprintf("%s%s", prefix, item.GetTitle())
197
197
+
},
198
198
+
}
199
199
+
200
200
+
list := NewDataList(source, opts)
201
201
+
if list.opts.Output != &buf {
202
202
+
t.Error("Custom output not set")
203
203
+
}
204
204
+
if !list.opts.Static {
205
205
+
t.Error("Static mode not set")
206
206
+
}
207
207
+
if list.opts.Title != "Test List" {
208
208
+
t.Error("Custom title not set")
209
209
+
}
210
210
+
if !list.opts.ShowSearch {
211
211
+
t.Error("ShowSearch not set")
212
212
+
}
213
213
+
if !list.opts.Searchable {
214
214
+
t.Error("Searchable not set")
215
215
+
}
216
216
+
})
217
217
+
})
218
218
+
219
219
+
t.Run("Static Mode", func(t *testing.T) {
220
220
+
t.Run("successful static display", func(t *testing.T) {
221
221
+
var buf bytes.Buffer
222
222
+
source := &MockListSource{items: createMockItems()}
223
223
+
224
224
+
list := NewDataList(source, DataListOptions{
225
225
+
Output: &buf,
226
226
+
Static: true,
227
227
+
Title: "Test List",
228
228
+
})
229
229
+
230
230
+
err := list.Browse(context.Background())
231
231
+
if err != nil {
232
232
+
t.Fatalf("Browse failed: %v", err)
233
233
+
}
234
234
+
235
235
+
output := buf.String()
236
236
+
if !strings.Contains(output, "Test List") {
237
237
+
t.Error("Title not displayed")
238
238
+
}
239
239
+
if !strings.Contains(output, "First Item") {
240
240
+
t.Error("First item not displayed")
241
241
+
}
242
242
+
if !strings.Contains(output, "Second Item") {
243
243
+
t.Error("Second item not displayed")
244
244
+
}
245
245
+
})
246
246
+
247
247
+
t.Run("static display with no items", func(t *testing.T) {
248
248
+
var buf bytes.Buffer
249
249
+
source := &MockListSource{items: []ListItem{}}
250
250
+
251
251
+
list := NewDataList(source, DataListOptions{
252
252
+
Output: &buf,
253
253
+
Static: true,
254
254
+
})
255
255
+
256
256
+
err := list.Browse(context.Background())
257
257
+
if err != nil {
258
258
+
t.Fatalf("Browse failed: %v", err)
259
259
+
}
260
260
+
261
261
+
output := buf.String()
262
262
+
if !strings.Contains(output, "No items found") {
263
263
+
t.Error("No items message not displayed")
264
264
+
}
265
265
+
})
266
266
+
267
267
+
t.Run("static display with load error", func(t *testing.T) {
268
268
+
var buf bytes.Buffer
269
269
+
source := &MockListSource{
270
270
+
loadError: errors.New("connection failed"),
271
271
+
}
272
272
+
273
273
+
list := NewDataList(source, DataListOptions{
274
274
+
Output: &buf,
275
275
+
Static: true,
276
276
+
})
277
277
+
278
278
+
err := list.Browse(context.Background())
279
279
+
if err == nil {
280
280
+
t.Fatal("Expected error, got nil")
281
281
+
}
282
282
+
283
283
+
output := buf.String()
284
284
+
if !strings.Contains(output, "Error: connection failed") {
285
285
+
t.Error("Error message not displayed")
286
286
+
}
287
287
+
})
288
288
+
289
289
+
t.Run("static display with filters", func(t *testing.T) {
290
290
+
var buf bytes.Buffer
291
291
+
source := &MockListSource{items: createMockItems()}
292
292
+
293
293
+
list := NewDataList(source, DataListOptions{
294
294
+
Output: &buf,
295
295
+
Static: true,
296
296
+
})
297
297
+
298
298
+
opts := ListOptions{
299
299
+
Filters: map[string]any{
300
300
+
"title": "First Item",
301
301
+
},
302
302
+
}
303
303
+
304
304
+
err := list.BrowseWithOptions(context.Background(), opts)
305
305
+
if err != nil {
306
306
+
t.Fatalf("Browse failed: %v", err)
307
307
+
}
308
308
+
309
309
+
output := buf.String()
310
310
+
if !strings.Contains(output, "First Item") {
311
311
+
t.Error("Filtered item not displayed")
312
312
+
}
313
313
+
if strings.Contains(output, "Second Item") {
314
314
+
t.Error("Non-matching item should be filtered out")
315
315
+
}
316
316
+
})
317
317
+
})
318
318
+
319
319
+
t.Run("Model", func(t *testing.T) {
320
320
+
t.Run("initial model state", func(t *testing.T) {
321
321
+
source := &MockListSource{items: createMockItems()}
322
322
+
keys := DefaultDataListKeys()
323
323
+
324
324
+
model := dataListModel{
325
325
+
source: source,
326
326
+
opts: DataListOptions{
327
327
+
Title: "Test",
328
328
+
},
329
329
+
keys: keys,
330
330
+
help: help.New(),
331
331
+
loading: true,
332
332
+
}
333
333
+
334
334
+
if model.selected != 0 {
335
335
+
t.Error("Initial selected should be 0")
336
336
+
}
337
337
+
if model.viewing {
338
338
+
t.Error("Initial viewing should be false")
339
339
+
}
340
340
+
if model.searching {
341
341
+
t.Error("Initial searching should be false")
342
342
+
}
343
343
+
if !model.loading {
344
344
+
t.Error("Initial loading should be true")
345
345
+
}
346
346
+
})
347
347
+
348
348
+
t.Run("load items command", func(t *testing.T) {
349
349
+
source := &MockListSource{items: createMockItems()}
350
350
+
351
351
+
model := dataListModel{
352
352
+
source: source,
353
353
+
keys: DefaultDataListKeys(),
354
354
+
listOpts: ListOptions{},
355
355
+
}
356
356
+
357
357
+
cmd := model.loadItems()
358
358
+
if cmd == nil {
359
359
+
t.Fatal("loadItems should return a command")
360
360
+
}
361
361
+
362
362
+
msg := cmd()
363
363
+
switch msg := msg.(type) {
364
364
+
case listLoadedMsg:
365
365
+
items := []ListItem(msg)
366
366
+
if len(items) != 3 {
367
367
+
t.Errorf("Expected 3 items, got %d", len(items))
368
368
+
}
369
369
+
case listErrorMsg:
370
370
+
t.Fatalf("Unexpected error: %v", error(msg))
371
371
+
default:
372
372
+
t.Fatalf("Unexpected message type: %T", msg)
373
373
+
}
374
374
+
})
375
375
+
376
376
+
t.Run("load items with error", func(t *testing.T) {
377
377
+
source := &MockListSource{
378
378
+
loadError: errors.New("load failed"),
379
379
+
}
380
380
+
381
381
+
model := dataListModel{
382
382
+
source: source,
383
383
+
listOpts: ListOptions{},
384
384
+
}
385
385
+
386
386
+
cmd := model.loadItems()
387
387
+
msg := cmd()
388
388
+
389
389
+
switch msg := msg.(type) {
390
390
+
case listErrorMsg:
391
391
+
err := error(msg)
392
392
+
if !strings.Contains(err.Error(), "load failed") {
393
393
+
t.Errorf("Expected load error, got: %v", err)
394
394
+
}
395
395
+
default:
396
396
+
t.Fatalf("Expected listErrorMsg, got: %T", msg)
397
397
+
}
398
398
+
})
399
399
+
400
400
+
t.Run("search items command", func(t *testing.T) {
401
401
+
source := &MockListSource{items: createMockItems()}
402
402
+
403
403
+
model := dataListModel{
404
404
+
source: source,
405
405
+
listOpts: ListOptions{},
406
406
+
}
407
407
+
408
408
+
cmd := model.searchItems("First")
409
409
+
if cmd == nil {
410
410
+
t.Fatal("searchItems should return a command")
411
411
+
}
412
412
+
413
413
+
msg := cmd()
414
414
+
switch msg := msg.(type) {
415
415
+
case listLoadedMsg:
416
416
+
items := []ListItem(msg)
417
417
+
if len(items) != 1 {
418
418
+
t.Errorf("Expected 1 search result, got %d", len(items))
419
419
+
}
420
420
+
if items[0].GetTitle() != "First Item" {
421
421
+
t.Error("Search should return matching item")
422
422
+
}
423
423
+
case listErrorMsg:
424
424
+
t.Fatalf("Unexpected error: %v", error(msg))
425
425
+
default:
426
426
+
t.Fatalf("Unexpected message type: %T", msg)
427
427
+
}
428
428
+
})
429
429
+
430
430
+
t.Run("search items with error", func(t *testing.T) {
431
431
+
source := &MockListSource{
432
432
+
items: createMockItems(),
433
433
+
searchError: errors.New("search failed"),
434
434
+
}
435
435
+
436
436
+
model := dataListModel{
437
437
+
source: source,
438
438
+
listOpts: ListOptions{},
439
439
+
}
440
440
+
441
441
+
cmd := model.searchItems("test")
442
442
+
msg := cmd()
443
443
+
444
444
+
switch msg := msg.(type) {
445
445
+
case listErrorMsg:
446
446
+
err := error(msg)
447
447
+
if !strings.Contains(err.Error(), "search failed") {
448
448
+
t.Errorf("Expected search error, got: %v", err)
449
449
+
}
450
450
+
default:
451
451
+
t.Fatalf("Expected listErrorMsg, got: %T", msg)
452
452
+
}
453
453
+
})
454
454
+
455
455
+
t.Run("view item command", func(t *testing.T) {
456
456
+
viewHandler := func(item ListItem) string {
457
457
+
return fmt.Sprintf("Viewing: %s", item.GetTitle())
458
458
+
}
459
459
+
460
460
+
model := dataListModel{
461
461
+
opts: DataListOptions{
462
462
+
ViewHandler: viewHandler,
463
463
+
},
464
464
+
}
465
465
+
466
466
+
item := createMockItems()[0]
467
467
+
cmd := model.viewItem(item)
468
468
+
msg := cmd()
469
469
+
470
470
+
switch msg := msg.(type) {
471
471
+
case listViewMsg:
472
472
+
content := string(msg)
473
473
+
if !strings.Contains(content, "Viewing: First Item") {
474
474
+
t.Error("View content not formatted correctly")
475
475
+
}
476
476
+
default:
477
477
+
t.Fatalf("Expected listViewMsg, got: %T", msg)
478
478
+
}
479
479
+
})
480
480
+
})
481
481
+
482
482
+
t.Run("Key Handling", func(t *testing.T) {
483
483
+
source := &MockListSource{items: createMockItems()}
484
484
+
485
485
+
t.Run("navigation keys", func(t *testing.T) {
486
486
+
model := dataListModel{
487
487
+
source: source,
488
488
+
items: createMockItems(),
489
489
+
selected: 1,
490
490
+
keys: DefaultDataListKeys(),
491
491
+
opts: DataListOptions{},
492
492
+
}
493
493
+
494
494
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
495
495
+
if m, ok := newModel.(dataListModel); ok {
496
496
+
if m.selected != 0 {
497
497
+
t.Errorf("Up key should move selection to 0, got %d", m.selected)
498
498
+
}
499
499
+
}
500
500
+
501
501
+
model.selected = 1
502
502
+
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
503
503
+
if m, ok := newModel.(dataListModel); ok {
504
504
+
if m.selected != 2 {
505
505
+
t.Errorf("Down key should move selection to 2, got %d", m.selected)
506
506
+
}
507
507
+
}
508
508
+
})
509
509
+
510
510
+
t.Run("boundary conditions", func(t *testing.T) {
511
511
+
model := dataListModel{
512
512
+
source: source,
513
513
+
items: createMockItems(),
514
514
+
selected: 0,
515
515
+
keys: DefaultDataListKeys(),
516
516
+
opts: DataListOptions{},
517
517
+
}
518
518
+
519
519
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
520
520
+
if m, ok := newModel.(dataListModel); ok {
521
521
+
if m.selected != 0 {
522
522
+
t.Error("Up key at top should not change selection")
523
523
+
}
524
524
+
}
525
525
+
526
526
+
model.selected = 2
527
527
+
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
528
528
+
if m, ok := newModel.(dataListModel); ok {
529
529
+
if m.selected != 2 {
530
530
+
t.Error("Down key at bottom should not change selection")
531
531
+
}
532
532
+
}
533
533
+
})
534
534
+
535
535
+
t.Run("search key", func(t *testing.T) {
536
536
+
model := dataListModel{
537
537
+
source: source,
538
538
+
keys: DefaultDataListKeys(),
539
539
+
opts: DataListOptions{
540
540
+
ShowSearch: true,
541
541
+
},
542
542
+
}
543
543
+
544
544
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
545
545
+
if m, ok := newModel.(dataListModel); ok {
546
546
+
if !m.searching {
547
547
+
t.Error("Search key should enable search mode")
548
548
+
}
549
549
+
if m.searchQuery != "" {
550
550
+
t.Error("Search query should be empty initially")
551
551
+
}
552
552
+
}
553
553
+
})
554
554
+
555
555
+
t.Run("search mode input", func(t *testing.T) {
556
556
+
model := dataListModel{
557
557
+
source: source,
558
558
+
keys: DefaultDataListKeys(),
559
559
+
searching: true,
560
560
+
opts: DataListOptions{
561
561
+
Searchable: true,
562
562
+
},
563
563
+
}
564
564
+
565
565
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")})
566
566
+
if m, ok := newModel.(dataListModel); ok {
567
567
+
if m.searchQuery != "a" {
568
568
+
t.Errorf("Expected search query 'a', got '%s'", m.searchQuery)
569
569
+
}
570
570
+
}
571
571
+
572
572
+
model.searchQuery = "ab"
573
573
+
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("backspace")})
574
574
+
if m, ok := newModel.(dataListModel); ok {
575
575
+
if m.searchQuery != "a" {
576
576
+
t.Errorf("Backspace should remove last character, got '%s'", m.searchQuery)
577
577
+
}
578
578
+
}
579
579
+
580
580
+
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("esc")})
581
581
+
if m, ok := newModel.(dataListModel); ok {
582
582
+
if m.searching {
583
583
+
t.Error("Escape should exit search mode")
584
584
+
}
585
585
+
}
586
586
+
})
587
587
+
588
588
+
t.Run("view key with handler", func(t *testing.T) {
589
589
+
viewHandler := func(item ListItem) string {
590
590
+
return "test view"
591
591
+
}
592
592
+
593
593
+
model := dataListModel{
594
594
+
source: source,
595
595
+
items: createMockItems(),
596
596
+
keys: DefaultDataListKeys(),
597
597
+
opts: DataListOptions{
598
598
+
ViewHandler: viewHandler,
599
599
+
},
600
600
+
}
601
601
+
602
602
+
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("v")})
603
603
+
if cmd == nil {
604
604
+
t.Error("View key should return command when handler is set")
605
605
+
}
606
606
+
})
607
607
+
608
608
+
t.Run("refresh key", func(t *testing.T) {
609
609
+
model := dataListModel{
610
610
+
source: source,
611
611
+
keys: DefaultDataListKeys(),
612
612
+
opts: DataListOptions{},
613
613
+
}
614
614
+
615
615
+
newModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
616
616
+
if cmd == nil {
617
617
+
t.Error("Refresh key should return command")
618
618
+
}
619
619
+
if m, ok := newModel.(dataListModel); ok {
620
620
+
if !m.loading {
621
621
+
t.Error("Refresh should set loading to true")
622
622
+
}
623
623
+
}
624
624
+
})
625
625
+
626
626
+
t.Run("help mode", func(t *testing.T) {
627
627
+
model := dataListModel{
628
628
+
keys: DefaultDataListKeys(),
629
629
+
showingHelp: true,
630
630
+
opts: DataListOptions{},
631
631
+
}
632
632
+
633
633
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
634
634
+
if m, ok := newModel.(dataListModel); ok {
635
635
+
if m.selected != 0 {
636
636
+
t.Error("Navigation should be ignored in help mode")
637
637
+
}
638
638
+
}
639
639
+
640
640
+
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
641
641
+
if m, ok := newModel.(dataListModel); ok {
642
642
+
if m.showingHelp {
643
643
+
t.Error("Help key should exit help mode")
644
644
+
}
645
645
+
}
646
646
+
})
647
647
+
648
648
+
t.Run("viewing mode", func(t *testing.T) {
649
649
+
model := dataListModel{
650
650
+
keys: DefaultDataListKeys(),
651
651
+
viewing: true,
652
652
+
viewContent: "test content",
653
653
+
opts: DataListOptions{},
654
654
+
}
655
655
+
656
656
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
657
657
+
if m, ok := newModel.(dataListModel); ok {
658
658
+
if m.viewing {
659
659
+
t.Error("Quit should exit viewing mode")
660
660
+
}
661
661
+
if m.viewContent != "" {
662
662
+
t.Error("Quit should clear view content")
663
663
+
}
664
664
+
}
665
665
+
})
666
666
+
})
667
667
+
668
668
+
t.Run("View", func(t *testing.T) {
669
669
+
source := &MockListSource{items: createMockItems()}
670
670
+
671
671
+
t.Run("normal view", func(t *testing.T) {
672
672
+
model := dataListModel{
673
673
+
source: source,
674
674
+
items: createMockItems(),
675
675
+
keys: DefaultDataListKeys(),
676
676
+
help: help.New(),
677
677
+
opts: DataListOptions{
678
678
+
Title: "Test List",
679
679
+
ItemRenderer: defaultItemRenderer,
680
680
+
},
681
681
+
}
682
682
+
683
683
+
view := model.View()
684
684
+
if !strings.Contains(view, "Test List") {
685
685
+
t.Error("Title not displayed")
686
686
+
}
687
687
+
if !strings.Contains(view, "First Item") {
688
688
+
t.Error("Item data not displayed")
689
689
+
}
690
690
+
if !strings.Contains(view, "> ") {
691
691
+
t.Error("Selection indicator not displayed")
692
692
+
}
693
693
+
})
694
694
+
695
695
+
t.Run("loading view", func(t *testing.T) {
696
696
+
model := dataListModel{
697
697
+
loading: true,
698
698
+
opts: DataListOptions{Title: "Test"},
699
699
+
}
700
700
+
701
701
+
view := model.View()
702
702
+
if !strings.Contains(view, "Loading...") {
703
703
+
t.Error("Loading message not displayed")
704
704
+
}
705
705
+
})
706
706
+
707
707
+
t.Run("error view", func(t *testing.T) {
708
708
+
model := dataListModel{
709
709
+
err: errors.New("test error"),
710
710
+
opts: DataListOptions{Title: "Test"},
711
711
+
}
712
712
+
713
713
+
view := model.View()
714
714
+
if !strings.Contains(view, "Error: test error") {
715
715
+
t.Error("Error message not displayed")
716
716
+
}
717
717
+
})
718
718
+
719
719
+
t.Run("empty items view", func(t *testing.T) {
720
720
+
model := dataListModel{
721
721
+
items: []ListItem{},
722
722
+
opts: DataListOptions{Title: "Test"},
723
723
+
}
724
724
+
725
725
+
view := model.View()
726
726
+
if !strings.Contains(view, "No items found") {
727
727
+
t.Error("Empty message not displayed")
728
728
+
}
729
729
+
})
730
730
+
731
731
+
t.Run("search mode view", func(t *testing.T) {
732
732
+
model := dataListModel{
733
733
+
searching: true,
734
734
+
searchQuery: "test",
735
735
+
opts: DataListOptions{Title: "Test"},
736
736
+
}
737
737
+
738
738
+
view := model.View()
739
739
+
if !strings.Contains(view, "Search: test") {
740
740
+
t.Error("Search query not displayed")
741
741
+
}
742
742
+
if !strings.Contains(view, "Press Enter to search") {
743
743
+
t.Error("Search instructions not displayed")
744
744
+
}
745
745
+
})
746
746
+
747
747
+
t.Run("viewing mode", func(t *testing.T) {
748
748
+
model := dataListModel{
749
749
+
viewing: true,
750
750
+
viewContent: "# Test Content\nDetails here",
751
751
+
opts: DataListOptions{},
752
752
+
}
753
753
+
754
754
+
view := model.View()
755
755
+
if !strings.Contains(view, "# Test Content") {
756
756
+
t.Error("View content not displayed")
757
757
+
}
758
758
+
if !strings.Contains(view, "Press q/esc/backspace to return") {
759
759
+
t.Error("Return instructions not displayed")
760
760
+
}
761
761
+
})
762
762
+
763
763
+
t.Run("search in title", func(t *testing.T) {
764
764
+
model := dataListModel{
765
765
+
items: createMockItems(),
766
766
+
searchQuery: "First",
767
767
+
keys: DefaultDataListKeys(),
768
768
+
help: help.New(),
769
769
+
opts: DataListOptions{
770
770
+
Title: "Test",
771
771
+
ItemRenderer: defaultItemRenderer,
772
772
+
},
773
773
+
}
774
774
+
775
775
+
view := model.View()
776
776
+
if !strings.Contains(view, "Search: First") {
777
777
+
t.Error("Search query should be displayed in title")
778
778
+
}
779
779
+
})
780
780
+
781
781
+
t.Run("custom item renderer", func(t *testing.T) {
782
782
+
customRenderer := func(item ListItem, selected bool) string {
783
783
+
if selected {
784
784
+
return fmt.Sprintf("*** %s ***", item.GetTitle())
785
785
+
}
786
786
+
return fmt.Sprintf(" %s", item.GetTitle())
787
787
+
}
788
788
+
789
789
+
model := dataListModel{
790
790
+
items: createMockItems(),
791
791
+
keys: DefaultDataListKeys(),
792
792
+
help: help.New(),
793
793
+
opts: DataListOptions{
794
794
+
ItemRenderer: customRenderer,
795
795
+
},
796
796
+
}
797
797
+
798
798
+
view := model.View()
799
799
+
if !strings.Contains(view, "*** First Item ***") {
800
800
+
t.Error("Custom renderer not applied for selected item")
801
801
+
}
802
802
+
})
803
803
+
})
804
804
+
805
805
+
t.Run("Update", func(t *testing.T) {
806
806
+
source := &MockListSource{items: createMockItems()}
807
807
+
808
808
+
t.Run("list loaded message", func(t *testing.T) {
809
809
+
model := dataListModel{
810
810
+
source: source,
811
811
+
loading: true,
812
812
+
opts: DataListOptions{},
813
813
+
}
814
814
+
815
815
+
items := createMockItems()[:2]
816
816
+
newModel, _ := model.Update(listLoadedMsg(items))
817
817
+
818
818
+
if m, ok := newModel.(dataListModel); ok {
819
819
+
if len(m.items) != 2 {
820
820
+
t.Errorf("Expected 2 items, got %d", len(m.items))
821
821
+
}
822
822
+
if m.loading {
823
823
+
t.Error("Loading should be set to false")
824
824
+
}
825
825
+
}
826
826
+
})
827
827
+
828
828
+
t.Run("selected index adjustment", func(t *testing.T) {
829
829
+
model := dataListModel{
830
830
+
selected: 5,
831
831
+
opts: DataListOptions{},
832
832
+
}
833
833
+
834
834
+
items := createMockItems()[:2]
835
835
+
newModel, _ := model.Update(listLoadedMsg(items))
836
836
+
837
837
+
if m, ok := newModel.(dataListModel); ok {
838
838
+
if m.selected != 1 {
839
839
+
t.Errorf("Selected should be adjusted to 1, got %d", m.selected)
840
840
+
}
841
841
+
}
842
842
+
})
843
843
+
844
844
+
t.Run("list view message", func(t *testing.T) {
845
845
+
model := dataListModel{
846
846
+
opts: DataListOptions{},
847
847
+
}
848
848
+
849
849
+
content := "Test view content"
850
850
+
newModel, _ := model.Update(listViewMsg(content))
851
851
+
852
852
+
if m, ok := newModel.(dataListModel); ok {
853
853
+
if !m.viewing {
854
854
+
t.Error("Viewing mode should be activated")
855
855
+
}
856
856
+
if m.viewContent != content {
857
857
+
t.Error("View content not set correctly")
858
858
+
}
859
859
+
}
860
860
+
})
861
861
+
862
862
+
t.Run("list error message", func(t *testing.T) {
863
863
+
model := dataListModel{
864
864
+
loading: true,
865
865
+
opts: DataListOptions{},
866
866
+
}
867
867
+
868
868
+
testErr := errors.New("test error")
869
869
+
newModel, _ := model.Update(listErrorMsg(testErr))
870
870
+
871
871
+
if m, ok := newModel.(dataListModel); ok {
872
872
+
if m.err == nil {
873
873
+
t.Error("Error should be set")
874
874
+
}
875
875
+
if m.err.Error() != "test error" {
876
876
+
t.Errorf("Expected 'test error', got %v", m.err)
877
877
+
}
878
878
+
if m.loading {
879
879
+
t.Error("Loading should be set to false on error")
880
880
+
}
881
881
+
}
882
882
+
})
883
883
+
884
884
+
t.Run("list count message", func(t *testing.T) {
885
885
+
model := dataListModel{
886
886
+
opts: DataListOptions{},
887
887
+
}
888
888
+
889
889
+
count := 42
890
890
+
newModel, _ := model.Update(listCountMsg(count))
891
891
+
892
892
+
if m, ok := newModel.(dataListModel); ok {
893
893
+
if m.totalCount != count {
894
894
+
t.Errorf("Expected count %d, got %d", count, m.totalCount)
895
895
+
}
896
896
+
}
897
897
+
})
898
898
+
})
899
899
+
900
900
+
t.Run("Default Keys", func(t *testing.T) {
901
901
+
keys := DefaultDataListKeys()
902
902
+
903
903
+
if len(keys.Numbers) != 9 {
904
904
+
t.Errorf("Expected 9 number bindings, got %d", len(keys.Numbers))
905
905
+
}
906
906
+
907
907
+
if keys.Actions == nil {
908
908
+
t.Error("Actions map should be initialized")
909
909
+
}
910
910
+
})
911
911
+
912
912
+
t.Run("Actions", func(t *testing.T) {
913
913
+
t.Run("action key handling", func(t *testing.T) {
914
914
+
actionCalled := false
915
915
+
action := ListAction{
916
916
+
Key: "d",
917
917
+
Description: "delete",
918
918
+
Handler: func(item ListItem) tea.Cmd {
919
919
+
actionCalled = true
920
920
+
return nil
921
921
+
},
922
922
+
}
923
923
+
924
924
+
keys := DefaultDataListKeys()
925
925
+
keys.Actions["d"] = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete"))
926
926
+
927
927
+
model := dataListModel{
928
928
+
source: &MockListSource{items: createMockItems()},
929
929
+
items: createMockItems(),
930
930
+
keys: keys,
931
931
+
opts: DataListOptions{
932
932
+
Actions: []ListAction{action},
933
933
+
},
934
934
+
}
935
935
+
936
936
+
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")})
937
937
+
if cmd != nil {
938
938
+
cmd()
939
939
+
}
940
940
+
941
941
+
if !actionCalled {
942
942
+
t.Error("Action handler should be called")
943
943
+
}
944
944
+
})
945
945
+
})
946
946
+
947
947
+
t.Run("Default Item Renderer", func(t *testing.T) {
948
948
+
item := createMockItems()[0]
949
949
+
950
950
+
t.Run("unselected item", func(t *testing.T) {
951
951
+
result := defaultItemRenderer(item, false)
952
952
+
if !strings.HasPrefix(result, " ") {
953
953
+
t.Error("Unselected item should have ' ' prefix")
954
954
+
}
955
955
+
if !strings.Contains(result, "First Item") {
956
956
+
t.Error("Item title should be displayed")
957
957
+
}
958
958
+
if !strings.Contains(result, "Description of first item") {
959
959
+
t.Error("Item description should be displayed")
960
960
+
}
961
961
+
})
962
962
+
963
963
+
t.Run("selected item", func(t *testing.T) {
964
964
+
result := defaultItemRenderer(item, true)
965
965
+
if !strings.HasPrefix(result, "> ") {
966
966
+
t.Error("Selected item should have '> ' prefix")
967
967
+
}
968
968
+
})
969
969
+
970
970
+
t.Run("item without description", func(t *testing.T) {
971
971
+
itemWithoutDesc := NewMockItem(1, "Test", "", "filter")
972
972
+
result := defaultItemRenderer(itemWithoutDesc, false)
973
973
+
if strings.Contains(result, " - ") {
974
974
+
t.Error("Item without description should not have separator")
975
975
+
}
976
976
+
})
977
977
+
})
978
978
+
}
+461
internal/ui/data_table.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
"io"
7
7
+
"os"
8
8
+
"strings"
9
9
+
10
10
+
"github.com/charmbracelet/bubbles/help"
11
11
+
"github.com/charmbracelet/bubbles/key"
12
12
+
tea "github.com/charmbracelet/bubbletea"
13
13
+
"github.com/charmbracelet/lipgloss"
14
14
+
"github.com/stormlightlabs/noteleaf/internal/models"
15
15
+
)
16
16
+
17
17
+
// DataRecord represents a single row of data in a table
18
18
+
type DataRecord interface {
19
19
+
models.Model
20
20
+
GetField(name string) any
21
21
+
}
22
22
+
23
23
+
// DataSource provides data for the table
24
24
+
type DataSource interface {
25
25
+
Load(ctx context.Context, opts DataOptions) ([]DataRecord, error)
26
26
+
Count(ctx context.Context, opts DataOptions) (int, error)
27
27
+
}
28
28
+
29
29
+
// Field defines a column in the table
30
30
+
type Field struct {
31
31
+
Name string
32
32
+
Title string
33
33
+
Width int
34
34
+
Formatter func(value any) string
35
35
+
}
36
36
+
37
37
+
// DataOptions configures data loading
38
38
+
type DataOptions struct {
39
39
+
Filters map[string]any
40
40
+
SortBy string
41
41
+
SortOrder string
42
42
+
Limit int
43
43
+
Offset int
44
44
+
}
45
45
+
46
46
+
// Action defines an action that can be performed on a record
47
47
+
type Action struct {
48
48
+
Key string
49
49
+
Description string
50
50
+
Handler func(record DataRecord) tea.Cmd
51
51
+
}
52
52
+
53
53
+
// DataTableKeyMap defines key bindings for table navigation
54
54
+
type DataTableKeyMap struct {
55
55
+
Up key.Binding
56
56
+
Down key.Binding
57
57
+
Enter key.Binding
58
58
+
View key.Binding
59
59
+
Refresh key.Binding
60
60
+
Quit key.Binding
61
61
+
Back key.Binding
62
62
+
Help key.Binding
63
63
+
Numbers []key.Binding
64
64
+
Actions map[string]key.Binding
65
65
+
}
66
66
+
67
67
+
func (k DataTableKeyMap) ShortHelp() []key.Binding {
68
68
+
return []key.Binding{k.Up, k.Down, k.Enter, k.Help, k.Quit}
69
69
+
}
70
70
+
71
71
+
func (k DataTableKeyMap) FullHelp() [][]key.Binding {
72
72
+
bindings := [][]key.Binding{
73
73
+
{k.Up, k.Down, k.Enter, k.View},
74
74
+
{k.Refresh, k.Help, k.Quit, k.Back},
75
75
+
}
76
76
+
77
77
+
if len(k.Actions) > 0 {
78
78
+
actionBindings := make([]key.Binding, 0, len(k.Actions))
79
79
+
for _, binding := range k.Actions {
80
80
+
actionBindings = append(actionBindings, binding)
81
81
+
}
82
82
+
bindings = append(bindings, actionBindings)
83
83
+
}
84
84
+
85
85
+
return bindings
86
86
+
}
87
87
+
88
88
+
// DefaultDataTableKeys returns the default key bindings
89
89
+
func DefaultDataTableKeys() DataTableKeyMap {
90
90
+
return DataTableKeyMap{
91
91
+
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ/k", "move up")),
92
92
+
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ/j", "move down")),
93
93
+
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
94
94
+
View: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view")),
95
95
+
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
96
96
+
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
97
97
+
Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")),
98
98
+
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
99
99
+
Numbers: []key.Binding{
100
100
+
key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to 1")),
101
101
+
key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to 2")),
102
102
+
key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to 3")),
103
103
+
key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to 4")),
104
104
+
key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to 5")),
105
105
+
key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to 6")),
106
106
+
key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to 7")),
107
107
+
key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to 8")),
108
108
+
key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to 9")),
109
109
+
},
110
110
+
Actions: make(map[string]key.Binding),
111
111
+
}
112
112
+
}
113
113
+
114
114
+
// DataTableOptions configures table behavior
115
115
+
type DataTableOptions struct {
116
116
+
Output io.Writer
117
117
+
Input io.Reader
118
118
+
Static bool
119
119
+
Title string
120
120
+
Fields []Field
121
121
+
Actions []Action
122
122
+
ViewHandler func(record DataRecord) string
123
123
+
}
124
124
+
125
125
+
// DataTable handles table display and interaction
126
126
+
type DataTable struct {
127
127
+
source DataSource
128
128
+
opts DataTableOptions
129
129
+
}
130
130
+
131
131
+
// NewDataTable creates a new data table
132
132
+
func NewDataTable(source DataSource, opts DataTableOptions) *DataTable {
133
133
+
if opts.Output == nil {
134
134
+
opts.Output = os.Stdout
135
135
+
}
136
136
+
if opts.Input == nil {
137
137
+
opts.Input = os.Stdin
138
138
+
}
139
139
+
if opts.Title == "" {
140
140
+
opts.Title = "Data"
141
141
+
}
142
142
+
143
143
+
return &DataTable{
144
144
+
source: source,
145
145
+
opts: opts,
146
146
+
}
147
147
+
}
148
148
+
149
149
+
type (
150
150
+
dataLoadedMsg []DataRecord
151
151
+
dataViewMsg string
152
152
+
dataErrorMsg error
153
153
+
dataCountMsg int
154
154
+
)
155
155
+
156
156
+
type dataTableModel struct {
157
157
+
records []DataRecord
158
158
+
selected int
159
159
+
viewing bool
160
160
+
viewContent string
161
161
+
err error
162
162
+
loading bool
163
163
+
source DataSource
164
164
+
opts DataTableOptions
165
165
+
keys DataTableKeyMap
166
166
+
help help.Model
167
167
+
showingHelp bool
168
168
+
totalCount int
169
169
+
currentPage int
170
170
+
dataOpts DataOptions
171
171
+
}
172
172
+
173
173
+
func (m dataTableModel) Init() tea.Cmd {
174
174
+
return tea.Batch(m.loadData(), m.loadCount())
175
175
+
}
176
176
+
177
177
+
func (m dataTableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
178
178
+
switch msg := msg.(type) {
179
179
+
case tea.KeyMsg:
180
180
+
if m.showingHelp {
181
181
+
switch {
182
182
+
case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help):
183
183
+
m.showingHelp = false
184
184
+
return m, nil
185
185
+
}
186
186
+
return m, nil
187
187
+
}
188
188
+
189
189
+
if m.viewing {
190
190
+
switch {
191
191
+
case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit):
192
192
+
m.viewing = false
193
193
+
m.viewContent = ""
194
194
+
return m, nil
195
195
+
case key.Matches(msg, m.keys.Help):
196
196
+
m.showingHelp = true
197
197
+
return m, nil
198
198
+
}
199
199
+
return m, nil
200
200
+
}
201
201
+
202
202
+
switch {
203
203
+
case key.Matches(msg, m.keys.Quit):
204
204
+
return m, tea.Quit
205
205
+
case key.Matches(msg, m.keys.Up):
206
206
+
if m.selected > 0 {
207
207
+
m.selected--
208
208
+
}
209
209
+
case key.Matches(msg, m.keys.Down):
210
210
+
if m.selected < len(m.records)-1 {
211
211
+
m.selected++
212
212
+
}
213
213
+
case key.Matches(msg, m.keys.Enter) || key.Matches(msg, m.keys.View):
214
214
+
if len(m.records) > 0 && m.selected < len(m.records) && m.opts.ViewHandler != nil {
215
215
+
return m, m.viewRecord(m.records[m.selected])
216
216
+
}
217
217
+
case key.Matches(msg, m.keys.Refresh):
218
218
+
m.loading = true
219
219
+
return m, tea.Batch(m.loadData(), m.loadCount())
220
220
+
case key.Matches(msg, m.keys.Help):
221
221
+
m.showingHelp = true
222
222
+
return m, nil
223
223
+
default:
224
224
+
for i, numKey := range m.keys.Numbers {
225
225
+
if key.Matches(msg, numKey) && i < len(m.records) {
226
226
+
m.selected = i
227
227
+
break
228
228
+
}
229
229
+
}
230
230
+
231
231
+
for actionKey, binding := range m.keys.Actions {
232
232
+
if key.Matches(msg, binding) && len(m.records) > 0 && m.selected < len(m.records) {
233
233
+
for _, action := range m.opts.Actions {
234
234
+
if action.Key == actionKey {
235
235
+
return m, action.Handler(m.records[m.selected])
236
236
+
}
237
237
+
}
238
238
+
}
239
239
+
}
240
240
+
}
241
241
+
case dataLoadedMsg:
242
242
+
m.records = []DataRecord(msg)
243
243
+
m.loading = false
244
244
+
if m.selected >= len(m.records) && len(m.records) > 0 {
245
245
+
m.selected = len(m.records) - 1
246
246
+
}
247
247
+
case dataViewMsg:
248
248
+
m.viewContent = string(msg)
249
249
+
m.viewing = true
250
250
+
case dataErrorMsg:
251
251
+
m.err = error(msg)
252
252
+
m.loading = false
253
253
+
case dataCountMsg:
254
254
+
m.totalCount = int(msg)
255
255
+
}
256
256
+
return m, nil
257
257
+
}
258
258
+
259
259
+
func (m dataTableModel) View() string {
260
260
+
var s strings.Builder
261
261
+
262
262
+
style := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex()))
263
263
+
264
264
+
if m.showingHelp {
265
265
+
return m.help.View(m.keys)
266
266
+
}
267
267
+
268
268
+
if m.viewing {
269
269
+
s.WriteString(m.viewContent)
270
270
+
s.WriteString("\n\n")
271
271
+
s.WriteString(style.Render("Press q/esc/backspace to return to list, ? for help"))
272
272
+
return s.String()
273
273
+
}
274
274
+
275
275
+
s.WriteString(TitleColorStyle.Render(m.opts.Title))
276
276
+
if m.totalCount > 0 {
277
277
+
s.WriteString(fmt.Sprintf(" (%d total)", m.totalCount))
278
278
+
}
279
279
+
s.WriteString("\n\n")
280
280
+
281
281
+
if m.loading {
282
282
+
s.WriteString("Loading...")
283
283
+
return s.String()
284
284
+
}
285
285
+
286
286
+
if m.err != nil {
287
287
+
s.WriteString(fmt.Sprintf("Error: %s", m.err))
288
288
+
return s.String()
289
289
+
}
290
290
+
291
291
+
if len(m.records) == 0 {
292
292
+
s.WriteString("No records found")
293
293
+
s.WriteString("\n\n")
294
294
+
s.WriteString(style.Render("Press r to refresh, q to quit"))
295
295
+
return s.String()
296
296
+
}
297
297
+
298
298
+
headerParts := make([]string, len(m.opts.Fields))
299
299
+
for i, field := range m.opts.Fields {
300
300
+
format := fmt.Sprintf("%%-%ds", field.Width)
301
301
+
headerParts[i] = fmt.Sprintf(format, field.Title)
302
302
+
}
303
303
+
headerLine := fmt.Sprintf(" %s", strings.Join(headerParts, " "))
304
304
+
s.WriteString(HeaderColorStyle.Render(headerLine))
305
305
+
s.WriteString("\n")
306
306
+
307
307
+
totalWidth := 3 + len(strings.Join(headerParts, " "))
308
308
+
s.WriteString(HeaderColorStyle.Render(strings.Repeat("โ", totalWidth)))
309
309
+
s.WriteString("\n")
310
310
+
311
311
+
for i, record := range m.records {
312
312
+
prefix := " "
313
313
+
if i == m.selected {
314
314
+
prefix = " > "
315
315
+
}
316
316
+
317
317
+
rowParts := make([]string, len(m.opts.Fields))
318
318
+
for j, field := range m.opts.Fields {
319
319
+
value := record.GetField(field.Name)
320
320
+
321
321
+
var displayValue string
322
322
+
if field.Formatter != nil {
323
323
+
displayValue = field.Formatter(value)
324
324
+
} else {
325
325
+
displayValue = fmt.Sprintf("%v", value)
326
326
+
}
327
327
+
328
328
+
if len(displayValue) > field.Width-1 {
329
329
+
displayValue = displayValue[:field.Width-4] + "..."
330
330
+
}
331
331
+
332
332
+
format := fmt.Sprintf("%%-%ds", field.Width)
333
333
+
rowParts[j] = fmt.Sprintf(format, displayValue)
334
334
+
}
335
335
+
336
336
+
line := fmt.Sprintf("%s%s", prefix, strings.Join(rowParts, " "))
337
337
+
338
338
+
if i == m.selected {
339
339
+
s.WriteString(SelectedColorStyle.Render(line))
340
340
+
} else {
341
341
+
s.WriteString(style.Render(line))
342
342
+
}
343
343
+
344
344
+
s.WriteString("\n")
345
345
+
}
346
346
+
347
347
+
s.WriteString("\n")
348
348
+
s.WriteString(m.help.View(m.keys))
349
349
+
350
350
+
return s.String()
351
351
+
}
352
352
+
353
353
+
func (m dataTableModel) loadData() tea.Cmd {
354
354
+
return func() tea.Msg {
355
355
+
records, err := m.source.Load(context.Background(), m.dataOpts)
356
356
+
if err != nil {
357
357
+
return dataErrorMsg(err)
358
358
+
}
359
359
+
return dataLoadedMsg(records)
360
360
+
}
361
361
+
}
362
362
+
363
363
+
func (m dataTableModel) loadCount() tea.Cmd {
364
364
+
return func() tea.Msg {
365
365
+
count, err := m.source.Count(context.Background(), m.dataOpts)
366
366
+
if err != nil {
367
367
+
return dataCountMsg(0)
368
368
+
}
369
369
+
return dataCountMsg(count)
370
370
+
}
371
371
+
}
372
372
+
373
373
+
func (m dataTableModel) viewRecord(record DataRecord) tea.Cmd {
374
374
+
return func() tea.Msg {
375
375
+
content := m.opts.ViewHandler(record)
376
376
+
return dataViewMsg(content)
377
377
+
}
378
378
+
}
379
379
+
380
380
+
// Browse opens an interactive table interface
381
381
+
func (dt *DataTable) Browse(ctx context.Context) error {
382
382
+
return dt.BrowseWithOptions(ctx, DataOptions{})
383
383
+
}
384
384
+
385
385
+
// BrowseWithOptions opens an interactive table with custom data options
386
386
+
func (dt *DataTable) BrowseWithOptions(ctx context.Context, dataOpts DataOptions) error {
387
387
+
if dt.opts.Static {
388
388
+
return dt.staticDisplay(ctx, dataOpts)
389
389
+
}
390
390
+
391
391
+
keys := DefaultDataTableKeys()
392
392
+
for _, action := range dt.opts.Actions {
393
393
+
keys.Actions[action.Key] = key.NewBinding(
394
394
+
key.WithKeys(action.Key),
395
395
+
key.WithHelp(action.Key, action.Description),
396
396
+
)
397
397
+
}
398
398
+
399
399
+
model := dataTableModel{
400
400
+
source: dt.source,
401
401
+
opts: dt.opts,
402
402
+
keys: keys,
403
403
+
help: help.New(),
404
404
+
dataOpts: dataOpts,
405
405
+
loading: true,
406
406
+
}
407
407
+
408
408
+
program := tea.NewProgram(model, tea.WithInput(dt.opts.Input), tea.WithOutput(dt.opts.Output))
409
409
+
_, err := program.Run()
410
410
+
return err
411
411
+
}
412
412
+
413
413
+
func (dt *DataTable) staticDisplay(ctx context.Context, dataOpts DataOptions) error {
414
414
+
records, err := dt.source.Load(ctx, dataOpts)
415
415
+
if err != nil {
416
416
+
fmt.Fprintf(dt.opts.Output, "Error: %s\n", err)
417
417
+
return err
418
418
+
}
419
419
+
420
420
+
fmt.Fprintf(dt.opts.Output, "%s\n\n", dt.opts.Title)
421
421
+
422
422
+
if len(records) == 0 {
423
423
+
fmt.Fprintf(dt.opts.Output, "No records found\n")
424
424
+
return nil
425
425
+
}
426
426
+
427
427
+
headerParts := make([]string, len(dt.opts.Fields))
428
428
+
for i, field := range dt.opts.Fields {
429
429
+
format := fmt.Sprintf("%%-%ds", field.Width)
430
430
+
headerParts[i] = fmt.Sprintf(format, field.Title)
431
431
+
}
432
432
+
fmt.Fprintf(dt.opts.Output, "%s\n", strings.Join(headerParts, " "))
433
433
+
434
434
+
totalWidth := len(strings.Join(headerParts, " "))
435
435
+
fmt.Fprintf(dt.opts.Output, "%s\n", strings.Repeat("โ", totalWidth))
436
436
+
437
437
+
for _, record := range records {
438
438
+
rowParts := make([]string, len(dt.opts.Fields))
439
439
+
for i, field := range dt.opts.Fields {
440
440
+
value := record.GetField(field.Name)
441
441
+
442
442
+
var displayValue string
443
443
+
if field.Formatter != nil {
444
444
+
displayValue = field.Formatter(value)
445
445
+
} else {
446
446
+
displayValue = fmt.Sprintf("%v", value)
447
447
+
}
448
448
+
449
449
+
if len(displayValue) > field.Width-1 {
450
450
+
displayValue = displayValue[:field.Width-4] + "..."
451
451
+
}
452
452
+
453
453
+
format := fmt.Sprintf("%%-%ds", field.Width)
454
454
+
rowParts[i] = fmt.Sprintf(format, displayValue)
455
455
+
}
456
456
+
457
457
+
fmt.Fprintf(dt.opts.Output, "%s\n", strings.Join(rowParts, " "))
458
458
+
}
459
459
+
460
460
+
return nil
461
461
+
}
+954
internal/ui/data_table_test.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"bytes"
5
5
+
"context"
6
6
+
"errors"
7
7
+
"fmt"
8
8
+
"strings"
9
9
+
"testing"
10
10
+
"time"
11
11
+
12
12
+
"github.com/charmbracelet/bubbles/help"
13
13
+
"github.com/charmbracelet/bubbles/key"
14
14
+
tea "github.com/charmbracelet/bubbletea"
15
15
+
)
16
16
+
17
17
+
type MockDataRecord struct {
18
18
+
id int64
19
19
+
fields map[string]any
20
20
+
created time.Time
21
21
+
modified time.Time
22
22
+
}
23
23
+
24
24
+
func (m MockDataRecord) GetID() int64 {
25
25
+
return m.id
26
26
+
}
27
27
+
28
28
+
func (m MockDataRecord) SetID(id int64) {
29
29
+
m.id = id
30
30
+
}
31
31
+
32
32
+
func (m MockDataRecord) GetTableName() string {
33
33
+
return "mock_records"
34
34
+
}
35
35
+
36
36
+
func (m MockDataRecord) GetCreatedAt() time.Time {
37
37
+
return m.created
38
38
+
}
39
39
+
40
40
+
func (m MockDataRecord) SetCreatedAt(t time.Time) {
41
41
+
m.created = t
42
42
+
}
43
43
+
44
44
+
func (m MockDataRecord) GetUpdatedAt() time.Time {
45
45
+
return m.modified
46
46
+
}
47
47
+
48
48
+
func (m MockDataRecord) SetUpdatedAt(t time.Time) {
49
49
+
m.modified = t
50
50
+
}
51
51
+
52
52
+
func (m MockDataRecord) GetField(name string) any {
53
53
+
return m.fields[name]
54
54
+
}
55
55
+
56
56
+
func NewMockRecord(id int64, fields map[string]any) MockDataRecord {
57
57
+
now := time.Now()
58
58
+
return MockDataRecord{
59
59
+
id: id,
60
60
+
fields: fields,
61
61
+
created: now,
62
62
+
modified: now,
63
63
+
}
64
64
+
}
65
65
+
66
66
+
type MockDataSource struct {
67
67
+
records []DataRecord
68
68
+
loadError error
69
69
+
countError error
70
70
+
loadDelay bool
71
71
+
}
72
72
+
73
73
+
func (m *MockDataSource) Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) {
74
74
+
if m.loadError != nil {
75
75
+
return nil, m.loadError
76
76
+
}
77
77
+
78
78
+
filtered := make([]DataRecord, 0)
79
79
+
for _, record := range m.records {
80
80
+
include := true
81
81
+
for filterField, filterValue := range opts.Filters {
82
82
+
if record.GetField(filterField) != filterValue {
83
83
+
include = false
84
84
+
break
85
85
+
}
86
86
+
}
87
87
+
if include {
88
88
+
filtered = append(filtered, record)
89
89
+
}
90
90
+
}
91
91
+
92
92
+
if opts.Limit > 0 && len(filtered) > opts.Limit {
93
93
+
filtered = filtered[:opts.Limit]
94
94
+
}
95
95
+
96
96
+
return filtered, nil
97
97
+
}
98
98
+
99
99
+
func (m *MockDataSource) Count(ctx context.Context, opts DataOptions) (int, error) {
100
100
+
if m.countError != nil {
101
101
+
return 0, m.countError
102
102
+
}
103
103
+
104
104
+
count := 0
105
105
+
for _, record := range m.records {
106
106
+
include := true
107
107
+
for filterField, filterValue := range opts.Filters {
108
108
+
if record.GetField(filterField) != filterValue {
109
109
+
include = false
110
110
+
break
111
111
+
}
112
112
+
}
113
113
+
if include {
114
114
+
count++
115
115
+
}
116
116
+
}
117
117
+
118
118
+
return count, nil
119
119
+
}
120
120
+
121
121
+
func createMockRecords() []DataRecord {
122
122
+
return []DataRecord{
123
123
+
NewMockRecord(1, map[string]any{
124
124
+
"name": "John Doe",
125
125
+
"status": "active",
126
126
+
"priority": "high",
127
127
+
"project": "alpha",
128
128
+
}),
129
129
+
NewMockRecord(2, map[string]any{
130
130
+
"name": "Jane Smith",
131
131
+
"status": "pending",
132
132
+
"priority": "medium",
133
133
+
"project": "beta",
134
134
+
}),
135
135
+
NewMockRecord(3, map[string]any{
136
136
+
"name": "Bob Johnson",
137
137
+
"status": "completed",
138
138
+
"priority": "low",
139
139
+
"project": "alpha",
140
140
+
}),
141
141
+
}
142
142
+
}
143
143
+
144
144
+
func createTestFields() []Field {
145
145
+
return []Field{
146
146
+
{Name: "name", Title: "Name", Width: 20},
147
147
+
{Name: "status", Title: "Status", Width: 12},
148
148
+
{Name: "priority", Title: "Priority", Width: 10, Formatter: func(v interface{}) string {
149
149
+
return strings.ToUpper(fmt.Sprintf("%v", v))
150
150
+
}},
151
151
+
{Name: "project", Title: "Project", Width: 15},
152
152
+
}
153
153
+
}
154
154
+
155
155
+
func TestDataTable(t *testing.T) {
156
156
+
t.Run("TestDataTableOptions", func(t *testing.T) {
157
157
+
t.Run("default options", func(t *testing.T) {
158
158
+
source := &MockDataSource{records: createMockRecords()}
159
159
+
opts := DataTableOptions{
160
160
+
Fields: createTestFields(),
161
161
+
}
162
162
+
163
163
+
table := NewDataTable(source, opts)
164
164
+
if table.opts.Output == nil {
165
165
+
t.Error("Output should default to os.Stdout")
166
166
+
}
167
167
+
if table.opts.Input == nil {
168
168
+
t.Error("Input should default to os.Stdin")
169
169
+
}
170
170
+
if table.opts.Title != "Data" {
171
171
+
t.Error("Title should default to 'Data'")
172
172
+
}
173
173
+
})
174
174
+
175
175
+
t.Run("custom options", func(t *testing.T) {
176
176
+
var buf bytes.Buffer
177
177
+
source := &MockDataSource{records: createMockRecords()}
178
178
+
opts := DataTableOptions{
179
179
+
Output: &buf,
180
180
+
Static: true,
181
181
+
Title: "Test Table",
182
182
+
Fields: createTestFields(),
183
183
+
ViewHandler: func(record DataRecord) string {
184
184
+
return fmt.Sprintf("Viewing: %v", record.GetField("name"))
185
185
+
},
186
186
+
}
187
187
+
188
188
+
table := NewDataTable(source, opts)
189
189
+
if table.opts.Output != &buf {
190
190
+
t.Error("Custom output not set")
191
191
+
}
192
192
+
if !table.opts.Static {
193
193
+
t.Error("Static mode not set")
194
194
+
}
195
195
+
if table.opts.Title != "Test Table" {
196
196
+
t.Error("Custom title not set")
197
197
+
}
198
198
+
})
199
199
+
})
200
200
+
201
201
+
t.Run("Static Mode", func(t *testing.T) {
202
202
+
t.Run("successful static display", func(t *testing.T) {
203
203
+
var buf bytes.Buffer
204
204
+
source := &MockDataSource{records: createMockRecords()}
205
205
+
206
206
+
table := NewDataTable(source, DataTableOptions{
207
207
+
Output: &buf,
208
208
+
Static: true,
209
209
+
Title: "Test Table",
210
210
+
Fields: createTestFields(),
211
211
+
})
212
212
+
213
213
+
err := table.Browse(context.Background())
214
214
+
if err != nil {
215
215
+
t.Fatalf("Browse failed: %v", err)
216
216
+
}
217
217
+
218
218
+
output := buf.String()
219
219
+
if !strings.Contains(output, "Test Table") {
220
220
+
t.Error("Title not displayed")
221
221
+
}
222
222
+
if !strings.Contains(output, "John Doe") {
223
223
+
t.Error("First record not displayed")
224
224
+
}
225
225
+
if !strings.Contains(output, "Jane Smith") {
226
226
+
t.Error("Second record not displayed")
227
227
+
}
228
228
+
if !strings.Contains(output, "Name") {
229
229
+
t.Error("Header not displayed")
230
230
+
}
231
231
+
})
232
232
+
233
233
+
t.Run("static display with no records", func(t *testing.T) {
234
234
+
var buf bytes.Buffer
235
235
+
source := &MockDataSource{records: []DataRecord{}}
236
236
+
237
237
+
table := NewDataTable(source, DataTableOptions{
238
238
+
Output: &buf,
239
239
+
Static: true,
240
240
+
Fields: createTestFields(),
241
241
+
})
242
242
+
243
243
+
err := table.Browse(context.Background())
244
244
+
if err != nil {
245
245
+
t.Fatalf("Browse failed: %v", err)
246
246
+
}
247
247
+
248
248
+
output := buf.String()
249
249
+
if !strings.Contains(output, "No records found") {
250
250
+
t.Error("No records message not displayed")
251
251
+
}
252
252
+
})
253
253
+
254
254
+
t.Run("static display with load error", func(t *testing.T) {
255
255
+
var buf bytes.Buffer
256
256
+
source := &MockDataSource{
257
257
+
loadError: errors.New("database error"),
258
258
+
}
259
259
+
260
260
+
table := NewDataTable(source, DataTableOptions{
261
261
+
Output: &buf,
262
262
+
Static: true,
263
263
+
Fields: createTestFields(),
264
264
+
})
265
265
+
266
266
+
err := table.Browse(context.Background())
267
267
+
if err == nil {
268
268
+
t.Fatal("Expected error, got nil")
269
269
+
}
270
270
+
271
271
+
output := buf.String()
272
272
+
if !strings.Contains(output, "Error: database error") {
273
273
+
t.Error("Error message not displayed")
274
274
+
}
275
275
+
})
276
276
+
277
277
+
t.Run("static display with filters", func(t *testing.T) {
278
278
+
var buf bytes.Buffer
279
279
+
source := &MockDataSource{records: createMockRecords()}
280
280
+
281
281
+
table := NewDataTable(source, DataTableOptions{
282
282
+
Output: &buf,
283
283
+
Static: true,
284
284
+
Fields: createTestFields(),
285
285
+
})
286
286
+
287
287
+
opts := DataOptions{
288
288
+
Filters: map[string]interface{}{
289
289
+
"status": "active",
290
290
+
},
291
291
+
}
292
292
+
293
293
+
err := table.BrowseWithOptions(context.Background(), opts)
294
294
+
if err != nil {
295
295
+
t.Fatalf("Browse failed: %v", err)
296
296
+
}
297
297
+
298
298
+
output := buf.String()
299
299
+
if !strings.Contains(output, "John Doe") {
300
300
+
t.Error("Active record not displayed")
301
301
+
}
302
302
+
if strings.Contains(output, "Jane Smith") {
303
303
+
t.Error("Pending record should be filtered out")
304
304
+
}
305
305
+
})
306
306
+
})
307
307
+
308
308
+
t.Run("Model", func(t *testing.T) {
309
309
+
t.Run("initial model state", func(t *testing.T) {
310
310
+
source := &MockDataSource{records: createMockRecords()}
311
311
+
keys := DefaultDataTableKeys()
312
312
+
313
313
+
model := dataTableModel{
314
314
+
source: source,
315
315
+
opts: DataTableOptions{
316
316
+
Fields: createTestFields(),
317
317
+
},
318
318
+
keys: keys,
319
319
+
help: help.New(),
320
320
+
loading: true,
321
321
+
}
322
322
+
323
323
+
if model.selected != 0 {
324
324
+
t.Error("Initial selected should be 0")
325
325
+
}
326
326
+
if model.viewing {
327
327
+
t.Error("Initial viewing should be false")
328
328
+
}
329
329
+
if !model.loading {
330
330
+
t.Error("Initial loading should be true")
331
331
+
}
332
332
+
})
333
333
+
334
334
+
t.Run("load data command", func(t *testing.T) {
335
335
+
source := &MockDataSource{records: createMockRecords()}
336
336
+
337
337
+
model := dataTableModel{
338
338
+
source: source,
339
339
+
keys: DefaultDataTableKeys(),
340
340
+
dataOpts: DataOptions{},
341
341
+
}
342
342
+
343
343
+
cmd := model.loadData()
344
344
+
if cmd == nil {
345
345
+
t.Fatal("loadData should return a command")
346
346
+
}
347
347
+
348
348
+
msg := cmd()
349
349
+
switch msg := msg.(type) {
350
350
+
case dataLoadedMsg:
351
351
+
records := []DataRecord(msg)
352
352
+
if len(records) != 3 {
353
353
+
t.Errorf("Expected 3 records, got %d", len(records))
354
354
+
}
355
355
+
case dataErrorMsg:
356
356
+
t.Fatalf("Unexpected error: %v", error(msg))
357
357
+
default:
358
358
+
t.Fatalf("Unexpected message type: %T", msg)
359
359
+
}
360
360
+
})
361
361
+
362
362
+
t.Run("load data with error", func(t *testing.T) {
363
363
+
source := &MockDataSource{
364
364
+
loadError: errors.New("connection failed"),
365
365
+
}
366
366
+
367
367
+
model := dataTableModel{
368
368
+
source: source,
369
369
+
dataOpts: DataOptions{},
370
370
+
}
371
371
+
372
372
+
cmd := model.loadData()
373
373
+
msg := cmd()
374
374
+
375
375
+
switch msg := msg.(type) {
376
376
+
case dataErrorMsg:
377
377
+
err := error(msg)
378
378
+
if !strings.Contains(err.Error(), "connection failed") {
379
379
+
t.Errorf("Expected connection error, got: %v", err)
380
380
+
}
381
381
+
default:
382
382
+
t.Fatalf("Expected dataErrorMsg, got: %T", msg)
383
383
+
}
384
384
+
})
385
385
+
386
386
+
t.Run("load count command", func(t *testing.T) {
387
387
+
source := &MockDataSource{records: createMockRecords()}
388
388
+
389
389
+
model := dataTableModel{
390
390
+
source: source,
391
391
+
dataOpts: DataOptions{},
392
392
+
}
393
393
+
394
394
+
cmd := model.loadCount()
395
395
+
msg := cmd()
396
396
+
397
397
+
switch msg := msg.(type) {
398
398
+
case dataCountMsg:
399
399
+
count := int(msg)
400
400
+
if count != 3 {
401
401
+
t.Errorf("Expected count 3, got %d", count)
402
402
+
}
403
403
+
default:
404
404
+
t.Fatalf("Expected dataCountMsg, got: %T", msg)
405
405
+
}
406
406
+
})
407
407
+
408
408
+
t.Run("load count with error", func(t *testing.T) {
409
409
+
source := &MockDataSource{
410
410
+
records: createMockRecords(),
411
411
+
countError: errors.New("count failed"),
412
412
+
}
413
413
+
414
414
+
model := dataTableModel{
415
415
+
source: source,
416
416
+
dataOpts: DataOptions{},
417
417
+
}
418
418
+
419
419
+
cmd := model.loadCount()
420
420
+
msg := cmd()
421
421
+
422
422
+
switch msg := msg.(type) {
423
423
+
case dataCountMsg:
424
424
+
count := int(msg)
425
425
+
if count != 0 {
426
426
+
t.Errorf("Expected count 0 on error, got %d", count)
427
427
+
}
428
428
+
default:
429
429
+
t.Fatalf("Expected dataCountMsg even on error, got: %T", msg)
430
430
+
}
431
431
+
})
432
432
+
433
433
+
t.Run("view record command", func(t *testing.T) {
434
434
+
viewHandler := func(record DataRecord) string {
435
435
+
return fmt.Sprintf("Viewing: %v", record.GetField("name"))
436
436
+
}
437
437
+
438
438
+
model := dataTableModel{
439
439
+
opts: DataTableOptions{
440
440
+
ViewHandler: viewHandler,
441
441
+
Fields: createTestFields(),
442
442
+
},
443
443
+
}
444
444
+
445
445
+
record := createMockRecords()[0]
446
446
+
cmd := model.viewRecord(record)
447
447
+
msg := cmd()
448
448
+
449
449
+
switch msg := msg.(type) {
450
450
+
case dataViewMsg:
451
451
+
content := string(msg)
452
452
+
if !strings.Contains(content, "Viewing: John Doe") {
453
453
+
t.Error("View content not formatted correctly")
454
454
+
}
455
455
+
default:
456
456
+
t.Fatalf("Expected dataViewMsg, got: %T", msg)
457
457
+
}
458
458
+
})
459
459
+
})
460
460
+
461
461
+
t.Run("Key Handling", func(t *testing.T) {
462
462
+
source := &MockDataSource{records: createMockRecords()}
463
463
+
464
464
+
t.Run("navigation keys", func(t *testing.T) {
465
465
+
model := dataTableModel{
466
466
+
source: source,
467
467
+
records: createMockRecords(),
468
468
+
selected: 1,
469
469
+
keys: DefaultDataTableKeys(),
470
470
+
opts: DataTableOptions{Fields: createTestFields()},
471
471
+
}
472
472
+
473
473
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
474
474
+
if m, ok := newModel.(dataTableModel); ok {
475
475
+
if m.selected != 0 {
476
476
+
t.Errorf("Up key should move selection to 0, got %d", m.selected)
477
477
+
}
478
478
+
}
479
479
+
480
480
+
model.selected = 1
481
481
+
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
482
482
+
if m, ok := newModel.(dataTableModel); ok {
483
483
+
if m.selected != 2 {
484
484
+
t.Errorf("Down key should move selection to 2, got %d", m.selected)
485
485
+
}
486
486
+
}
487
487
+
})
488
488
+
489
489
+
t.Run("boundary conditions", func(t *testing.T) {
490
490
+
model := dataTableModel{
491
491
+
source: source,
492
492
+
records: createMockRecords(),
493
493
+
selected: 0,
494
494
+
keys: DefaultDataTableKeys(),
495
495
+
opts: DataTableOptions{Fields: createTestFields()},
496
496
+
}
497
497
+
498
498
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
499
499
+
if m, ok := newModel.(dataTableModel); ok {
500
500
+
if m.selected != 0 {
501
501
+
t.Error("Up key at top should not change selection")
502
502
+
}
503
503
+
}
504
504
+
505
505
+
model.selected = 2
506
506
+
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
507
507
+
if m, ok := newModel.(dataTableModel); ok {
508
508
+
if m.selected != 2 {
509
509
+
t.Error("Down key at bottom should not change selection")
510
510
+
}
511
511
+
}
512
512
+
})
513
513
+
514
514
+
t.Run("number shortcuts", func(t *testing.T) {
515
515
+
model := dataTableModel{
516
516
+
source: source,
517
517
+
records: createMockRecords(),
518
518
+
keys: DefaultDataTableKeys(),
519
519
+
opts: DataTableOptions{Fields: createTestFields()},
520
520
+
}
521
521
+
522
522
+
for i := 1; i <= 3; i++ {
523
523
+
key := fmt.Sprintf("%d", i)
524
524
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
525
525
+
if m, ok := newModel.(dataTableModel); ok {
526
526
+
expectedIndex := i - 1
527
527
+
if m.selected != expectedIndex {
528
528
+
t.Errorf("Number key %s should select index %d, got %d", key, expectedIndex, m.selected)
529
529
+
}
530
530
+
}
531
531
+
}
532
532
+
})
533
533
+
534
534
+
t.Run("view key with handler", func(t *testing.T) {
535
535
+
viewHandler := func(record DataRecord) string {
536
536
+
return "test view"
537
537
+
}
538
538
+
539
539
+
model := dataTableModel{
540
540
+
source: source,
541
541
+
records: createMockRecords(),
542
542
+
keys: DefaultDataTableKeys(),
543
543
+
opts: DataTableOptions{
544
544
+
Fields: createTestFields(),
545
545
+
ViewHandler: viewHandler,
546
546
+
},
547
547
+
}
548
548
+
549
549
+
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("v")})
550
550
+
if cmd == nil {
551
551
+
t.Error("View key should return command when handler is set")
552
552
+
}
553
553
+
})
554
554
+
555
555
+
t.Run("view key without handler", func(t *testing.T) {
556
556
+
model := dataTableModel{
557
557
+
source: source,
558
558
+
records: createMockRecords(),
559
559
+
keys: DefaultDataTableKeys(),
560
560
+
opts: DataTableOptions{Fields: createTestFields()},
561
561
+
}
562
562
+
563
563
+
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("v")})
564
564
+
if cmd != nil {
565
565
+
t.Error("View key should not return command when no handler is set")
566
566
+
}
567
567
+
})
568
568
+
569
569
+
t.Run("quit key", func(t *testing.T) {
570
570
+
model := dataTableModel{
571
571
+
keys: DefaultDataTableKeys(),
572
572
+
opts: DataTableOptions{Fields: createTestFields()},
573
573
+
}
574
574
+
575
575
+
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
576
576
+
if cmd == nil {
577
577
+
t.Error("Quit key should return quit command")
578
578
+
}
579
579
+
})
580
580
+
581
581
+
t.Run("refresh key", func(t *testing.T) {
582
582
+
model := dataTableModel{
583
583
+
source: source,
584
584
+
keys: DefaultDataTableKeys(),
585
585
+
opts: DataTableOptions{Fields: createTestFields()},
586
586
+
}
587
587
+
588
588
+
newModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
589
589
+
if cmd == nil {
590
590
+
t.Error("Refresh key should return command")
591
591
+
}
592
592
+
if m, ok := newModel.(dataTableModel); ok {
593
593
+
if !m.loading {
594
594
+
t.Error("Refresh should set loading to true")
595
595
+
}
596
596
+
}
597
597
+
})
598
598
+
599
599
+
t.Run("help mode", func(t *testing.T) {
600
600
+
model := dataTableModel{
601
601
+
keys: DefaultDataTableKeys(),
602
602
+
showingHelp: true,
603
603
+
opts: DataTableOptions{Fields: createTestFields()},
604
604
+
}
605
605
+
606
606
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
607
607
+
if m, ok := newModel.(dataTableModel); ok {
608
608
+
if m.selected != 0 {
609
609
+
t.Error("Navigation should be ignored in help mode")
610
610
+
}
611
611
+
}
612
612
+
613
613
+
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
614
614
+
if m, ok := newModel.(dataTableModel); ok {
615
615
+
if m.showingHelp {
616
616
+
t.Error("Help key should exit help mode")
617
617
+
}
618
618
+
}
619
619
+
})
620
620
+
621
621
+
t.Run("viewing mode", func(t *testing.T) {
622
622
+
model := dataTableModel{
623
623
+
keys: DefaultDataTableKeys(),
624
624
+
viewing: true,
625
625
+
viewContent: "test content",
626
626
+
opts: DataTableOptions{Fields: createTestFields()},
627
627
+
}
628
628
+
629
629
+
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
630
630
+
if m, ok := newModel.(dataTableModel); ok {
631
631
+
if m.viewing {
632
632
+
t.Error("Quit should exit viewing mode")
633
633
+
}
634
634
+
if m.viewContent != "" {
635
635
+
t.Error("Quit should clear view content")
636
636
+
}
637
637
+
}
638
638
+
})
639
639
+
})
640
640
+
641
641
+
t.Run("View", func(t *testing.T) {
642
642
+
source := &MockDataSource{records: createMockRecords()}
643
643
+
644
644
+
t.Run("normal view", func(t *testing.T) {
645
645
+
model := dataTableModel{
646
646
+
source: source,
647
647
+
records: createMockRecords(),
648
648
+
keys: DefaultDataTableKeys(),
649
649
+
help: help.New(),
650
650
+
opts: DataTableOptions{Title: "Test", Fields: createTestFields()},
651
651
+
}
652
652
+
653
653
+
view := model.View()
654
654
+
if !strings.Contains(view, "Test") {
655
655
+
t.Error("Title not displayed")
656
656
+
}
657
657
+
if !strings.Contains(view, "John Doe") {
658
658
+
t.Error("Record data not displayed")
659
659
+
}
660
660
+
if !strings.Contains(view, "Name") {
661
661
+
t.Error("Headers not displayed")
662
662
+
}
663
663
+
if !strings.Contains(view, " > ") {
664
664
+
t.Error("Selection indicator not displayed")
665
665
+
}
666
666
+
})
667
667
+
668
668
+
t.Run("loading view", func(t *testing.T) {
669
669
+
model := dataTableModel{
670
670
+
loading: true,
671
671
+
opts: DataTableOptions{Title: "Test", Fields: createTestFields()},
672
672
+
}
673
673
+
674
674
+
view := model.View()
675
675
+
if !strings.Contains(view, "Loading...") {
676
676
+
t.Error("Loading message not displayed")
677
677
+
}
678
678
+
})
679
679
+
680
680
+
t.Run("error view", func(t *testing.T) {
681
681
+
model := dataTableModel{
682
682
+
err: errors.New("test error"),
683
683
+
opts: DataTableOptions{Title: "Test", Fields: createTestFields()},
684
684
+
}
685
685
+
686
686
+
view := model.View()
687
687
+
if !strings.Contains(view, "Error: test error") {
688
688
+
t.Error("Error message not displayed")
689
689
+
}
690
690
+
})
691
691
+
692
692
+
t.Run("empty records view", func(t *testing.T) {
693
693
+
model := dataTableModel{
694
694
+
records: []DataRecord{},
695
695
+
opts: DataTableOptions{Title: "Test", Fields: createTestFields()},
696
696
+
}
697
697
+
698
698
+
view := model.View()
699
699
+
if !strings.Contains(view, "No records found") {
700
700
+
t.Error("Empty message not displayed")
701
701
+
}
702
702
+
})
703
703
+
704
704
+
t.Run("viewing mode", func(t *testing.T) {
705
705
+
model := dataTableModel{
706
706
+
viewing: true,
707
707
+
viewContent: "# Test Content\nDetails here",
708
708
+
opts: DataTableOptions{Fields: createTestFields()},
709
709
+
}
710
710
+
711
711
+
view := model.View()
712
712
+
if !strings.Contains(view, "# Test Content") {
713
713
+
t.Error("View content not displayed")
714
714
+
}
715
715
+
if !strings.Contains(view, "Press q/esc/backspace to return") {
716
716
+
t.Error("Return instructions not displayed")
717
717
+
}
718
718
+
})
719
719
+
720
720
+
t.Run("help mode", func(t *testing.T) {
721
721
+
model := dataTableModel{
722
722
+
showingHelp: true,
723
723
+
keys: DefaultDataTableKeys(),
724
724
+
help: help.New(),
725
725
+
opts: DataTableOptions{Fields: createTestFields()},
726
726
+
}
727
727
+
728
728
+
view := model.View()
729
729
+
if view == "" {
730
730
+
t.Error("Help view should not be empty")
731
731
+
}
732
732
+
})
733
733
+
734
734
+
t.Run("field formatters", func(t *testing.T) {
735
735
+
fields := []Field{
736
736
+
{Name: "priority", Title: "Priority", Width: 10, Formatter: func(v interface{}) string {
737
737
+
return strings.ToUpper(fmt.Sprintf("%v", v))
738
738
+
}},
739
739
+
}
740
740
+
741
741
+
model := dataTableModel{
742
742
+
records: createMockRecords(),
743
743
+
opts: DataTableOptions{Fields: fields},
744
744
+
}
745
745
+
746
746
+
view := model.View()
747
747
+
if !strings.Contains(view, "HIGH") {
748
748
+
t.Error("Field formatter not applied")
749
749
+
}
750
750
+
})
751
751
+
752
752
+
t.Run("long field truncation", func(t *testing.T) {
753
753
+
longRecord := NewMockRecord(1, map[string]any{
754
754
+
"name": "This is a very long name that should be truncated",
755
755
+
})
756
756
+
757
757
+
fields := []Field{
758
758
+
{Name: "name", Title: "Name", Width: 10},
759
759
+
}
760
760
+
761
761
+
model := dataTableModel{
762
762
+
records: []DataRecord{longRecord},
763
763
+
opts: DataTableOptions{Fields: fields},
764
764
+
}
765
765
+
766
766
+
view := model.View()
767
767
+
if !strings.Contains(view, "...") {
768
768
+
t.Error("Long field should be truncated with ellipsis")
769
769
+
}
770
770
+
})
771
771
+
})
772
772
+
773
773
+
t.Run("Update", func(t *testing.T) {
774
774
+
source := &MockDataSource{records: createMockRecords()}
775
775
+
776
776
+
t.Run("data loaded message", func(t *testing.T) {
777
777
+
model := dataTableModel{
778
778
+
source: source,
779
779
+
loading: true,
780
780
+
opts: DataTableOptions{Fields: createTestFields()},
781
781
+
}
782
782
+
783
783
+
records := createMockRecords()[:2]
784
784
+
newModel, _ := model.Update(dataLoadedMsg(records))
785
785
+
786
786
+
if m, ok := newModel.(dataTableModel); ok {
787
787
+
if len(m.records) != 2 {
788
788
+
t.Errorf("Expected 2 records, got %d", len(m.records))
789
789
+
}
790
790
+
if m.loading {
791
791
+
t.Error("Loading should be set to false")
792
792
+
}
793
793
+
}
794
794
+
})
795
795
+
796
796
+
t.Run("selected index adjustment", func(t *testing.T) {
797
797
+
model := dataTableModel{
798
798
+
selected: 5,
799
799
+
opts: DataTableOptions{Fields: createTestFields()},
800
800
+
}
801
801
+
802
802
+
records := createMockRecords()[:2]
803
803
+
newModel, _ := model.Update(dataLoadedMsg(records))
804
804
+
805
805
+
if m, ok := newModel.(dataTableModel); ok {
806
806
+
if m.selected != 1 {
807
807
+
t.Errorf("Selected should be adjusted to 1, got %d", m.selected)
808
808
+
}
809
809
+
}
810
810
+
})
811
811
+
812
812
+
t.Run("data view message", func(t *testing.T) {
813
813
+
model := dataTableModel{
814
814
+
opts: DataTableOptions{Fields: createTestFields()},
815
815
+
}
816
816
+
817
817
+
content := "Test view content"
818
818
+
newModel, _ := model.Update(dataViewMsg(content))
819
819
+
820
820
+
if m, ok := newModel.(dataTableModel); ok {
821
821
+
if !m.viewing {
822
822
+
t.Error("Viewing mode should be activated")
823
823
+
}
824
824
+
if m.viewContent != content {
825
825
+
t.Error("View content not set correctly")
826
826
+
}
827
827
+
}
828
828
+
})
829
829
+
830
830
+
t.Run("data error message", func(t *testing.T) {
831
831
+
model := dataTableModel{
832
832
+
loading: true,
833
833
+
opts: DataTableOptions{Fields: createTestFields()},
834
834
+
}
835
835
+
836
836
+
testErr := errors.New("test error")
837
837
+
newModel, _ := model.Update(dataErrorMsg(testErr))
838
838
+
839
839
+
if m, ok := newModel.(dataTableModel); ok {
840
840
+
if m.err == nil {
841
841
+
t.Error("Error should be set")
842
842
+
}
843
843
+
if m.err.Error() != "test error" {
844
844
+
t.Errorf("Expected 'test error', got %v", m.err)
845
845
+
}
846
846
+
if m.loading {
847
847
+
t.Error("Loading should be set to false on error")
848
848
+
}
849
849
+
}
850
850
+
})
851
851
+
852
852
+
t.Run("data count message", func(t *testing.T) {
853
853
+
model := dataTableModel{
854
854
+
opts: DataTableOptions{Fields: createTestFields()},
855
855
+
}
856
856
+
857
857
+
count := 42
858
858
+
newModel, _ := model.Update(dataCountMsg(count))
859
859
+
860
860
+
if m, ok := newModel.(dataTableModel); ok {
861
861
+
if m.totalCount != count {
862
862
+
t.Errorf("Expected count %d, got %d", count, m.totalCount)
863
863
+
}
864
864
+
}
865
865
+
})
866
866
+
})
867
867
+
868
868
+
t.Run("Default Keys", func(t *testing.T) {
869
869
+
keys := DefaultDataTableKeys()
870
870
+
871
871
+
if len(keys.Numbers) != 9 {
872
872
+
t.Errorf("Expected 9 number bindings, got %d", len(keys.Numbers))
873
873
+
}
874
874
+
875
875
+
if keys.Actions == nil {
876
876
+
t.Error("Actions map should be initialized")
877
877
+
}
878
878
+
})
879
879
+
880
880
+
t.Run("Actions", func(t *testing.T) {
881
881
+
t.Run("action key handling", func(t *testing.T) {
882
882
+
actionCalled := false
883
883
+
action := Action{
884
884
+
Key: "d",
885
885
+
Description: "delete",
886
886
+
Handler: func(record DataRecord) tea.Cmd {
887
887
+
actionCalled = true
888
888
+
return nil
889
889
+
},
890
890
+
}
891
891
+
892
892
+
keys := DefaultDataTableKeys()
893
893
+
keys.Actions["d"] = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete"))
894
894
+
895
895
+
model := dataTableModel{
896
896
+
source: &MockDataSource{records: createMockRecords()},
897
897
+
records: createMockRecords(),
898
898
+
keys: keys,
899
899
+
opts: DataTableOptions{
900
900
+
Fields: createTestFields(),
901
901
+
Actions: []Action{action},
902
902
+
},
903
903
+
}
904
904
+
905
905
+
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")})
906
906
+
if cmd != nil {
907
907
+
cmd()
908
908
+
}
909
909
+
910
910
+
if !actionCalled {
911
911
+
t.Error("Action handler should be called")
912
912
+
}
913
913
+
})
914
914
+
})
915
915
+
916
916
+
t.Run("Field", func(t *testing.T) {
917
917
+
t.Run("field without formatter", func(t *testing.T) {
918
918
+
field := Field{Name: "test", Title: "Test", Width: 10}
919
919
+
920
920
+
record := NewMockRecord(1, map[string]interface{}{
921
921
+
"test": "value",
922
922
+
})
923
923
+
924
924
+
value := record.GetField(field.Name)
925
925
+
displayValue := fmt.Sprintf("%v", value)
926
926
+
927
927
+
if displayValue != "value" {
928
928
+
t.Errorf("Expected 'value', got '%s'", displayValue)
929
929
+
}
930
930
+
})
931
931
+
932
932
+
t.Run("field with formatter", func(t *testing.T) {
933
933
+
field := Field{
934
934
+
Name: "test",
935
935
+
Title: "Test",
936
936
+
Width: 10,
937
937
+
Formatter: func(v interface{}) string {
938
938
+
return strings.ToUpper(fmt.Sprintf("%v", v))
939
939
+
},
940
940
+
}
941
941
+
942
942
+
record := NewMockRecord(1, map[string]interface{}{
943
943
+
"test": "value",
944
944
+
})
945
945
+
946
946
+
value := record.GetField(field.Name)
947
947
+
displayValue := field.Formatter(value)
948
948
+
949
949
+
if displayValue != "VALUE" {
950
950
+
t.Errorf("Expected 'VALUE', got '%s'", displayValue)
951
951
+
}
952
952
+
})
953
953
+
})
954
954
+
}