web: UX review small fixes !502
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 | 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 | 15 | - Empty states on the web state the fact instead of a CLI command, and |
| 10 | 16 | drop "yet" on a finished item; the merge request list offers a New |
| 11 | 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 | 134 | defer page.Body.Close() |
| 135 | 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 | 138 | // A static render while the build runs returns at once. |
| 139 | 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 | 420 | Template string |
| 421 | 421 | Templates []control.IssueTemplate |
| 422 | 422 | Draft *draft |
| 423 | CanWrite bool | |
| 423 | 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 | 426 | return |
| 426 | 427 | } |
| 427 | 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 | 456 | Template string |
| 456 | 457 | Templates []control.IssueTemplate |
| 457 | 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 | 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 | 475 | var created control.Created |
| 474 | 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 | 490 | code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created) |
| 476 | 491 | if code != protocol.ExitOK { |
| 477 | 492 | http.Error(w, msg, statusForExit(code)) |
| 478 | 493 | return |
| 479 | 494 | } |
| 480 | 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 | 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 | 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). | |
| 323 | func 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 21 | func 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. | |
| 46 | func 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). | |
| 62 | func 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. | |
| 104 | func 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. | |
| 175 | func 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. | |
| 229 | func 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). | |
| 289 | func 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). | |
| 65 | func 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). | |
| 79 | func 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 | ||
| 62 | 94 | func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { |
| 63 | 95 | rows := make([]reviewRow, 0, len(reviews)) |
| 64 | 96 | for _, r := range reviews { |
internal/httpd/repohead_test.go +1 −1
| @@ -39,7 +39,7 @@ func TestRepoHeaderTwoRows(t *testing.T) { | ||
| 39 | 39 | } |
| 40 | 40 | for _, want := range []string{ |
| 41 | 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 | 43 | `title="Bookmarked lists it under Bookmarks"`, |
| 44 | 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 | 2003 | if u.ID == 0 { |
| 2004 | 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. | |
| 2012 | func (s *Server) canWriteRepoAs(u store.User, repo store.Repo) bool { | |
| 2006 | 2013 | grant, _ := s.st.AccessRole(repo.ID, u.ID) |
| 2007 | 2014 | return policy.CanWrite(u, repo, grant) |
| 2008 | 2015 | } |
internal/web/templates/build.html +1 −1
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | {{end}} |
| 13 | 13 | </div> |
| 14 | 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 | 16 | <pre class="code buildlog" tabindex="0">{{.Log}}</pre> |
| 17 | 17 | {{else if .Steps}}{{$total := len .Build.Steps}}{{range .Steps}} |
| 18 | 18 | <details class="difffold buildstep"{{if .Failed}} id="failed" open{{end}}> |
internal/web/templates/issue.html +1
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | <div class="withaside"> |
| 12 | 12 | <div class="mainside"> |
| 13 | <h2>Discussion</h2> | |
| 13 | 14 | {{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary> |
| 14 | 15 | {{if .Draft.Is "edit"}}{{template "previewblock" .Draft.HTML}}{{end}} |
| 15 | 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 | 9 | <p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p> |
| 10 | 10 | <p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p> |
| 11 | 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 | 17 | <p><button type="submit">Open issue</button> {{template "previewbtn"}}</p> |
| 14 | 18 | </form> |
| 15 | 19 | {{end}} |
internal/web/templates/layout.html +10 −11
| @@ -15,33 +15,32 @@ | ||
| 15 | 15 | |
| 16 | 16 | <nav class="rail" aria-label="Site"> |
| 17 | 17 | <a class="brand" href="/" aria-label="{{.Site}} home">{{template "mark"}}<span class="vh">{{.Site}}</span></a> |
| 18 | {{$items := railOptItems .}} | |
| 18 | 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 | are kept in step by hand: add a square there and add it here. */}} | |
| 20 | menu below. Every one of those destinations — strip, menu and | |
| 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 | 23 | <ul class="raillist"> |
| 22 | 24 | {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}} |
| 23 | 25 | <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li> |
| 24 | 26 | <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li> |
| 25 | 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 | 29 | </ul> |
| 28 | 30 | <span class="railgap"></span> |
| 29 | 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}} | |
| 31 | {{if .Admin}}<li class="railopt">{{template "raillink" dict "Href" "/admin" "Icon" "shield" "Name" "Admin" "Current" (eq (str . "Tab") "admin")}}</li>{{end}} | |
| 32 | {{if .Viewer}}<li class="railopt">{{template "raillink" (index $items 1)}}</li> | |
| 33 | {{if (index $items 2).Show}}<li class="railopt">{{template "raillink" (index $items 2)}}</li>{{end}}{{end}} | |
| 32 | 34 | </ul> |
| 33 | 35 | <div class="railfoot"> |
| 34 | 36 | {{if .Viewer}}<details class="railmore"> |
| 35 | 37 | <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary> |
| 36 | 38 | <div class="raildrop"> |
| 37 | <a href="/new">{{template "icon" "plus"}} New repository</a> | |
| 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> | |
| 39 | {{range $items}}{{if .Show}}<a href="{{.Href}}">{{template "icon" .Icon}} {{.Name}}</a>{{end}}{{end}} | |
| 41 | 40 | </div> |
| 42 | 41 | </details> |
| 43 | 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 | 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 | 45 | </div> |
| 47 | 46 | </nav> |
| @@ -61,7 +60,7 @@ | ||
| 61 | 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 | 61 | <span class="grow"></span> |
| 63 | 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 | 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 | 65 | <a class="button btn" href="/{{.OwnerName}}/{{.Name}}/fork">Fork</a>{{end}} |
| 67 | 66 | </div> |
internal/web/templates/mr.html +1
| @@ -26,6 +26,7 @@ | ||
| 26 | 26 | |
| 27 | 27 | {{if eq .View "conversation"}} |
| 28 | 28 | <div class="prose"> |
| 29 | <h2>Discussion</h2> | |
| 29 | 30 | {{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary> |
| 30 | 31 | {{if .Draft.Is "edit"}}{{template "previewblock" .Draft.HTML}}{{end}} |
| 31 | 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 | 67 | // changes that URL and a browser holding a cached copy cannot miss it. |
| 68 | 68 | var 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). | |
| 73 | type 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. | |
| 86 | func 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 | ||
| 101 | func 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. | |
| 118 | func 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 | ||
| 70 | 129 | var funcs = template.FuncMap{ |
| 71 | 130 | "gitbayVersion": func() string { return version() }, |
| 72 | 131 | "gitbayCommit": func() string { return fullVersion() }, |
| @@ -122,6 +181,7 @@ var funcs = template.FuncMap{ | ||
| 122 | 181 | } |
| 123 | 182 | return "?" |
| 124 | 183 | }, |
| 184 | "railOptItems": railOptItems, | |
| 125 | 185 | // str is field, narrowed to strings: missing or non-string fields |
| 126 | 186 | // yield "", which comparisons handle without erroring. |
| 127 | 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). | |
| 249 | func 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 | } | |