From 88a1d379f707062ebac734104a35a83065ef6b78 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 19 Jul 2026 21:41:53 +0300 Subject: [PATCH] feat(web/beads): filters for the parade board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- web/beads.go | 136 ++++++++++++++++++++++++++++++++++++--- web/beads_test.go | 98 ++++++++++++++++++++++++++++ web/realdata_test.go | 19 +++++- web/templates/beads.html | 33 ++++++++++ 4 files changed, 274 insertions(+), 12 deletions(-) diff --git a/web/beads.go b/web/beads.go index 7e09f7955ede1d011ba21ab51f7364a75ca76576..fc1e787a382932d00346c7a3606c73e20e1c1547 100644 --- a/web/beads.go +++ b/web/beads.go @@ -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 diff --git a/web/beads_test.go b/web/beads_test.go index b1f164b48097b542476d0f7609d852aa872872b8..5d3e6c28a03d19ce87b917625696302b8253d6c4 100644 --- a/web/beads_test.go +++ b/web/beads_test.go @@ -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) { diff --git a/web/realdata_test.go b/web/realdata_test.go index 4576a96480bd152814fcc2ec5bf11213b2bebb58..d68aa9d57b54352b5069f09d813d5763a77fd1b8 100644 --- a/web/realdata_test.go +++ b/web/realdata_test.go @@ -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) diff --git a/web/templates/beads.html b/web/templates/beads.html index 52cf89c40121052a210866e6e86ea5aaa2173f0c..36782b3f64331db4fa413831b33426a1dcd3872a 100644 --- a/web/templates/beads.html +++ b/web/templates/beads.html @@ -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 {
Showing the first {{.Data.Total}} of {{.Data.ShownOf}} issues.
{{end}} +
+ {{if .Ref}}{{end}} + + + + + + + {{if .Data.Filter.Active}}Clear{{end}} +
+
{{.Data.Counts.Rolling}}
Rolling
{{.Data.Counts.LinedUp}}
Lined Up