~bigbes/sr-ht-dolt

88a1d379f707062ebac734104a35a83065ef6b78 — Eugene Blikh 29 days ago 57832d6
feat(web/beads): filters for the parade board

Add a filter bar to the beads board: text search (id/title), issue type,
priority, assignee, and label. Filters are query-param based (GET, sticky
across submits) and applied server-side before lane bucketing, so the marquee
counts reflect the filtered set. Dropdown options are collected from the full
issue set (they don't shrink as filters narrow the board); a Clear link shows
when any filter is active. Verified on the 143-issue tarantool-etcd board
(type=bug → 30).
4 files changed, 274 insertions(+), 12 deletions(-)

M web/beads.go
M web/beads_test.go
M web/realdata_test.go
M web/templates/beads.html
M web/beads.go => web/beads.go +126 -10
@@ 64,11 64,13 @@ type BeadsData struct {
	Mode string // "board" | "detail" | "epic"

	// board mode
	Lanes     []BeadsLane
	Counts    BeadsCounts
	Total     int  // total issues placed on the board
	Truncated bool // an input table exceeded beadsMax and was clipped
	ShownOf   int  // when Truncated: the reported table total
	Lanes      []BeadsLane
	Counts     BeadsCounts
	Total      int  // issues placed on the board (after filtering)
	Truncated  bool // an input table exceeded beadsMax and was clipped
	ShownOf    int  // when Truncated: the reported table total
	Filter     BeadsFilter        // active board filters (sticky form state)
	FilterOpts BeadsFilterOptions // distinct values for the filter dropdowns

	// detail / epic modes
	Issue        *BeadIssue


@@ 83,6 85,56 @@ type BeadsData struct {
	SubtaskTotal int // len(Subtasks); the progress denominator
}

// BeadsFilter holds the active board filters, parsed from the query string and
// echoed back into the form so selections stick across submits. Empty fields
// mean "no constraint".
type BeadsFilter struct {
	Query    string // substring match over id + title (case-insensitive)
	Type     string // exact issue_type
	Priority string // exact priority ("0".."3")
	Assignee string // exact assignee
	Label    string // issue must carry this label
}

// Active reports whether any filter is set (drives the "Clear" link and the
// empty-board wording).
func (f BeadsFilter) Active() bool {
	return f.Query != "" || f.Type != "" || f.Priority != "" || f.Assignee != "" || f.Label != ""
}

// matches reports whether one issue row passes every set filter.
func (f BeadsFilter) matches(id string, row []string, cols map[string]int, labels []string) bool {
	if f.Type != "" && cell(cols, row, "issue_type") != f.Type {
		return false
	}
	if f.Priority != "" && cell(cols, row, "priority") != f.Priority {
		return false
	}
	if f.Assignee != "" && cell(cols, row, "assignee") != f.Assignee {
		return false
	}
	if f.Label != "" && !containsString(labels, f.Label) {
		return false
	}
	if f.Query != "" {
		hay := strings.ToLower(id + " " + cell(cols, row, "title"))
		if !strings.Contains(hay, strings.ToLower(f.Query)) {
			return false
		}
	}
	return true
}

// BeadsFilterOptions lists the distinct values present across all issues, so the
// filter dropdowns offer only real choices. Collected from the unfiltered set so
// the options don't shrink as a filter narrows the board.
type BeadsFilterOptions struct {
	Types      []string
	Priorities []string // "0".."3"
	Assignees  []string
	Labels     []string
}

// BeadsLane is one parade lane and the cards in it.
type BeadsLane struct {
	Name   string // human label, e.g. "Rolling"


@@ 301,10 353,24 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
			labelsByIssue, catByStatus, catByIssue), nil
	}

	// Board mode: bucket every issue into exactly one lane.
	// Board mode: parse the sticky filters and collect dropdown options from the
	// full issue set (options stay stable as filters narrow the board).
	filter := BeadsFilter{
		Query:    strings.TrimSpace(query.Get("q")),
		Type:     query.Get("type"),
		Priority: query.Get("priority"),
		Assignee: query.Get("assignee"),
		Label:    query.Get("label"),
	}
	opts := collectFilterOptions(issues, issueCols, labelsByIssue)

	// Bucket every matching issue into exactly one lane.
	var rolling, linedUp, stalled, pastStand []BeadCard
	for _, r := range issues.Rows {
		id := cell(issueCols, r, "id")
		if !filter.matches(id, r, issueCols, labelsByIssue[id]) {
			continue
		}
		card := BeadCard{
			ID:        id,
			Title:     cell(issueCols, r, "title"),


@@ 352,11 418,13 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
			LinedUp:   len(linedUp),
			Stalled:   len(stalled),
			PastStand: len(pastStand),
			Total:     len(issues.Rows),
			Total:     len(rolling) + len(linedUp) + len(stalled) + len(pastStand),
		},
		Total:     len(issues.Rows),
		Truncated: truncated,
		ShownOf:   shownOf,
		Total:      len(rolling) + len(linedUp) + len(stalled) + len(pastStand),
		Truncated:  truncated,
		ShownOf:    shownOf,
		Filter:     filter,
		FilterOpts: opts,
	}
	return data, nil
}


@@ 727,6 795,54 @@ func humanizeEvent(eventType, oldVal, newVal, note string) (summary, text string
	}
}

// collectFilterOptions gathers the distinct issue_type / priority / assignee
// values and label names across all issues, sorted, for the filter dropdowns.
func collectFilterOptions(issues *browse.RowPage, cols map[string]int, labelsByIssue map[string][]string) BeadsFilterOptions {
	types, prios, assignees, labels := map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}
	for _, r := range issues.Rows {
		if t := cell(cols, r, "issue_type"); t != "" {
			types[t] = true
		}
		if p := cell(cols, r, "priority"); p != "" {
			prios[p] = true
		}
		if a := cell(cols, r, "assignee"); a != "" {
			assignees[a] = true
		}
	}
	for _, lbs := range labelsByIssue {
		for _, l := range lbs {
			labels[l] = true
		}
	}
	return BeadsFilterOptions{
		Types:      sortedKeys(types),
		Priorities: sortedKeys(prios), // single digits sort numerically as strings
		Assignees:  sortedKeys(assignees),
		Labels:     sortedKeys(labels),
	}
}

// sortedKeys returns a set's keys in ascending order.
func sortedKeys(set map[string]bool) []string {
	out := make([]string, 0, len(set))
	for k := range set {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

// containsString reports whether s is in xs.
func containsString(xs []string, s string) bool {
	for _, x := range xs {
		if x == s {
			return true
		}
	}
	return false
}

// depActivity synthesizes a History entry for a dependency edge touching `want`.
// beads emits no audit event when a link is added, but the dependencies row
// carries created_at/created_by, so edge additions — most usefully subtasks

M web/beads_test.go => web/beads_test.go +98 -0
@@ 4,6 4,7 @@ import (
	"context"
	"net/http"
	"net/url"
	"sort"
	"strings"
	"testing"



@@ 209,6 210,73 @@ func TestBeadsBuildBoardCounts(t *testing.T) {
	}
}

// boardIDs returns every card id on the board, across all lanes.
func boardIDs(d *BeadsData) []string {
	var out []string
	for i := range d.Lanes {
		out = append(out, cardIDs(&d.Lanes[i])...)
	}
	sort.Strings(out)
	return out
}

func TestBeadsBoardFilters(t *testing.T) {
	repo := &core.Repo{OwnerName: "a", Name: "b"}
	cases := []struct {
		name  string
		query url.Values
		want  []string // sorted ids expected on the board
	}{
		{"type", url.Values{"type": {"feature"}}, []string{"i-blocked", "i-open"}},
		{"priority", url.Values{"priority": {"1"}}, []string{"i-blocked", "i-open"}},
		{"assignee", url.Values{"assignee": {"bob"}}, []string{"i-prog"}},
		{"label", url.Values{"label": {"urgent"}}, []string{"i-open"}},
		{"query-title", url.Values{"q": {"ready"}}, []string{"i-open"}},
		{"query-id", url.Values{"q": {"i-done"}}, []string{"i-done"}},
		{"combined-empty", url.Values{"type": {"feature"}, "assignee": {"bob"}}, nil},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := (&beadsView{}).Build(context.Background(), beadsFixture(), repo, "main", tc.query)
			if err != nil {
				t.Fatalf("Build: %v", err)
			}
			d := got.(*BeadsData)
			ids := boardIDs(d)
			if strings.Join(ids, ",") != strings.Join(tc.want, ",") {
				t.Errorf("board ids = %v, want %v", ids, tc.want)
			}
			if d.Total != len(tc.want) || d.Counts.Total != len(tc.want) {
				t.Errorf("total = %d/%d, want %d", d.Total, d.Counts.Total, len(tc.want))
			}
			if !d.Filter.Active() {
				t.Errorf("Filter.Active() = false, want true")
			}
		})
	}
}

func TestBeadsBoardFilterOptions(t *testing.T) {
	got, _ := (&beadsView{}).Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
	d := got.(*BeadsData)
	o := d.FilterOpts
	if strings.Join(o.Types, ",") != "bug,chore,feature" {
		t.Errorf("types = %v", o.Types)
	}
	if strings.Join(o.Priorities, ",") != "0,1,2" {
		t.Errorf("priorities = %v", o.Priorities)
	}
	if strings.Join(o.Assignees, ",") != "alice,bob,carol,dave" {
		t.Errorf("assignees = %v", o.Assignees)
	}
	if strings.Join(o.Labels, ",") != "backend,urgent" {
		t.Errorf("labels = %v", o.Labels)
	}
	if d.Filter.Active() {
		t.Errorf("no query set, Filter.Active() should be false")
	}
}

// TestBeadsBlockedByDepOnly proves the dependency-derived block signal works
// even when is_blocked is not set: an issue with a "blocks" dep to a non-closed
// target lands in Stalled; the same dep to a CLOSED target does not.


@@ 319,6 387,36 @@ func TestBeadsHandleViewBoard(t *testing.T) {
	if !strings.Contains(body, "/~alice/db/tree/") {
		t.Errorf("board missing Tables tab link; body=%s", body)
	}
	// The filter bar renders with option lists drawn from the data.
	for _, want := range []string{`class="beads-filter"`, "All types", ">feature<", "All priorities"} {
		if !strings.Contains(body, want) {
			t.Errorf("board missing filter control %q", want)
		}
	}
}

func TestBeadsBoardFilterRender(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?type=feature", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("filtered board: got %d; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	// The active type is preselected and a Clear link appears.
	if !strings.Contains(body, `value="feature" selected`) {
		t.Errorf("type filter not preselected; body=%s", body)
	}
	if !strings.Contains(body, "beads-filter-clear") {
		t.Errorf("Clear link missing when a filter is active")
	}
	// Only feature issues on the board; the bug (i-prog) is filtered out.
	if !strings.Contains(body, "i-open") || strings.Contains(body, "i-prog") {
		t.Errorf("filtered board should show features only; body=%s", body)
	}
}

func TestBeadsHandleViewDetail(t *testing.T) {

M web/realdata_test.go => web/realdata_test.go +17 -2
@@ 55,13 55,28 @@ func TestRealBeadsStore(t *testing.T) {
	}
	t.Logf("issues cols=%v total=%d", issues.Columns, issues.Total)

	// Drive the real view. With BEADS_REAL_ISSUE set to an epic id, dump the
	v := &beadsView{}

	// Board mode: dump the filter options, then the effect of a type filter.
	board, err := v.Build(ctx, dbh, nil, ref, url.Values{})
	if err != nil {
		t.Fatalf("board build: %v", err)
	}
	bd := board.(*BeadsData)
	t.Logf("board total=%d types=%v priorities=%v labels=%v",
		bd.Total, bd.FilterOpts.Types, bd.FilterOpts.Priorities, bd.FilterOpts.Labels)
	if len(bd.FilterOpts.Types) > 0 {
		ft := bd.FilterOpts.Types[0]
		filtered, _ := v.Build(ctx, dbh, nil, ref, url.Values{"type": {ft}})
		t.Logf("filter type=%q → %d issues (of %d)", ft, filtered.(*BeadsData).Total, bd.Total)
	}

	// Drive the detail view. With BEADS_REAL_ISSUE set to an epic id, dump the
	// subtask rollup and merged history so a human can eyeball the output.
	want := os.Getenv("BEADS_REAL_ISSUE")
	if want == "" {
		return
	}
	v := &beadsView{}
	raw, err := v.Build(ctx, dbh, nil, ref, url.Values{"issue": {want}})
	if err != nil {
		t.Fatalf("build: %v", err)

M web/templates/beads.html => web/templates/beads.html +33 -0
@@ 36,6 36,16 @@
}

/* summary: one flat hairline-bordered strip, cells divided by hairlines */
/* filter bar: flat, hairline-bordered controls in the todo.sr.ht idiom */
.beads-filter { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; margin-bottom: 1rem; }
.beads-filter input, .beads-filter select, .beads-filter button {
  font: inherit; font-size: .82rem; padding: .25rem .45rem; color: var(--bd-fg);
  background: var(--bd-bg); border: 1px solid var(--bd-border); border-radius: 0;
}
.beads-filter input[type="search"] { min-width: 12rem; flex: 1 1 12rem; }
.beads-filter button { background: var(--bd-panel); cursor: pointer; }
.beads-filter .beads-filter-clear { font-size: .82rem; color: var(--bd-muted); align-self: center; }

.beads-summary { display: flex; flex-wrap: wrap; border: 1px solid var(--bd-border); margin-bottom: 1rem; }
.beads-summary .stat {
  flex: 1 1 7rem; min-width: 6.5rem; padding: .35rem .6rem;


@@ 281,6 291,29 @@ pre.field-body {
<div class="alert alert-warning">Showing the first {{.Data.Total}} of {{.Data.ShownOf}} issues.</div>
{{end}}

<form class="beads-filter" method="get" action="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads">
  {{if .Ref}}<input type="hidden" name="ref" value="{{.Ref}}">{{end}}
  <input type="search" name="q" value="{{.Data.Filter.Query}}" placeholder="Search id / title">
  <select name="type" aria-label="Type">
    <option value="">All types</option>
    {{range .Data.FilterOpts.Types}}<option value="{{.}}"{{if eq . $.Data.Filter.Type}} selected{{end}}>{{.}}</option>{{end}}
  </select>
  <select name="priority" aria-label="Priority">
    <option value="">All priorities</option>
    {{range .Data.FilterOpts.Priorities}}<option value="{{.}}"{{if eq . $.Data.Filter.Priority}} selected{{end}}>P{{.}}</option>{{end}}
  </select>
  <select name="assignee" aria-label="Assignee">
    <option value="">Anyone</option>
    {{range .Data.FilterOpts.Assignees}}<option value="{{.}}"{{if eq . $.Data.Filter.Assignee}} selected{{end}}>@{{.}}</option>{{end}}
  </select>
  <select name="label" aria-label="Label">
    <option value="">Any label</option>
    {{range .Data.FilterOpts.Labels}}<option value="{{.}}"{{if eq . $.Data.Filter.Label}} selected{{end}}>{{.}}</option>{{end}}
  </select>
  <button type="submit">Filter</button>
  {{if .Data.Filter.Active}}<a class="beads-filter-clear" href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads{{if .Ref}}?ref={{.Ref}}{{end}}">Clear</a>{{end}}
</form>

<div class="beads-summary">
  <div class="stat rolling"><div class="n">{{.Data.Counts.Rolling}}</div><div class="l"><span class="swatch"></span>Rolling</div></div>
  <div class="stat lined-up"><div class="n">{{.Data.Counts.LinedUp}}</div><div class="l"><span class="swatch"></span>Lined Up</div></div>