web: UX review small fixes !502

merged merged by cmc on 2026-09-28 23:04 UTC · krz/gitbay:web-ux-small-fixes into main

15 files changed, +528 −22

Layout: unified · split

CHANGELOG.org +6
@@ -6,6 +6,12 @@ anything beyond "replace the binary and restart" is needed.
6 6
7* Unreleased 7* Unreleased
8 8
9- The new-issue form takes labels, milestone and assignee in one step
10 for writers; the watch button names watching, muted and default; a
11 Discussion heading sits before comment threads; the build page's
12 live note says the page updates itself; and the rail and the phone
13 More menu render from one list (#271).
14
9- Empty states on the web state the fact instead of a CLI command, and 15- 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 16 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 17 merge request link, a fork link, or a sign-in prompt depending on
e2e/buildfollow_test.go +1 −1
@@ -133,7 +133,7 @@ func TestBuildLogFollow(t *testing.T) {
133 } 133 }
134 defer page.Body.Close() 134 defer page.Body.Close()
135 web := newStreamReader(page.Body) 135 web := newStreamReader(page.Body)
136 web.waitFor(t, "Live: the log streams here") 136 web.waitFor(t, "This page updates itself until the build ends")
137 137
138 // A static render while the build runs returns at once. 138 // A static render while the build runs returns at once.
139 static := &http.Client{Timeout: 10 * time.Second} 139 static := &http.Client{Timeout: 10 * time.Second}
internal/httpd/accounts.go +17 −7
@@ -420,8 +420,9 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
420 Template string 420 Template string
421 Templates []control.IssueTemplate 421 Templates []control.IssueTemplate
422 Draft *draft 422 Draft *draft
423 CanWrite bool
423 }{p, d.Body, d.Format, r.FormValue("title"), r.FormValue("labels"), 424 }{p, d.Body, d.Format, r.FormValue("title"), r.FormValue("labels"),
424 "", control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), d}) 425 "", control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), d, s.canWriteRepoAs(u, p.Repo)})
425 return 426 return
426 } 427 }
427 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch) 428 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
@@ -455,7 +456,8 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
455 Template string 456 Template string
456 Templates []control.IssueTemplate 457 Templates []control.IssueTemplate
457 Draft *draft 458 Draft *draft
458 }{p, body, format, "", "", tplName, templates, nil}) 459 CanWrite bool
460 }{p, body, format, "", "", tplName, templates, nil, s.canWriteRepoAs(u, p.Repo)})
459} 461}
460 462
461// Issue and merge request writes run the command the CLI runs, so the 463// Issue and merge request writes run the command the CLI runs, so the
@@ -472,17 +474,25 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto
472 } 474 }
473 var created control.Created 475 var created control.Created
474 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"} 476 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
477 // Labels, milestone and assignee go on the same dispatch issue create
478 // itself resolves and applies: a typo in any of them creates nothing,
479 // and the label/milestone/assign code paths run so notifications and
480 // events happen (#271). issue create refuses the whole create when any
481 // of them is set without write access, so a reader's hand-crafted POST
482 // carrying one is dropped here rather than failing the create.
483 if repo, err := s.st.RepoByPath(repoPath); err == nil && s.canWriteRepoAs(u, repo) {
484 argv = append(argv, fieldArgs("--label", r.FormValue("labels"))...)
485 if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" {
486 argv = append(argv, "--milestone", milestone)
487 }
488 argv = append(argv, fieldArgs("--assignee", r.FormValue("assignee"))...)
489 }
475 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created) 490 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created)
476 if code != protocol.ExitOK { 491 if code != protocol.ExitOK {
477 http.Error(w, msg, statusForExit(code)) 492 http.Error(w, msg, statusForExit(code))
478 return 493 return
479 } 494 }
480 n := created.Number 495 n := created.Number
481 // Labels need write access, matching the SSH rule; the command refuses
482 // otherwise and the issue stands without them.
483 if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
484 s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
485 }
486 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther) 496 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
487} 497}
488 498
internal/httpd/buildpages_test.go +14
@@ -317,3 +317,17 @@ func TestBuildPageStepSummaryIsFirstLine(t *testing.T) {
317 t.Errorf("summary shows the whole multi-line step") 317 t.Errorf("summary shows the whole multi-line step")
318 } 318 }
319} 319}
320
321// The Live note says the page updates itself, in plain words, rather
322// than the more technical "streams here" (#271).
323func TestBuildPageLiveNoteSaysItUpdatesItself(t *testing.T) {
324 var sb strings.Builder
325 if err := web.Render(&sb, "build.html", buildView{
326 repoPage: testRepoPage(), Live: true,
327 }); err != nil {
328 t.Fatalf("render: %v", err)
329 }
330 if !strings.Contains(sb.String(), "This page updates itself") {
331 t.Error(`Live note does not say the page updates itself`)
332 }
333}
internal/httpd/issuecreate_test.go added +333
@@ -0,0 +1,333 @@
1package httpd
2
3import (
4 "html/template"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/store"
14 "gitbay.org/gitbay/internal/web"
15)
16
17// A heading precedes the issue's comment thread, matching the merge
18// request page, so a screen-reader user skimming by heading has a
19// landmark before the first comment rather than falling straight from
20// the edit box into the body (#271).
21func TestIssuePageHasDiscussionHeading(t *testing.T) {
22 var sb strings.Builder
23 if err := web.Render(&sb, "issue.html", struct {
24 repoPage
25 Issue store.Issue
26 BodyHTML template.HTML
27 Comments []renderedComment
28 CanEdit bool
29 CanWrite bool
30 Milestones []store.Milestone
31 Notice string
32 LabelColors map[string]template.CSS
33 Draft *draft
34 }{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}}); err != nil {
35 t.Fatalf("render: %v", err)
36 }
37 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
38 t.Error("no Discussion heading")
39 }
40}
41
42// sessionCookieFor gives uid a real web session, the way canWriteRepo's
43// call to s.viewer(r) needs (internal/httpd/accounts.go:37-47), since
44// issueCreateForm gates the milestone/assignee fields on it rather than
45// on the handler's own user parameter.
46func sessionCookieFor(t *testing.T, s *Server, st *store.Store, uid int64) *http.Cookie {
47 t.Helper()
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// The new-issue form takes milestone and assignee, resolved on the same
59// issue create dispatch as the title and labels, so a typo in either
60// creates nothing and the label/milestone/assign code paths still run
61// notifications and events (#271).
62func TestIssueCreateFormHasMilestoneAndAssigneeForWriter(t *testing.T) {
63 st, err := store.Open(":memory:")
64 if err != nil {
65 t.Fatal(err)
66 }
67 defer st.Close()
68 if err := st.MigrateUp(); err != nil {
69 t.Fatal(err)
70 }
71 uid, err := st.CreateUser("alice", false)
72 if err != nil {
73 t.Fatal(err)
74 }
75 u := store.User{ID: uid, Username: "alice"}
76 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
77 t.Fatal(err)
78 }
79
80 cfg := config.Default()
81 cfg.Web.Mode = "accounts"
82 s := New(cfg, st)
83 req := httptest.NewRequest("GET", "/alice/app/issues/new", nil)
84 req.SetPathValue("owner", "alice")
85 req.SetPathValue("repo", "app")
86 req.AddCookie(sessionCookieFor(t, s, st, uid))
87 rr := httptest.NewRecorder()
88 s.issueCreateForm(rr, req, u)
89
90 body := rr.Body.String()
91 if !strings.Contains(body, `name="labels"`) {
92 t.Error("no labels field for a writer")
93 }
94 if !strings.Contains(body, `name="milestone"`) {
95 t.Error("no milestone field for a writer")
96 }
97 if !strings.Contains(body, `name="assignee"`) {
98 t.Error("no assignee field for a writer")
99 }
100}
101
102// A reader (no write access) sees no milestone/assignee fields, and can
103// still create an issue with title and body alone.
104func TestIssueCreateFormHidesMilestoneAndAssigneeForReader(t *testing.T) {
105 st, err := store.Open(":memory:")
106 if err != nil {
107 t.Fatal(err)
108 }
109 defer st.Close()
110 if err := st.MigrateUp(); err != nil {
111 t.Fatal(err)
112 }
113 ownerID, err := st.CreateUser("alice", false)
114 if err != nil {
115 t.Fatal(err)
116 }
117 readerID, err := st.CreateUser("bob", false)
118 if err != nil {
119 t.Fatal(err)
120 }
121 reader := store.User{ID: readerID, Username: "bob"}
122 if _, err := st.CreateRepo("user", ownerID, "app", "public"); err != nil {
123 t.Fatal(err)
124 }
125
126 cfg := config.Default()
127 cfg.Web.Mode = "accounts"
128 s := New(cfg, st)
129 req := httptest.NewRequest("GET", "/alice/app/issues/new", nil)
130 req.SetPathValue("owner", "alice")
131 req.SetPathValue("repo", "app")
132 req.AddCookie(sessionCookieFor(t, s, st, readerID))
133 rr := httptest.NewRecorder()
134 s.issueCreateForm(rr, req, reader)
135
136 body := rr.Body.String()
137 if strings.Contains(body, `name="labels"`) {
138 t.Error("reader should not see a labels field")
139 }
140 if strings.Contains(body, `name="milestone"`) {
141 t.Error("reader should not see a milestone field")
142 }
143 if strings.Contains(body, `name="assignee"`) {
144 t.Error("reader should not see an assignee field")
145 }
146
147 form := url.Values{"title": {"a bug"}, "body": {"steps"}}
148 submit := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
149 submit.Header.Set("Content-Type", "application/x-www-form-urlencoded")
150 submit.SetPathValue("owner", "alice")
151 submit.SetPathValue("repo", "app")
152 rr2 := httptest.NewRecorder()
153 s.issueCreateSubmit(rr2, submit, reader)
154 if rr2.Code != http.StatusSeeOther {
155 t.Fatalf("reader create: status %d, body %q", rr2.Code, rr2.Body.String())
156 }
157
158 repo, err := st.RepoByPath("alice/app")
159 if err != nil {
160 t.Fatal(err)
161 }
162 issue, err := st.IssueByNumber(repo.ID, 1)
163 if err != nil {
164 t.Fatalf("issue not created: %v", err)
165 }
166 if issue.Title != "a bug" {
167 t.Fatalf("got title %q", issue.Title)
168 }
169}
170
171// A reader's hand-crafted POST carrying a labels value still creates a
172// plain issue: issue create requires write access for --label, so
173// issueCreateSubmit drops labels/milestone/assignee from the argv for a
174// non-writer rather than sending them and failing the whole create.
175func TestIssueCreateSubmitReaderLabelIsDropped(t *testing.T) {
176 st, err := store.Open(":memory:")
177 if err != nil {
178 t.Fatal(err)
179 }
180 defer st.Close()
181 if err := st.MigrateUp(); err != nil {
182 t.Fatal(err)
183 }
184 ownerID, err := st.CreateUser("alice", false)
185 if err != nil {
186 t.Fatal(err)
187 }
188 readerID, err := st.CreateUser("bob", false)
189 if err != nil {
190 t.Fatal(err)
191 }
192 reader := store.User{ID: readerID, Username: "bob"}
193 if _, err := st.CreateRepo("user", ownerID, "app", "public"); err != nil {
194 t.Fatal(err)
195 }
196 repo, err := st.RepoByPath("alice/app")
197 if err != nil {
198 t.Fatal(err)
199 }
200
201 s := New(config.Default(), st)
202 form := url.Values{
203 "title": {"a bug"},
204 "body": {"steps"},
205 "labels": {"bug"},
206 }
207 req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
208 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
209 req.SetPathValue("owner", "alice")
210 req.SetPathValue("repo", "app")
211 rr := httptest.NewRecorder()
212 s.issueCreateSubmit(rr, req, reader)
213 if rr.Code != http.StatusSeeOther {
214 t.Fatalf("reader create: status %d, body %q", rr.Code, rr.Body.String())
215 }
216
217 issue, err := st.IssueByNumber(repo.ID, 1)
218 if err != nil {
219 t.Fatalf("issue not created: %v", err)
220 }
221 if len(issue.Labels) != 0 {
222 t.Errorf("labels = %v, want none", issue.Labels)
223 }
224}
225
226// A writer creates an issue with a milestone and an assignee in one
227// request; both land on the issue because they go through the same
228// dispatch as the create.
229func TestIssueCreateSubmitSetsMilestoneAndAssignee(t *testing.T) {
230 st, err := store.Open(":memory:")
231 if err != nil {
232 t.Fatal(err)
233 }
234 defer st.Close()
235 if err := st.MigrateUp(); err != nil {
236 t.Fatal(err)
237 }
238 uid, err := st.CreateUser("alice", false)
239 if err != nil {
240 t.Fatal(err)
241 }
242 u := store.User{ID: uid, Username: "alice"}
243 if _, err := st.CreateUser("bob", false); err != nil {
244 t.Fatal(err)
245 }
246 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
247 t.Fatal(err)
248 }
249 repo, err := st.RepoByPath("alice/app")
250 if err != nil {
251 t.Fatal(err)
252 }
253 if _, err := st.CreateMilestone(repo, "v1", "", ""); err != nil {
254 t.Fatal(err)
255 }
256
257 s := New(config.Default(), st)
258 form := url.Values{
259 "title": {"needs a fix"},
260 "body": {"details"},
261 "milestone": {"v1"},
262 "assignee": {"bob"},
263 }
264 req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
265 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
266 req.SetPathValue("owner", "alice")
267 req.SetPathValue("repo", "app")
268 rr := httptest.NewRecorder()
269 s.issueCreateSubmit(rr, req, u)
270 if rr.Code != http.StatusSeeOther {
271 t.Fatalf("status %d, body %q", rr.Code, rr.Body.String())
272 }
273
274 issue, err := st.IssueByNumber(repo.ID, 1)
275 if err != nil {
276 t.Fatalf("issue not created: %v", err)
277 }
278 if issue.Milestone != "v1" {
279 t.Errorf("milestone = %q, want v1", issue.Milestone)
280 }
281 if len(issue.Assignees) != 1 || issue.Assignees[0] != "bob" {
282 t.Errorf("assignees = %v, want [bob]", issue.Assignees)
283 }
284}
285
286// A bad assignee creates nothing: issue create resolves the assignee
287// before writing the issue, so a typo leaves the repo without a
288// half-created issue (#271).
289func TestIssueCreateSubmitBadAssigneeCreatesNothing(t *testing.T) {
290 st, err := store.Open(":memory:")
291 if err != nil {
292 t.Fatal(err)
293 }
294 defer st.Close()
295 if err := st.MigrateUp(); err != nil {
296 t.Fatal(err)
297 }
298 uid, err := st.CreateUser("alice", false)
299 if err != nil {
300 t.Fatal(err)
301 }
302 u := store.User{ID: uid, Username: "alice"}
303 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
304 t.Fatal(err)
305 }
306 repo, err := st.RepoByPath("alice/app")
307 if err != nil {
308 t.Fatal(err)
309 }
310
311 s := New(config.Default(), st)
312 form := url.Values{
313 "title": {"needs a fix"},
314 "body": {"details"},
315 "assignee": {"nobody-such-user"},
316 }
317 req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
318 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
319 req.SetPathValue("owner", "alice")
320 req.SetPathValue("repo", "app")
321 rr := httptest.NewRecorder()
322 s.issueCreateSubmit(rr, req, u)
323 if rr.Code == http.StatusSeeOther {
324 t.Fatalf("expected a failure status, got redirect")
325 }
326 if !strings.Contains(rr.Body.String(), "nobody-such-user") {
327 t.Errorf("error body %q does not name the bad assignee", rr.Body.String())
328 }
329
330 if _, err := st.IssueByNumber(repo.ID, 1); err == nil {
331 t.Fatal("issue was created despite the bad assignee")
332 }
333}
internal/httpd/mrpage_test.go +32
@@ -59,6 +59,38 @@ func TestMRDiffViewNamesAPrunedHead(t *testing.T) {
59 } 59 }
60} 60}
61 61
62// A heading precedes the comment thread, so a screen-reader user
63// skimming by heading does not fall from the aside's groups straight
64// into the first comment with no landmark (#271).
65func TestMRPageHasDiscussionHeading(t *testing.T) {
66 var sb strings.Builder
67 if err := web.Render(&sb, "mr.html", mrPageData{
68 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
69 }); err != nil {
70 t.Fatalf("render: %v", err)
71 }
72 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
73 t.Error("no Discussion heading")
74 }
75}
76
77// The watch button names all three states it cycles through, including
78// muted, which MR 1 made reachable (#271).
79func TestRepoHeaderWatchButtonNamesMutedState(t *testing.T) {
80 var sb strings.Builder
81 rp := testRepoPage()
82 rp.Viewer = "cmc" // the watch button only renders for a signed-in viewer
83 rp.Watch = "muted"
84 if err := web.Render(&sb, "mr.html", mrPageData{
85 repoPage: rp, MR: testMR("open"), View: "conversation",
86 }); err != nil {
87 t.Fatalf("render: %v", err)
88 }
89 if !strings.Contains(sb.String(), "Muted") {
90 t.Error(`watch button does not render "Muted" for a muted repo`)
91 }
92}
93
62func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { 94func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
63 rows := make([]reviewRow, 0, len(reviews)) 95 rows := make([]reviewRow, 0, len(reviews))
64 for _, r := range reviews { 96 for _, r := range reviews {
internal/httpd/repohead_test.go +1 −1
@@ -39,7 +39,7 @@ func TestRepoHeaderTwoRows(t *testing.T) {
39 } 39 }
40 for _, want := range []string{ 40 for _, want := range []string{
41 `title="Pinned repositories show on your dashboard"`, 41 `title="Pinned repositories show on your dashboard"`,
42 `title="Watching sends every issue, request and build to your inbox"`, 42 `title="Only what involves you. Click to watch everything."`,
43 `title="Bookmarked lists it under Bookmarks"`, 43 `title="Bookmarked lists it under Bookmarks"`,
44 `<p class="repodesc">A CLI-first git forge.`, 44 `<p class="repodesc">A CLI-first git forge.`,
45 } { 45 } {
internal/httpd/web.go +7
@@ -2003,6 +2003,13 @@ func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
2003 if u.ID == 0 { 2003 if u.ID == 0 {
2004 return false 2004 return false
2005 } 2005 }
2006 return s.canWriteRepoAs(u, repo)
2007}
2008
2009// canWriteRepoAs is canWriteRepo for a handler that already has its
2010// viewer as a parameter (behind requireUser) rather than needing to
2011// resolve one from the request's session cookie.
2012func (s *Server) canWriteRepoAs(u store.User, repo store.Repo) bool {
2006 grant, _ := s.st.AccessRole(repo.ID, u.ID) 2013 grant, _ := s.st.AccessRole(repo.ID, u.ID)
2007 return policy.CanWrite(u, repo, grant) 2014 return policy.CanWrite(u, repo, grant)
2008} 2015}
internal/web/templates/build.html +1 −1
@@ -12,7 +12,7 @@
12{{end}} 12{{end}}
13</div> 13</div>
14<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{with .Duration}} · ran {{.}}{{end}}{{end}}{{if .Failed}} · <a href="#failed">Jump to failure</a>{{end}}</p> 14<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{with .Duration}} · ran {{.}}{{end}}{{end}}{{if .Failed}} · <a href="#failed">Jump to failure</a>{{end}}</p>
15{{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p> 15{{if .Live}}<p class="meta">This page updates itself until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p>
16<pre class="code buildlog" tabindex="0">{{.Log}}</pre> 16<pre class="code buildlog" tabindex="0">{{.Log}}</pre>
17{{else if .Steps}}{{$total := len .Build.Steps}}{{range .Steps}} 17{{else if .Steps}}{{$total := len .Build.Steps}}{{range .Steps}}
18<details class="difffold buildstep"{{if .Failed}} id="failed" open{{end}}> 18<details class="difffold buildstep"{{if .Failed}} id="failed" open{{end}}>
internal/web/templates/issue.html +1
@@ -10,6 +10,7 @@
10 10
11<div class="withaside"> 11<div class="withaside">
12<div class="mainside"> 12<div class="mainside">
13<h2>Discussion</h2>
13{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary> 14{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary>
14{{if .Draft.Is "edit"}}{{template "previewblock" .Draft.HTML}}{{end}} 15{{if .Draft.Is "edit"}}{{template "previewblock" .Draft.HTML}}{{end}}
15<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform"> 16<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform">
internal/web/templates/issuenew.html +5 −1
@@ -9,7 +9,11 @@
9<p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p> 9<p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p>
10<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p> 10<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p>
11{{template "formatpicker" .Format}} 11{{template "formatpicker" .Format}}
12<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)" value="{{.Labels}}"></p> 12{{if .CanWrite}}
13<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (optional)" value="{{.Labels}}"></p>
14<p><input type="text" name="milestone" aria-label="Milestone" placeholder="milestone (optional)"></p>
15<p><input type="text" name="assignee" aria-label="Assignee" placeholder="assignees, space-separated (optional)"></p>
16{{end}}
13<p><button type="submit">Open issue</button> {{template "previewbtn"}}</p> 17<p><button type="submit">Open issue</button> {{template "previewbtn"}}</p>
14</form> 18</form>
15{{end}} 19{{end}}
internal/web/templates/layout.html +10 −11
@@ -15,33 +15,32 @@
15 15
16<nav class="rail" aria-label="Site"> 16<nav class="rail" aria-label="Site">
17 <a class="brand" href="/" aria-label="{{.Site}} home">{{template "mark"}}<span class="vh">{{.Site}}</span></a> 17 <a class="brand" href="/" aria-label="{{.Site}} home">{{template "mark"}}<span class="vh">{{.Site}}</span></a>
18 {{$items := railOptItems .}}
18 {{/* A square is .railopt when the phone rail drops it into the More 19 {{/* A square is .railopt when the phone rail drops it into the More
19 menu below. The menu repeats those destinations, so the two lists 20 menu below. Every one of those destinations — strip, menu and
20 are kept in step by hand: add a square there and add it here. */}} 21 the foot's own Log out link — renders off $items, so the strip
22 and the menu can never drift out of step. */}}
21 <ul class="raillist"> 23 <ul class="raillist">
22 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}} 24 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}}
23 <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li> 25 <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
24 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li> 26 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
25 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li> 27 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
26 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}} 28 <li class="railopt">{{template "raillink" (index $items 0)}}</li>{{end}}
27 </ul> 29 </ul>
28 <span class="railgap"></span> 30 <span class="railgap"></span>
29 <ul class="raillist"> 31 <ul class="raillist">
30 {{if .Viewer}}<li class="railopt">{{template "raillink" dict "Href" "/settings" "Icon" "gear" "Name" "Settings" "Current" (eq (str . "Tab") "account")}}</li>{{end}} 32 {{if .Viewer}}<li class="railopt">{{template "raillink" (index $items 1)}}</li>
31 {{if .Admin}}<li class="railopt">{{template "raillink" dict "Href" "/admin" "Icon" "shield" "Name" "Admin" "Current" (eq (str . "Tab") "admin")}}</li>{{end}} 33 {{if (index $items 2).Show}}<li class="railopt">{{template "raillink" (index $items 2)}}</li>{{end}}{{end}}
32 </ul> 34 </ul>
33 <div class="railfoot"> 35 <div class="railfoot">
34 {{if .Viewer}}<details class="railmore"> 36 {{if .Viewer}}<details class="railmore">
35 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary> 37 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
36 <div class="raildrop"> 38 <div class="raildrop">
37 <a href="/new">{{template "icon" "plus"}} New repository</a> 39 {{range $items}}{{if .Show}}<a href="{{.Href}}">{{template "icon" .Icon}} {{.Name}}</a>{{end}}{{end}}
38 <a href="/settings">{{template "icon" "gear"}} Settings</a>
39 {{if .Admin}}<a href="/admin">{{template "icon" "shield"}} Admin</a>{{end}}
40 <a href="/logout">{{template "icon" "signout"}} Log out</a>
41 </div> 40 </div>
42 </details> 41 </details>
43 <a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a> 42 <a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a>
44 <a class="railopt railicon" href="/logout" aria-label="Log out" title="Log out">{{template "icon" "signout"}}<span class="vh">Log out</span></a> 43 {{$logout := index $items 3}}<a class="railopt railicon" href="{{$logout.Href}}" aria-label="{{$logout.Name}}" title="{{$logout.Name}}">{{template "icon" $logout.Icon}}<span class="vh">{{$logout.Name}}</span></a>
45 {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}} 44 {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}}
46 </div> 45 </div>
47</nav> 46</nav>
@@ -61,7 +60,7 @@
61 {{if eq $top "code"}}{{if or (field $ "Desc") (field $ "Topics") $.Repo.Settings.Website}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}{{with $.Repo.Settings.Website}}<a class="site" href="{{.}}" rel="nofollow">{{.}}</a>{{end}}</p>{{end}}{{end}} 60 {{if eq $top "code"}}{{if or (field $ "Desc") (field $ "Topics") $.Repo.Settings.Website}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}{{with $.Repo.Settings.Website}}<a class="site" href="{{.}}" rel="nofollow">{{.}}</a>{{end}}</p>{{end}}{{end}}
62 <span class="grow"></span> 61 <span class="grow"></span>
63 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}" title="Pinned repositories show on your dashboard"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form> 62 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}" title="Pinned repositories show on your dashboard"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
64 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form> 63 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if ne (str $ "Watch") ""}}true{{else}}false{{end}}" title="{{if eq (str $ "Watch") "watching"}}Watching: every issue, request and build. Click to mute.{{else if eq (str $ "Watch") "muted"}}Muted: nothing from this repository. Click to stop muting.{{else}}Only what involves you. Click to watch everything.{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else if eq (str $ "Watch") "muted"}}Muted{{else}}Watch{{end}}</button></form>
65 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}" title="Bookmarked lists it under Bookmarks">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form> 64 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}" title="Bookmarked lists it under Bookmarks">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form>
66 <a class="button btn" href="/{{.OwnerName}}/{{.Name}}/fork">Fork</a>{{end}} 65 <a class="button btn" href="/{{.OwnerName}}/{{.Name}}/fork">Fork</a>{{end}}
67 </div> 66 </div>
internal/web/templates/mr.html +1
@@ -26,6 +26,7 @@
26 26
27{{if eq .View "conversation"}} 27{{if eq .View "conversation"}}
28<div class="prose"> 28<div class="prose">
29<h2>Discussion</h2>
29{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary> 30{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary>
30{{if .Draft.Is "edit"}}{{template "previewblock" .Draft.HTML}}{{end}} 31{{if .Draft.Is "edit"}}{{template "previewblock" .Draft.HTML}}{{end}}
31<form method="post" action="{{$base}}/edit" class="commentform"> 32<form method="post" action="{{$base}}/edit" class="commentform">
internal/web/web.go +60
@@ -67,6 +67,65 @@ var fullVersion = sync.OnceValue(func() string {
67// changes that URL and a browser holding a cached copy cannot miss it. 67// changes that URL and a browser holding a cached copy cannot miss it.
68var StyleVersion string 68var StyleVersion string
69 69
70// railItem is one destination the rail's icon strip and the phone
71// "More" menu both render — from this one list, so a destination added
72// here reaches both instead of the two being hand-kept in step (#271).
73type railItem struct {
74 Href string
75 Icon string
76 Name string
77 Current bool
78 Count int64 // unused by railOptItems; present so "raillink" can read it uniformly
79 Show bool
80}
81
82// railField and railBool read a named field off the page value the
83// layout was given — the same reflection str/field already do for the
84// repo header, duplicated narrowly here rather than exported, since
85// railOptItems is their only other caller.
86func railField(v any, name string) string {
87 rv := reflect.ValueOf(v)
88 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
89 rv = rv.Elem()
90 }
91 if rv.Kind() != reflect.Struct {
92 return ""
93 }
94 f := rv.FieldByName(name)
95 if !f.IsValid() || f.Kind() != reflect.String {
96 return ""
97 }
98 return f.String()
99}
100
101func railBool(v any, name string) bool {
102 rv := reflect.ValueOf(v)
103 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
104 rv = rv.Elem()
105 }
106 if rv.Kind() != reflect.Struct {
107 return false
108 }
109 f := rv.FieldByName(name)
110 return f.IsValid() && f.Kind() == reflect.Bool && f.Bool()
111}
112
113// railOptItems is the rail's "New repository", "Settings", "Admin" and
114// "Log out" destinations, in the order the rail shows them. v is the
115// page value the layout renders (any page struct that embeds
116// basePage), read by field name since the layout has no single common
117// type for every page.
118func railOptItems(v any) []railItem {
119 tab := railField(v, "Tab")
120 admin := railBool(v, "Admin")
121 return []railItem{
122 {Href: "/new", Icon: "plus", Name: "New repository", Show: true},
123 {Href: "/settings", Icon: "gear", Name: "Settings", Current: tab == "account", Show: true},
124 {Href: "/admin", Icon: "shield", Name: "Admin", Current: tab == "admin", Show: admin},
125 {Href: "/logout", Icon: "signout", Name: "Log out", Show: true},
126 }
127}
128
70var funcs = template.FuncMap{ 129var funcs = template.FuncMap{
71 "gitbayVersion": func() string { return version() }, 130 "gitbayVersion": func() string { return version() },
72 "gitbayCommit": func() string { return fullVersion() }, 131 "gitbayCommit": func() string { return fullVersion() },
@@ -122,6 +181,7 @@ var funcs = template.FuncMap{
122 } 181 }
123 return "?" 182 return "?"
124 }, 183 },
184 "railOptItems": railOptItems,
125 // str is field, narrowed to strings: missing or non-string fields 185 // str is field, narrowed to strings: missing or non-string fields
126 // yield "", which comparisons handle without erroring. 186 // yield "", which comparisons handle without erroring.
127 "str": func(v any, name string) string { 187 "str": func(v any, name string) string {
internal/web/web_test.go +39
@@ -242,3 +242,42 @@ func TestMixedTextLinksAreUnderlined(t *testing.T) {
242 } 242 }
243 } 243 }
244} 244}
245
246// The main rail and the phone "More" menu render New repository,
247// Settings, Admin and Log out from one list, so adding a destination in
248// one place reaches both (#271).
249func TestRailOptItemsDriveBothRailAndMoreMenu(t *testing.T) {
250 items := railOptItems(struct {
251 Tab string
252 Admin bool
253 }{Tab: "admin", Admin: true})
254 if len(items) != 4 {
255 t.Fatalf("got %d items, want 4 (New repository, Settings, Admin, Log out)", len(items))
256 }
257 if items[2].Name != "Admin" || !items[2].Show {
258 t.Errorf("Admin item: %+v", items[2])
259 }
260 if !items[2].Current {
261 t.Error("Admin item should be Current when Tab is admin")
262 }
263
264 nonAdmin := railOptItems(struct {
265 Tab string
266 Admin bool
267 }{Tab: "account"})
268 if nonAdmin[2].Show {
269 t.Error("Admin item should not Show for a non-admin viewer")
270 }
271 if !nonAdmin[1].Current {
272 t.Error("Settings item should be Current when Tab is account")
273 }
274
275 // TestRailIconsAreLabelled's regex checks a raillink call site for a
276 // literal "Icon" and "Name" argument; a call built off railOptItems
277 // does not match that pattern, so it is checked here instead.
278 for i, it := range items {
279 if it.Icon == "" || it.Name == "" || it.Href == "" {
280 t.Errorf("item %d missing a field: %+v", i, it)
281 }
282 }
283}