[gold] Port list-page-sk to TypeScript.

Bug: skia:10246
Change-Id: Idce01410281eccaebd95831990f8bf8795fb149a
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/403836
Reviewed-by: Kevin Lubick <kjlubick@google.com>
Commit-Queue: Kevin Lubick <kjlubick@google.com>
diff --git a/golden/go/web/frontend/generate_typescript_rpc_types/main.go b/golden/go/web/frontend/generate_typescript_rpc_types/main.go
index c95fc4c..9ff9629 100644
--- a/golden/go/web/frontend/generate_typescript_rpc_types/main.go
+++ b/golden/go/web/frontend/generate_typescript_rpc_types/main.go
@@ -70,6 +70,9 @@
 	// Response for the /json/v1/ignores RPC endpoint.
 	generator.Add(frontend.IgnoresResponse{})
 
+	// Response for the /json/v1/list RPC endpoint.
+	generator.Add(frontend.ListTestsResponse{})
+
 	generator.AddUnionWithName(expectations.AllLabel, "Label")
 	generator.AddUnionWithName(common.AllRefClosest, "RefClosest")
 }
diff --git a/golden/go/web/frontend/types.go b/golden/go/web/frontend/types.go
index 9ff1e18..2df0cf7 100644
--- a/golden/go/web/frontend/types.go
+++ b/golden/go/web/frontend/types.go
@@ -357,4 +357,10 @@
 	PositiveDigests  int            `json:"positive_digests"`
 	NegativeDigests  int            `json:"negative_digests"`
 	UntriagedDigests int            `json:"untriaged_digests"`
+	TotalDigests     int            `json:"total_digests"`
+}
+
+// ListTestsResponse is the response for /json/v1/list.
+type ListTestsResponse struct {
+	Tests []TestSummary `json:"tests"`
 }
diff --git a/golden/go/web/web.go b/golden/go/web/web.go
index bf71344..fb65325 100644
--- a/golden/go/web/web.go
+++ b/golden/go/web/web.go
@@ -1219,6 +1219,7 @@
 				PositiveDigests:  s.Pos,
 				NegativeDigests:  s.Neg,
 				UntriagedDigests: s.Untriaged,
+				TotalDigests:     s.Pos + s.Neg + s.Untriaged,
 			})
 		}
 	}
@@ -1226,9 +1227,10 @@
 	sort.Slice(tests, func(i, j int) bool {
 		return tests[i].Name < tests[j].Name
 	})
-	// Outputs: []frontend.TestSummary
-	//   Frontend will have option to hide tests with no digests.
-	sendJSONResponse(w, tests)
+
+	// Frontend will have option to hide tests with no digests.
+	response := frontend.ListTestsResponse{Tests: tests}
+	sendJSONResponse(w, response)
 }
 
 // TriageLogHandler returns the entries in the triagelog paginated
diff --git a/golden/go/web/web_test.go b/golden/go/web/web_test.go
index 93d4c06..8962700 100644
--- a/golden/go/web/web_test.go
+++ b/golden/go/web/web_test.go
@@ -2570,35 +2570,35 @@
 	}
 
 	test("all GM tests from all traces", "/json/list?corpus=gm&include_ignored_traces=true",
-		`[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":1},`+
-			`{"name":"test_two","positive_digests":2,"negative_digests":0,"untriaged_digests":2}]`)
+		`{"tests":[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":1,"total_digests":2},`+
+			`{"name":"test_two","positive_digests":2,"negative_digests":0,"untriaged_digests":2,"total_digests":4}]}`)
 
 	test("all GM tests at head from all traces", "/json/list?corpus=gm&at_head_only=true&include_ignored_traces=true",
-		`[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":0},`+
-			`{"name":"test_two","positive_digests":2,"negative_digests":0,"untriaged_digests":1}]`)
+		`{"tests":[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":0,"total_digests":1},`+
+			`{"name":"test_two","positive_digests":2,"negative_digests":0,"untriaged_digests":1,"total_digests":3}]}`)
 
 	test("all GM tests for device beta from all traces", "/json/list?corpus=gm&trace_values=device%3Dbeta&include_ignored_traces=true",
-		`[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":1},`+
-			`{"name":"test_two","positive_digests":1,"negative_digests":0,"untriaged_digests":0}]`)
+		`{"tests":[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":1,"total_digests":2},`+
+			`{"name":"test_two","positive_digests":1,"negative_digests":0,"untriaged_digests":0,"total_digests":1}]}`)
 
 	test("all GM tests for device delta at head from all traces", "/json/list?corpus=gm&trace_values=device%3Ddelta&at_head_only=true&include_ignored_traces=true",
-		`[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":0},`+
-			`{"name":"test_two","positive_digests":0,"negative_digests":0,"untriaged_digests":1}]`)
+		`{"tests":[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":0,"total_digests":1},`+
+			`{"name":"test_two","positive_digests":0,"negative_digests":0,"untriaged_digests":1,"total_digests":1}]}`)
 
 	// Reminder that device delta and test_two match ignore rules
 	test("all GM tests", "/json/list?corpus=gm",
-		`[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":1}]`)
+		`{"tests":[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":1,"total_digests":2}]}`)
 
 	test("all GM tests at head", "/json/list?corpus=gm&at_head_only=true",
-		`[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":0}]`)
+		`{"tests":[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":0,"total_digests":1}]}`)
 
 	test("all GM tests for device beta", "/json/list?corpus=gm&trace_values=device%3Dbeta",
-		`[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":1}]`)
+		`{"tests":[{"name":"test_one","positive_digests":1,"negative_digests":0,"untriaged_digests":1,"total_digests":2}]}`)
 
 	test("all GM tests for device delta at head", "/json/list?corpus=gm&trace_values=device%3Ddelta&at_head_only=true",
-		"[]")
+		`{"tests":[]}`)
 
-	test("non existent corpus", "/json/list?corpus=notthere", "[]")
+	test("non existent corpus", "/json/list?corpus=notthere", `{"tests":[]}`)
 }
 
 func TestListTestsHandler_InvalidQueries_BadRequestError(t *testing.T) {
diff --git a/golden/modules/list-page-sk/index.js b/golden/modules/list-page-sk/index.ts
similarity index 100%
rename from golden/modules/list-page-sk/index.js
rename to golden/modules/list-page-sk/index.ts
diff --git a/golden/modules/list-page-sk/list-page-sk-demo.js b/golden/modules/list-page-sk/list-page-sk-demo.ts
similarity index 78%
rename from golden/modules/list-page-sk/list-page-sk-demo.js
rename to golden/modules/list-page-sk/list-page-sk-demo.ts
index c390573..aaf2f68 100644
--- a/golden/modules/list-page-sk/list-page-sk-demo.js
+++ b/golden/modules/list-page-sk/list-page-sk-demo.ts
@@ -1,12 +1,12 @@
 import './index';
 import '../gold-scaffold-sk';
-import { $$ } from 'common-sk/modules/dom';
 import { delay } from '../demo_util';
 import { manyParams } from '../shared_demo_data';
 import { testOnlySetSettings } from '../settings';
 import { sampleByTestList } from './test_data';
 import { exampleStatusData } from '../last-commit-sk/demo_data';
 import fetchMock from 'fetch-mock';
+import { ListPageSk } from './list-page-sk';
 
 testOnlySetSettings({
   title: 'Testing Gold',
@@ -21,7 +21,6 @@
 // By adding these elements after all the fetches are mocked out, they should load ok.
 const newScaf = document.createElement('gold-scaffold-sk');
 newScaf.setAttribute('testing_offline', 'true');
-const body = $$('body');
-body.insertBefore(newScaf, body.childNodes[0]); // Make it the first element in body.
-const page = document.createElement('list-page-sk');
-newScaf.appendChild(page);
+// Make it the first element in body.
+document.body.insertBefore(newScaf, document.body.childNodes[0]);
+newScaf.appendChild(new ListPageSk());
diff --git a/golden/modules/list-page-sk/list-page-sk.js b/golden/modules/list-page-sk/list-page-sk.js
deleted file mode 100644
index 0d3f503..0000000
--- a/golden/modules/list-page-sk/list-page-sk.js
+++ /dev/null
@@ -1,283 +0,0 @@
-/**
- * @module module/list-page-sk
- * @description <h2><code>list-page-sk</code></h2>
- *
- * This page summarizes the outputs of various tests. It shows the amount of digests produced,
- * as well as a few options to configure what range of traces to enumerate.
- *
- * It is a top level element.
- */
-import { define } from 'elements-sk/define';
-import { html } from 'lit-html';
-import { $$ } from 'common-sk/modules/dom';
-import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
-import { stateReflector } from 'common-sk/modules/stateReflector';
-import { ElementSk } from '../../../infra-sk/modules/ElementSk';
-import { sendBeginTask, sendEndTask, sendFetchError } from '../common';
-import { defaultCorpus } from '../settings';
-
-import '../corpus-selector-sk';
-import '../query-dialog-sk';
-import '../sort-toggle-sk';
-import 'elements-sk/checkbox-sk';
-import 'elements-sk/icon/group-work-icon-sk';
-import 'elements-sk/icon/tune-icon-sk';
-import { SearchCriteriaToHintableObject } from '../search-controls-sk';
-import { fromObject } from 'common-sk/modules/query';
-
-const template = (ele) => html`
-<div>
-  <corpus-selector-sk .corpora=${ele._corpora}
-      .selectedCorpus=${ele._currentCorpus} @corpus-selected=${ele._currentCorpusChanged}>
-  </corpus-selector-sk>
-
-  <div class=query_params>
-    <button class=show_query_dialog @click=${ele._showQueryDialog}>
-      <tune-icon-sk></tune-icon-sk>
-    </button>
-    <pre>${searchQuery(ele._currentCorpus, ele._currentQuery)}</pre>
-    <checkbox-sk label="Digests at Head Only" class=head_only
-             ?checked=${!ele._showAllDigests} @click=${ele._toggleAllDigests}></checkbox-sk>
-    <checkbox-sk label="Disregard Ignore Rules" class=ignore_rules
-             ?checked=${ele._disregardIgnoreRules} @click=${ele._toggleIgnoreRules}></checkbox-sk>
-  </div>
-</div>
-
-<!-- lit-html (or maybe html in general) doesn't like sort-toggle-sk to go inside the table.-->
-<sort-toggle-sk id=sort_table .data=${ele._byTestCounts} @sort-changed=${ele._render}>
-  <table>
-     <thead>
-         <tr>
-          <th data-key=name data-sort-toggle-sk=up>Test name</th>
-          <th data-key=positive_digests>Positive</th>
-          <th data-key=negative_digests>Negative</th>
-          <th data-key=untriaged_digests>Untriaged</th>
-          <th data-key=total_digests>Total</th>
-          <th>Cluster View</th>
-        </tr>
-    </thead>
-    <tbody>
-      <!-- repeat was tested here; map is about twice as fast as using the repeat directive
-           (which moves the existing elements). This is because reusing the existing templates
-           is pretty fast because there isn't a lot to change.-->
-      ${ele._byTestCounts.map((row) => testRow(row, ele))}
-    </tbody>
-  </table>
-</sort-toggle-sk>
-
-<query-dialog-sk @edit=${ele._currentQueryChanged}></query-dialog-sk>
-`;
-
-const testRow = (row, ele) => {
-  // Returns a HintableObject for building the GET parameters to the search page.
-  const makeSearchCriteria = (opts) => SearchCriteriaToHintableObject({
-    corpus: ele._currentCorpus,
-    leftHandTraceFilter: {'name': [row.name]},
-    includePositiveDigests: opts.positive,
-    includeNegativeDigests: opts.negative,
-    includeUntriagedDigests: opts.untriaged,
-    includeDigestsNotAtHead: ele._showAllDigests ? 'true' : 'false',
-    includeIgnoredDigests: ele._disregardIgnoreRules ? 'true' : 'false',
-  });
-
-  const searchPageHref = (opts) => {
-    const searchCriteria = makeSearchCriteria(opts);
-    const queryParameters = fromObject(searchCriteria);
-    return `/search?${queryParameters}`;
-  };
-
-  const clusterPageHref = () => {
-    const hintableObject = {
-      ...makeSearchCriteria({positive: true, negative: true, untriaged: true}),
-      left_filter: '',
-      grouping: row.name,
-    };
-    return `/cluster?${fromObject(hintableObject)}`;
-  }
-
-  return html`
-<tr>
-  <td>
-    <a href="${searchPageHref({positive: true, negative: true, untriaged: true})}"
-       target=_blank rel=noopener>
-      ${row.name}
-    </a>
-  </td>
-  <td class=center>
-    <a href="${searchPageHref({positive: true, negative: false, untriaged: false})}"
-       target=_blank rel=noopener>
-     ${row.positive_digests}
-    </a>
-  </td>
-  <td class=center>
-    <a href="${searchPageHref({positive: false, negative: true, untriaged: false})}"
-       target=_blank rel=noopener>
-     ${row.negative_digests}
-    </a>
-  </td>
-  <td class=center>
-    <a href="${searchPageHref({positive: false, negative: false, untriaged: true})}"
-       target=_blank rel=noopener>
-     ${row.untriaged_digests}
-    </a>
-  </td>
-  <td class=center>
-    <a href="${searchPageHref({positive: true, negative: true, untriaged: true})}"
-       target=_blank rel=noopener>
-      ${row.total_digests}
-    </a>
-  </td>
-  <td class=center>
-    <a href="${clusterPageHref()}" target=_blank rel=noopener>
-      <group-work-icon-sk></group-work-icon-sk>
-    </a>
-  </td>
-</tr>`;
-};
-
-const searchQuery = (corpus, query) => {
-  if (!query) {
-    return `source_type=${corpus}`;
-  }
-  return `source_type=${corpus}, \n${query.split('&').join(',\n')}`;
-};
-
-define('list-page-sk', class extends ElementSk {
-  constructor() {
-    super(template);
-
-    this._corpora = [];
-    this._paramset = {};
-
-    this._currentQuery = '';
-    this._currentCorpus = '';
-
-    this._showAllDigests = false;
-    this._disregardIgnoreRules = false;
-
-    this._stateChanged = stateReflector(
-      /* getState */() => ({
-        // provide empty values
-        all_digests: this._showAllDigests,
-        disregard_ignores: this._disregardIgnoreRules,
-        corpus: this._currentCorpus,
-        query: this._currentQuery,
-      }), /* setState */(newState) => {
-        if (!this._connected) {
-          return;
-        }
-        // default values if not specified.
-        this._showAllDigests = newState.all_digests || false;
-        this._disregardIgnoreRules = newState.disregard_ignores || false;
-        this._currentCorpus = newState.corpus || defaultCorpus();
-        this._currentQuery = newState.query || '';
-        this._fetch();
-        this._render();
-      },
-    );
-
-    this._byTestCounts = [];
-
-    // Allows us to abort fetches if we fetch again.
-    this._fetchController = null;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this._render();
-  }
-
-  _currentCorpusChanged(e) {
-    e.stopPropagation();
-    this._currentCorpus = e.detail;
-    this._stateChanged();
-    this._render();
-    this._fetch();
-  }
-
-  _currentQueryChanged(e) {
-    e.stopPropagation();
-    this._currentQuery = e.detail;
-    this._stateChanged();
-    this._render();
-    this._fetch();
-  }
-
-  _fetch() {
-    if (this._fetchController) {
-      // Kill any outstanding requests
-      this._fetchController.abort();
-    }
-
-    // Make a fresh abort controller for each set of fetches.
-    // They cannot be re-used once aborted.
-    this._fetchController = new AbortController();
-    const extra = {
-      signal: this._fetchController.signal,
-    };
-
-    sendBeginTask(this);
-    sendBeginTask(this);
-
-    let url = `/json/v1/list?corpus=${encodeURIComponent(this._currentCorpus)}`;
-    if (!this._showAllDigests) {
-      url += '&at_head_only=true';
-    }
-    if (this._disregardIgnoreRules) {
-      url += '&include_ignored_traces=true';
-    }
-    if (this._currentQuery) {
-      url += `&trace_values=${encodeURIComponent(this._currentQuery)}`;
-    }
-    fetch(url, extra)
-      .then(jsonOrThrow)
-      .then((jsonList) => {
-        this._byTestCounts = jsonList;
-        this._byTestCounts.forEach((row) => {
-          row.total_digests = row.positive_digests + row.negative_digests + row.untriaged_digests;
-        });
-        this._render();
-        // By default, sort the data by name in ascending order (to match the direction set above).
-        $$('#sort_table', this).sort('name', 'up');
-        sendEndTask(this);
-      })
-      .catch((e) => sendFetchError(this, e, 'list'));
-
-    // TODO(kjlubick) when the search page gets a makeover to have just the params for the given
-    //   corpus show up, we should do the same here. First idea is to have a separate corpora
-    //   endpoint and then make paramset take a corpus.
-    fetch('/json/v1/paramset', extra)
-      .then(jsonOrThrow)
-      .then((paramset) => {
-        // We split the paramset into a list of corpora...
-        this._corpora = paramset.source_type || [];
-        // ...and the rest of the keys. This is to make it so the layout is
-        // consistent with other pages (e.g. the search page, the by blame page, etc).
-        delete paramset.source_type;
-        this._paramset = paramset;
-        this._render();
-        sendEndTask(this);
-      })
-      .catch((e) => sendFetchError(this, e, 'paramset'));
-  }
-
-  _showQueryDialog() {
-    $$('query-dialog-sk').open(this._paramset, this._currentQuery);
-  }
-
-  _toggleAllDigests(e) {
-    e.preventDefault();
-    this._showAllDigests = !this._showAllDigests;
-    this._stateChanged();
-    this._render();
-    this._fetch();
-  }
-
-  _toggleIgnoreRules(e) {
-    e.preventDefault();
-    this._disregardIgnoreRules = !this._disregardIgnoreRules;
-    this._stateChanged();
-    this._render();
-    this._fetch();
-  }
-});
diff --git a/golden/modules/list-page-sk/list-page-sk.ts b/golden/modules/list-page-sk/list-page-sk.ts
new file mode 100644
index 0000000..4c1a84f
--- /dev/null
+++ b/golden/modules/list-page-sk/list-page-sk.ts
@@ -0,0 +1,302 @@
+/**
+ * @module module/list-page-sk
+ * @description <h2><code>list-page-sk</code></h2>
+ *
+ * This page summarizes the outputs of various tests. It shows the amount of digests produced,
+ * as well as a few options to configure what range of traces to enumerate.
+ *
+ * It is a top level element.
+ */
+import { define } from 'elements-sk/define';
+import { html } from 'lit-html';
+import { $$ } from 'common-sk/modules/dom';
+import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
+import { stateReflector } from 'common-sk/modules/stateReflector';
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import { sendBeginTask, sendEndTask, sendFetchError } from '../common';
+import { defaultCorpus } from '../settings';
+
+import '../corpus-selector-sk';
+import '../query-dialog-sk';
+import '../sort-toggle-sk';
+import 'elements-sk/checkbox-sk';
+import 'elements-sk/icon/group-work-icon-sk';
+import 'elements-sk/icon/tune-icon-sk';
+import { SearchCriteriaToHintableObject } from '../search-controls-sk';
+import { fromObject } from 'common-sk/modules/query';
+import { QueryDialogSk } from '../query-dialog-sk/query-dialog-sk';
+import { SortToggleSk } from '../sort-toggle-sk/sort-toggle-sk';
+import { SearchCriteriaHintableObject } from '../search-controls-sk/search-controls-sk';
+import { HintableObject } from 'common-sk/modules/hintable';
+import { ListTestsResponse, ParamSet, TestSummary } from '../rpc_types';
+
+const searchQuery = (corpus: string, query: string): string => {
+  if (!query) {
+    return `source_type=${corpus}`;
+  }
+  return `source_type=${corpus}, \n${query.split('&').join(',\n')}`;
+};
+
+export class ListPageSk extends ElementSk {
+  private static template = (ele: ListPageSk) => html`
+    <div>
+      <corpus-selector-sk .corpora=${ele.corpora}
+          .selectedCorpus=${ele.currentCorpus} @corpus-selected=${ele.currentCorpusChanged}>
+      </corpus-selector-sk>
+
+      <div class=query_params>
+        <button class=show_query_dialog @click=${ele.showQueryDialog}>
+          <tune-icon-sk></tune-icon-sk>
+        </button>
+        <pre>${searchQuery(ele.currentCorpus, ele.currentQuery)}</pre>
+        <checkbox-sk label="Digests at Head Only" class=head_only
+                 ?checked=${!ele.showAllDigests} @click=${ele.toggleAllDigests}></checkbox-sk>
+        <checkbox-sk label="Disregard Ignore Rules" class=ignore_rules
+                 ?checked=${ele.disregardIgnoreRules} @click=${ele.toggleIgnoreRules}></checkbox-sk>
+      </div>
+    </div>
+
+    <!-- lit-html (or maybe html in general) doesn't like sort-toggle-sk to go inside the table.-->
+    <sort-toggle-sk id=sort_table .data=${ele.byTestCounts} @sort-changed=${ele._render}>
+      <table>
+         <thead>
+             <tr>
+              <th data-key=name data-sort-toggle-sk=up>Test name</th>
+              <th data-key=positive_digests>Positive</th>
+              <th data-key=negative_digests>Negative</th>
+              <th data-key=untriaged_digests>Untriaged</th>
+              <th data-key=total_digests>Total</th>
+              <th>Cluster View</th>
+            </tr>
+        </thead>
+        <tbody>
+          <!-- repeat was tested here; map is about twice as fast as using the repeat directive
+               (which moves the existing elements). This is because reusing the existing templates
+               is pretty fast because there isn't a lot to change.-->
+          ${ele.byTestCounts.map((row) => ListPageSk.testRow(row, ele))}
+        </tbody>
+      </table>
+    </sort-toggle-sk>
+
+    <query-dialog-sk @edit=${ele.currentQueryChanged}></query-dialog-sk>
+  `;
+
+  private static testRow = (row: TestSummary, ele: ListPageSk) => {
+    interface MakeSearchCriteriaOpts {
+      positive: boolean;
+      negative: boolean;
+      untriaged: boolean;
+    }
+
+    // Returns a HintableObject for building the GET parameters to the search page.
+    const makeSearchCriteria = (opts: MakeSearchCriteriaOpts): SearchCriteriaHintableObject =>
+        SearchCriteriaToHintableObject({
+          corpus: ele.currentCorpus,
+          leftHandTraceFilter: {'name': [row.name]},
+          includePositiveDigests: opts.positive,
+          includeNegativeDigests: opts.negative,
+          includeUntriagedDigests: opts.untriaged,
+          includeDigestsNotAtHead: ele.showAllDigests,
+          includeIgnoredDigests: ele.disregardIgnoreRules,
+        });
+
+    const searchPageHref = (opts: MakeSearchCriteriaOpts) => {
+      const searchCriteria = makeSearchCriteria(opts);
+      const queryParameters = fromObject(searchCriteria as HintableObject);
+      return `/search?${queryParameters}`;
+    };
+
+    const clusterPageHref = () => {
+      const hintableObject: HintableObject = {
+        ...makeSearchCriteria({
+          positive: true,
+          negative: true,
+          untriaged: true
+        }),
+        left_filter: '',
+        grouping: row.name,
+      };
+      return `/cluster?${fromObject(hintableObject)}`;
+    }
+
+    return html`
+      <tr>
+        <td>
+          <a href="${searchPageHref({positive: true, negative: true, untriaged: true})}"
+             target=_blank rel=noopener>
+            ${row.name}
+          </a>
+        </td>
+        <td class=center>
+          <a href="${searchPageHref({positive: true, negative: false, untriaged: false})}"
+             target=_blank rel=noopener>
+           ${row.positive_digests}
+          </a>
+        </td>
+        <td class=center>
+          <a href="${searchPageHref({positive: false, negative: true, untriaged: false})}"
+             target=_blank rel=noopener>
+           ${row.negative_digests}
+          </a>
+        </td>
+        <td class=center>
+          <a href="${searchPageHref({positive: false, negative: false, untriaged: true})}"
+             target=_blank rel=noopener>
+           ${row.untriaged_digests}
+          </a>
+        </td>
+        <td class=center>
+          <a href="${searchPageHref({positive: true, negative: true, untriaged: true})}"
+             target=_blank rel=noopener>
+            ${row.total_digests}
+          </a>
+        </td>
+        <td class=center>
+          <a href="${clusterPageHref()}" target=_blank rel=noopener>
+            <group-work-icon-sk></group-work-icon-sk>
+          </a>
+        </td>
+      </tr>
+    `;
+  };
+
+  private corpora: string[] = [];
+  private paramset: ParamSet = {};
+
+  private currentQuery = '';
+  private currentCorpus = '';
+
+  private showAllDigests = false;
+  private disregardIgnoreRules = false;
+
+  private byTestCounts: TestSummary[] = [];
+
+  private readonly stateChanged: () => void;
+
+  // Allows us to abort fetches if we fetch again.
+  private fetchController?: AbortController;
+
+  constructor() {
+    super(ListPageSk.template);
+
+    this.stateChanged = stateReflector(
+        /* getState */() => ({
+          // provide empty values
+          all_digests: this.showAllDigests,
+          disregard_ignores: this.disregardIgnoreRules,
+          corpus: this.currentCorpus,
+          query: this.currentQuery,
+        }), /* setState */(newState) => {
+          if (!this._connected) {
+            return;
+          }
+          // default values if not specified.
+          this.showAllDigests = newState.all_digests as boolean || false;
+          this.disregardIgnoreRules = newState.disregard_ignores as boolean || false;
+          this.currentCorpus = newState.corpus as string || defaultCorpus();
+          this.currentQuery = newState.query as string || '';
+          this.fetch();
+          this._render();
+        },
+    );
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._render();
+  }
+
+  private currentCorpusChanged(e: CustomEvent<string>) {
+    e.stopPropagation();
+    this.currentCorpus = e.detail;
+    this.stateChanged();
+    this._render();
+    this.fetch();
+  }
+
+  private currentQueryChanged(e: CustomEvent<string>) {
+    e.stopPropagation();
+    this.currentQuery = e.detail;
+    this.stateChanged();
+    this._render();
+    this.fetch();
+  }
+
+  private fetch() {
+    if (this.fetchController) {
+      // Kill any outstanding requests
+      this.fetchController.abort();
+    }
+
+    // Make a fresh abort controller for each set of fetches.
+    // They cannot be re-used once aborted.
+    this.fetchController = new AbortController();
+    const extra = {
+      signal: this.fetchController.signal,
+    };
+
+    sendBeginTask(this);
+    sendBeginTask(this);
+
+    let url = `/json/v1/list?corpus=${encodeURIComponent(this.currentCorpus)}`;
+    if (!this.showAllDigests) {
+      url += '&at_head_only=true';
+    }
+    if (this.disregardIgnoreRules) {
+      url += '&include_ignored_traces=true';
+    }
+    if (this.currentQuery) {
+      url += `&trace_values=${encodeURIComponent(this.currentQuery)}`;
+    }
+    fetch(url, extra)
+        .then(jsonOrThrow)
+        .then((response: ListTestsResponse) => {
+          this.byTestCounts = response.tests || [];
+          this._render();
+          // By default, sort the data by name in ascending order (to match the direction set
+          // above).
+          $$<SortToggleSk<TestSummary>>('#sort_table', this)!.sort('name', 'up');
+          sendEndTask(this);
+        })
+        .catch((e) => sendFetchError(this, e, 'list'));
+
+    // TODO(kjlubick) when the search page gets a makeover to have just the params for the given
+    //   corpus show up, we should do the same here. First idea is to have a separate corpora
+    //   endpoint and then make paramset take a corpus.
+    fetch('/json/v1/paramset', extra)
+        .then(jsonOrThrow)
+        .then((paramset: ParamSet) => {
+          // We split the paramset into a list of corpora...
+          this.corpora = paramset.source_type || [];
+          // ...and the rest of the keys. This is to make it so the layout is
+          // consistent with other pages (e.g. the search page, the by blame page, etc).
+          delete paramset.source_type;
+          this.paramset = paramset;
+          this._render();
+          sendEndTask(this);
+        })
+        .catch((e) => sendFetchError(this, e, 'paramset'));
+  }
+
+  private showQueryDialog() {
+    $$<QueryDialogSk>('query-dialog-sk')!.open(this.paramset, this.currentQuery);
+  }
+
+  private toggleAllDigests(e: Event) {
+    e.preventDefault();
+    this.showAllDigests = !this.showAllDigests;
+    this.stateChanged();
+    this._render();
+    this.fetch();
+  }
+
+  private toggleIgnoreRules(e: Event) {
+    e.preventDefault();
+    this.disregardIgnoreRules = !this.disregardIgnoreRules;
+    this.stateChanged();
+    this._render();
+    this.fetch();
+  }
+}
+
+define('list-page-sk', ListPageSk);
diff --git a/golden/modules/list-page-sk/list-page-sk_test.js b/golden/modules/list-page-sk/list-page-sk_test.ts
similarity index 65%
rename from golden/modules/list-page-sk/list-page-sk_test.js
rename to golden/modules/list-page-sk/list-page-sk_test.ts
index 1cd503b..207f9a8 100644
--- a/golden/modules/list-page-sk/list-page-sk_test.js
+++ b/golden/modules/list-page-sk/list-page-sk_test.ts
@@ -10,11 +10,19 @@
 } from '../../../infra-sk/modules/test_util';
 import { sampleByTestList } from './test_data';
 import { testOnlySetSettings } from '../settings';
+import { ListPageSk } from './list-page-sk';
+import { expect } from 'chai';
+import { CorpusSelectorSk } from '../corpus-selector-sk/corpus-selector-sk';
+import { QueryDialogSk } from '../query-dialog-sk/query-dialog-sk';
+import { QueryDialogSkPO} from '../query-dialog-sk/query-dialog-sk_po';
+import { CorpusSelectorSkPO } from '../corpus-selector-sk/corpus-selector-sk_po';
 
 describe('list-page-sk', () => {
-  const newInstance = setUpElementUnderTest('list-page-sk');
+  const newInstance = setUpElementUnderTest<ListPageSk>('list-page-sk');
 
-  let listPageSk;
+  let listPageSk: ListPageSk;
+  let queryDialogSkPO: QueryDialogSkPO;
+  let corpusSelectorSkPO: CorpusSelectorSkPO;
 
   beforeEach(async () => {
     // Clear out any query params we might have to not mess with our current state.
@@ -38,6 +46,12 @@
     const event = eventPromise('end-task');
     listPageSk = newInstance();
     await event;
+
+    queryDialogSkPO =
+        new QueryDialogSkPO(listPageSk.querySelector<QueryDialogSk>('query-dialog-sk')!);
+    corpusSelectorSkPO =
+        new CorpusSelectorSkPO(
+            listPageSk.querySelector<CorpusSelectorSk<string>>('corpus-selector-sk')!);
   });
 
   afterEach(() => {
@@ -54,17 +68,24 @@
       expect(rows).to.have.length(2);
     });
 
-    it('should have 3 corpora loaded in, with the default selected', () => {
-      const corpusSelector = $$('corpus-selector-sk', listPageSk);
-      expect(corpusSelector.corpora).to.have.length(3);
-      expect(corpusSelector.selectedCorpus).to.equal('gm');
+    it('should have 3 corpora loaded in, with the default selected', async () => {
+      expect(await corpusSelectorSkPO.getCorpora()).to.have.length(3);
+      expect(await corpusSelectorSkPO.getSelectedCorpus()).to.equal('gm');
     });
 
     it('does not have source_type (corpus) in the params', () => {
-      expect(listPageSk._paramset.source_type).to.be.undefined;
+      // Field "paramset" is private, thus the cast to any. Is this test really necessary?
+      expect((listPageSk as any).paramset.source_type).to.be.undefined;
     });
 
-    const expectedSearchPageHref = (opts) => {
+    const expectedSearchPageHref =
+        (opts: {
+          positive: boolean,
+          negative: boolean,
+          untriaged: boolean,
+          showAllDigests: boolean,
+          disregardIgnoreRules: boolean
+        }): string => {
       return '/search?' + [
         'corpus=gm',
         `include_ignored=${opts.disregardIgnoreRules}`,
@@ -81,7 +102,8 @@
       ].join('&');
     };
 
-    const expectedClusterPageHref = (opts) => {
+    const expectedClusterPageHref =
+        (opts: {showAllDigests: boolean, disregardIgnoreRules: boolean}): string => {
       return '/cluster?' + [
         'corpus=gm',
         'grouping=this_is_another_test',
@@ -100,8 +122,8 @@
     };
 
     it('should have links for searching and the cluster view', () => {
-      const secondRow = $$('table tbody tr:nth-child(2)', listPageSk);
-      const links = $('a', secondRow);
+      const secondRow = $$<HTMLTableRowElement>('table tbody tr:nth-child(2)', listPageSk)!;
+      const links = $<HTMLAnchorElement>('a', secondRow)!;
       expect(links).to.have.length(6);
 
       // First link should be to the search results for all digests.
@@ -154,11 +176,14 @@
         expectedClusterPageHref({showAllDigests: false, disregardIgnoreRules: false}));
     });
 
-    it('updates the links based on toggle positions', () => {
-      listPageSk._showAllDigests = true;
-      listPageSk._disregardIgnoreRules = true;
-      listPageSk._render();
-      const secondRow = $$('table tbody tr:nth-child(2)', listPageSk);
+    it('updates the links based on toggle positions', async () => {
+      fetchMock.get('/json/v1/list?corpus=gm', sampleByTestList);
+      fetchMock.get('/json/v1/list?corpus=gm&include_ignored_traces=true', sampleByTestList);
+
+      await clickDigestsAtHeadOnlyCheckbox(listPageSk)
+      await clickDisregardIgnoreRulesCheckbox(listPageSk)
+
+      const secondRow = $$<HTMLTableRowElement>('table tbody tr:nth-child(2)', listPageSk)!;
       const links = $('a', secondRow);
       expect(links).to.have.length(6);
 
@@ -213,79 +238,84 @@
     });
 
     it('updates the sort order by clicking on sort-toggle-sk', async () => {
-      let firstRow = $$('table tbody tr:nth-child(1)', listPageSk);
-      expect($$('td', firstRow).innerText).to.equal('this_is_a_test');
+      let firstRow = $$<HTMLTableRowElement>('table tbody tr:nth-child(1)', listPageSk)!;
+      expect($$<HTMLTableDataCellElement>('td', firstRow)!.innerText).to.equal('this_is_a_test');
 
       // After first click, it will be sorting in descending order by number of negatives.
       clickOnNegativeHeader(listPageSk);
 
-      firstRow = $$('table tbody tr:nth-child(1)', listPageSk);
-      expect($$('td', firstRow).innerText).to.equal('this_is_another_test');
+      firstRow = $$<HTMLTableRowElement>('table tbody tr:nth-child(1)', listPageSk)!;
+      expect($$<HTMLTableDataCellElement>('td', firstRow)!.innerText)
+          .to.equal('this_is_another_test');
 
       // After second click, it will be sorting in ascending order by number of negatives.
       clickOnNegativeHeader(listPageSk);
 
-      firstRow = $$('table tbody tr:nth-child(1)', listPageSk);
-      expect($$('td', firstRow).innerText).to.equal('this_is_a_test');
+      firstRow = $$<HTMLTableRowElement>('table tbody tr:nth-child(1)', listPageSk)!;
+      expect($$<HTMLTableDataCellElement>('td', firstRow)!.innerText).to.equal('this_is_a_test');
     });
   }); // end describe('html layout')
 
   describe('RPC calls', () => {
     it('has a checkbox to toggle use of ignore rules', async () => {
-      fetchMock.get('/json/v1/list?corpus=gm&at_head_only=true&include_ignored_traces=true', sampleByTestList);
+      fetchMock.get(
+          '/json/v1/list?corpus=gm&at_head_only=true&include_ignored_traces=true',
+          sampleByTestList);
 
-      const checkbox = $$('checkbox-sk.ignore_rules input', listPageSk);
-      const event = eventPromise('end-task');
-      checkbox.click();
-      await event;
+      await clickDisregardIgnoreRulesCheckbox(listPageSk);
       expectQueryStringToEqual('?corpus=gm&disregard_ignores=true');
     });
 
     it('has a checkbox to toggle measuring at head', async () => {
       fetchMock.get('/json/v1/list?corpus=gm', sampleByTestList);
 
-      const checkbox = $$('checkbox-sk.head_only input', listPageSk);
-      const event = eventPromise('end-task');
-      checkbox.click();
-      await event;
+      await clickDigestsAtHeadOnlyCheckbox(listPageSk);
       expectQueryStringToEqual('?all_digests=true&corpus=gm');
     });
 
     it('changes the corpus based on an event from corpus-selector-sk', async () => {
-      fetchMock.get('/json/v1/list?corpus=corpus%20with%20spaces&at_head_only=true', sampleByTestList);
+      fetchMock.get(
+          '/json/v1/list?corpus=corpus%20with%20spaces&at_head_only=true', sampleByTestList);
 
-      const corpusSelector = $$('corpus-selector-sk', listPageSk);
       const event = eventPromise('end-task');
-      corpusSelector.dispatchEvent(
-        new CustomEvent('corpus-selected', {
-          detail: 'corpus with spaces',
-          bubbles: true,
-        }),
-      );
+      await corpusSelectorSkPO.clickCorpus('corpus with spaces');
       await event;
+
       expectQueryStringToEqual('?corpus=corpus%20with%20spaces');
     });
 
     it('changes the search params based on an event from query-dialog-sk', async () => {
       fetchMock.get(
-        '/json/v1/list?corpus=gm&at_head_only=true&trace_values=alpha_type%3DOpaque%26arch%3Darm64',
+        '/json/v1/list?' +
+          'corpus=gm&at_head_only=true&trace_values=alpha_type%3DOpaque%26arch%3Darm64',
         sampleByTestList,
       );
 
-      const queryDialog = $$('query-dialog-sk', listPageSk);
       const event = eventPromise('end-task');
-      queryDialog.dispatchEvent(
-        new CustomEvent('edit', {
-          detail: 'alpha_type=Opaque&arch=arm64',
-          bubbles: true,
-        }),
-      );
+      $$<HTMLButtonElement>('.show_query_dialog', listPageSk)!.click();
+      await queryDialogSkPO.setSelection({'alpha_type': ['Opaque'], 'arch': ['arm64']});
+      await queryDialogSkPO.clickShowMatchesBtn();
       await event;
+
       expectQueryStringToEqual('?corpus=gm&query=alpha_type%3DOpaque%26arch%3Darm64');
     });
   });
 });
 
-function clickOnNegativeHeader(ele) {
-  $$('table > thead > tr > th:nth-child(3)', ele).click();
+function clickOnNegativeHeader(ele: ListPageSk) {
+  $$<HTMLTableHeaderCellElement>('table > thead > tr > th:nth-child(3)', ele)!.click();
+}
+
+async function clickDigestsAtHeadOnlyCheckbox(listPageSk: ListPageSk) {
+  const checkbox = $$<HTMLInputElement>('checkbox-sk.head_only input', listPageSk)!;
+  const event = eventPromise('end-task');
+  checkbox.click();
+  await event;
+}
+
+async function clickDisregardIgnoreRulesCheckbox(listPageSk: ListPageSk) {
+  const checkbox = $$<HTMLInputElement>('checkbox-sk.ignore_rules input', listPageSk)!;
+  const event = eventPromise('end-task');
+  checkbox.click();
+  await event;
 }
diff --git a/golden/modules/list-page-sk/test_data.js b/golden/modules/list-page-sk/test_data.js
deleted file mode 100644
index 1f02426..0000000
--- a/golden/modules/list-page-sk/test_data.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export const sampleByTestList = [{
-  name: 'this_is_a_test',
-  positive_digests: 19,
-  negative_digests: 24,
-  untriaged_digests: 103,
-}, {
-  name: 'this_is_another_test',
-  positive_digests: 79,
-  negative_digests: 48,
-  untriaged_digests: 3,
-}];
diff --git a/golden/modules/list-page-sk/test_data.ts b/golden/modules/list-page-sk/test_data.ts
new file mode 100644
index 0000000..f2c02e4
--- /dev/null
+++ b/golden/modules/list-page-sk/test_data.ts
@@ -0,0 +1,17 @@
+import {ListTestsResponse} from '../rpc_types';
+
+export const sampleByTestList: ListTestsResponse = {
+  tests: [{
+      name: 'this_is_a_test',
+      positive_digests: 19,
+      negative_digests: 24,
+      untriaged_digests: 103,
+      total_digests: 146,
+    }, {
+    name: 'this_is_another_test',
+    positive_digests: 79,
+    negative_digests: 48,
+    untriaged_digests: 3,
+    total_digests: 130,
+  }],
+};
diff --git a/golden/modules/rpc_types.ts b/golden/modules/rpc_types.ts
index 2ed23e0..7283b13 100644
--- a/golden/modules/rpc_types.ts
+++ b/golden/modules/rpc_types.ts
@@ -199,6 +199,18 @@
 	rules: IgnoreRule[] | null;
 }
 
+export interface TestSummary {
+	name: TestName;
+	positive_digests: number;
+	negative_digests: number;
+	untriaged_digests: number;
+	total_digests: number;
+}
+
+export interface ListTestsResponse {
+	tests: TestSummary[] | null;
+}
+
 export type ParamSet = { [key: string]: string[] };
 
 export type ParamSetResponse = { [key: string]: string[] | null };