Commit c8d6b79a83
Verified · cmc
e2e/labelweb_test.go +14
| @@ -51,6 +51,20 @@ func TestLabelsWeb(t *testing.T) { | ||
| 51 | 51 | t.Errorf("bad colour accepted:\n%s", body) |
| 52 | 52 | } |
| 53 | 53 | |
| 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&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 | ||
| 54 | 68 | // Removing a label needs its name typed; a bare post is refused and |
| 55 | 69 | // the label stays. |
| 56 | 70 | _, body = browserPost(t, alice, base+"/labels", url.Values{ |
internal/httpd/facets.go added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 11 | type 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. | |
| 19 | type 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. | |
| 27 | func 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. | |
| 45 | func 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 12 | func 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 | ||
| 25 | func 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 { | ||
| 13 | 13 | State string |
| 14 | 14 | Query string |
| 15 | 15 | Filters []listFilter |
| 16 | Facets []facetGroup | |
| 16 | 17 | MRs []mrRow |
| 17 | 18 | Older string |
| 18 | 19 | } |
internal/httpd/web.go +14 −2
| @@ -1738,18 +1738,24 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) { | ||
| 1738 | 1738 | issues[i].Labels = labels[issues[i].ID] |
| 1739 | 1739 | } |
| 1740 | 1740 | } |
| 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) | |
| 1741 | 1746 | s.render(w, "issues.html", struct { |
| 1742 | 1747 | repoPage |
| 1743 | 1748 | State string |
| 1744 | 1749 | Label string |
| 1745 | 1750 | Query string |
| 1746 | 1751 | Filters []listFilter |
| 1752 | Facets []facetGroup | |
| 1747 | 1753 | Issues []store.Issue |
| 1748 | 1754 | LabelColors map[string]template.CSS |
| 1749 | 1755 | Older string |
| 1750 | 1756 | }{p, state, f.Label, f.Search, |
| 1751 | 1757 | 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}) | |
| 1753 | 1759 | } |
| 1754 | 1760 | |
| 1755 | 1761 | func (s *Server) issue(w http.ResponseWriter, r *http.Request) { |
| @@ -1901,17 +1907,23 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { | ||
| 1901 | 1907 | m.Labels = labels[m.ID] |
| 1902 | 1908 | rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]} |
| 1903 | 1909 | } |
| 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) | |
| 1904 | 1915 | s.render(w, "mrs.html", struct { |
| 1905 | 1916 | repoPage |
| 1906 | 1917 | State string |
| 1907 | 1918 | Query string |
| 1908 | 1919 | Filters []listFilter |
| 1920 | Facets []facetGroup | |
| 1909 | 1921 | MRs []mrRow |
| 1910 | 1922 | LabelColors map[string]template.CSS |
| 1911 | 1923 | Older string |
| 1912 | 1924 | }{p, state, mf.Search, |
| 1913 | 1925 | 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}) | |
| 1915 | 1927 | } |
| 1916 | 1928 | |
| 1917 | 1929 | func (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); } | ||
| 1390 | 1390 | .filenav li a.dir { color: var(--link); } |
| 1391 | 1391 | .filenav li a.up { color: var(--muted); } |
| 1392 | 1392 | |
| 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 | ||
| 1393 | 1412 | .refchip { |
| 1394 | 1413 | display: inline-flex; |
| 1395 | 1414 | align-items: center; |
| @@ -1602,6 +1621,11 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1602 | 1621 | /* the tree page is the navigator on a phone */ |
| 1603 | 1622 | .blobgrid { grid-template-columns: 1fr; } |
| 1604 | 1623 | .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); } | |
| 1605 | 1629 | } |
| 1606 | 1630 | |
| 1607 | 1631 | @media (max-width: 52rem) { |
internal/web/templates/issues.html +6 −6
| @@ -1,19 +1,17 @@ | ||
| 1 | 1 | {{define "width"}}wide{{end}} |
| 2 | 2 | {{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | <div class="withcol"> | |
| 5 | {{template "sidecol" .Facets}} | |
| 6 | <div class="colmain"> | |
| 4 | 7 | <div class="listhead"> |
| 5 | 8 | <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}} | |
| 12 | 9 | <form method="get" class="searchform compact"> |
| 13 | 10 | <input type="search" name="q" aria-label="Search issues" value="{{.Query}}" placeholder="search title and body"> |
| 14 | 11 | <button type="submit" class="btn">Search</button> |
| 15 | 12 | <input type="hidden" name="state" value="{{.State}}"> |
| 16 | 13 | </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}} | |
| 17 | 15 | <span class="spacer"></span> |
| 18 | 16 | <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> |
| 19 | 17 | </div> |
| @@ -30,4 +28,6 @@ | ||
| 30 | 28 | {{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}} |
| 31 | 29 | </ul> |
| 32 | 30 | {{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}} |
| 31 | </div> | |
| 32 | </div> | |
| 33 | 33 | {{end}} |
internal/web/templates/layout.html +7
| @@ -122,6 +122,13 @@ | ||
| 122 | 122 | {{end}}</ul> |
| 123 | 123 | </nav>{{end}} |
| 124 | 124 | |
| 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 | ||
| 125 | 132 | {{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}} |
| 126 | 133 | |
| 127 | 134 | {{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 @@ | ||
| 1 | 1 | {{define "width"}}wide{{end}} |
| 2 | 2 | {{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | <div class="withcol"> | |
| 5 | {{template "sidecol" .Facets}} | |
| 6 | <div class="colmain"> | |
| 4 | 7 | <div class="listhead"> |
| 5 | 8 | <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> | |
| 12 | 9 | <form method="get" class="searchform compact"> |
| 13 | 10 | <input type="search" name="q" aria-label="Search merge requests" value="{{.Query}}" placeholder="search title and body"> |
| 14 | 11 | <button type="submit" class="btn">Search</button> |
| @@ -32,4 +29,6 @@ | ||
| 32 | 29 | {{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}} |
| 33 | 30 | </ul> |
| 34 | 31 | {{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}} |
| 32 | </div> | |
| 33 | </div> | |
| 35 | 34 | {{end}} |