Explain empty TUI views instead of showing a bare table
An account folder is only registered in the index by an import, so a freshly configured account.toml produced an empty accounts table with nothing to say why or what to do about it. Each view now explains its own emptiness and names the key that resolves it, distinguishing configured-but-not-imported from no-folders-at-all, and telling apart a filter that matched nothing from having no data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,10 @@ money parsers # available statement parsers
|
||||
money config # which data root is in use, and why
|
||||
```
|
||||
|
||||
An account folder appears in the app only once its statements have been
|
||||
imported — creating an `account.toml` is not enough on its own. Run
|
||||
`money import` (or press `i` in the TUI) after adding one.
|
||||
|
||||
### TUI keys
|
||||
|
||||
| Key | Action |
|
||||
|
||||
+64
-8
@@ -68,6 +68,7 @@ var (
|
||||
statusStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
|
||||
errStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")).Padding(0, 1)
|
||||
helpStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1)
|
||||
emptyStyle = lipgloss.NewStyle().Padding(1, 2)
|
||||
)
|
||||
|
||||
// Run starts the interface.
|
||||
@@ -477,15 +478,22 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
func (m *Model) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(titleStyle.Render(m.title()) + "\n")
|
||||
b.WriteString(titleStyle.Render(m.title()))
|
||||
b.WriteString("\n")
|
||||
|
||||
switch m.view {
|
||||
case viewAccounts:
|
||||
b.WriteString(m.accountTable.View())
|
||||
case viewTxns:
|
||||
b.WriteString(m.txnTable.View())
|
||||
case viewReport:
|
||||
b.WriteString(m.reportTable.View())
|
||||
// An empty table is just a bare header, which says nothing about why it is
|
||||
// empty or what to do next.
|
||||
if empty := m.emptyMessage(); empty != "" {
|
||||
b.WriteString(emptyStyle.Render(empty))
|
||||
} else {
|
||||
switch m.view {
|
||||
case viewAccounts:
|
||||
b.WriteString(m.accountTable.View())
|
||||
case viewTxns:
|
||||
b.WriteString(m.txnTable.View())
|
||||
case viewReport:
|
||||
b.WriteString(m.reportTable.View())
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
@@ -504,6 +512,54 @@ func (m *Model) View() string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// emptyMessage explains an empty view and names the key that fixes it, or
|
||||
// returns "" when there is something to show.
|
||||
func (m *Model) emptyMessage() string {
|
||||
switch m.view {
|
||||
case viewAccounts:
|
||||
if len(m.accountTable.Rows()) > 0 {
|
||||
return ""
|
||||
}
|
||||
// An account.toml on disk is not enough: folders are only registered
|
||||
// in the index by an import, so say that rather than showing nothing.
|
||||
if n := len(m.accounts); n > 0 {
|
||||
return fmt.Sprintf(
|
||||
"No accounts imported yet.\n\n"+
|
||||
"%d account folder(s) configured in %s.\n"+
|
||||
"Press i to import their statements (same as running `money import`).",
|
||||
n, m.root)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"No account folders found in %s.\n\n"+
|
||||
"An account is a folder containing an account.toml.\n"+
|
||||
"Create one, drop statements in, then press i to import.",
|
||||
m.root)
|
||||
|
||||
case viewTxns:
|
||||
if len(m.txns) > 0 {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case m.filter.Search != "":
|
||||
return fmt.Sprintf("No transactions match /%s.\n\nPress / to change the search.", m.filter.Search)
|
||||
case m.onlyUntagged:
|
||||
return "Nothing untagged.\n\nEvery transaction here has a tag. Press u to see them all."
|
||||
case m.filter.AccountSlug != "":
|
||||
return fmt.Sprintf(
|
||||
"No transactions in %s.\n\nPress i to import, or a to see every account.",
|
||||
m.filter.AccountSlug)
|
||||
}
|
||||
return "No transactions yet.\n\nPress i to import (same as running `money import`)."
|
||||
|
||||
case viewReport:
|
||||
if len(m.reportTable.Rows()) > 0 {
|
||||
return ""
|
||||
}
|
||||
return "Nothing to report yet.\n\nPress i to import some statements first."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Model) title() string {
|
||||
scope := "all accounts"
|
||||
if m.filter.AccountSlug != "" {
|
||||
|
||||
@@ -257,6 +257,109 @@ func TestReportViewExcludesTransfers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newEmptyModel builds a model over an index with nothing in it.
|
||||
func newEmptyModel(t *testing.T, accounts []*config.Account) *Model {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "index.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
m := New("/data/root", db, accounts, rules.New(&config.Rules{}))
|
||||
if err := m.reload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
|
||||
return m
|
||||
}
|
||||
|
||||
// Configured but never imported is the confusing case: the folders exist on
|
||||
// disk, but nothing is registered in the index until an import runs.
|
||||
func TestEmptyAccountsWithConfiguredFolders(t *testing.T) {
|
||||
m := newEmptyModel(t, []*config.Account{
|
||||
{Slug: "checking", Currency: "EUR", Parser: "csv"},
|
||||
{Slug: "savings", Currency: "EUR", Parser: "csv"},
|
||||
})
|
||||
|
||||
view := m.View()
|
||||
for _, want := range []string{"No accounts imported yet", "2 account folder(s)", "/data/root", "Press i to import"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("accounts view missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyAccountsWithNoFolders(t *testing.T) {
|
||||
m := newEmptyModel(t, nil)
|
||||
|
||||
view := m.View()
|
||||
for _, want := range []string{"No account folders found", "/data/root", "account.toml"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("accounts view missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTransactionsMessages(t *testing.T) {
|
||||
m := newEmptyModel(t, nil)
|
||||
key(t, m, "2")
|
||||
|
||||
if view := m.View(); !strings.Contains(view, "No transactions yet") {
|
||||
t.Errorf("expected an empty-transactions message:\n%s", view)
|
||||
}
|
||||
|
||||
// Each way of emptying the view explains itself differently.
|
||||
m.filter.AccountSlug = "checking"
|
||||
if err := m.reloadTxns(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view := m.View(); !strings.Contains(view, "No transactions in checking") {
|
||||
t.Errorf("expected an account-scoped message:\n%s", view)
|
||||
}
|
||||
|
||||
m.filter.AccountSlug = ""
|
||||
m.filter.Search = "lidl"
|
||||
if err := m.reloadTxns(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view := m.View(); !strings.Contains(view, "No transactions match /lidl") {
|
||||
t.Errorf("expected a search message:\n%s", view)
|
||||
}
|
||||
|
||||
m.filter.Search = ""
|
||||
m.onlyUntagged = true
|
||||
if err := m.reloadTxns(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view := m.View(); !strings.Contains(view, "Nothing untagged") {
|
||||
t.Errorf("expected an untagged message:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyReportMessage(t *testing.T) {
|
||||
m := newEmptyModel(t, nil)
|
||||
key(t, m, "3")
|
||||
if view := m.View(); !strings.Contains(view, "Nothing to report yet") {
|
||||
t.Errorf("expected an empty-report message:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
// With data present, the tables render instead of the empty-state text.
|
||||
func TestNoEmptyMessageWhenPopulated(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
for _, k := range []string{"1", "2", "3"} {
|
||||
key(t, m, k)
|
||||
if got := m.emptyMessage(); got != "" {
|
||||
t.Errorf("view %s showed an empty message with data present: %q", k, got)
|
||||
}
|
||||
}
|
||||
key(t, m, "2")
|
||||
if !strings.Contains(m.View(), "LIDL SOFIA") {
|
||||
t.Error("expected the transactions table to render")
|
||||
}
|
||||
}
|
||||
|
||||
func findTxn(t *testing.T, db *store.DB, desc string) model.Transaction {
|
||||
t.Helper()
|
||||
txns, err := db.Transactions(store.Filter{})
|
||||
|
||||
Reference in New Issue
Block a user