content/static: display NotAtLatest badge

Display a variant of the "latest" badge when the unit is not
in the latest version of the module.

For golang/go#337631

Change-Id: I82aa30711f1f1e162e44fd1db9195c6e05718de1
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/280612
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
diff --git a/content/static/css/stylesheet.css b/content/static/css/stylesheet.css
index 6021a27..550421f 100644
--- a/content/static/css/stylesheet.css
+++ b/content/static/css/stylesheet.css
@@ -496,6 +496,27 @@
 .DetailsHeader-badge--latest a {
   display: none;
 }
+.DetailsHeader-badge--latest span.DetailsHeader-span--notAtLatest {
+  display: none;
+}
+
+.DetailsHeader-badge--notAtLatest {
+  background: var(--blue);
+}
+.DetailsHeader-badge--notAtLatest a {
+  display: none;
+}
+.DetailsHeader-badge--notAtLatest span.DetailsHeader-span--latest {
+  display: none;
+}
+.DetailsHeader-badge--notAtLatest .UnitMetaDetails-icon {
+  z-index: 1;
+}
+.DetailsHeader-badge--notAtLatest .UnitMetaDetails-toggletipBubble {
+  color: black;
+  text-transform: none;
+}
+
 .DetailsHeader-badge--goToLatest {
   background: var(--pink);
 }
diff --git a/content/static/css/unit_meta.css b/content/static/css/unit_meta.css
index 0c0f889e..81420af 100644
--- a/content/static/css/unit_meta.css
+++ b/content/static/css/unit_meta.css
@@ -63,7 +63,7 @@
   cursor: pointer;
   width: 1.625rem;
 }
-.UnitMetaDetails [role='status'] {
+.UnitMetaDetails-toggletip [role='status'] {
   height: 0;
   position: absolute;
   width: 0;
diff --git a/content/static/html/helpers/_unit_header.tmpl b/content/static/html/helpers/_unit_header.tmpl
index 6170fc8..0a22951 100644
--- a/content/static/html/helpers/_unit_header.tmpl
+++ b/content/static/html/helpers/_unit_header.tmpl
@@ -57,7 +57,13 @@
                 data-mpath="{{.Unit.ModulePath}}"
                 data-ppath="{{.Unit.Path}}"
                 data-pagetype="{{.PageType}}">
-              <span>Latest</span>
+              <span class="DetailsHeader-span--latest">Latest</span>
+              {{if (.Experiments.IsActive "not-at-latest")}}
+                <span class="DetailsHeader-span--notAtLatest">
+                    Latest
+                    {{template "unit_meta_details_toggletip" "This package is not in the latest version of its module."}}
+                </span>
+              {{end}}
               <a href="{{.LatestURL}}">Go to latest</a>
             </div>
           </span>
diff --git a/internal/experiment.go b/internal/experiment.go
index 73abee8..476c5f0 100644
--- a/internal/experiment.go
+++ b/internal/experiment.go
@@ -8,6 +8,7 @@
 const (
 	ExperimentGetUnitMetaQuery   = "get-unit-meta-query"
 	ExperimentGoldmark           = "goldmark"
+	ExperimentNotAtLatest        = "not-at-latest"
 	ExperimentReadmeOutline      = "readme-outline"
 	ExperimentUnitSidebarDetails = "unit-sidebar-details"
 )
@@ -17,6 +18,7 @@
 var Experiments = map[string]string{
 	ExperimentGetUnitMetaQuery:   "Enable the new get unit meta query, which reads from the paths table.",
 	ExperimentGoldmark:           "Enable the usage of rendering markdown using goldmark instead of blackfriday.",
+	ExperimentNotAtLatest:        "Enable the display of a 'not at latest' badge.",
 	ExperimentReadmeOutline:      "Enable the readme outline in the side nav.",
 	ExperimentUnitSidebarDetails: "Enable the details section in the right sidebar.",
 }
diff --git a/internal/frontend/server_test.go b/internal/frontend/server_test.go
index 93f8a27..292e07d 100644
--- a/internal/frontend/server_test.go
+++ b/internal/frontend/server_test.go
@@ -271,6 +271,26 @@
 			},
 		},
 	},
+	// A module with a package that is not in the module's latest version. Since
+	// our testModule struct can only describe modules where all packages are at
+	// all versions, we need two of them.
+	{
+		path:            "golang.org/x/tools",
+		redistributable: true,
+		versions:        []string{"v1.1.0"},
+		packages: []testPackage{
+			{name: "blog", suffix: "blog"},
+			{name: "vet", suffix: "cmd/vet"},
+		},
+	},
+	{
+		path:            "golang.org/x/tools",
+		redistributable: true,
+		versions:        []string{"v1.2.0"},
+		packages: []testPackage{
+			{name: "blog", suffix: "blog"},
+		},
+	},
 }
 
 func insertTestModules(ctx context.Context, t *testing.T, mods []testModule) {
@@ -327,6 +347,22 @@
 	}
 )
 
+var notAtLatestPkg = &pagecheck.Page{
+	ModulePath:             "golang.org/x/tools",
+	Suffix:                 "cmd/vet",
+	Title:                  "vet",
+	ModuleURL:              "github.com/golang/tools",
+	Version:                "v1.1.0",
+	FormattedVersion:       "v1.1.0",
+	LicenseType:            "MIT",
+	LicenseFilePath:        "LICENSE",
+	MissingInMinor:         true,
+	IsLatestMajor:          true,
+	UnitURLFormat:          "/golang.org/x/tools/cmd/vet%s",
+	LatestLink:             "/golang.org/x/tools/cmd/vet",
+	LatestMajorVersionLink: "/golang.org/x/tools",
+}
+
 // serverTestCases are the test cases valid for any experiment. For experiments
 // that modify any part of the behaviour covered by the test cases in
 // serverTestCase(), a new test generator should be created and added to
@@ -996,6 +1032,14 @@
 			checkLink(4, "title2", "about:invalid#zGoSafez"),
 		),
 	},
+	{
+		name: "not at latest",
+		// A package which is at its own latest minor version but not at the
+		// latest minor version of its module.
+		urlPath:        "/golang.org/x/tools/cmd/vet",
+		wantStatusCode: http.StatusOK,
+		want:           in("", pagecheck.UnitHeader(notAtLatestPkg, false, true)),
+	},
 }
 
 // TestServer checks the contents of served pages by looking for
@@ -1025,7 +1069,7 @@
 			testCasesFunc: func() []serverTestCase {
 				return append(serverTestCases(), linksTestCases...)
 			},
-			experiments: []string{internal.ExperimentReadmeOutline, internal.ExperimentGoldmark},
+			experiments: []string{internal.ExperimentReadmeOutline, internal.ExperimentGoldmark, internal.ExperimentNotAtLatest},
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
@@ -1218,8 +1262,8 @@
 		t.Fatal(err)
 	}
 	mw := middleware.Chain(
-		middleware.LatestVersions(s.GetLatestInfo),
-		middleware.Experiment(exp))
+		middleware.Experiment(exp),
+		middleware.LatestVersions(s.GetLatestInfo))
 	return s, mw(mux), func() {
 		teardown()
 		postgres.ResetTestDB(testDB, t)
diff --git a/internal/middleware/latestversion.go b/internal/middleware/latestversion.go
index 5960584..6409168 100644
--- a/internal/middleware/latestversion.go
+++ b/internal/middleware/latestversion.go
@@ -13,6 +13,7 @@
 
 	"golang.org/x/mod/module"
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/experiment"
 	"golang.org/x/pkgsite/internal/log"
 )
 
@@ -51,6 +52,8 @@
 				switch {
 				case latest.MinorVersion == "":
 					latestMinorClass += "--unknown"
+				case latest.MinorVersion == version && !latest.UnitExistsAtMinor && experiment.IsActive(r.Context(), internal.ExperimentNotAtLatest):
+					latestMinorClass += "--notAtLatest"
 				case latest.MinorVersion == version:
 					latestMinorClass += "--latest"
 				default:
diff --git a/internal/middleware/latestversion_test.go b/internal/middleware/latestversion_test.go
index 977eb89..6b7a148 100644
--- a/internal/middleware/latestversion_test.go
+++ b/internal/middleware/latestversion_test.go
@@ -13,34 +13,35 @@
 	"testing"
 
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/experiment"
 )
 
 func TestLatestMinorVersion(t *testing.T) {
 	for _, test := range []struct {
 		name   string
-		latest latestFunc
+		latest internal.LatestInfo
 		in     string
 		want   string
 	}{
 		{
 			name:   "package version is not latest",
-			latest: constLatestFunc("v1.2.3", "", ""),
+			latest: internal.LatestInfo{MinorVersion: "v1.2.3"},
 			in: `
                 <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$"
-					 data-version="v1.0.0" data-mpath="p1/p2" data-ppath="p1/p2/p3" data-pagetype="pkg">
+                    data-version="v1.0.0" data-mpath="p1/p2" data-ppath="p1/p2/p3" data-pagetype="pkg">
                     <span>Latest</span>
                     <a href="p1/p2@$$GODISCOVERY_LATESTMINORVERSION$$/p3">Go to latest</a>
                 </div>`,
 			want: `
                 <div class="DetailsHeader-badge DetailsHeader-badge--goToLatest"
-					 data-version="v1.0.0" data-mpath="p1/p2" data-ppath="p1/p2/p3" data-pagetype="pkg">
+                    data-version="v1.0.0" data-mpath="p1/p2" data-ppath="p1/p2/p3" data-pagetype="pkg">
                     <span>Latest</span>
                     <a href="p1/p2@v1.2.3/p3">Go to latest</a>
                 </div>`,
 		},
 		{
 			name:   "package version is latest",
-			latest: constLatestFunc("v1.2.3", "", ""),
+			latest: internal.LatestInfo{MinorVersion: "v1.2.3", UnitExistsAtMinor: true},
 			in: `
                 <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$"
 					 data-version="v1.2.3" data-mpath="p1/p2" data-ppath="p1/p2/p3" data-pagetype="pkg">
@@ -56,7 +57,7 @@
 		},
 		{
 			name:   "package version with build is latest",
-			latest: constLatestFunc("v1.2.3+build", "", ""),
+			latest: internal.LatestInfo{MinorVersion: "v1.2.3+build", UnitExistsAtMinor: true},
 			in: `
                 <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$"
 					 data-version="v1.2.3&#43;build" data-mpath="p1/p2" data-ppath="p1/p2/p3" data-pagetype="pkg">
@@ -72,7 +73,7 @@
 		},
 		{
 			name:   "module version is not latest",
-			latest: constLatestFunc("v1.2.3", "", ""),
+			latest: internal.LatestInfo{MinorVersion: "v1.2.3"},
 			in: `
                 <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$"
 					 data-version="v1.0.0" data-mpath="p1/p2" data-ppath="" data-pagetype="pkg">
@@ -88,56 +89,77 @@
 		},
 		{
 			name:   "module version is latest",
-			latest: constLatestFunc("v1.2.3", "", ""),
+			latest: internal.LatestInfo{MinorVersion: "v1.2.3", UnitExistsAtMinor: true},
 			in: `
-                <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$"
+		        <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$"
 					 data-version="v1.2.3" data-mpath="p1/p2" data-ppath="" data-pagetype="pkg">
-                    <span>Latest</span>
-                    <a href="mod/p1/p2@$$GODISCOVERY_LATESTMINORVERSION$$">Go to latest</a>
-                </div>`,
+		            <span>Latest</span>
+		            <a href="mod/p1/p2@$$GODISCOVERY_LATESTMINORVERSION$$">Go to latest</a>
+		        </div>`,
 			want: `
-                <div class="DetailsHeader-badge DetailsHeader-badge--latest"
+		        <div class="DetailsHeader-badge DetailsHeader-badge--latest"
 					 data-version="v1.2.3" data-mpath="p1/p2" data-ppath="" data-pagetype="pkg">
-                    <span>Latest</span>
-                    <a href="mod/p1/p2@v1.2.3">Go to latest</a>
-                </div>`,
+		            <span>Latest</span>
+		            <a href="mod/p1/p2@v1.2.3">Go to latest</a>
+		        </div>`,
+		},
+		{
+			name:   "package not in module latest",
+			latest: internal.LatestInfo{MinorVersion: "v1.2.3", UnitExistsAtMinor: false},
+			in: `
+		        <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$"
+					 data-version="v1.2.3" data-mpath="p1/p2" data-ppath="" data-pagetype="pkg">
+		            <span>Latest</span>
+		            <a href="mod/p1/p2@$$GODISCOVERY_LATESTMINORVERSION$$">Go to latest</a>
+		        </div>`,
+			want: `
+		        <div class="DetailsHeader-badge DetailsHeader-badge--notAtLatest"
+					 data-version="v1.2.3" data-mpath="p1/p2" data-ppath="" data-pagetype="pkg">
+		            <span>Latest</span>
+		            <a href="mod/p1/p2@v1.2.3">Go to latest</a>
+		        </div>`,
 		},
 		{
 			name:   "latest func returns empty string",
-			latest: constLatestFunc("", "", ""),
+			latest: internal.LatestInfo{},
 			in: `
-                <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$"
+		        <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$"
 					 data-version="v1.2.3" data-mpath="p1/p2" data-ppath="" data-pagetype="pkg">
-                    <span>Latest</span>
-                    <a href="mod/p1/p2@$$GODISCOVERY_LATESTMINORVERSION$$">Go to latest</a>
-                </div>`,
+		            <span>Latest</span>
+		            <a href="mod/p1/p2@$$GODISCOVERY_LATESTMINORVERSION$$">Go to latest</a>
+		        </div>`,
 			want: `
-                <div class="DetailsHeader-badge DetailsHeader-badge--unknown"
+		        <div class="DetailsHeader-badge DetailsHeader-badge--unknown"
 					 data-version="v1.2.3" data-mpath="p1/p2" data-ppath="" data-pagetype="pkg">
-                    <span>Latest</span>
-                    <a href="mod/p1/p2@">Go to latest</a>
-                </div>`,
+		            <span>Latest</span>
+		            <a href="mod/p1/p2@">Go to latest</a>
+		        </div>`,
 		},
 		{
 			name:   "no regexp match",
-			latest: constLatestFunc("v1.2.3", "", ""),
+			latest: internal.LatestInfo{MinorVersion: "v1.2.3"},
 			in: `
-                <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$">
-                    <span>Latest</span>
-                    <a href="mod/p1/p2@$$GODISCOVERY_LATESTMINORVERSION$$">Go to latest</a>
-                </div>`,
+		        <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$">
+		            <span>Latest</span>
+		            <a href="mod/p1/p2@$$GODISCOVERY_LATESTMINORVERSION$$">Go to latest</a>
+		        </div>`,
 			want: `
-                <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$">
-                    <span>Latest</span>
-                    <a href="mod/p1/p2@$$GODISCOVERY_LATESTMINORVERSION$$">Go to latest</a>
-                </div>`,
+		        <div class="DetailsHeader-badge $$GODISCOVERY_LATESTMINORCLASS$$">
+		            <span>Latest</span>
+		            <a href="mod/p1/p2@$$GODISCOVERY_LATESTMINORVERSION$$">Go to latest</a>
+		        </div>`,
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
 			handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				fmt.Fprint(w, test.in)
 			})
-			ts := httptest.NewServer(LatestVersions(test.latest)(handler))
+			lfunc := func(context.Context, string, string) internal.LatestInfo { return test.latest }
+			lv := LatestVersions(lfunc)(handler)
+			addExp := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				lv.ServeHTTP(w, r.WithContext(experiment.NewContext(r.Context(), internal.ExperimentNotAtLatest)))
+			})
+			ts := httptest.NewServer(addExp)
 			defer ts.Close()
 			resp, err := ts.Client().Get(ts.URL)
 			if err != nil {
@@ -155,28 +177,17 @@
 	}
 }
 
-func constLatestFunc(minorVersion, majorModPath, majorPackagePath string) latestFunc {
-	return func(context.Context, string, string) internal.LatestInfo {
-		return internal.LatestInfo{
-			MinorVersion:    minorVersion,
-			MinorModulePath: "",
-			MajorModulePath: majorModPath,
-			MajorUnitPath:   majorPackagePath,
-		}
-	}
-}
-
 func TestLatestMajorVersion(t *testing.T) {
 	for _, test := range []struct {
 		name        string
-		latest      latestFunc
+		latest      internal.LatestInfo
 		modulePaths []string
 		in          string
 		want        string
 	}{
 		{
 			name:   "module path is not at latest",
-			latest: constLatestFunc("", "foo.com/bar/v3", "foo.com/bar/v3"),
+			latest: internal.LatestInfo{MajorModulePath: "foo.com/bar/v3", MajorUnitPath: "foo.com/bar/v3"},
 			modulePaths: []string{
 				"foo.com/bar",
 				"foo.com/bar/v2",
@@ -199,7 +210,7 @@
 		},
 		{
 			name:   "module path is at latest",
-			latest: constLatestFunc("", "foo.com/bar/v3", "foo.com/bar/v3"),
+			latest: internal.LatestInfo{MajorModulePath: "foo.com/bar/v3", MajorUnitPath: "foo.com/bar/v3"},
 			modulePaths: []string{
 				"foo.com/bar",
 				"foo.com/bar/v2",
@@ -222,7 +233,7 @@
 		},
 		{
 			name:   "full path is not at the latest",
-			latest: constLatestFunc("", "foo.com/bar/v3", "foo.com/bar/v3/far"),
+			latest: internal.LatestInfo{MajorModulePath: "foo.com/bar/v3", MajorUnitPath: "foo.com/bar/v3/far"},
 			modulePaths: []string{
 				"foo.com/bar",
 				"foo.com/bar/v2",
@@ -248,7 +259,8 @@
 			handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				fmt.Fprint(w, test.in)
 			})
-			ts := httptest.NewServer(LatestVersions(test.latest)(handler))
+			lfunc := func(context.Context, string, string) internal.LatestInfo { return test.latest }
+			ts := httptest.NewServer(LatestVersions(lfunc)(handler))
 			defer ts.Close()
 			resp, err := ts.Client().Get(ts.URL)
 			if err != nil {
diff --git a/internal/testing/pagecheck/pagecheck.go b/internal/testing/pagecheck/pagecheck.go
index 2225e05..c705e18 100644
--- a/internal/testing/pagecheck/pagecheck.go
+++ b/internal/testing/pagecheck/pagecheck.go
@@ -43,6 +43,9 @@
 	// IsLatestMinor is the latest minor version of this module.
 	IsLatestMinor bool
 
+	// MissingInMinor says that the unit is missing in the latest minor version of this module.
+	MissingInMinor bool
+
 	// IsLatestMajor is the latest major version of this series.
 	IsLatestMajor bool
 
@@ -198,9 +201,12 @@
 // versionBadge checks the latest-version badge on a header.
 func versionBadge(p *Page) htmlcheck.Checker {
 	class := "DetailsHeader-badge"
-	if p.IsLatestMinor {
+	switch {
+	case p.MissingInMinor:
+		class += "--notAtLatest"
+	case p.IsLatestMinor:
 		class += "--latest"
-	} else {
+	default:
 		class += "--goToLatest"
 	}
 	return in(`[data-test-id="UnitHeader-minorVersionBanner"]`,