[ct] Add pagination-sk.

Change-Id: Id887c4318ddeaeab5fc7afbd857b734120778fa8
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/304808
Reviewed-by: Ravi Mistry <rmistry@google.com>
Commit-Queue: Weston Tracey <westont@google.com>
diff --git a/ct/modules/colors.css b/ct/modules/colors.css
index 5df5aeb..4903c9d 100644
--- a/ct/modules/colors.css
+++ b/ct/modules/colors.css
@@ -5,6 +5,8 @@
 
     @import '../colors';
 */
+@import '~elements-sk/themes/themes.css';
+
 
 /* Use the default theme except for the primary colors, and we'll use
  * complete default dark theme.
diff --git a/ct/modules/pagination-sk/index.js b/ct/modules/pagination-sk/index.js
new file mode 100644
index 0000000..7524a74
--- /dev/null
+++ b/ct/modules/pagination-sk/index.js
@@ -0,0 +1,2 @@
+import './pagination-sk';
+import './pagination-sk.scss';
diff --git a/ct/modules/pagination-sk/pagination-sk-demo.html b/ct/modules/pagination-sk/pagination-sk-demo.html
new file mode 100644
index 0000000..f4bc45b
--- /dev/null
+++ b/ct/modules/pagination-sk/pagination-sk-demo.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Pagination-sk Demo</title>
+    <meta charset="utf-8"/>
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  </head>
+  <body>
+    <h2>Pagination-sk Demo</h2><theme-chooser-sk></theme-chooser-sk>
+    <div id=container></div>
+  </body>
+</html>
diff --git a/ct/modules/pagination-sk/pagination-sk-demo.js b/ct/modules/pagination-sk/pagination-sk-demo.js
new file mode 100644
index 0000000..f30c926
--- /dev/null
+++ b/ct/modules/pagination-sk/pagination-sk-demo.js
@@ -0,0 +1,11 @@
+import './index';
+import '../../../infra-sk/modules/theme-chooser-sk';
+import { $$ } from 'common-sk/modules/dom';
+
+function newTaskQueue(parentSelector) {
+  const p = document.createElement('pagination-sk');
+  p.pagination = { total: 100, size: 10, offset: 0 };
+  $$(parentSelector).appendChild(p);
+}
+
+newTaskQueue('#container');
diff --git a/ct/modules/pagination-sk/pagination-sk.js b/ct/modules/pagination-sk/pagination-sk.js
new file mode 100644
index 0000000..427d940
--- /dev/null
+++ b/ct/modules/pagination-sk/pagination-sk.js
@@ -0,0 +1,112 @@
+/**
+ * @fileoverview A custom element that supports a pagination.
+ */
+
+import 'elements-sk/icon/first-page-icon-sk';
+import 'elements-sk/icon/last-page-icon-sk';
+import 'elements-sk/icon/chevron-left-icon-sk';
+import 'elements-sk/icon/chevron-right-icon-sk';
+import 'elements-sk/styles/buttons';
+
+import { define } from 'elements-sk/define';
+import { html } from 'lit-html';
+
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+
+const template = (el) => html`
+<div>
+  <button class=action data-page=0
+    ?disabled=${el._onFirstPage()} @click=${el._update}>
+    <first-page-icon-sk></first-page-icon-sk>
+  </button>
+  <button class=action data-page=${el._page - 1}
+    ?disabled=${el._onFirstPage()} @click=${el._update}>
+    <chevron-left-icon-sk></chevron-left-icon-sk>
+  </button>
+  ${el._pageButtons.map((page) => html`
+    <button data-page=${page}
+     @click=${el._update}
+     ?disabled=${page === el._page}>${page + 1}</button>`)}
+  <button class=action data-page=${el._page + 1}
+    ?disabled=${el._onLastPage()} @click=${el._update}>
+    <chevron-right-icon-sk></chevron-right-icon-sk>
+  </button>
+  <button class=action data-page=${el._allPages - 1}
+    ?disabled=${el._onLastPage()} @click=${el._update}>
+    <last-page-icon-sk></last-page-icon-sk>(${el._allPages})
+  </button>
+</div>
+`;
+
+define('pagination-sk', class extends ElementSk {
+  constructor() {
+    super(template);
+    this._upgradeProperty('pagination');
+    this._upgradeProperty('showPages');
+    this._showPages = this._showPages || 5;
+    this._pagination = this._pagination || { size: 10, offset: 0, total: 0 };
+    this._showPagesOffset = Math.floor(this._showPages / 2);
+    this._computePageButtons();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._render();
+  }
+
+  _computePageButtons() {
+    this._pageButtons = [];
+    this._allPages = Math.ceil(this._pagination.total / this._pagination.size);
+    this._showPagesOffset = Math.floor(this._showPages / 2);
+
+    this._page = Math.floor(this._pagination.offset / this._pagination.size);
+    const start = Math.max(Math.min(this._page - this._showPagesOffset,
+      this._allPages - this._showPages), 0);
+    const end = Math.min(start + this._showPages - 1, this._allPages - 1);
+    for (let i = start; i <= end; i++) {
+      this._pageButtons.push(i);
+    }
+    this._render();
+  }
+
+  _update(e) {
+    const targetPage = e.currentTarget.dataset.page;
+    this._pagination.offset = targetPage * this.pagination.size;
+    this._computePageButtons();
+    this.dispatchEvent(new CustomEvent('page-changed',
+      { bubbles: true, detail: { offset: this._pagination.offset } }));
+  }
+
+  _onFirstPage() {
+    return this._page === 0;
+  }
+
+  _onLastPage() {
+    return this._allPages === 0 || this._page === (this._allPages - 1);
+  }
+
+  /**
+   * @prop {Object} pagination - Pagination data {offset, size, total}.
+   */
+  get pagination() {
+    return this._pagination;
+  }
+
+  set pagination(val) {
+    this._pagination = val;
+    this._computePageButtons();
+  }
+
+  /**
+   * @prop {Number} showPages - Number of page buttons to display, centered
+   * around the current page.
+   */
+  get showPages() {
+    return this._showPages;
+  }
+
+  set showPages(val) {
+    this._showPages = val;
+    this._computePageButtons();
+  }
+});
diff --git a/ct/modules/pagination-sk/pagination-sk.scss b/ct/modules/pagination-sk/pagination-sk.scss
new file mode 100644
index 0000000..5772b3c
--- /dev/null
+++ b/ct/modules/pagination-sk/pagination-sk.scss
@@ -0,0 +1,13 @@
+@import '../colors.css';
+
+pagination-sk {
+
+  button {
+    border-radius: 5px;
+  }
+
+  button:not(.action)[disabled] {
+    font-weight: 900;
+    text-decoration: underline;
+  }
+}
\ No newline at end of file
diff --git a/ct/modules/pagination-sk/pagination-sk_test.js b/ct/modules/pagination-sk/pagination-sk_test.js
new file mode 100644
index 0000000..ee3e438
--- /dev/null
+++ b/ct/modules/pagination-sk/pagination-sk_test.js
@@ -0,0 +1,167 @@
+import './index';
+
+import { $, $$ } from 'common-sk/modules/dom';
+
+import {
+  eventPromise,
+  setUpElementUnderTest,
+} from '../../../infra-sk/modules/test_util';
+
+describe('pagination-sk', () => {
+  const newInstance = (() => {
+    const factory = setUpElementUnderTest('pagination-sk');
+    return (paginationData) => factory((el) => {
+      if (paginationData) { el.pagination = paginationData; }
+    });
+  })();
+
+  let paginator;
+  // Array of 4 buttons, First, Previous, Next, Last, respectively.
+  const controlButtons = () => paginator.querySelectorAll('button.action');
+  // All present numbered page buttons.
+  const pageButtons = () => paginator.querySelectorAll('button:not(.action)');
+  // Button of current page, disabled.
+  const currentPageButton = () => paginator.querySelector('button:not(.action)[disabled]');
+  // All page buttons other than currentPageButton.
+  const clickablePageButtons = () => paginator.querySelectorAll('button:not(.action):not([disabled])');
+  // Should the First and Previous buttons be disabled (are we on page zero).
+  const expectFirstPreviousDisabled = (disabled) => {
+    if (disabled) {
+      expect(controlButtons()[0]).to.match('[disabled]');
+      expect(controlButtons()[1]).to.match('[disabled]');
+    } else {
+      expect(controlButtons()[0]).to.not.match('[disabled]');
+      expect(controlButtons()[1]).to.not.match('[disabled]');
+    }
+  };
+  // Should the Next and Last buttons be disabled (are we on last page).
+  const expectNextLastDisabled = (disabled) => {
+    if (disabled) {
+      expect(controlButtons()[2]).to.match('[disabled]');
+      expect(controlButtons()[3]).to.match('[disabled]');
+    } else {
+      expect(controlButtons()[2]).to.not.match('[disabled]');
+      expect(controlButtons()[3]).to.not.match('[disabled]');
+    }
+  };
+
+  // Helpers for clicking control and page buttons.
+  const clickFirst = () => {
+    controlButtons()[0].click();
+  };
+
+  const clickLast = () => {
+    controlButtons()[3].click();
+  };
+
+  const clickPrevious = () => {
+    controlButtons()[1].click();
+  };
+
+  const clickNext = () => {
+    controlButtons()[2].click();
+  };
+
+  // Click the page button at the provided 0-based index. e.g. for page
+  // buttons ['5','6','7'] (where showPages===3),
+  // clickNthPageButton(2) will click the 'page 7' button.
+  const clickNthPageButton = (i) => {
+    pageButtons()[i].click();
+  };
+
+  it('loads with control buttons', async () => {
+    paginator = newInstance();
+    // Default with no data is the 4 control(action) buttons, disabled.
+    expect(pageButtons()).to.have.length(0);
+    expect($('button.action:disabled', paginator)).to.have.length(4);
+  });
+
+  it('loads with page buttons', async () => {
+    paginator = newInstance({ size: 10, offset: 0, total: 100 });
+    // Default with enough data shows up to 5 page buttons, plus 4 controls.
+    expect(pageButtons()).to.have.length(5);
+    expect(pageButtons()).to.have.text(['1', '2', '3', '4', '5']);
+    expect(currentPageButton()).to.have.text('1');
+    expect(clickablePageButtons()).to.have.text(['2', '3', '4', '5']);
+    // We begin at the first page, 'first', 'previous' buttons are disabled.
+    expectFirstPreviousDisabled(true);
+    expectNextLastDisabled(false);
+  });
+
+  it('allows navigation with first/last buttons', async () => {
+    paginator = newInstance({ size: 10, offset: 0, total: 100 });
+    expect(pageButtons()).to.have.text(['1', '2', '3', '4', '5']);
+    let pageChangedEvent = eventPromise('page-changed');
+    clickLast();
+    expect(await pageChangedEvent).to.have.nested.property('detail.offset', 90);
+    expect(pageButtons()).to.have.text(['6', '7', '8', '9', '10']);
+    expect(currentPageButton()).to.have.text('10');
+    expect(clickablePageButtons()).to.have.text(['6', '7', '8', '9']);
+    expectFirstPreviousDisabled(false);
+    expectNextLastDisabled(true);
+    // Now return to first.
+    pageChangedEvent = eventPromise('page-changed');
+    clickFirst();
+    expect(await pageChangedEvent).to.have.nested.property('detail.offset', 0);
+    expect(pageButtons()).to.have.text(['1', '2', '3', '4', '5']);
+    expect(currentPageButton()).to.have.text('1');
+    expect(clickablePageButtons()).to.have.text(['2', '3', '4', '5']);
+    expectFirstPreviousDisabled(true);
+    expectNextLastDisabled(false);
+  });
+
+  it('allows navigation with previous/next buttons', async () => {
+    paginator = newInstance({ size: 10, offset: 0, total: 100 });
+    expect(pageButtons()).to.have.text(['1', '2', '3', '4', '5']);
+    expect(currentPageButton()).to.have.text('1');
+    let pageChangedEvent = eventPromise('page-changed');
+    clickNext();
+    expect(await pageChangedEvent).to.have.nested.property('detail.offset', 10);
+    // Page buttons don't scroll until active page button is in the middle.
+    expect(pageButtons()).to.have.text(['1', '2', '3', '4', '5']);
+    expect(currentPageButton()).to.have.text('2');
+    expect(clickablePageButtons()).to.have.text(['1', '3', '4', '5']);
+    expectFirstPreviousDisabled(false);
+    expectNextLastDisabled(false);
+    // Button number scroll when we go two more.
+    pageChangedEvent = eventPromise('page-changed');
+    clickNext();
+    expect(await pageChangedEvent).to.have.nested.property('detail.offset', 20);
+    clickNext();
+    expect(pageButtons()).to.have.text(['2', '3', '4', '5', '6']);
+    expect(currentPageButton()).to.have.text('4');
+    expect(clickablePageButtons()).to.have.text(['2', '3', '5', '6']);
+    expectFirstPreviousDisabled(false);
+    expectNextLastDisabled(false);
+    // Now go back one.
+    clickPrevious();
+    expect(pageButtons()).to.have.text(['1', '2', '3', '4', '5']);
+    expect(currentPageButton()).to.have.text('3');
+    expect(clickablePageButtons()).to.have.text(['1', '2', '4', '5']);
+    expectFirstPreviousDisabled(false);
+    expectNextLastDisabled(false);
+  });
+
+  it('allows navigation with page buttons', async () => {
+    paginator = newInstance({ size: 10, offset: 0, total: 100 });
+    expect(pageButtons()).to.have.text(['1', '2', '3', '4', '5']);
+    // Go to page 5.
+    const pageChangedEvent = eventPromise('page-changed');
+    clickNthPageButton(4);
+    expect(await pageChangedEvent).to.have.nested.property('detail.offset', 40);
+    expect(pageButtons()).to.have.text(['3', '4', '5', '6', '7']);
+    expect(currentPageButton()).to.have.text('5');
+    expect(clickablePageButtons()).to.have.text(['3', '4', '6', '7']);
+    // Go to page 7, then 9, then 10.
+    clickNthPageButton(4);
+    expect(pageButtons()).to.have.text(['5', '6', '7', '8', '9']);
+    expect(currentPageButton()).to.have.text('7');
+    clickNthPageButton(4);
+    expect(pageButtons()).to.have.text(['6', '7', '8', '9', '10']);
+    expect(currentPageButton()).to.have.text('9');
+    clickNthPageButton(4);
+    expect(pageButtons()).to.have.text(['6', '7', '8', '9', '10']);
+    expect(currentPageButton()).to.have.text('10');
+    expectNextLastDisabled(true);
+  });
+});
diff --git a/ct/modules/pagination-sk/test_data.js b/ct/modules/pagination-sk/test_data.js
new file mode 100644
index 0000000..532a9ac
--- /dev/null
+++ b/ct/modules/pagination-sk/test_data.js
@@ -0,0 +1,161 @@
+export const singleResultCanDelete = {
+  data: [
+    {
+      DatastoreKey: 'ChMiEWNsdXN0ZXItdGVsZW1ldHJ5EhYKEUNocm9taXVtUGVyZlRhc2tzEPUk',
+      TsAdded: 20200310143034,
+      TsStarted: 20200310143040,
+      TsCompleted: 0,
+      Username: 'user@example.com',
+      Failure: false,
+      RepeatAfterDays: 2,
+      SwarmingLogs: 'https://chrome-swarming.appspot.com/tasklist?l=500&c=name&c=created_ts&c=bot&c=duration&c=state&f=runid:rmistry-ChromiumPerf-4725&st=1262304000000',
+      TaskDone: false,
+      SwarmingTaskID: '4addac5a188dbc10',
+      RepeatRuns: 1,
+    },
+  ],
+  ids: [
+    1,
+  ],
+  pagination: {
+    offset: 0,
+    size: 1,
+    total: 2,
+  },
+  permissions: [
+    {
+      DeleteAllowed: true,
+      RedoAllowed: false,
+    },
+  ],
+};
+
+export const singleResultNoDelete = {
+  data: [
+    {
+      DatastoreKey: 'ChMiEWNsdXN0ZXItdGVsZW1ldHJ5EhYKEUNocm9taXVtUGVyZlRhc2tzEPUk',
+      TsAdded: 20200310143034,
+      TsStarted: 20200310143040,
+      TsCompleted: 0,
+      Username: 'user@example.com',
+      Failure: false,
+      RepeatAfterDays: 2,
+      SwarmingLogs: 'https://chrome-swarming.appspot.com/tasklist?l=500&c=name&c=created_ts&c=bot&c=duration&c=state&f=runid:rmistry-ChromiumPerf-4725&st=1262304000000',
+      TaskDone: false,
+      SwarmingTaskID: '4addac5a188dbc10',
+      RepeatRuns: 1,
+    },
+  ],
+  ids: [
+    2,
+  ],
+  pagination: {
+    offset: 0,
+    size: 1,
+    total: 2,
+  },
+  permissions: [
+    {
+      DeleteAllowed: false,
+      RedoAllowed: false,
+    },
+  ],
+};
+
+export const resultSetOneItem = singleResultNoDelete;
+
+export const resultSetTwoItems = {
+  data: [
+    {
+      DatastoreKey: 'ChMiEWNsdXN0ZXItdGVsZW1ldHJ5EhoKFUNocm9taXVtQW5hbHlzaXNUYXNrcxD7Dw',
+      TsAdded: 20200309185034,
+      TsStarted: 20200309185121,
+      TsCompleted: 20200309203134,
+      Username: 'user@example.com',
+      Failure: false,
+      RepeatAfterDays: 1,
+      SwarmingLogs: 'https://chrome-swarming.appspot.com/tasklist?l=500&c=name&c=created_ts&c=bot&c=duration&c=state&f=runid:alexmt-ChromiumAnalysis-2043&st=1262304000000',
+      TaskDone: true,
+      SwarmingTaskID: '4ad974a75652da10',
+      Benchmark: 'ad_tagging.cluster_telemetry',
+      PageSets: '10k',
+      IsTestPageSet: false,
+      BenchmarkArgs: '--output-format=csv --skip-typ-expectations-tags-validation --legacy-json-trace-format',
+      BrowserArgs: '',
+      Description: 'Regular AdTagging accuracy run',
+      CustomWebpagesGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      ChromiumPatchGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      SkiaPatchGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      CatapultPatchGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      BenchmarkPatchGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      V8PatchGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      RunInParallel: false,
+      Platform: 'Linux',
+      RunOnGCE: false,
+      RawOutput: 'https://ct.skia.org/results/cluster-telemetry/tasks/benchmark_runs/alexmt-ChromiumAnalysis-2043/consolidated_outputs/alexmt-ChromiumAnalysis-2043.output',
+      ValueColumnName: 'sum',
+      MatchStdoutTxt: '',
+      ChromiumHash: '',
+      ApkGsPath: '',
+      TelemetryIsolateHash: '',
+      CCList: null,
+      TaskPriority: 110,
+      GroupName: 'AdTagging',
+    },
+    {
+      DatastoreKey: 'ChMiEWNsdXN0ZXItdGVsZW1ldHJ5EhoKFUNocm9taXVtQW5hbHlzaXNUYXNrcxD5Dw',
+      TsAdded: 20200308152034,
+      TsStarted: 20200308152039,
+      TsCompleted: 20200309010734,
+      Username: 'someoneWithAReallyLongUsernameJustToSeeHowItGoes@example.com',
+      Failure: false,
+      RepeatAfterDays: 2,
+      SwarmingLogs: 'https://chrome-swarming.appspot.com/tasklist?l=500&c=name&c=created_ts&c=bot&c=duration&c=state&f=runid:dproy-ChromiumAnalysis-2041&st=1262304000000',
+      TaskDone: true,
+      SwarmingTaskID: '4ad38d6496eef010',
+      Benchmark: 'loading.cluster_telemetry',
+      PageSets: 'VoltMobile10k',
+      IsTestPageSet: false,
+      BenchmarkArgs: '--output-format=csv --pageset-repeat=1 --skip-typ-expectations-tags-validation --legacy-json-trace-format --traffic-setting=Regular-4G --use-live-sites',
+      BrowserArgs: '',
+      Description: 'Regular run for Volt 10k pages (Chrome M80, live sites) ',
+      CustomWebpagesGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      ChromiumPatchGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      SkiaPatchGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      CatapultPatchGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      BenchmarkPatchGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      V8PatchGSPath: 'patches/da39a3ee5e6b4b0d3255bfef95601890afd80709.patch',
+      RunInParallel: false,
+      Platform: 'Android',
+      RunOnGCE: false,
+      RawOutput: 'https://ct.skia.org/results/cluster-telemetry/tasks/benchmark_runs/dproy-ChromiumAnalysis-2041/consolidated_outputs/dproy-ChromiumAnalysis-2041.output',
+      ValueColumnName: 'avg',
+      MatchStdoutTxt: '',
+      ChromiumHash: '',
+      ApkGsPath: 'gs://chrome-unsigned/android-B0urB0N/80.0.3987.87/arm_64/ChromeModern.apk',
+      TelemetryIsolateHash: 'b13b9f6b50847aab5f395c5be7ee1d71e2e7abd3',
+      CCList: null,
+      TaskPriority: 100,
+      GroupName: 'volt10k-m80',
+    },
+  ],
+  ids: [
+    3,
+    4,
+  ],
+  pagination: {
+    offset: 0,
+    size: 10,
+    total: 2,
+  },
+  permissions: [
+    {
+      DeleteAllowed: false,
+      RedoAllowed: true,
+    },
+    {
+      DeleteAllowed: false,
+      RedoAllowed: true,
+    },
+  ],
+};