internal/frontend: display module importer count

Display the count of importing modules in the header section of the main
package page, along with the package count.

Add a help tooltip to explain why we're doing this.

Cq-Include-Trybots: luci.golang.try:x_pkgsite-gotip-linux-amd64_docker-screentest

For golang/go#80242.

Change-Id: If184d698aa8f2cee26921490dddad40d6a6a6964
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/850925
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/internal/frontend/main.go b/internal/frontend/main.go
index a08ede8..142ed2c 100644
--- a/internal/frontend/main.go
+++ b/internal/frontend/main.go
@@ -59,10 +59,15 @@
 	// See https://golang.org/issue/42968.
 	ModuleReadmeLinks []link
 
-	// ImportedByCount is the number of packages that import this path.
-	// When the count is > limit it will read as 'limit+'. This field
-	// is not supported when using a datasource proxy.
-	ImportedByCount string
+	// ImportedByPackages describes the number of packages that import this path,
+	// for example "1,234 packages". It is "? packages" if the count hasn't been
+	// computed. This field is not supported when using a datasource proxy.
+	ImportedByPackages string
+
+	// ImportedByModules describes the number of modules that import this path,
+	// for example "1,234 modules". It is "? modules" if the count hasn't been
+	// computed. This field is not supported when using a datasource proxy.
+	ImportedByModules string
 
 	DocBody       safehtml.HTML
 	DocOutline    safehtml.HTML
@@ -202,35 +207,49 @@
 	isStableVersion := semver.Major(um.Version) != "v0" && versionType == version.TypeRelease
 	pr := message.NewPrinter(language.English)
 	return &MainDetails{
-		ExpandReadme:      expandReadme,
-		Directories:       unitDirectories(append(subdirectories, nestedModules...)),
-		Licenses:          transformLicenseMetadata(unit.Licenses),
-		CommitTime:        absoluteTime(um.CommitTime),
-		Readme:            readme.HTML,
-		ReadmeOutline:     readme.Outline,
-		ReadmeLinks:       readme.Links,
-		DocLinks:          docLinks,
-		ModuleReadmeLinks: modLinks,
-		DocOutline:        docParts.Outline,
-		DocBody:           docParts.Body,
-		DocSynopsis:       synopsis,
-		GOOS:              goos,
-		GOARCH:            goarch,
-		BuildContexts:     buildContexts,
-		SourceFiles:       files,
-		RepositoryURL:     um.SourceInfo.RepoURL(),
-		SourceURL:         um.SourceInfo.DirectoryURL(internal.Suffix(um.Path, um.ModulePath)),
-		MobileOutline:     docParts.MobileOutline,
-		NumImports:        pr.Sprint(unit.NumImports),
-		ImportedByCount:   pr.Sprint(unit.NumImportedBy),
-		IsPackage:         unit.IsPackage(),
-		ModFileURL:        um.SourceInfo.ModuleURL() + "/go.mod",
-		IsTaggedVersion:   isTaggedVersion,
-		IsStableVersion:   isStableVersion,
-		IsRedistributable: unit.IsRedistributable,
+		ExpandReadme:       expandReadme,
+		Directories:        unitDirectories(append(subdirectories, nestedModules...)),
+		Licenses:           transformLicenseMetadata(unit.Licenses),
+		CommitTime:         absoluteTime(um.CommitTime),
+		Readme:             readme.HTML,
+		ReadmeOutline:      readme.Outline,
+		ReadmeLinks:        readme.Links,
+		DocLinks:           docLinks,
+		ModuleReadmeLinks:  modLinks,
+		DocOutline:         docParts.Outline,
+		DocBody:            docParts.Body,
+		DocSynopsis:        synopsis,
+		GOOS:               goos,
+		GOARCH:             goarch,
+		BuildContexts:      buildContexts,
+		SourceFiles:        files,
+		RepositoryURL:      um.SourceInfo.RepoURL(),
+		SourceURL:          um.SourceInfo.DirectoryURL(internal.Suffix(um.Path, um.ModulePath)),
+		MobileOutline:      docParts.MobileOutline,
+		NumImports:         pr.Sprint(unit.NumImports),
+		ImportedByPackages: importedByDisplay(pr, "package", unit.NumImportedBy),
+		ImportedByModules:  importedByDisplay(pr, "module", unit.NumImportedByModules),
+		IsPackage:          unit.IsPackage(),
+		ModFileURL:         um.SourceInfo.ModuleURL() + "/go.mod",
+		IsTaggedVersion:    isTaggedVersion,
+		IsStableVersion:    isStableVersion,
+		IsRedistributable:  unit.IsRedistributable,
 	}, nil
 }
 
+// importedByDisplay returns the text describing n importing modules,
+// or "? WORD" if n is negative (meaning the count hasn't been computed).
+func importedByDisplay(pr *message.Printer, word string, n int) string {
+	switch {
+	case n < 0:
+		return pr.Sprintf("? %ss", word)
+	case n == 1:
+		return pr.Sprintf("1 %s", word)
+	default:
+		return pr.Sprintf("%d %ss", n, word)
+	}
+}
+
 func cleanDocumentation(docs []*internal.Documentation) []*internal.Documentation {
 	// If there is more than one row but the first is all/all, ignore the others.
 	// Should never happen;  temporary fix until the DB is cleaned up.
diff --git a/internal/frontend/main_test.go b/internal/frontend/main_test.go
index 129fe92..2c6e780 100644
--- a/internal/frontend/main_test.go
+++ b/internal/frontend/main_test.go
@@ -11,6 +11,8 @@
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/testing/fakedatasource"
 	"golang.org/x/pkgsite/internal/testing/sample"
+	"golang.org/x/text/language"
+	"golang.org/x/text/message"
 )
 
 func TestGetImportedByCount(t *testing.T) {
@@ -59,3 +61,21 @@
 		})
 	}
 }
+
+func TestImportedByDisplay(t *testing.T) {
+	pr := message.NewPrinter(language.English)
+	for _, test := range []struct {
+		n    int
+		want string
+	}{
+		{-1, "? modules"},
+		{0, "0 modules"},
+		{1, "1 module"},
+		{2, "2 modules"},
+		{15833, "15,833 modules"},
+	} {
+		if got := importedByDisplay(pr, "module", test.n); got != test.want {
+			t.Errorf("importedByDisplay(%d) = %q, want %q", test.n, got, test.want)
+		}
+	}
+}
diff --git a/internal/postgres/unit.go b/internal/postgres/unit.go
index 4cc9884..dcfa421 100644
--- a/internal/postgres/unit.go
+++ b/internal/postgres/unit.go
@@ -549,6 +549,8 @@
 		docSelect = "d.source,"
 	}
 
+	// TODO(jba): the imported-by counts should return -1 instead of
+	// 0 if the unit is not in search_documents.
 	query := fmt.Sprintf(`
         SELECT
 			r.file_path,
@@ -567,7 +569,15 @@
 				-- Only package_path_id is needed b/c it is the PK for
 				-- search_documents.
 				WHERE package_path_id = $1
-				), 0) AS num_imported_by
+				), 0) AS num_imported_by,
+			COALESCE((
+				SELECT CASE
+					WHEN imported_by_module_count_updated_at IS NULL THEN -1
+					ELSE imported_by_module_count
+				END
+				FROM search_documents
+				WHERE package_path_id = $1
+				), 0) AS num_imported_by_modules
 		FROM units u
 		LEFT JOIN readmes r
 		ON r.unit_id = u.id
@@ -594,6 +604,7 @@
 		&doc.Source,
 		&u.NumImports,
 		&u.NumImportedBy,
+		&u.NumImportedByModules,
 	)
 	switch err {
 	case sql.ErrNoRows:
diff --git a/internal/testing/pagecheck/pagecheck.go b/internal/testing/pagecheck/pagecheck.go
index 08dee43..4f3a87e 100644
--- a/internal/testing/pagecheck/pagecheck.go
+++ b/internal/testing/pagecheck/pagecheck.go
@@ -101,7 +101,7 @@
 		in(`[data-test-id="UnitHeader-importedby"]`,
 			in("a",
 				href(urlPath+"?tab=importedby"),
-				text(`Imported by: [0-9]+\+?`))))
+				text(`Imported by: [0-9,]+ modules, [0-9,]+ packages`))))
 	if !isPackage {
 		importsDetails = nil
 	}
diff --git a/internal/unit.go b/internal/unit.go
index 448ec9d..15e6d83 100644
--- a/internal/unit.go
+++ b/internal/unit.go
@@ -49,7 +49,10 @@
 	LicenseContents []*licenses.License
 	NumImports      int
 	NumImportedBy   int
-	Licenses        []*licenses.Metadata
+	// NumImportedByModules is the number of modules that import this unit,
+	// or -1 if it has not been computed.
+	NumImportedByModules int
+	Licenses             []*licenses.Metadata
 	// Note: IsRedistributable applies to the unit;
 	// UnitMeta.ModuleInfo.IsRedistributable applies to the module.
 	IsRedistributable bool
diff --git a/internal/worker/fetch_test.go b/internal/worker/fetch_test.go
index cd26f1f..2427496 100644
--- a/internal/worker/fetch_test.go
+++ b/internal/worker/fetch_test.go
@@ -76,6 +76,7 @@
 			Filepath: "bar/README",
 			Contents: "Another README file for testing.",
 		},
+		NumImportedByModules: -1,
 	}
 
 	testCases := []struct {
@@ -130,6 +131,7 @@
 					GOOS:     "linux",
 					GOARCH:   "amd64",
 				}},
+				NumImportedByModules: -1,
 			},
 			wantDoc: []string{"Baz returns the string &#34;baz&#34;."},
 		}, {
@@ -154,8 +156,9 @@
 					{Types: []string{"0BSD"}, FilePath: "LICENSE"},
 					{Types: []string{"UNKNOWN"}, FilePath: "unk/LICENSE.md"},
 				},
-				IsRedistributable: false,
-				NumImports:        2,
+				IsRedistributable:    false,
+				NumImports:           2,
+				NumImportedByModules: -1,
 			},
 		}, {
 			modulePath: buildConstraintsModulePath,
@@ -184,6 +187,7 @@
 					GOOS:     "linux",
 					GOARCH:   "amd64",
 				}},
+				NumImportedByModules: -1,
 			},
 			wantDoc: []string{"const CacheLinePadSize = 3"},
 			dontWantDoc: []string{
diff --git a/internal/worker/refetch_test.go b/internal/worker/refetch_test.go
index 36a51af..d51b27d 100644
--- a/internal/worker/refetch_test.go
+++ b/internal/worker/refetch_test.go
@@ -118,7 +118,8 @@
 				Licenses:          sample.LicenseMetadata(),
 			},
 		},
-		SymbolHistory: map[string]string{"Bar": "v1.0.0"},
+		SymbolHistory:        map[string]string{"Bar": "v1.0.0"},
+		NumImportedByModules: -1,
 	}
 	got, err := testDB.GetUnitMeta(ctx, pkgBar, internal.UnknownModulePath, version)
 	if err != nil {
diff --git a/static/frontend/unit/_header.tmpl b/static/frontend/unit/_header.tmpl
index 641678b..dbabc45 100644
--- a/static/frontend/unit/_header.tmpl
+++ b/static/frontend/unit/_header.tmpl
@@ -182,10 +182,22 @@
 
 {{define "detail-item-importedby"}}
   <span class="go-Main-headerDetailItem" data-test-id="UnitHeader-importedby">
-    <a href="{{$.URLPath}}?tab=importedby" aria-label="Imported By: {{.Details.ImportedByCount}}"
+    <a href="{{$.URLPath}}?tab=importedby" aria-label="Imported By {{.Details.ImportedByModules}}, {{.Details.ImportedByPackages}}"
         data-gtmc="header link" aria-describedby="importedby-description">
-       <span class="go-textSubtle">Imported by: </span>{{.Details.ImportedByCount}}
+       <span class="go-textSubtle">Imported by: </span>{{.Details.ImportedByModules}}, {{.Details.ImportedByPackages}}
     </a>
+    <details class="go-Tooltip js-tooltip" data-gtmc="tooltip">
+      <summary class="go-textSubtle">
+        <img class="go-Icon" role="button" tabindex="0" src="/static/shared/icon/help_gm_grey_24dp.svg" alt="" aria-label="Toggle imported-by help tooltip" height="24" width="24">
+      </summary>
+      <p aria-live="polite" role="tooltip">
+      The first number is the count of modules that contain a package that imports this one.
+      The module count is a better indicator of popularity than the package count.
+      A module with 100 importing packages is counted only once instead of 100 times,
+      reflecting the fact that the choice to use the package was likely made only once,
+      not 100 times. <br/>
+      A '?' means that the value hasn't been computed yet.</p>
+    </details>
   </span>
   <div class="screen-reader-only" id="importedby-description" hidden>
     Opens a new window with list of known importers.
diff --git a/tests/screentest/testcases.ci.txt b/tests/screentest/testcases.ci.txt
index 3a90bba..6600d43 100644
--- a/tests/screentest/testcases.ci.txt
+++ b/tests/screentest/testcases.ci.txt
@@ -3,7 +3,7 @@
 block https://codecov.io/* https://travis-ci.com/* https://travis-ci.org/* https://sourcegraph.com/*
 
 {{$ready := "[role='treeitem'][aria-selected='true']"}}
-{{$hideElements := "const s=document.createElement('style');s.innerHTML='*{transition:none!important;animation:none!important;caret-color:transparent!important} .js-surveyBanner { display: none !important; }';document.head.appendChild(s);document.querySelector(\"[data-test-id='UnitHeader-importedby']\")?.remove();document.querySelector(\"[data-test-id='meta-link-depsdev']\")?.remove();"}}
+{{$hideElements := "const s=document.createElement('style');s.innerHTML='*{transition:none!important;animation:none!important;caret-color:transparent!important} .js-surveyBanner { display: none !important; }';document.head.appendChild(s);document.querySelector(\"[data-test-id='UnitHeader-importedby']\")?.remove();document.querySelector(\"[data-test-id='meta-link-depsdev']\")?.remove();window.dispatchEvent(new Event('resize'));"}}
 {{$scrollTop := "window.scrollTo({top:0});"}}
 
 test vuln
diff --git a/tests/screentest/testdata/testcases.ci/vuln-toolchain-module-540x1080.want.png b/tests/screentest/testdata/testcases.ci/vuln-toolchain-module-540x1080.want.png
index a011761..1ed1a2e 100644
--- a/tests/screentest/testdata/testcases.ci/vuln-toolchain-module-540x1080.want.png
+++ b/tests/screentest/testdata/testcases.ci/vuln-toolchain-module-540x1080.want.png
Binary files differ
diff --git a/tests/screentest/testdata/testcases.ci/vuln-toolchain-module.want.png b/tests/screentest/testdata/testcases.ci/vuln-toolchain-module.want.png
index 0c0e206..9ae8965 100644
--- a/tests/screentest/testdata/testcases.ci/vuln-toolchain-module.want.png
+++ b/tests/screentest/testdata/testcases.ci/vuln-toolchain-module.want.png
Binary files differ