[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,
+ },
+ ],
+};