~bigbes/sr-ht-dolt

b7e8d134b3f68dd4b5ff27d1c86f1ddd35a30408 — Eugene Blikh 29 days ago ce06498
feat(web): Overview/Tables/Beads tab bar + full issue fields on beads detail

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.
M web/beads.go => web/beads.go +14 -1
@@ 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],
	}


M web/beads_test.go => web/beads_test.go +27 -6
@@ 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)
		}
	}
}

M web/handlers_browse.go => web/handlers_browse.go +11 -0
@@ 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,

M web/templates/beads.html => web/templates/beads.html +5 -0
@@ 119,9 119,14 @@
    <tbody>
      {{if .CreatedBy}}<tr><td class="field-label">Created by</td><td>{{.CreatedBy}}</td></tr>{{end}}
      {{if .Owner}}<tr><td class="field-label">Owner</td><td>{{.Owner}}</td></tr>{{end}}
      {{if .EstimatedMinutes}}<tr><td class="field-label">Estimate</td><td>{{.EstimatedMinutes}} min</td></tr>{{end}}
      {{if .ExternalRef}}<tr><td class="field-label">External ref</td><td>{{.ExternalRef}}</td></tr>{{end}}
      {{if .SpecID}}<tr><td class="field-label">Spec</td><td>{{.SpecID}}</td></tr>{{end}}
      {{if .CreatedAt}}<tr><td class="field-label">Created</td><td>{{.CreatedAt}}</td></tr>{{end}}
      {{if .StartedAt}}<tr><td class="field-label">Started</td><td>{{.StartedAt}}</td></tr>{{end}}
      {{if .UpdatedAt}}<tr><td class="field-label">Updated</td><td>{{.UpdatedAt}}</td></tr>{{end}}
      {{if .ClosedAt}}<tr><td class="field-label">Closed</td><td>{{.ClosedAt}}</td></tr>{{end}}
      {{if .CloseReason}}<tr><td class="field-label">Close reason</td><td>{{.CloseReason}}</td></tr>{{end}}
    </tbody>
  </table>
  {{if .Description}}<div class="field-label">Description</div><pre class="field-body">{{.Description}}</pre>{{end}}

M web/templates/overview.html => web/templates/overview.html +1 -3
@@ 8,9 8,7 @@
<p>{{.Repo.Description}}</p>
{{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)}}

<div class="clone-url">
  <h4>Clone</h4>

M web/templates/partials.html => web/templates/partials.html +13 -9
@@ 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}}
<ul class="nav nav-tabs mb-3">
  <li class="nav-item">
    <a class="nav-link{{if eq $current "overview"}} active{{end}}"
       href="/~{{$repo.OwnerName}}/{{$repo.Name}}">Overview</a>
  </li>
  <li class="nav-item">
    <a class="nav-link{{if eq $current "tables"}} active{{end}}"
       href="{{if $ref}}/~{{$repo.OwnerName}}/{{$repo.Name}}/tree/{{$ref}}{{else}}/~{{$repo.OwnerName}}/{{$repo.Name}}{{end}}">Tables</a>
  </li>
  {{range .Views}}
  <li class="nav-item">
    <a class="nav-link{{if eq .Name $current}} active{{end}}"
       href="/~{{$repo.OwnerName}}/{{$repo.Name}}/view/{{.Name}}">{{.Label}}</a>
  </li>
  {{end}}
  <li class="nav-item">
    <a class="nav-link{{if eq $current ""}} active{{end}}"
       href="{{if $ref}}/~{{$repo.OwnerName}}/{{$repo.Name}}/tree/{{$ref}}{{else}}/~{{$repo.OwnerName}}/{{$repo.Name}}{{end}}">Tables</a>
  </li>
</ul>
{{- end}}


M web/templates/table.html => web/templates/table.html +1 -0
@@ 3,6 3,7 @@
  <a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a>
  &middot; {{.Table}}
</h2>
{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "tables" "Ref" .Ref)}}
<p class="text-muted">
  <code>{{.Ref}}</code> &middot; {{.Rows.Total}} rows
</p>

M web/templates/tree.html => web/templates/tree.html +1 -0
@@ 1,5 1,6 @@
{{define "content" -}}
<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> &middot; tree</h2>
{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "tables" "Ref" .Ref)}}
<p class="text-muted">Tables at <code>{{.Ref}}</code></p>

{{if .Tables}}

M web/views_test.go => web/views_test.go +15 -0
@@ 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