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 | 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 |
internal/web/templates/layout.html +9 −10
| @@ -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> |
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 | } | |