From b7e8d134b3f68dd4b5ff27d1c86f1ddd35a30408 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 19 Jul 2026 18:58:35 +0300 Subject: [PATCH] feat(web): Overview/Tables/Beads tab bar + full issue fields on beads detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give every database page a consistent three-tab bar — Overview, Tables, then the applicable views (Beads) — in that order. Previously the bar only carried [views, Tables] and the Overview page marked Tables active, so there was no way to tell you were on the overview. viewtabs now takes an explicit Current sentinel ("overview" | "tables" | view slug) and the tree/table row pages render the bar too (handlers compute applicable views for the ref). Surface the issue fields the beads detail pane was dropping. The closure reason (close_reason) recorded by `bd close -r` was never shown; auditing against bd's own field set also turned up estimated_minutes, external_ref, spec_id, and started_at. All render conditionally, so issues that don't set them stay uncluttered. --- web/beads.go | 15 ++++++++++++++- web/beads_test.go | 33 +++++++++++++++++++++++++++------ web/handlers_browse.go | 11 +++++++++++ web/templates/beads.html | 5 +++++ web/templates/overview.html | 4 +--- web/templates/partials.html | 22 +++++++++++++--------- web/templates/table.html | 1 + web/templates/tree.html | 1 + web/views_test.go | 15 +++++++++++++++ 9 files changed, 88 insertions(+), 19 deletions(-) diff --git a/web/beads.go b/web/beads.go index a18ef42270d6168d2b49f86bb8bd7b6d0261ed91..aa264b86b88390fdac7679a33247196d2689708c 100644 --- a/web/beads.go +++ b/web/beads.go @@ -131,7 +131,10 @@ type BeadComment struct { CreatedAt string } -// BeadIssue is the full issue shown in the detail pane. +// BeadIssue is the full issue shown in the detail pane. The field set mirrors +// the user-facing columns bd surfaces for an issue (see `bd show`): identity and +// status, the four long-text bodies, effort/reference metadata, the full +// timestamp trail, and the close reason recorded when an issue is resolved. type BeadIssue struct { ID string Title string @@ -143,13 +146,18 @@ type BeadIssue struct { Assignee string CreatedBy string Owner string + EstimatedMinutes string + ExternalRef string + SpecID string Description string Design string AcceptanceCriteria string Notes string CreatedAt string + StartedAt string UpdatedAt string ClosedAt string + CloseReason string Labels []string } @@ -340,13 +348,18 @@ func (v *beadsView) buildDetail( Assignee: cell(issueCols, row, "assignee"), CreatedBy: cell(issueCols, row, "created_by"), Owner: cell(issueCols, row, "owner"), + EstimatedMinutes: cell(issueCols, row, "estimated_minutes"), + ExternalRef: cell(issueCols, row, "external_ref"), + SpecID: cell(issueCols, row, "spec_id"), Description: cell(issueCols, row, "description"), Design: cell(issueCols, row, "design"), AcceptanceCriteria: cell(issueCols, row, "acceptance_criteria"), Notes: cell(issueCols, row, "notes"), CreatedAt: cell(issueCols, row, "created_at"), + StartedAt: cell(issueCols, row, "started_at"), UpdatedAt: cell(issueCols, row, "updated_at"), ClosedAt: cell(issueCols, row, "closed_at"), + CloseReason: cell(issueCols, row, "close_reason"), Labels: labelsByIssue[want], } diff --git a/web/beads_test.go b/web/beads_test.go index 5756bfda80e603f26ecec418a6d5a90efedbe54c..bbce5e00e91fa1214eb4e2b063cb254e7c776e2a 100644 --- a/web/beads_test.go +++ b/web/beads_test.go @@ -36,13 +36,14 @@ func beadsTables() []browse.TableInfo { // with is_blocked LAST so column-name mapping (not positional) is exercised. func beadsFixture() *fakeSession { issues := &browse.RowPage{ - // Column order chosen so nothing is at a "natural" index; is_blocked is last. - Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"}, + // Column order chosen so nothing is at a "natural" index; is_blocked is last + // and close_reason sits mid-row so name (not positional) mapping is exercised. + Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "closed_at", "close_reason", "is_blocked"}, Rows: [][]string{ - {"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "0"}, - {"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "0"}, - {"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "0"}, - {"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "1"}, + {"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "NULL", "NULL", "0"}, + {"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "NULL", "NULL", "0"}, + {"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "2024-01-04", "Fixed in commit abc123", "0"}, + {"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "NULL", "NULL", "1"}, }, Total: 4, } @@ -331,3 +332,23 @@ func TestBeadsHandleViewDetail(t *testing.T) { t.Errorf("detail missing back link; body=%s", body) } } + +// A closed issue's detail pane must surface its close reason (and the closed +// timestamp), so the resolution recorded by `bd close -r` is not lost. +func TestBeadsDetailShowsCloseReason(t *testing.T) { + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = beadsFixture() + setViews(t, h, &beadsView{}) + + rec := h.do("GET", "/~alice/db/view/beads?issue=i-done", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{"Close reason", "Fixed in commit abc123", "2024-01-04"} { + if !strings.Contains(body, want) { + t.Errorf("closed-issue detail missing %q; body=%s", want, body) + } + } +} diff --git a/web/handlers_browse.go b/web/handlers_browse.go index 067bbe86d1d2c2bf037b06628bded032ab4bb8ab..25915653edaefe821f25fe409f81fc37c9c59e17 100644 --- a/web/handlers_browse.go +++ b/web/handlers_browse.go @@ -146,11 +146,13 @@ func (a *app) handleTree(w http.ResponseWriter, r *http.Request) { Repo *core.Repo Ref string Tables []browse.TableInfo + Views []View }{ basePage: a.newBasePage(r, "Tree "+ref+" — "+repo.OwnerName+"/"+repo.Name), Repo: repo, Ref: ref, Tables: tables, + Views: applicableViews(a.views, tables), } a.render(w, http.StatusOK, "tree.html", view) } @@ -192,12 +194,20 @@ func (a *app) handleTable(w http.ResponseWriter, r *http.Request) { totalPages = 1 } + // Fingerprint the tables at this ref for the tab bar; a failure here must not + // break the row view, so it degrades to no alternative-view tabs. + var views []View + if tables, err := sess.Tables(r.Context(), ref); err == nil { + views = applicableViews(a.views, tables) + } + view := struct { basePage Repo *core.Repo Ref string Table string Rows *browse.RowPage + Views []View Page int TotalPages int HasPrev bool @@ -208,6 +218,7 @@ func (a *app) handleTable(w http.ResponseWriter, r *http.Request) { Ref: ref, Table: table, Rows: rows, + Views: views, Page: page, TotalPages: totalPages, HasPrev: page > 1, diff --git a/web/templates/beads.html b/web/templates/beads.html index 1cb8696245d4e6877d29c34d88f14d630ef078d9..108700774621329cf894528b5e3072ea5429a304 100644 --- a/web/templates/beads.html +++ b/web/templates/beads.html @@ -119,9 +119,14 @@ {{if .CreatedBy}}Created by{{.CreatedBy}}{{end}} {{if .Owner}}Owner{{.Owner}}{{end}} + {{if .EstimatedMinutes}}Estimate{{.EstimatedMinutes}} min{{end}} + {{if .ExternalRef}}External ref{{.ExternalRef}}{{end}} + {{if .SpecID}}Spec{{.SpecID}}{{end}} {{if .CreatedAt}}Created{{.CreatedAt}}{{end}} + {{if .StartedAt}}Started{{.StartedAt}}{{end}} {{if .UpdatedAt}}Updated{{.UpdatedAt}}{{end}} {{if .ClosedAt}}Closed{{.ClosedAt}}{{end}} + {{if .CloseReason}}Close reason{{.CloseReason}}{{end}} {{if .Description}}
Description
{{.Description}}
{{end}} diff --git a/web/templates/overview.html b/web/templates/overview.html index 741da4f4d4fdafc6022c13f48f0ef46814dff1d0..aa591aa85964fa043f2ebd0a0aa9f517ef283a56 100644 --- a/web/templates/overview.html +++ b/web/templates/overview.html @@ -8,9 +8,7 @@

{{.Repo.Description}}

{{end}} -{{if .Views}} -{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "" "Ref" .DefaultBranch)}} -{{end}} +{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "overview" "Ref" .DefaultBranch)}}

Clone

diff --git a/web/templates/partials.html b/web/templates/partials.html index b47dc14d5e922c879cbc0f3e3fa0543755fbbd87..1b206621023215d308d2298ebd41bb484829bacc 100644 --- a/web/templates/partials.html +++ b/web/templates/partials.html @@ -9,26 +9,30 @@ {{- end}} {{/* - viewtabs renders the alternative-view tab bar for a repo: one tab per - applicable View plus a "Tables" tab for the always-available generic browser. + viewtabs renders the repo's tab bar: a fixed "Overview" tab, a "Tables" tab for + the always-available generic browser, then one tab per applicable View. Invoke it with a dict context: - {{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "" "Ref" .DefaultBranch)}} - .Current is the active view's slug ("" selects the Tables tab); .Ref is the - branch/ref the Tables tab links to (falls back to the overview when empty). + {{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "overview" "Ref" .DefaultBranch)}} + .Current selects the active tab: "overview", "tables", or a view's slug. .Ref is + the branch/ref the Tables tab links to (falls back to the overview when empty). */}} {{define "viewtabs" -}} {{$repo := .Repo}}{{$current := .Current}}{{$ref := .Ref}} {{- end}} diff --git a/web/templates/table.html b/web/templates/table.html index b838cc4201ebdc5c3b72e38927ebe0021ec8b2bc..365c4c7c09a0dd03a054eb80413188e58eed9520 100644 --- a/web/templates/table.html +++ b/web/templates/table.html @@ -3,6 +3,7 @@ ~{{.Repo.OwnerName}}/{{.Repo.Name}} · {{.Table}} +{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "tables" "Ref" .Ref)}}

{{.Ref}} · {{.Rows.Total}} rows

diff --git a/web/templates/tree.html b/web/templates/tree.html index 9785088c91faa00e9081b429f0000b4e9e252df9..3c4b0588ecf085d9ebbe73a6e80f2ffa2841043a 100644 --- a/web/templates/tree.html +++ b/web/templates/tree.html @@ -1,5 +1,6 @@ {{define "content" -}}

~{{.Repo.OwnerName}}/{{.Repo.Name}} · tree

+{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "tables" "Ref" .Ref)}}

Tables at {{.Ref}}

{{if .Tables}} diff --git a/web/views_test.go b/web/views_test.go index f2dafbdc7ce48c8b1c1c4d79063a7e55292908af..0e6d6635c358cf61c2397d45a97b154475e73643 100644 --- a/web/views_test.go +++ b/web/views_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/url" + "regexp" "strings" "testing" @@ -168,6 +169,20 @@ func TestOverviewShowsViewTabs(t *testing.T) { if !strings.Contains(body, "nav-tabs") { t.Fatalf("overview missing tab bar; body=%s", body) } + // The bar carries three tabs in order: Overview, Tables, then the view. + iOverview := strings.Index(body, ">Overview<") + iTables := strings.Index(body, ">Tables<") + iView := strings.Index(body, "/~alice/db/view/issues") + if iOverview < 0 || iTables < 0 { + t.Fatalf("overview missing Overview/Tables tabs; body=%s", body) + } + if !(iOverview < iTables && iTables < iView) { + t.Fatalf("tab order should be Overview < Tables < view; got %d, %d, %d", iOverview, iTables, iView) + } + // The active tab on the overview is Overview, not Tables. + if !regexp.MustCompile(`nav-link active"[^>]*>Overview<`).MatchString(body) { + t.Fatalf("overview tab should be active on the overview page; body=%s", body) + } } // setViews overrides the app's view snapshot without touching the global