cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: generic data list & tables

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