Commit c8d6b79a83

c8d6b79a8341e390de2232748e1f8857a461c4ff

parent: dbe7f67685

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-19 21:35 UTC

web: a facet column on the issue and merge request lists

State, labels with counts and open milestones beside the rows; a facet
keeps the other filters and clears itself when active.

Ref #226
e2e/labelweb_test.go +14
@@ -51,6 +51,20 @@ func TestLabelsWeb(t *testing.T) {
5151 t.Errorf("bad colour accepted:\n%s", body)
5252 }
5353
54 // The issue list's column lists the label with its count and a link
55 // that keeps the state (desktop layout spec).
56 status, page = browserGet(t, alice, base+"/issues?state=open")
57 if status != 200 || !strings.Contains(page, `<nav class="sidecol" aria-label="Filters">`) {
58 t.Fatalf("issues page lacks the side column: %d", status)
59 }
60 if !strings.Contains(page, `href="?label=bug&amp;state=open">bug <i>1</i></a>`) {
61 t.Fatalf("issues column lacks the bug facet:\n%s", page)
62 }
63 status, page = browserGet(t, alice, base+"/issues?state=open&label=bug")
64 if status != 200 || !strings.Contains(page, `aria-current="page" href="?state=open">bug <i>1</i></a>`) {
65 t.Fatalf("active facet does not clear itself:\n%s", page)
66 }
67
5468 // Removing a label needs its name typed; a bare post is refused and
5569 // the label stays.
5670 _, body = browserPost(t, alice, base+"/labels", url.Values{
internal/httpd/facets.go added +74
@@ -0,0 +1,74 @@
1package httpd
2
3import (
4 "net/url"
5
6 "gitbay.org/gitbay/internal/store"
7)
8
9// facetItem is one link in a list page's side column: a value the list
10// narrows to. Clicking an active item clears it.
11type facetItem struct {
12 Label string
13 Count int64
14 Href string
15 Active bool
16}
17
18// facetGroup is one heading in the column: State, Labels, Milestones.
19type facetGroup struct {
20 Title string
21 Items []facetItem
22}
23
24// facetHref returns "?..." with every parameter of base kept, key set to
25// value, or dropped when value is "". url.Values encodes sorted, so the
26// tests and the links agree byte for byte.
27func facetHref(base url.Values, key, value string) string {
28 q := url.Values{}
29 for k, vs := range base {
30 if k == key || len(vs) == 0 || vs[0] == "" {
31 continue
32 }
33 q.Set(k, vs[0])
34 }
35 if value != "" {
36 q.Set(key, value)
37 }
38 return "?" + q.Encode()
39}
40
41// listFacets builds the issue or merge request list's column from the
42// active parameters, the states the page offers, and the repository's
43// labels and open milestones. Counts are the rows' own: a label's issue
44// count on the issue list, its MR count on the MR list.
45func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup {
46 var st facetGroup
47 st.Title = "State"
48 for _, s := range states {
49 st.Items = append(st.Items, facetItem{Label: s, Href: facetHref(base, "state", s), Active: s == state})
50 }
51 lb := facetGroup{Title: "Labels"}
52 for _, l := range labels {
53 n := l.Issues
54 if forMRs {
55 n = l.MRs
56 }
57 active := base.Get("label") == l.Name
58 href := facetHref(base, "label", l.Name)
59 if active {
60 href = facetHref(base, "label", "")
61 }
62 lb.Items = append(lb.Items, facetItem{Label: l.Name, Count: n, Href: href, Active: active})
63 }
64 mg := facetGroup{Title: "Milestones"}
65 for _, m := range ms {
66 active := base.Get("milestone") == m.Title
67 href := facetHref(base, "milestone", m.Title)
68 if active {
69 href = facetHref(base, "milestone", "")
70 }
71 mg.Items = append(mg.Items, facetItem{Label: m.Title, Count: int64(m.OpenItems), Href: href, Active: active})
72 }
73 return []facetGroup{st, lb, mg}
74}
internal/httpd/facets_test.go added +52
@@ -0,0 +1,52 @@
1package httpd
2
3import (
4 "net/url"
5 "testing"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10// A facet link keeps every other active filter, sets its own, and clears
11// its own when it is already active (desktop layout spec).
12func TestFacetHrefKeepsOtherFilters(t *testing.T) {
13 base := url.Values{"state": {"open"}, "label": {"bug"}, "q": {"crash"}}
14 if got := facetHref(base, "milestone", "v2"); got != "?label=bug&milestone=v2&q=crash&state=open" {
15 t.Errorf("set: %q", got)
16 }
17 if got := facetHref(base, "label", ""); got != "?q=crash&state=open" {
18 t.Errorf("clear: %q", got)
19 }
20 if got := facetHref(base, "state", "closed"); got != "?label=bug&q=crash&state=closed" {
21 t.Errorf("replace: %q", got)
22 }
23}
24
25func TestListFacetsGroups(t *testing.T) {
26 base := url.Values{"state": {"open"}, "label": {"bug"}}
27 labels := []store.Label{{Name: "bug", Issues: 2, MRs: 1}, {Name: "docs", Issues: 0, MRs: 3}}
28 ms := []store.Milestone{{Title: "v2", OpenItems: 4}}
29 groups := listFacets(base, []string{"open", "closed", "all"}, "open", labels, ms, false)
30 if len(groups) != 3 || groups[0].Title != "State" || groups[1].Title != "Labels" || groups[2].Title != "Milestones" {
31 t.Fatalf("groups: %+v", groups)
32 }
33 st := groups[0].Items
34 if !st[0].Active || st[0].Href != "?label=bug&state=open" || st[1].Active || st[1].Href != "?label=bug&state=closed" {
35 t.Errorf("state items: %+v", st)
36 }
37 lb := groups[1].Items
38 if lb[0].Label != "bug" || lb[0].Count != 2 || !lb[0].Active || lb[0].Href != "?state=open" {
39 t.Errorf("active label clears itself: %+v", lb[0])
40 }
41 if lb[1].Label != "docs" || lb[1].Count != 0 || lb[1].Active || lb[1].Href != "?label=docs&state=open" {
42 t.Errorf("inactive label: %+v", lb[1])
43 }
44 if m := groups[2].Items[0]; m.Label != "v2" || m.Count != 4 || m.Href != "?label=bug&milestone=v2&state=open" {
45 t.Errorf("milestone: %+v", m)
46 }
47 // on the MR list a label's count is its MR count
48 mr := listFacets(base, []string{"open"}, "open", labels, nil, true)
49 if mr[1].Items[1].Count != 3 {
50 t.Errorf("mr count: %+v", mr[1].Items[1])
51 }
52}
internal/httpd/mrsrow_test.go +1
@@ -13,6 +13,7 @@ type mrsPageData struct {
1313 State string
1414 Query string
1515 Filters []listFilter
16 Facets []facetGroup
1617 MRs []mrRow
1718 Older string
1819}
internal/httpd/web.go +14 −2
@@ -1738,18 +1738,24 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
17381738 issues[i].Labels = labels[issues[i].ID]
17391739 }
17401740 }
1741 base := url.Values{"state": {state}, "label": {f.Label}, "assignee": {f.Assignee}, "author": {f.Author}, "milestone": {f.Milestone}, "q": {f.Search}}
1742 readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1743 allLabels, _ := s.st.ListLabels(p.Repo, readable)
1744 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
1745 facets := listFacets(base, []string{"open", "closed", "all"}, state, allLabels, openMS, false)
17411746 s.render(w, "issues.html", struct {
17421747 repoPage
17431748 State string
17441749 Label string
17451750 Query string
17461751 Filters []listFilter
1752 Facets []facetGroup
17471753 Issues []store.Issue
17481754 LabelColors map[string]template.CSS
17491755 Older string
17501756 }{p, state, f.Label, f.Search,
17511757 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1752 issues, s.labelColors(p.Repo), older})
1758 facets, issues, s.labelColors(p.Repo), older})
17531759}
17541760
17551761func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
@@ -1901,17 +1907,23 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
19011907 m.Labels = labels[m.ID]
19021908 rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]}
19031909 }
1910 base := url.Values{"state": {state}, "label": {mf.Label}, "author": {mf.Author}, "milestone": {mf.Milestone}, "q": {mf.Search}}
1911 readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1912 allLabels, _ := s.st.ListLabels(p.Repo, readable)
1913 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
1914 facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
19041915 s.render(w, "mrs.html", struct {
19051916 repoPage
19061917 State string
19071918 Query string
19081919 Filters []listFilter
1920 Facets []facetGroup
19091921 MRs []mrRow
19101922 LabelColors map[string]template.CSS
19111923 Older string
19121924 }{p, state, mf.Search,
19131925 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
1914 rows, s.labelColors(p.Repo), older})
1926 facets, rows, s.labelColors(p.Repo), older})
19151927}
19161928
19171929func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
internal/web/static/style.css +24
@@ -1390,6 +1390,25 @@ a.memberchip .role { color: var(--muted); }
13901390.filenav li a.dir { color: var(--link); }
13911391.filenav li a.up { color: var(--muted); }
13921392
1393/* ---- side column: facets or sections beside a list or a form ---- */
1394.withcol { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; }
1395.withcol.narrow { grid-template-columns: 15rem minmax(0, 56rem); }
1396.colmain { min-width: 0; }
1397.sidecol { position: sticky; top: var(--sp-4); font-size: var(--fs-2); }
1398.sidecol .grp { margin-bottom: var(--sp-4); }
1399.sidecol ul { list-style: none; margin: 0; padding: 0; }
1400.sidecol li a {
1401 display: flex; align-items: baseline; gap: var(--sp-2);
1402 padding: 4px var(--sp-2);
1403 border-radius: var(--r-ctl);
1404 color: var(--fg);
1405}
1406.sidecol li a:hover { background: var(--hover); text-decoration: none; }
1407.sidecol li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 500; }
1408.sidecol li a i { margin-left: auto; font-style: normal; color: var(--muted); font-size: var(--fs-1); font-variant-numeric: tabular-nums; }
1409.sidecol form.searchform { margin-top: var(--sp-2); }
1410.sidecol form.searchform input[type="text"] { min-width: 0; width: 100%; }
1411
13931412.refchip {
13941413 display: inline-flex;
13951414 align-items: center;
@@ -1602,6 +1621,11 @@ svg.icon { vertical-align: -0.125em; }
16021621 /* the tree page is the navigator on a phone */
16031622 .blobgrid { grid-template-columns: 1fr; }
16041623 .filenav { display: none; }
1624
1625 /* the column follows the content on a phone, the way the aside does */
1626 .withcol, .withcol.narrow { grid-template-columns: 1fr; }
1627 .sidecol { position: static; order: 2; }
1628 .sidecol .grp { display: inline-block; vertical-align: top; margin-right: var(--sp-5); }
16051629}
16061630
16071631@media (max-width: 52rem) {
internal/web/templates/issues.html +6 −6
@@ -1,19 +1,17 @@
11{{define "width"}}wide{{end}}
22{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
33{{define "content"}}
4<div class="withcol">
5{{template "sidecol" .Facets}}
6<div class="colmain">
47<div class="listhead">
58 <h1>Issues</h1>
6 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" aria-current="page" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "closed"}}class="active" aria-current="page" {{end}}href="?state=closed">closed</a>
9 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
10 </nav>
11 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
129 <form method="get" class="searchform compact">
1310 <input type="search" name="q" aria-label="Search issues" value="{{.Query}}" placeholder="search title and body">
1411 <button type="submit" class="btn">Search</button>
1512 <input type="hidden" name="state" value="{{.State}}">
1613 </form>
14 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
1715 <span class="spacer"></span>
1816 <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/labels">labels</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p>
1917</div>
@@ -30,4 +28,6 @@
3028{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues — open one with <code>gitbay issue create {{.Repo.OwnerName}}/{{.Repo.Name}} --title "..."</code></li>{{end}}{{end}}
3129</ul>
3230{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
31</div>
32</div>
3333{{end}}
internal/web/templates/layout.html +7
@@ -122,6 +122,13 @@
122122 {{end}}</ul>
123123</nav>{{end}}
124124
125{{define "sidecol"}}<nav class="sidecol" aria-label="Filters">
126 {{range .}}{{if .Items}}<div class="grp">
127 <h2 class="colhead">{{.Title}}</h2>
128 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}{{if .Count}} <i>{{.Count}}</i>{{end}}</a></li>{{end}}</ul>
129 </div>{{end}}{{end}}
130</nav>{{end}}
131
125132{{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}}
126133
127134{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}}
internal/web/templates/mrs.html +5 −6
@@ -1,14 +1,11 @@
11{{define "width"}}wide{{end}}
22{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
33{{define "content"}}
4<div class="withcol">
5{{template "sidecol" .Facets}}
6<div class="colmain">
47<div class="listhead">
58 <h1>Merge requests</h1>
6 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" aria-current="page" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "merged"}}class="active" aria-current="page" {{end}}href="?state=merged">merged</a>
9 <a {{if eq .State "closed"}}class="active" aria-current="page" {{end}}href="?state=closed">closed</a>
10 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
11 </nav>
129 <form method="get" class="searchform compact">
1310 <input type="search" name="q" aria-label="Search merge requests" value="{{.Query}}" placeholder="search title and body">
1411 <button type="submit" class="btn">Search</button>
@@ -32,4 +29,6 @@
3229{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}}{{end}}
3330</ul>
3431{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
32</div>
33</div>
3534{{end}}