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>