[gold] Add click handlers to cluster-digests-sk

Bug: skia:9525
Change-Id: Ie5b694fc276629e2000cd8a4aca7867aff83b568
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/301545
Reviewed-by: Leandro Lovisolo <lovisolo@google.com>
diff --git a/golden/modules/cluster-digests-sk/cluster-digests-sk.js b/golden/modules/cluster-digests-sk/cluster-digests-sk.js
index b52486e3..c8ccc5f 100644
--- a/golden/modules/cluster-digests-sk/cluster-digests-sk.js
+++ b/golden/modules/cluster-digests-sk/cluster-digests-sk.js
@@ -13,6 +13,9 @@
  *
  * @evt layout-complete; fired when the force layout has stabilized (i.e. finished rendering).
  *
+ * @evt selection-changed; fired when a digest is clicked on (or the selection is cleared). Detail
+ *   contains a list of digests that are selected.
+ *
  */
 import { define } from 'elements-sk/define';
 import { html } from 'lit-html';
@@ -40,6 +43,9 @@
     // has been laid out as.
     this._width = 400;
     this._height = 400;
+
+    // An array of digests (strings) that correspond to the currently selected digests (if any).
+    this._selectedDigests = [];
   }
 
   connectedCallback() {
@@ -48,7 +54,7 @@
   }
 
   /**
-   * Recomputes the positions of the digest nodes given the value of links. It expects all the svg
+   * Recomputes the positions of the digest nodes given the value of links. It expects all the SVG
    * elements (e.g. circles, lines) to already be created; this function will simply update the
    * X and Y values accordingly.
    */
@@ -71,7 +77,7 @@
     const linkForce = d3Force.forceLink(this._links)
       .distance((d) => d.value / this._linkTightness);
 
-    // This force keeps the diagram centered in the svg.
+    // This force keeps the diagram centered in the SVG.
     // See https://github.com/d3/d3-force#centering
     const centerForce = d3Force.forceCenter(this._width / 2, this._height / 2);
 
@@ -107,10 +113,19 @@
       });
   }
 
+  _getNodeCSSClass(d) {
+    let base = `node ${d.status}`;
+    if (this._selectedDigests.indexOf(d.name) >= 0) {
+      base += ' selected';
+    }
+    return base;
+  }
+
   /**
    * Sets the new data to render in a cluster view.
    *
-   * @param nodes Array<Object>: contains Strings for keys digest and status.
+   * @param nodes Array<Object>: contains Strings for keys digest (called name for historical
+   *   reasons) and status.
    * @param links Array<Object>: contains Numbers for keys source, target, and value. source and
    *   target refer to the index of the nodes array. value represents how far apart those two
    *   nodes should be.
@@ -120,13 +135,13 @@
     this._links = links;
 
     this._render();
-    // For reasons unknown, after render, we don't always see the svg element rendered in our
-    // DOM, so we schedule the drawing for the next animation frame (when we *do* see the svg
+    // For reasons unknown, after render, we don't always see the SVG element rendered in our
+    // DOM, so we schedule the drawing for the next animation frame (when we *do* see the SVG
     // in the DOM).
     window.requestAnimationFrame(() => {
       const clusterSk = $$('svg', this);
 
-      // Delete existing svg elements
+      // Delete existing SVG elements
       d3Select.select(clusterSk)
         .selectAll('.link,.node')
         .remove();
@@ -149,11 +164,53 @@
         .data(this._nodes)
         .enter()
         .append('circle')
-        .attr('class', (d) => `node ${d.status}`)
+        .attr('class', (d) => this._getNodeCSSClass(d))
         .attr('r', 12)
-        .attr('stroke', 'black');
+        .attr('stroke', 'black')
+        .attr('data-digest', (d) => d.name)
+        .on('click tap', (d) => {
+          // Capture this event (prevent it from propagating to the SVG).
+          const evt = d3Select.event;
+          evt.preventDefault();
+          evt.stopPropagation();
+
+          const digest = d.name;
+          if (this._selectedDigests.indexOf(digest) >= 0) {
+            return; // It's already selected, do nothing.
+          }
+          if (evt.shiftKey || evt.ctrlKey || evt.metaKey) {
+            // Support multiselection if shift, control or meta is held.
+            this._selectedDigests.push(digest);
+          } else {
+            // Clear the existing selection and replace it with this digest.
+            this._selectedDigests = [digest];
+          }
+          this._updateSelection();
+        });
+
+      d3Select.select(clusterSk).on('click tap', () => {
+        // Capture this event (prevent it from propagating outside the SVG).
+        const evt = d3Select.event;
+        evt.preventDefault();
+        evt.stopPropagation();
+
+        this._selectedDigests = [];
+        this._updateSelection();
+      });
 
       this._layout();
     });
   }
+
+  _updateSelection() {
+    d3Select.select($$('svg', this))
+      .selectAll('circle.node')
+      .data(this._nodes)
+      .attr('class', (d) => this._getNodeCSSClass(d));
+
+    this.dispatchEvent(new CustomEvent('selection-changed', {
+      bubbles: true,
+      detail: this._selectedDigests,
+    }));
+  }
 });
diff --git a/golden/modules/cluster-digests-sk/cluster-digests-sk_puppeteer_test.ts b/golden/modules/cluster-digests-sk/cluster-digests-sk_puppeteer_test.ts
index a0cb128..77f42dc 100644
--- a/golden/modules/cluster-digests-sk/cluster-digests-sk_puppeteer_test.ts
+++ b/golden/modules/cluster-digests-sk/cluster-digests-sk_puppeteer_test.ts
@@ -1,6 +1,9 @@
 import { expect } from 'chai';
-import { addEventListenersToPuppeteerPage, takeScreenshot, TestBed } from '../../../puppeteer-tests/util';
+import { addEventListenersToPuppeteerPage, EventName,
+    takeScreenshot, TestBed } from '../../../puppeteer-tests/util';
 import { loadGoldWebpack } from '../common_puppeteer_test/common_puppeteer_test';
+import { ElementHandle } from 'puppeteer';
+import { positiveDigest, negativeDigest, untriagedDigest } from './test_data';
 
 describe('cluster-digests-sk', () => {
     let testBed: TestBed;
@@ -8,12 +11,16 @@
         testBed = await loadGoldWebpack();
     });
 
+    let promiseFactory: <T>(eventName: EventName) => Promise<T>;
+    let clusterDigestsSk: ElementHandle;
+
     beforeEach(async () => {
-        const eventPromise = await addEventListenersToPuppeteerPage(testBed.page,
-            ['layout-complete']);
-        const loaded = eventPromise('layout-complete'); // Emitted when layout stabilizes.
+        promiseFactory = await addEventListenersToPuppeteerPage(testBed.page,
+            ['layout-complete', 'selection-changed']);
+        const loaded = promiseFactory('layout-complete'); // Emitted when layout stabilizes.
         await testBed.page.goto(`${testBed.baseUrl}/dist/cluster-digests-sk.html`);
         await loaded;
+        clusterDigestsSk = (await testBed.page.$('#cluster svg'))!;
     });
 
     it('should render the demo page', async () => {
@@ -22,7 +29,65 @@
     });
 
     it('should take a screenshot', async () => {
-        const clusterDigestsSk = await testBed.page.$('#cluster svg');
-        await takeScreenshot(clusterDigestsSk!, 'gold', 'cluster-digests-sk');
+        await takeScreenshot(clusterDigestsSk, 'gold', 'cluster-digests-sk');
     });
+
+    it('supports single digest selection via clicking', async () => {
+        await clickNodeAndExpectSelectionChangedEvent(positiveDigest, [positiveDigest]);
+
+        await takeScreenshot(clusterDigestsSk, 'gold', 'cluster-digests-sk_one-positive-selected');
+
+        await clickNodeAndExpectSelectionChangedEvent(untriagedDigest, [untriagedDigest]);
+
+        await takeScreenshot(clusterDigestsSk, 'gold',
+            'cluster-digests-sk_one-untriaged-selected');
+    });
+
+    it('supports multiple digest selection via shift clicking', async () => {
+        await clickNodeAndExpectSelectionChangedEvent(negativeDigest, [negativeDigest]);
+
+        await shiftClickNodeAndExpectSelectionChangedEvent(positiveDigest,
+            [negativeDigest, positiveDigest]);
+
+        await takeScreenshot(clusterDigestsSk, 'gold', 'cluster-digests-sk_two-digests-selected');
+
+        await shiftClickNodeAndExpectSelectionChangedEvent(untriagedDigest,
+            [negativeDigest, positiveDigest, untriagedDigest]);
+
+        await takeScreenshot(clusterDigestsSk, 'gold',
+            'cluster-digests-sk_three-digests-selected');
+    });
+
+    it('clears selection by clicking anywhere on the svg that is not on a node', async () => {
+        await clickNodeAndExpectSelectionChangedEvent(negativeDigest, [negativeDigest]);
+
+        const clickEvent = promiseFactory<Array<string>>('selection-changed');
+        await clusterDigestsSk.click();
+        const evt = await clickEvent;
+        expect(evt).to.deep.equal([]);
+    });
+
+    async function clickNodeWithDigest(digest: string) {
+        await testBed.page.click(`circle.node[data-digest="${digest}"]`);
+    }
+
+    async function shiftClickNodeWithDigest(digest: string) {
+        await testBed.page.keyboard.down('Shift');
+        await clickNodeWithDigest(digest);
+        await testBed.page.keyboard.up('Shift');
+    }
+
+    async function clickNodeAndExpectSelectionChangedEvent(digest: string, expectedSelection: string[]) {
+        const clickEvent = promiseFactory<Array<string>>('selection-changed');
+        await clickNodeWithDigest(digest);
+        const evt = await clickEvent;
+        expect(evt).to.deep.equal(expectedSelection);
+    }
+
+    async function shiftClickNodeAndExpectSelectionChangedEvent(digest: string, expectedSelection: string[]) {
+        const clickEvent = promiseFactory<Array<string>>('selection-changed');
+        await shiftClickNodeWithDigest(digest);
+        const evt = await clickEvent;
+        expect(evt).to.deep.equal(expectedSelection);
+    }
 });
diff --git a/golden/modules/cluster-digests-sk/test_data.js b/golden/modules/cluster-digests-sk/test_data.js
index 54c4fa5..7bbca74 100644
--- a/golden/modules/cluster-digests-sk/test_data.js
+++ b/golden/modules/cluster-digests-sk/test_data.js
@@ -1,17 +1,21 @@
+export const positiveDigest = '99c58c7002073346ff55f446d47d6311';
+export const negativeDigest = 'ec3b8f27397d99581e06eaa46d6d5837';
+export const untriagedDigest = '6246b773851984c726cb2e1cb13510c2';
+
 // This is the data returned from Gold's /clusterdiff RPC. Not all of it is used in
 // cluster-digests-sk.
 export const clusterDiffJSON = {
   nodes: [
     {
-      name: '99c58c7002073346ff55f446d47d6311',
+      name: positiveDigest,
       status: 'positive',
     },
     {
-      name: '6246b773851984c726cb2e1cb13510c2',
+      name: untriagedDigest,
       status: 'untriaged',
     },
     {
-      name: 'ec3b8f27397d99581e06eaa46d6d5837',
+      name: negativeDigest,
       status: 'negative',
     },
   ],