Implement filtering and lazy loading of checks

Change-Id: I9662d193ffb8334bcac1ffb5f15e5dd8dc2fe646
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 34945a9..e448374 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -15,7 +15,14 @@
  * limitations under the License.
  */
 import {html} from 'lit-html';
-import {css, customElement, property, PropertyValues} from 'lit-element';
+import {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  PropertyValues,
+  query,
+} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
 import {Category, CheckRun, Link, RunStatus, Tag} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
@@ -26,6 +33,8 @@
   iconForCategory,
   isRunning,
 } from '../../services/checks/checks-util';
+import {assertIsDefined} from '../../utils/common-util';
+import {whenVisible} from '../../utils/dom-util';
 
 @customElement('gr-result-row')
 class GrResultRow extends GrLitElement {
@@ -38,6 +47,9 @@
   @property({type: Boolean, reflect: true})
   isExpandable = false;
 
+  @property()
+  shouldRender = false;
+
   static get styles() {
     return [
       sharedStyles,
@@ -126,8 +138,26 @@
     super.update(changedProperties);
   }
 
+  firstUpdated() {
+    const loading = this.shadowRoot?.querySelector('.container');
+    assertIsDefined(loading, '"Loading" element');
+    whenVisible(loading, () => this.setAttribute('shouldRender', 'true'), 200);
+  }
+
   render() {
     if (!this.result) return '';
+    if (!this.shouldRender) {
+      return html`
+        <tr class="container">
+          <td class="iconCol"></td>
+          <td class="nameCol">
+            <div><span class="loading">Loading...</span></div>
+          </td>
+          <td class="summaryCol"></td>
+          <td class="expanderCol"></td>
+        </tr>
+      `;
+    }
     return html`
       <tr class="container" @click="${this.toggleExpanded}">
         <td class="iconCol">
@@ -256,6 +286,12 @@
 
 @customElement('gr-checks-results')
 export class GrChecksResults extends GrLitElement {
+  @query('#filterInput')
+  filterInput?: HTMLInputElement;
+
+  @internalProperty()
+  filterRegExp = new RegExp('');
+
   @property()
   runs: CheckRun[] = [];
 
@@ -267,6 +303,11 @@
           display: block;
           padding: var(--spacing-xl);
         }
+        input#filterInput {
+          margin-top: var(--spacing-s);
+          padding: var(--spacing-s) var(--spacing-m);
+          min-width: 400px;
+        }
         .categoryHeader {
           margin-top: var(--spacing-l);
           margin-left: var(--spacing-l);
@@ -310,12 +351,30 @@
   render() {
     return html`
       <div><h2 class="heading-2">Results</h2></div>
-      ${this.renderNoCompleted()} ${this.renderSection(Category.ERROR)}
+      ${this.renderFilter()} ${this.renderNoCompleted()}
+      ${this.renderSection(Category.ERROR)}
       ${this.renderSection(Category.WARNING)}
       ${this.renderSection(Category.INFO)} ${this.renderSuccess()}
     `;
   }
 
+  renderFilter() {
+    if (this.runs.length === 0) return;
+    return html`
+      <input
+        id="filterInput"
+        type="text"
+        placeholder="Filter results by regular expression"
+        @input="${this.onInput}"
+      />
+    `;
+  }
+
+  onInput() {
+    assertIsDefined(this.filterInput, 'filter <input> element');
+    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+  }
+
   renderNoCompleted() {
     if (this.runs.some(hasCompleted)) return;
     let text = 'No results';
@@ -358,6 +417,11 @@
   renderRun(category: Category, run: CheckRun) {
     return html`${run.results
       ?.filter(result => result.category === category)
+      .filter(
+        result =>
+          this.filterRegExp.test(run.checkName) ||
+          this.filterRegExp.test(result.summary)
+      )
       .map(
         result =>
           html`<gr-result-row .result="${{...run, ...result}}"></gr-result-row>`
@@ -365,7 +429,9 @@
   }
 
   renderSuccess() {
-    const runs = this.runs.filter(hasCompletedWithoutResults);
+    const runs = this.runs
+      .filter(hasCompletedWithoutResults)
+      .filter(r => this.filterRegExp.test(r.checkName));
     if (runs.length === 0) return;
     return html`
       <h3 class="categoryHeader heading-3">
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 5b7428b..71ad041 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -16,7 +16,13 @@
  */
 import {html} from 'lit-html';
 import {classMap} from 'lit-html/directives/class-map';
-import {css, customElement, property} from 'lit-element';
+import {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  query,
+} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
 import {Action, CheckRun, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
@@ -35,15 +41,14 @@
   fakeRun4,
   updateStateSetResults,
 } from '../../services/checks/checks-model';
+import {assertIsDefined, toggleSetMembership} from '../../utils/common-util';
+import {whenVisible} from '../../utils/dom-util';
 
-/* The RunSelectedEvent is only used locally to communicate from <gr-checks-run>
-   to its <gr-checks-runs> parent. */
-
-interface RunSelectedEventDetail {
+export interface RunSelectedEventDetail {
   checkName: string;
 }
 
-type RunSelectedEvent = CustomEvent<RunSelectedEventDetail>;
+export type RunSelectedEvent = CustomEvent<RunSelectedEventDetail>;
 
 declare global {
   interface HTMLElementEventMap {
@@ -55,8 +60,8 @@
   target.dispatchEvent(
     new CustomEvent('run-selected', {
       detail: {checkName},
-      composed: false,
-      bubbles: false,
+      composed: true,
+      bubbles: true,
     })
   );
 }
@@ -124,9 +129,21 @@
           background-color: var(--selected-background);
           padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
         }
+        div.chip.deselected {
+          border: 1px solid var(--gray-foreground);
+          background-color: transparent;
+          padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
+        }
         div.chip.selected iron-icon {
           color: var(--selected-foreground);
         }
+        div.chip.deselected iron-icon {
+          color: var(--gray-foreground);
+        }
+        .chip.selected gr-button.action,
+        .chip.deselected gr-button.action {
+          display: none;
+        }
         gr-button.action {
           --padding: var(--spacing-xs) var(--spacing-m);
           /* The button should fit into the 20px line-height. The negative
@@ -139,15 +156,40 @@
     ];
   }
 
+  @query('.chip')
+  chipElement?: HTMLElement;
+
   @property()
   run!: CheckRun;
 
   @property()
   selected = false;
 
+  @property()
+  deselected = false;
+
+  @property()
+  shouldRender = false;
+
+  firstUpdated() {
+    assertIsDefined(this.chipElement, 'chip element');
+    whenVisible(
+      this.chipElement,
+      () => this.setAttribute('shouldRender', 'true'),
+      200
+    );
+  }
+
   render() {
-    const icon = this.selected ? 'check-circle' : iconForRun(this.run);
-    const classes = {chip: true, [icon]: true, selected: this.selected};
+    if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
+
+    const icon = this.selected ? 'filter' : iconForRun(this.run);
+    const classes = {
+      chip: true,
+      [icon]: true,
+      selected: this.selected,
+      deselected: this.deselected,
+    };
     const action = primaryRunAction(this.run);
 
     return html`
@@ -185,6 +227,12 @@
 
 @customElement('gr-checks-runs')
 export class GrChecksRuns extends GrLitElement {
+  @query('#filterInput')
+  filterInput?: HTMLInputElement;
+
+  @internalProperty()
+  filterRegExp = new RegExp('');
+
   @property()
   runs: CheckRun[] = [];
 
@@ -207,6 +255,11 @@
           padding-top: var(--spacing-l);
           text-transform: capitalize;
         }
+        input#filterInput {
+          margin-top: var(--spacing-s);
+          padding: var(--spacing-s) var(--spacing-m);
+          width: 100%;
+        }
         .testing {
           margin-top: var(--spacing-xxl);
           color: var(--deemphasized-text-color);
@@ -227,6 +280,12 @@
   render() {
     return html`
       <h2 class="heading-2">Runs</h2>
+      <input
+        id="filterInput"
+        type="text"
+        placeholder="Filter runs by regular expression"
+        @input="${this.onInput}"
+      />
       ${this.renderSection(RunStatus.COMPLETED)}
       ${this.renderSection(RunStatus.RUNNING)}
       ${this.renderSection(RunStatus.RUNNABLE)}
@@ -253,6 +312,11 @@
     `;
   }
 
+  onInput() {
+    assertIsDefined(this.filterInput, 'filter <input> element');
+    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+  }
+
   none() {
     updateStateSetResults('f0', []);
     updateStateSetResults('f1', []);
@@ -277,6 +341,7 @@
   renderSection(status: RunStatus) {
     const runs = this.runs
       .filter(r => r.status === status)
+      .filter(r => this.filterRegExp.test(r.checkName))
       .sort(compareByWorstCategory);
     if (runs.length === 0) return;
     return html`
@@ -289,20 +354,17 @@
 
   renderRun(run: CheckRun) {
     const selected = this.selectedRuns.has(run.checkName);
+    const deselected = !selected && this.selectedRuns.size > 0;
     return html`<gr-checks-run
       .run="${run}"
       .selected="${selected}"
+      .deselected="${deselected}"
       @run-selected="${this.handleRunSelected}"
     ></gr-checks-run>`;
   }
 
   handleRunSelected(e: RunSelectedEvent) {
-    const checkName = e.detail.checkName;
-    if (this.selectedRuns.has(checkName)) {
-      this.selectedRuns.delete(checkName);
-    } else {
-      this.selectedRuns.add(checkName);
-    }
+    toggleSetMembership(this.selectedRuns, e.detail.checkName);
     this.requestUpdate();
   }
 }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 61f8bd1..0ce81ed 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -32,7 +32,11 @@
   ActionTriggeredEvent,
   fireActionTriggered,
 } from '../../services/checks/checks-util';
-import {checkRequiredProperty} from '../../utils/common-util';
+import {
+  checkRequiredProperty,
+  toggleSetMembership,
+} from '../../utils/common-util';
+import {RunSelectedEvent} from './gr-checks-runs';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -53,6 +57,8 @@
   @property()
   changeNum: NumericChangeId | undefined = undefined;
 
+  private selectedRuns = new Set<string>();
+
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
@@ -100,6 +106,9 @@
 
   render() {
     const ps = `Patchset ${this.currentPatchNum} (Latest)`;
+    const filteredRuns = this.runs.filter(
+      r => this.selectedRuns.size === 0 || this.selectedRuns.has(r.checkName)
+    );
     return html`
       <div class="header">
         <div class="left">
@@ -118,10 +127,14 @@
         </div>
       </div>
       <div class="container">
-        <gr-checks-runs class="runs" .runs="${this.runs}"></gr-checks-runs>
+        <gr-checks-runs
+          class="runs"
+          .runs="${this.runs}"
+          @run-selected="${this.handleRunSelected}"
+        ></gr-checks-runs>
         <gr-checks-results
           class="results"
-          .runs="${this.runs}"
+          .runs="${filteredRuns}"
         ></gr-checks-results>
       </div>
     `;
@@ -148,6 +161,11 @@
       action.name
     );
   }
+
+  handleRunSelected(e: RunSelectedEvent) {
+    toggleSetMembership(this.selectedRuns, e.detail.checkName);
+    this.requestUpdate();
+  }
 }
 
 @customElement('gr-checks-top-level-action')
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 0a3ef5b..7745da8 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -128,6 +128,8 @@
       <g id="message"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#launch-->
       <g id="launch"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#filter-->
+      <g id="filter"><path d="M0,0h24 M24,24H0" fill="none"/><path d="M4.25,5.61C6.27,8.2,10,13,10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-6c0,0,3.72-4.8,5.74-7.39 C20.25,4.95,19.78,4,18.95,4H5.04C4.21,4,3.74,4.95,4.25,5.61z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
       <!-- This is just a placeholder, i.e. an empty icon that has the same size as a normal icon. -->
       <g id="placeholder"><path d="M0 0h24v24H0z" fill="none"/></g>
     </defs>
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 2246251..f4d6d51 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -111,3 +111,14 @@
   }
   return true;
 }
+
+/**
+ * Add value, if the set does not contain it. Otherwise remove it.
+ */
+export function toggleSetMembership<T>(set: Set<T>, value: T): void {
+  if (set.has(value)) {
+    set.delete(value);
+  } else {
+    set.add(value);
+  }
+}
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index aa83173..32014d7 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -16,6 +16,7 @@
  */
 
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {check} from './common-util';
 
 /**
  * Event emitted from polymer elements.
@@ -258,3 +259,22 @@
     (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream)
   );
 }
+
+export function whenVisible(
+  element: Element,
+  callback: () => void,
+  marginPx = 0
+) {
+  const observer = new IntersectionObserver(
+    (entries: IntersectionObserverEntry[]) => {
+      check(entries.length === 1, 'Expected one intersection observer entry.');
+      const entry = entries[0];
+      if (entry.isIntersecting) {
+        observer.unobserve(entry.target);
+        callback();
+      }
+    },
+    {rootMargin: `${marginPx}px`}
+  );
+  observer.observe(element);
+}