[task scheduler] Add task-scheduler-scaffold-sk element

Change-Id: I1791ccf17bf252837504430c7a9af8c89d122d23
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/329218
Reviewed-by: Weston Tracey <westont@google.com>
Commit-Queue: Eric Boren <borenet@google.com>
diff --git a/task_scheduler/modules/task-scheduler-scaffold-sk/index.ts b/task_scheduler/modules/task-scheduler-scaffold-sk/index.ts
new file mode 100644
index 0000000..8790bcc
--- /dev/null
+++ b/task_scheduler/modules/task-scheduler-scaffold-sk/index.ts
@@ -0,0 +1,2 @@
+import './task-scheduler-scaffold-sk';
+import './task-scheduler-scaffold-sk.scss';
diff --git a/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk-demo.html b/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk-demo.html
new file mode 100644
index 0000000..66c42bf
--- /dev/null
+++ b/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk-demo.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>task-scheduler-scaffold-sk demo</title>
+  </head>
+  <body>
+    <task-scheduler-scaffold-sk title="task-scheduler-scaffold-sk demo">
+      <main>Content goes here.</main>
+    </task-scheduler-scaffold-sk>
+  </body>
+</html>
diff --git a/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk-demo.ts b/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk-demo.ts
new file mode 100644
index 0000000..ad9456f
--- /dev/null
+++ b/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk-demo.ts
@@ -0,0 +1,15 @@
+import fetchMock from 'fetch-mock';
+
+const loginURL = 'https://' + window.location.host + '/loginstatus/';
+fetchMock.get(loginURL, {
+  Email: 'user@google.com',
+  LoginURL: 'https://accounts.google.com/',
+  IsAGoogler: true,
+});
+fetchMock.get('https://skia.org/loginstatus/', {
+  Email: 'user@google.com',
+  LoginURL: 'https://accounts.google.com/',
+  IsAGoogler: true,
+});
+
+import './index';
diff --git a/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk.scss b/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk.scss
new file mode 100644
index 0000000..cb2ace5
--- /dev/null
+++ b/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk.scss
@@ -0,0 +1,64 @@
+@import '~elements-sk/colors';
+@import '../colors';
+
+task-scheduler-scaffold-sk {
+  * {
+    font-family: Roboto, Helvetica, Arial, 'Bitstream Vera Sans', sans-serif;
+  }
+
+  header {
+    h1 {
+      font-weight: 400;
+      font-size: 1.5em;
+      padding: 0 0.25em;
+    }
+
+    .spinner-spacer {
+      min-width: 40px;
+    }
+
+    spinner-sk {
+      width: 24px;
+      height: 24px;
+      margin: 0;
+      border: 8px solid var(--on-primary);
+      border-left: 8px solid var(--primary-variant);
+    }
+  }
+
+  nav {
+    display: flex;
+    flex-direction: column;
+    a {
+      padding: 1em 1em;
+      color: var(--on-surface); /* Override User Agent SS*/
+      text-decoration: none;
+    }
+    span {
+      padding: 0 0.5em;
+      vertical-align: middle;
+    }
+  }
+
+  login-sk {
+    margin-right: 1em;
+  }
+
+  /* Overrides to elements-sk elements since they set color internally. */
+  svg.icon-sk-svg {
+    fill: var(--on-surface);
+  }
+
+  app-sk aside {
+    /* Soften the sidebar border a bit. */
+    border-right: 1px solid var(--surface-2dp, #ddd);
+  }
+}
+
+// Make sure the app doesn't leave empty space on the bottom. e.g. blank
+// white in darkmode.
+html,
+body,
+app-sk {
+  height: 100%;
+}
diff --git a/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk.ts b/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk.ts
new file mode 100644
index 0000000..efc827a
--- /dev/null
+++ b/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk.ts
@@ -0,0 +1,224 @@
+/**
+ * @module task_scheduler/modules/task-scheduler-scaffold-sk
+ * @description <h2><code>task-scheduler-scaffold-sk</code></h2>
+ *
+ * Contains the title bar, side bar, and error-toast for all the Task Scheduler
+ * pages. The rest of every Task Scheduler page should be a child of this
+ * element.
+ *
+ * Has a spinner-sk that can be activated when it hears "begin-task" events and
+ * keeps spinning until it hears an equal number of "end-task" events.
+ *
+ * The error-toast element responds to fetch-error events and normal error-sk
+ * events.
+ *
+ * @attr {string} app_title - The title to show in the page banner.
+ *
+ * @attr {boolean} testing_offline - If we should operate entirely in offline
+ *     mode.
+ */
+import { define } from 'elements-sk/define';
+import { errorMessage } from 'elements-sk/errorMessage';
+import { html } from 'lit-html';
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+
+import '../../../infra-sk/modules/app-sk';
+import '../../../infra-sk/modules/login-sk';
+import '../../../infra-sk/modules/theme-chooser-sk';
+
+import 'elements-sk/error-toast-sk';
+import 'elements-sk/icon/block-icon-sk';
+import 'elements-sk/icon/help-icon-sk';
+import 'elements-sk/icon/home-icon-sk';
+import 'elements-sk/icon/search-icon-sk';
+import 'elements-sk/icon/send-icon-sk';
+import 'elements-sk/spinner-sk';
+import { SpinnerSk } from 'elements-sk/spinner-sk/spinner-sk';
+
+/**
+ * Moves the elements from one NodeList to the given HTMLElement.
+ */
+function move(from: HTMLCollection | NodeList, to: HTMLElement) {
+  Array.prototype.slice.call(from).forEach((ele) => to.appendChild(ele));
+}
+
+export class TaskSchedulerScaffoldSk extends ElementSk {
+  private main: HTMLElement | null = null;
+  private busyTaskCount: number = 0;
+  private spinner: SpinnerSk | null = null;
+  private static template = (ele: TaskSchedulerScaffoldSk) => html`
+    <app-sk>
+      <header class="primary-container-themes-sk">
+        <h1>${ele.title}</h1>
+        <div class="spinner-spacer">
+          <spinner-sk></spinner-sk>
+        </div>
+        <div class="spacer"></div>
+        <login-sk
+          ?testing_offline=${ele.testingOffline}
+          login_host="${ele.loginHost}"
+        ></login-sk>
+        <theme-chooser-sk></theme-chooser-sk>
+      </header>
+
+      <aside class="surface-themes-sk">
+        <nav>
+          <a href="/" tab-index="0">
+            <home-icon-sk></home-icon-sk><span>Home</span>
+          </a>
+          <a href="/trigger">
+            <send-icon-sk></send-icon-sk><span>Trigger Tasks</span>
+          </a>
+          <a href="/skip_tasks">
+            <block-icon-sk></block-icon-sk><span>Skip Tasks</span>
+          </a>
+          <a href="/jobs/search">
+            <search-icon-sk></search-icon-sk><span>Search Jobs</span>
+          </a>
+          <a
+            href="https://skia.googlesource.com/buildbot/+/master/task_scheduler/README.md"
+          >
+            <help-icon-sk></help-icon-sk><span>Docs</span>
+          </a>
+        </nav>
+      </aside>
+
+      <main></main>
+
+      <footer><error-toast-sk></error-toast-sk></footer>
+    </app-sk>
+  `;
+
+  constructor() {
+    super(TaskSchedulerScaffoldSk.template);
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    // Don't call more than once.
+    if (this.main) {
+      return;
+    }
+    this.addEventListener('begin-task', this.addBusyTask);
+    this.addEventListener('end-task', this.finishedTask);
+    this.addEventListener('fetch-error', this.fetchError);
+
+    // We aren't using shadow dom so we need to manually move the children of
+    // task-scheduler-scaffold-sk to be children of 'main'. We have to do this for the
+    // existing elements and for all future mutations.
+
+    // Create a temporary holding spot for elements we're moving.
+    const div = document.createElement('div');
+    move(this.children, div);
+
+    // Now that we've moved all the old children out of the way we can render
+    // the template.
+    this._render();
+
+    this.spinner = this.querySelector('header spinner-sk');
+
+    // Move the old children back under main.
+    this.main = this.querySelector('main');
+    if (this.main) {
+      move(div.children, this.main);
+    }
+
+    // Move all future children under main also.
+    const observer = new MutationObserver((mutList) => {
+      mutList.forEach((mut) => {
+        if (this.main) {
+          move(mut.addedNodes, this.main);
+        }
+      });
+    });
+    observer.observe(this, { childList: true });
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    this.removeEventListener('begin-task', this.addBusyTask);
+    this.removeEventListener('end-task', this.finishedTask);
+    this.removeEventListener('fetch-error', this.fetchError);
+  }
+
+  /** @prop loginHost Host name used for login. */
+  get loginHost() {
+    return window.location.host;
+  }
+
+  /** @prop title Reflects the app_title attribute for ease of use. */
+  get title() {
+    return <string>this.getAttribute('title');
+  }
+
+  set title(val: string) {
+    this.setAttribute('title', val);
+  }
+
+  /** @prop busy Indicates if there any on-going tasks (e.g. RPCs). This also
+   * mirrors the status of the embedded spinner-sk. Read-only. */
+  get busy() {
+    return !!this.busyTaskCount;
+  }
+
+  /** @prop testingOffline {boolean} Reflects the testing_offline attribute for
+   * ease of use. */
+  get testingOffline() {
+    return this.hasAttribute('testing_offline');
+  }
+
+  set testingOffline(val) {
+    if (val) {
+      this.setAttribute('testing_offline', '');
+    } else {
+      this.removeAttribute('testing_offline');
+    }
+  }
+
+  /**
+   * Indicate there are some number of tasks (e.g. RPCs) the app is waiting on
+   * and should be in the "busy" state, if it isn't already.
+   */
+  private addBusyTask() {
+    this.busyTaskCount++;
+    if (this.spinner && this.busyTaskCount > 0) {
+      this.spinner.active = true;
+    }
+  }
+
+  /**
+   * Removes one task from the busy count. If there are no more tasks to wait
+   * for, the app will leave the "busy" state and emit the "busy-end" event.
+   */
+  private finishedTask() {
+    this.busyTaskCount--;
+    if (this.busyTaskCount <= 0) {
+      this.busyTaskCount = 0;
+      if (this.spinner) {
+        this.spinner.active = false;
+      }
+      this.dispatchEvent(new CustomEvent('busy-end', { bubbles: true }));
+    }
+  }
+
+  /** Handles a fetch error. Expects the detail of error to contain:
+   *  error: the error given to fetch.
+   *  loading: A string explaining what was being fetched.
+   */
+  private fetchError(e: any) {
+    const loadingWhat = e.detail.loading;
+    e = e.detail.error;
+    if (e.name !== 'AbortError') {
+      // We can ignore AbortError since they fire anytime an AbortController was
+      // canceled. Chrome and Firefox report a DOMException in this case:
+      // https://developer.mozilla.org/en-US/docs/Web/API/DOMException
+      errorMessage(
+        `Unexpected error loading ${loadingWhat}: ${e.message}`,
+        5000
+      );
+    }
+    this.finishedTask();
+  }
+}
+
+define('task-scheduler-scaffold-sk', TaskSchedulerScaffoldSk);
diff --git a/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk_puppeteer_test.ts b/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk_puppeteer_test.ts
new file mode 100644
index 0000000..3ca9723
--- /dev/null
+++ b/task_scheduler/modules/task-scheduler-scaffold-sk/task-scheduler-scaffold-sk_puppeteer_test.ts
@@ -0,0 +1,28 @@
+import * as path from 'path';
+import {
+  setUpPuppeteerAndDemoPageServer,
+  takeScreenshot,
+} from '../../../puppeteer-tests/util';
+
+describe('task-scheduler-scaffold-sk', () => {
+  const testBed = setUpPuppeteerAndDemoPageServer(
+    path.join(__dirname, '..', '..', 'webpack.config.ts')
+  );
+
+  beforeEach(async () => {
+    await testBed.page.goto(
+      `${testBed.baseUrl}/dist/task-scheduler-scaffold-sk.html`
+    );
+    await testBed.page.setViewport({ width: 650, height: 400 });
+  });
+
+  describe('screenshots', () => {
+    it('shows the default view', async () => {
+      await takeScreenshot(
+        testBed.page,
+        'task-scheduler',
+        'task-scheduler-scaffold-sk'
+      );
+    });
+  });
+});