web: one register for empty states and contribution hints !501

merged merged by cmc on 2026-09-28 23:00 UTC · krz/gitbay:web-empty-states into main

15 files changed, +269 −17

Layout: unified · split

CHANGELOG.org +6
@@ -6,6 +6,12 @@ anything beyond "replace the binary and restart" is needed.
66
77* Unreleased
88
9- Empty states on the web state the fact instead of a CLI command, and
10 drop "yet" on a finished item; the merge request list offers a New
11 merge request link, a fork link, or a sign-in prompt depending on
12 what the visitor can do; and the search page's scope caption is
13 always visible, not only before a first search (#270).
14
915Credentials and sessions: revocation, delegation, expiry and an idle
1016timeout (#256, #257, #276, #277).
1117
e2e/searchweb_test.go +1 −1
@@ -91,7 +91,7 @@ func TestGlobalSearchAndNotificationsWeb(t *testing.T) {
9191 t.Fatalf("mark all read: %d", status)
9292 }
9393 _, body = browserGet(t, browser, inst.base()+"/notifications")
94 if !strings.Contains(body, "nothing unread") {
94 if !strings.Contains(body, "no unread notifications") {
9595 t.Fatalf("unread list after sweep:\n%s", body)
9696 }
9797 _, body = browserGet(t, browser, inst.base()+"/notifications?all=1")
internal/httpd/buildpages_test.go +63
@@ -57,6 +57,69 @@ func TestBuildsPageRendersCommandOutput(t *testing.T) {
5757 }
5858}
5959
60// A writer sees the instruction to add CI; a reader without push access
61// sees only the fact, since the instruction is not theirs to act on
62// (#270).
63func TestBuildsEmptyStateOmitsInstructionForReaders(t *testing.T) {
64 var sb strings.Builder
65 filter := buildFilter{}
66 err := web.Render(&sb, "builds.html", struct {
67 repoPage
68 Builds []control.BuildOut
69 Jobs []control.JobOut
70 Runs []buildRun
71 Filter buildFilter
72 Facets []facetGroup
73 Refs []string
74 Older string
75 CanWrite bool
76 Notice string
77 }{
78 testRepoPage(), nil, nil, nil, filter, nil, nil, "", false, "",
79 })
80 if err != nil {
81 t.Fatalf("render: %v", err)
82 }
83 out := sb.String()
84 if !strings.Contains(out, "no builds") {
85 t.Error(`missing "no builds"`)
86 }
87 if strings.Contains(out, "ci.yml") {
88 t.Error("a reader without push access should not see the push instruction")
89 }
90}
91
92// A writer with zero builds gets the one instruction the page can give
93// them (#270).
94func TestBuildsEmptyStateShowsInstructionForWriters(t *testing.T) {
95 var sb strings.Builder
96 filter := buildFilter{}
97 err := web.Render(&sb, "builds.html", struct {
98 repoPage
99 Builds []control.BuildOut
100 Jobs []control.JobOut
101 Runs []buildRun
102 Filter buildFilter
103 Facets []facetGroup
104 Refs []string
105 Older string
106 CanWrite bool
107 Notice string
108 }{
109 testRepoPage(), nil, nil, nil, filter, nil, nil, "", true, "",
110 })
111 if err != nil {
112 t.Fatalf("render: %v", err)
113 }
114 out := sb.String()
115 if !strings.Contains(out, "no builds") {
116 t.Error(`missing "no builds"`)
117 }
118 if !strings.Contains(out, "push a commit with a .gitbay/ci.yml") {
119 t.Error("a writer with no builds should see the push instruction")
120 }
121}
122
60123func TestBuildPageRendersCommandOutput(t *testing.T) {
61124 var sb strings.Builder
62125 err := web.Render(&sb, "build.html", buildView{
internal/httpd/mrpage_test.go +60
@@ -83,6 +83,66 @@ func testMR(state string) store.MR {
8383 HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"}
8484}
8585
86// A finished merge request states an empty label list as a fact, not a
87// promise something is still coming (#270).
88func TestMRPageLabelsNoYetOnFinishedState(t *testing.T) {
89 var sb strings.Builder
90 if err := web.Render(&sb, "mr.html", mrPageData{
91 repoPage: testRepoPage(), MR: testMR("merged"), View: "conversation",
92 }); err != nil {
93 t.Fatalf("render: %v", err)
94 }
95 if !strings.Contains(sb.String(), "no labels") {
96 t.Error(`merged MR with no labels should read "no labels", not "none yet"`)
97 }
98}
99
100// An open MR can still gain labels, so its empty state keeps "none yet"
101// rather than the finished-item wording (#270).
102func TestMRPageLabelsNoneYetOnOpenState(t *testing.T) {
103 var sb strings.Builder
104 if err := web.Render(&sb, "mr.html", mrPageData{
105 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
106 }); err != nil {
107 t.Fatalf("render: %v", err)
108 }
109 out := sb.String()
110 if !strings.Contains(out, "none yet") {
111 t.Error(`open MR with no labels should read "none yet"`)
112 }
113 if strings.Contains(out, "no labels") {
114 t.Error(`open MR with no labels should not read "no labels"`)
115 }
116}
117
118// A closed MR is finished the same way a merged one is (#270).
119func TestMRPageLabelsNoLabelsOnClosedState(t *testing.T) {
120 var sb strings.Builder
121 if err := web.Render(&sb, "mr.html", mrPageData{
122 repoPage: testRepoPage(), MR: testMR("closed"), View: "conversation",
123 }); err != nil {
124 t.Fatalf("render: %v", err)
125 }
126 if !strings.Contains(sb.String(), "no labels") {
127 t.Error(`closed MR with no labels should read "no labels", not "none yet"`)
128 }
129}
130
131func TestMRPageReviewersEmptyStateDropsNobody(t *testing.T) {
132 var sb strings.Builder
133 if err := web.Render(&sb, "mr.html", mrPageData{
134 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
135 }); err != nil {
136 t.Fatalf("render: %v", err)
137 }
138 if strings.Contains(sb.String(), "nobody yet") {
139 t.Error(`reviewers empty state should read "no reviewers"`)
140 }
141 if !strings.Contains(sb.String(), "no reviewers") {
142 t.Error(`missing "no reviewers"`)
143 }
144}
145
86146// The header states what happened to the MR: who opened, merged, or closed
87147// it, and when. A merge or close with no stamp (imports, pre-0029 merges)
88148// names the state without claiming a byline it cannot back up.
internal/httpd/mrslist_test.go added +86
@@ -0,0 +1,86 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8 "time"
9
10 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/store"
12)
13
14// A repository's MR list offers the right next step by access level: a
15// writer gets "New merge request", a signed-in reader without push gets
16// a fork link, and a signed-out visitor gets a sign-in prompt (#270).
17func TestMRsListContributionHintByAccess(t *testing.T) {
18 st, err := store.Open(":memory:")
19 if err != nil {
20 t.Fatal(err)
21 }
22 defer st.Close()
23 if err := st.MigrateUp(); err != nil {
24 t.Fatal(err)
25 }
26 owner, err := st.CreateUser("alice", false)
27 if err != nil {
28 t.Fatal(err)
29 }
30 reader, err := st.CreateUser("bob", false)
31 if err != nil {
32 t.Fatal(err)
33 }
34 if _, err := st.CreateRepo("user", owner, "app", "public"); err != nil {
35 t.Fatal(err)
36 }
37
38 cfg := config.Default()
39 cfg.Web.Mode = "accounts"
40 s := New(cfg, st)
41
42 // mrs reads the viewer through s.viewer(r), which resolves a
43 // session cookie (internal/httpd/accounts.go:37-47) rather than
44 // taking the viewer as a parameter the way a POST handler test
45 // does. Give a real viewer a real session; leave the request
46 // cookie-less for the anonymous case.
47 sessionFor := func(uid int64) *http.Cookie {
48 tok, hash, err := store.NewToken()
49 if err != nil {
50 t.Fatal(err)
51 }
52 if err := st.CreateWebSession(hash, uid, time.Hour); err != nil {
53 t.Fatal(err)
54 }
55 return s.sessionCookieFor(tok)
56 }
57
58 get := func(uid int64) string {
59 req := httptest.NewRequest("GET", "/alice/app/mrs", nil)
60 req.SetPathValue("owner", "alice")
61 req.SetPathValue("repo", "app")
62 if uid != 0 {
63 req.AddCookie(sessionFor(uid))
64 }
65 rr := httptest.NewRecorder()
66 s.mrs(rr, req)
67 return rr.Body.String()
68 }
69 anonymous := get(0)
70 if !strings.Contains(anonymous, "Sign in to propose a change") {
71 t.Errorf("signed-out visitor: missing sign-in prompt:\n%s", anonymous)
72 }
73 if strings.Contains(anonymous, "New merge request") {
74 t.Error("signed-out visitor should not see New merge request")
75 }
76
77 readerOut := get(reader)
78 if !strings.Contains(readerOut, "Fork this repository to propose a change") {
79 t.Errorf("reader without push: missing fork hint:\n%s", readerOut)
80 }
81
82 ownerOut := get(owner)
83 if !strings.Contains(ownerOut, "New merge request") {
84 t.Errorf("owner: missing New merge request link:\n%s", ownerOut)
85 }
86}
internal/httpd/mrsrow_test.go +7 −6
@@ -10,12 +10,13 @@ import (
1010// mrsPageData mirrors the anonymous struct the mrs handler renders with.
1111type mrsPageData struct {
1212 repoPage
13 State string
14 Query string
15 Filters []listFilter
16 Facets []facetGroup
17 MRs []mrRow
18 Older string
13 State string
14 Query string
15 Filters []listFilter
16 Facets []facetGroup
17 MRs []mrRow
18 Older string
19 CanWrite bool
1920}
2021
2122func renderMRs(t *testing.T, rows []mrRow, state string) string {
internal/httpd/searchweb_test.go added +28
@@ -0,0 +1,28 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/web"
8)
9
10// The scope sentence is a permanent caption, not a first-visit-only
11// hint: a visitor who has already searched still needs to know what a
12// search here does and does not cover (#270).
13func TestGlobalSearchScopeCaptionAlwaysShown(t *testing.T) {
14 var sb strings.Builder
15 if err := web.Render(&sb, "globalsearch.html", struct {
16 basePage
17 Tab string
18 Query string
19 Kind string
20 QueryErr string
21 Results []searchResult
22 }{basePage{Site: "gitbay"}, "sitesearch", "gitbay", "", "", nil}); err != nil {
23 t.Fatalf("render: %v", err)
24 }
25 if !strings.Contains(sb.String(), "File contents are searched per repository") {
26 t.Error("scope caption missing once a query is present")
27 }
28}
internal/httpd/web.go +3 −1
@@ -2037,6 +2037,7 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
20372037 return
20382038 }
20392039 p.Tab = "merge requests"
2040 canWrite := s.canWriteRepo(r, p.Repo)
20402041 state := r.URL.Query().Get("state")
20412042 if state == "" {
20422043 state = "open"
@@ -2096,9 +2097,10 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
20962097 MRs []mrRow
20972098 LabelColors map[string]template.CSS
20982099 Older string
2100 CanWrite bool
20992101 }{p, state, mf.Search,
21002102 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
2101 facets, rows, s.labelColors(p.Repo), older})
2103 facets, rows, s.labelColors(p.Repo), older, canWrite})
21022104}
21032105
21042106func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
internal/web/templates/builds.html +1 −1
@@ -50,7 +50,7 @@
5050 {{range .Builds}}<a class="chip check-{{.Status}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/builds/{{.Number}}">{{.Job}}</a>{{end}}
5151 </div>
5252</li>
53{{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}}
53{{else}}<li class="empty">no builds{{if .CanWrite}} — push a commit with a .gitbay/ci.yml{{end}}</li>{{end}}
5454</ul>
5555{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
5656</div>
internal/web/templates/dashboard.html +1 −1
@@ -26,7 +26,7 @@
2626 <ul class="pins">
2727 {{range .Pins}}<li><a href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a><span class="n" title="{{.Issues}} open issue{{if ne .Issues 1}}s{{end}}, {{.MRs}} open merge request{{if ne .MRs 1}}s{{end}}{{with .Build}}, last build {{.}}{{end}}"><b{{if .Issues}} class="wants"{{end}}>{{.Issues}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{.MRs}} <span class="vh">open merge requests</span></b> <span class="dot{{if eq .Build "success"}} ok{{else if or (eq .Build "failure") (eq .Build "error")}} bad{{else if or (eq .Build "pending") (eq .Build "running")}} pend{{end}}"><span class="vh">{{with .Build}}last build {{.}}{{else}}no builds{{end}}</span></span></span></li>
2828 {{end}}</ul>
29 {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
29 {{else}}<p class="none">nothing pinned — press Pin on a repository you visit</p>{{end}}
3030</aside>
3131
3232<section class="dashmain">
internal/web/templates/globalsearch.html +1 −2
@@ -43,9 +43,8 @@
4343{{/* The count line above already says nothing matched, so this one
4444 carries the way out instead of repeating it. */}}
4545{{else}}<p class="empty-note">Try fewer words{{if .Kind}}, <a href="?q={{.Query}}">search everything</a>,{{end}} or <a href="/explore">browse the repositories</a>.</p>{{end}}
46{{else}}
47<p class="empty-note">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p>
4846{{end}}
47<p class="meta">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p>
4948</div>
5049</div>
5150{{end}}
internal/web/templates/layout.html +5
@@ -66,6 +66,11 @@
6666 <a class="button btn" href="/{{.OwnerName}}/{{.Name}}/fork">Fork</a>{{end}}
6767 </div>
6868 {{if eq $top "code"}}{{if field $ "Mirrors"}}<p class="repometa">{{range $i, $m := field $ "Mirrors"}}{{if $i}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}}{{end}}
69 {{/* A tab's own count badge is omitted at zero (an empty <i> reads as
70 stray punctuation next to the tab word); the dashboard pin row's
71 count chip always shows its number, zero included, the same as
72 every other count chip in the app. Two elements, two rules,
73 decided once here (#270). */}}
6974 <nav class="tabs" aria-label="Repository">
7075 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a>
7176 <a {{if eq $top "issues"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">Issues{{with field $ "OpenIssues"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}</a>
internal/web/templates/mr.html +2 −2
@@ -144,7 +144,7 @@
144144 <div class="grp">
145145 <h2>Reviewers</h2>
146146 {{if .MR.ReviewRequests}}<p class="row">{{range .MR.ReviewRequests}}<a href="/{{.}}">{{.}}</a> {{end}}</p>
147 {{else}}<p class="none">nobody yet</p>{{end}}
147 {{else}}<p class="none">no reviewers</p>{{end}}
148148 {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}}
149149 <form method="post" action="{{$base}}/review-request" class="actions">
150150 <input type="text" name="add" aria-label="Add reviewers" autocomplete="off" spellcheck="false" placeholder="add, space-separated">
@@ -175,7 +175,7 @@
175175 <div class="grp">
176176 <h2>Labels</h2>
177177 {{if .MR.Labels}}<p class="row">{{range .MR.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs?label={{.}}">{{.}}</a> {{end}}</p>
178 {{else}}<p class="none">none yet</p>{{end}}
178 {{else}}<p class="none">{{if or (eq .MR.State "merged") (eq .MR.State "closed")}}no labels{{else}}none yet{{end}}</p>{{end}}
179179 {{if .CanWrite}}
180180 <form method="post" action="{{$base}}/label" class="actions">
181181 <input type="text" name="add" aria-label="Add labels" autocomplete="off" spellcheck="false" placeholder="add, space-separated">
internal/web/templates/mrs.html +4 −2
@@ -13,7 +13,9 @@
1313 </form>
1414 {{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}}
1515</div>
16{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}}
16{{if .CanWrite}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>
17{{else if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">Fork this repository to propose a change</a></p>
18{{else}}<p class="meta"><a href="/login">Sign in to propose a change</a></p>{{end}}
1719<ul class="issuelist rows">
1820{{range .MRs}}{{$n := .Number}}<li>
1921 <div class="issuemain">
@@ -26,7 +28,7 @@
2628 </div>
2729</li>
2830{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li>
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}}
31{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests</li>{{end}}{{end}}
3032</ul>
3133{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
3234</div>
internal/web/templates/notifications.html +1 −1
@@ -18,6 +18,6 @@
1818 </div>
1919 {{if not .Read}}<form method="post" action="/notifications" class="inline"><input type="hidden" name="id" value="{{.ID}}"><button type="submit" class="btn">Mark read</button></form>{{end}}
2020</li>
21{{else}}<li class="empty">{{if .All}}nothing here yet{{else}}nothing unread — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}}
21{{else}}<li class="empty">{{if .All}}nothing to show{{else}}no unread notifications — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}}
2222</ul>
2323{{end}}