Commit bf4c721a48
Verified · cmc
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 |
internal/web/templates/layout.html +9 −10
| @@ -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> |
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. |
| 68 | var StyleVersion string | 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 | var funcs = template.FuncMap{ | 129 | var 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). | ||
| 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 | } | ||