Commit 09b21346c2
Verified · cmc
e2e/dashboard_test.go +13 −14
| @@ -93,8 +93,8 @@ func TestDashboard(t *testing.T) { | ||
| 93 | 93 | t.Fatalf("dashboard: %d", status) |
| 94 | 94 | } |
| 95 | 95 | for _, want := range []string{ |
| 96 | `aria-label="Pinned repositories"`, `</span>app</a>`, // the dashboard lists the pinned repo | |
| 97 | "Waiting on your review", "Assigned to you", "Recent activity", | |
| 96 | `aria-label="Pinned repositories"`, `class="pins"`, `</span>app</a>`, // the dashboard lists the pinned repo | |
| 97 | "Waiting on your review", "assigned to you", "Recent activity", | |
| 98 | 98 | "from bob", "alice/app!1", "todo one", "alice/app#1", |
| 99 | 99 | `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, |
| 100 | 100 | "Yours anywhere, and every one in a repository you can write to.", |
| @@ -104,17 +104,16 @@ func TestDashboard(t *testing.T) { | ||
| 104 | 104 | } |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | // D01: alice has nothing assigned to her, so that section condenses to | |
| 108 | // an empty heading, and populated sections (she has a review waiting) | |
| 109 | // sort before it. | |
| 110 | emptyHeading := `<h2 class="empty">Assigned to you <span class="count">0</span></h2>` | |
| 111 | if !strings.Contains(body, emptyHeading) { | |
| 112 | t.Fatalf("dashboard missing condensed empty heading %q:\n%s", emptyHeading, body) | |
| 113 | } | |
| 114 | reviewIdx := strings.Index(body, "Waiting on your review") | |
| 115 | assignedIdx := strings.Index(body, emptyHeading) | |
| 116 | if reviewIdx < 0 || assignedIdx < 0 || reviewIdx > assignedIdx { | |
| 117 | t.Fatalf("populated heading should come before the empty one: review=%d assigned=%d", reviewIdx, assignedIdx) | |
| 107 | // The empty queue is a tile with a zero; a populated one is a tile | |
| 108 | // that links to its rows, which render in the middle column. | |
| 109 | if !strings.Contains(body, `<div class="tile"><b>0</b><span>assigned to you</span></div>`) { | |
| 110 | t.Fatalf("dashboard missing the zero tile for assigned:\n%s", body) | |
| 111 | } | |
| 112 | if !strings.Contains(body, `<a class="tile wants" href="#reviews"><b>1</b><span>waiting on your review</span></a>`) { | |
| 113 | t.Fatalf("dashboard missing the review tile:\n%s", body) | |
| 114 | } | |
| 115 | if !strings.Contains(body, `<h2 id="reviews">Waiting on your review`) || strings.Contains(body, `<h2 class="empty">`) { | |
| 116 | t.Fatalf("queues do not render as tiles plus rows:\n%s", body) | |
| 118 | 117 | } |
| 119 | 118 | |
| 120 | 119 | // The diff has its own view rather than a fold at the foot of the |
| @@ -352,7 +351,7 @@ func TestDashboardQueues(t *testing.T) { | ||
| 352 | 351 | t.Fatalf("review: %s", errOut) |
| 353 | 352 | } |
| 354 | 353 | _, after := browserGet(t, inst.login(t, aliceKey), inst.base()+"/") |
| 355 | if !strings.Contains(after, `<h2 class="empty">Waiting on your review <span class="count">0</span></h2>`) { | |
| 354 | if !strings.Contains(after, `<div class="tile"><b>0</b><span>waiting on your review</span></div>`) { | |
| 356 | 355 | t.Fatalf("reviewed MR still waiting:\n%s", after) |
| 357 | 356 | } |
| 358 | 357 | } |
internal/httpd/dashpins.go added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "gitbay.org/gitbay/internal/policy" | |
| 5 | "gitbay.org/gitbay/internal/store" | |
| 6 | ) | |
| 7 | ||
| 8 | // pinnedRow is one pinned repository on the dashboard with the counts | |
| 9 | // that say whether it wants attention: open issues, open merge requests | |
| 10 | // and the newest build's status ("" when it has none). | |
| 11 | type pinnedRow struct { | |
| 12 | Owner string | |
| 13 | Name string | |
| 14 | Issues int | |
| 15 | MRs int | |
| 16 | Build string | |
| 17 | } | |
| 18 | ||
| 19 | // pinnedRows reads the viewer's pinned repositories the way railFor does, | |
| 20 | // then adds the counts. Three reads per pinned repository, on the | |
| 21 | // dashboard only. | |
| 22 | func (s *Server) pinnedRows(viewer store.User) []pinnedRow { | |
| 23 | pinned, _ := s.st.PinnedRepos(viewer.ID) | |
| 24 | var rows []pinnedRow | |
| 25 | for _, rp := range pinned { | |
| 26 | grant, _ := s.st.AccessRole(rp.ID, viewer.ID) | |
| 27 | if !policy.CanRead(viewer, rp, grant) { | |
| 28 | continue | |
| 29 | } | |
| 30 | row := pinnedRow{Owner: rp.OwnerName, Name: rp.Name} | |
| 31 | row.Issues, row.MRs = s.st.OpenCounts(rp.ID) | |
| 32 | if builds, err := s.st.ListBuilds(rp.ID, store.BuildFilter{}, 1); err == nil && len(builds) > 0 { | |
| 33 | row.Build = builds[0].Status | |
| 34 | } | |
| 35 | rows = append(rows, row) | |
| 36 | } | |
| 37 | return rows | |
| 38 | } | |
internal/httpd/dashpins_test.go added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/store" | |
| 8 | "gitbay.org/gitbay/internal/web" | |
| 9 | ) | |
| 10 | ||
| 11 | // The dashboard is three columns: pinned repositories with counts, the | |
| 12 | // tile strip and queue rows, the activity feed. Tiles carry every queue's | |
| 13 | // count; only a non-empty queue lists rows (desktop layout spec). | |
| 14 | func TestDashboardTilesAndPins(t *testing.T) { | |
| 15 | var sb strings.Builder | |
| 16 | var base basePage | |
| 17 | base.Viewer = "alice" | |
| 18 | err := web.Render(&sb, "dashboard.html", struct { | |
| 19 | basePage | |
| 20 | Tab string | |
| 21 | Pins []pinnedRow | |
| 22 | Reviews []store.DashboardItem | |
| 23 | Assigned []store.DashboardItem | |
| 24 | MRs []store.DashboardItem | |
| 25 | Issues []store.DashboardItem | |
| 26 | Feed []feedLine | |
| 27 | }{base, "dashboard", []pinnedRow{{Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}}, nil, nil, nil, | |
| 28 | []store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil}) | |
| 29 | if err != nil { | |
| 30 | t.Fatal(err) | |
| 31 | } | |
| 32 | out := sb.String() | |
| 33 | for _, want := range []string{ | |
| 34 | `<div class="dashgrid">`, | |
| 35 | `<aside class="dashpins" aria-label="Pinned repositories">`, | |
| 36 | `<span class="owner">krz/</span>gitbay</a>`, | |
| 37 | `<b class="wants">3</b>`, `<span class="dot ok"></span>`, | |
| 38 | `<a class="tile wants" href="#issues"><b>1</b><span>open issues</span></a>`, | |
| 39 | `<div class="tile"><b>0</b><span>waiting on your review</span></div>`, | |
| 40 | `<div class="tile"><b>0</b><span>assigned to you</span></div>`, | |
| 41 | `<div class="tile"><b>0</b><span>open merge requests</span></div>`, | |
| 42 | `<h2 id="issues">Open issues <span class="count">1</span></h2>`, | |
| 43 | `<aside class="feedcol" aria-label="Recent activity">`, | |
| 44 | } { | |
| 45 | if !strings.Contains(out, want) { | |
| 46 | t.Errorf("dashboard lacks %q", want) | |
| 47 | } | |
| 48 | } | |
| 49 | if strings.Contains(out, `<h2 class="empty">`) { | |
| 50 | t.Error("an empty queue still renders as a heading; the tile carries it") | |
| 51 | } | |
| 52 | } | |
internal/httpd/web.go +2 −1
| @@ -191,12 +191,13 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store. | ||
| 191 | 191 | s.render(w, "dashboard.html", struct { |
| 192 | 192 | basePage |
| 193 | 193 | Tab string |
| 194 | Pins []pinnedRow | |
| 194 | 195 | Reviews []store.DashboardItem |
| 195 | 196 | Assigned []store.DashboardItem |
| 196 | 197 | MRs []store.DashboardItem |
| 197 | 198 | Issues []store.DashboardItem |
| 198 | 199 | Feed []feedLine |
| 199 | }{s.baseFor(viewer), "dashboard", reviews, assigned, mrs, issues, feedLines(events)}) | |
| 200 | }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, feedLines(events)}) | |
| 200 | 201 | } |
| 201 | 202 | |
| 202 | 203 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
internal/web/static/style.css +54 −8
| @@ -1041,9 +1041,47 @@ pre.message { | ||
| 1041 | 1041 | .blobimage { text-align: center; } |
| 1042 | 1042 | .blobimage img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--r-card); background: var(--surface); } |
| 1043 | 1043 | |
| 1044 | /* pinned repositories, on the dashboard now that the rail is icons only */ | |
| 1045 | .pinned { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-bottom: var(--sp-4); } | |
| 1046 | .pinned .owner { color: var(--muted); } | |
| 1044 | /* ---- dashboard: pinned column, tiles and queues, feed ---- */ | |
| 1045 | .dashgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; } | |
| 1046 | .dashgrid h1 { margin-top: 0; } | |
| 1047 | .dashgrid > aside { position: sticky; top: var(--sp-5); } | |
| 1048 | .dashgrid .dashmain h2 { margin-top: var(--sp-5); } | |
| 1049 | .dashgrid .dashmain .tiles + h2 { margin-top: 0; } | |
| 1050 | .colhead { | |
| 1051 | margin: 0 0 var(--sp-2); | |
| 1052 | font-size: var(--fs-0); | |
| 1053 | font-weight: 500; | |
| 1054 | letter-spacing: 0.08em; | |
| 1055 | text-transform: uppercase; | |
| 1056 | color: var(--muted); | |
| 1057 | } | |
| 1058 | .pins { list-style: none; margin: 0; padding: 0; font-size: var(--fs-2); } | |
| 1059 | .pins li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 0; border-bottom: 1px solid var(--faint); } | |
| 1060 | .pins li:last-child { border-bottom: 0; } | |
| 1061 | .pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1062 | .pins a:hover { color: var(--link); } | |
| 1063 | .pins .owner { color: var(--muted); } | |
| 1064 | .pins .n { margin-left: auto; flex: none; display: flex; gap: var(--sp-2); font-size: var(--fs-1); color: var(--muted); font-variant-numeric: tabular-nums; } | |
| 1065 | .pins .n b { font-weight: 600; color: var(--fg); } | |
| 1066 | .pins .n b.wants { color: var(--warn); } | |
| 1067 | .pins .dot { margin: 0; } | |
| 1068 | /* count tiles: the queues' sizes in one strip; a non-zero count is orange, | |
| 1069 | what wants you */ | |
| 1070 | .tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); margin: var(--sp-4) 0 var(--sp-5); } | |
| 1071 | .tile { | |
| 1072 | display: block; | |
| 1073 | background: var(--surface); | |
| 1074 | border: 1px solid var(--line); | |
| 1075 | border-radius: var(--r-card); | |
| 1076 | padding: var(--sp-3) var(--sp-4); | |
| 1077 | color: var(--fg); | |
| 1078 | } | |
| 1079 | a.tile:hover { background: var(--hover); text-decoration: none; } | |
| 1080 | .tile b { display: block; font-size: var(--fs-5); font-weight: 600; line-height: 1.2; } | |
| 1081 | .tile.wants b { color: var(--warn); } | |
| 1082 | .tile span { font-size: var(--fs-1); color: var(--muted); } | |
| 1083 | .feedcol .feedline { font-size: var(--fs-2); margin-bottom: var(--sp-2); } | |
| 1084 | .feedcol .feedline .none { font-size: var(--fs-1); } | |
| 1047 | 1085 | |
| 1048 | 1086 | /* ---- two-column: conversation and aside ---- */ |
| 1049 | 1087 | .withaside { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; } |
| @@ -1075,11 +1113,6 @@ form.actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var | ||
| 1075 | 1113 | form.actions button { flex: 1 1 auto; } |
| 1076 | 1114 | form.actions select, form.actions textarea { width: 100%; } |
| 1077 | 1115 | .feedline { margin-bottom: var(--sp-2); } |
| 1078 | /* the dashboard's activity feed is a section after the queues, one column */ | |
| 1079 | .feed { margin-top: var(--sp-6); max-width: 78ch; } | |
| 1080 | .feed h2 { margin-bottom: var(--sp-3); } | |
| 1081 | .feed .feedline { font-size: var(--fs-2); } | |
| 1082 | .feed .none { color: var(--muted); font-size: var(--fs-1); } | |
| 1083 | 1116 | |
| 1084 | 1117 | /* ---- comments and threads ---- */ |
| 1085 | 1118 | article.comment { |
| @@ -1524,6 +1557,11 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1524 | 1557 | .review { font-size: var(--fs-2); } |
| 1525 | 1558 | |
| 1526 | 1559 | /* ---- breakpoints ---- */ |
| 1560 | @media (max-width: 80rem) { | |
| 1561 | .dashgrid { grid-template-columns: minmax(0, 1fr) 20rem; } | |
| 1562 | .dashgrid > .dashpins { grid-column: 1 / -1; position: static; } | |
| 1563 | } | |
| 1564 | ||
| 1527 | 1565 | @media (max-width: 62rem) { |
| 1528 | 1566 | /* one column, the aside after the thread: a phone shows the |
| 1529 | 1567 | description first, checks and reviewers after it (#232) */ |
| @@ -1532,6 +1570,14 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1532 | 1570 | /* rows go back to two lines where one does not fit */ |
| 1533 | 1571 | ul.issuelist.rows .issuemain, ul.repolist.rows li, ul.loglist.rows .commitmain { display: block; } |
| 1534 | 1572 | ul.issuelist.rows .title, ul.repolist.rows .desc { white-space: normal; } |
| 1573 | ||
| 1574 | .dashgrid { grid-template-columns: 1fr; } | |
| 1575 | .dashgrid > aside { position: static; } | |
| 1576 | /* the pinned column goes back to a chip row on a phone */ | |
| 1577 | .pins { display: flex; flex-wrap: wrap; gap: var(--sp-2); } | |
| 1578 | .pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); } | |
| 1579 | .pins .n, .dashpins .meta { display: none; } | |
| 1580 | .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 1535 | 1581 | } |
| 1536 | 1582 | |
| 1537 | 1583 | @media (max-width: 52rem) { |
internal/web/templates/dashboard.html +30 −14
| @@ -12,27 +12,43 @@ | ||
| 12 | 12 | </ul> |
| 13 | 13 | {{end}} |
| 14 | 14 | {{define "queue"}} |
| 15 | <h2>{{$.Title}} <span class="count">{{len $.Items}}</span></h2> | |
| 15 | <h2 id="{{$.ID}}">{{$.Title}} <span class="count">{{len $.Items}}</span></h2> | |
| 16 | 16 | {{if $.Hint}}<p class="hint">{{$.Hint}}</p>{{end}} |
| 17 | 17 | {{template "itemlist" dict "Items" $.Items "Kind" $.Kind "Empty" $.Empty}} |
| 18 | 18 | {{end}} |
| 19 | {{define "tile"}}{{if $.N}}<a class="tile wants" href="#{{$.ID}}"><b>{{$.N}}</b><span>{{$.Label}}</span></a>{{else}}<div class="tile"><b>0</b><span>{{$.Label}}</span></div>{{end}}{{end}} | |
| 19 | 20 | {{define "content"}} |
| 20 | <h1>Dashboard</h1> | |
| 21 | {{with .Rail.Pinned}}<p class="pinned" aria-label="Pinned repositories">{{range .}}<a class="chip" href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a> {{end}}</p>{{end}} | |
| 21 | <div class="dashgrid"> | |
| 22 | 22 | |
| 23 | {{if .Reviews}}{{template "queue" dict "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}} | |
| 24 | {{if .Assigned}}{{template "queue" dict "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}} | |
| 25 | {{if .MRs}}{{template "queue" dict "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}} | |
| 26 | {{if .Issues}}{{template "queue" dict "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}} | |
| 23 | <aside class="dashpins" aria-label="Pinned repositories"> | |
| 24 | <h2 class="colhead">Pinned</h2> | |
| 25 | {{if .Pins}}<ul class="pins"> | |
| 26 | {{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}}">{{if .Issues}}<b class="wants">{{.Issues}}</b>{{else}}<b>0</b>{{end}} {{if .MRs}}<b class="wants">{{.MRs}}</b>{{else}}<b>0</b>{{end}} <span class="dot{{if eq .Build "success"}} ok{{else if eq .Build "failure"}} bad{{else if .Build}} pend{{end}}"></span></span></li> | |
| 27 | {{end}}</ul> | |
| 28 | <p class="meta">issues · merge requests · last build</p> | |
| 29 | {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}} | |
| 30 | </aside> | |
| 27 | 31 | |
| 28 | {{if not .Reviews}}<h2 class="empty">Waiting on your review <span class="count">0</span></h2>{{end}} | |
| 29 | {{if not .Assigned}}<h2 class="empty">Assigned to you <span class="count">0</span></h2>{{end}} | |
| 30 | {{if not .MRs}}<h2 class="empty">Open merge requests <span class="count">0</span></h2>{{end}} | |
| 31 | {{if not .Issues}}<h2 class="empty">Open issues <span class="count">0</span></h2>{{end}} | |
| 32 | <section class="dashmain"> | |
| 33 | <h1>Dashboard</h1> | |
| 34 | <div class="tiles"> | |
| 35 | {{template "tile" dict "N" (len .Issues) "ID" "issues" "Label" "open issues"}} | |
| 36 | {{template "tile" dict "N" (len .Reviews) "ID" "reviews" "Label" "waiting on your review"}} | |
| 37 | {{template "tile" dict "N" (len .Assigned) "ID" "assigned" "Label" "assigned to you"}} | |
| 38 | {{template "tile" dict "N" (len .MRs) "ID" "mrs" "Label" "open merge requests"}} | |
| 39 | </div> | |
| 40 | {{if .Reviews}}{{template "queue" dict "ID" "reviews" "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}} | |
| 41 | {{if .Assigned}}{{template "queue" dict "ID" "assigned" "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}} | |
| 42 | {{if .MRs}}{{template "queue" dict "ID" "mrs" "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}} | |
| 43 | {{if .Issues}}{{template "queue" dict "ID" "issues" "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}} | |
| 44 | {{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open anywhere you can write to.</p>{{end}} | |
| 45 | </section> | |
| 32 | 46 | |
| 33 | <section class="feed"> | |
| 34 | <h2>Recent activity</h2> | |
| 47 | <aside class="feedcol" aria-label="Recent activity"> | |
| 48 | <h2 class="colhead">Recent activity</h2> | |
| 35 | 49 | {{range .Feed}}<p class="feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p> |
| 36 | 50 | {{else}}<p class="none">No activity yet</p>{{end}} |
| 37 | </section> | |
| 51 | </aside> | |
| 52 | ||
| 53 | </div> | |
| 38 | 54 | {{end}} |