Implement workflow execution UI and logic

This CL adds a provisional workflow execution UI (which will need to be
thoroughly improved in the future), and most importantly the logic for
running workflows and actions inside workflows.

Bug: twpowertools:74
Change-Id: I94944a623a2411bef9d2b5244fea707e69a49790
diff --git a/src/contentScripts/communityConsole/workflows/actionRunners/replyWithCR.js b/src/contentScripts/communityConsole/workflows/actionRunners/replyWithCR.js
new file mode 100644
index 0000000..e8f665c
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/actionRunners/replyWithCR.js
@@ -0,0 +1,66 @@
+import {CCApi} from '../../../../common/api.js';
+import {getAuthUser} from '../../../../common/communityConsoleUtils.js';
+
+const kPiiScanType_ScanNone = 0;
+const kType_Reply = 1;
+const kType_RecommendedAnswer = 3;
+const kPostMethodCommunityConsole = 4;
+
+export default class CRRunner {
+  constructor() {
+    this._CRs = [];
+    this._haveCRsBeenLoaded = false;
+  }
+
+  loadCRs() {
+    return CCApi(
+               'ListCannedResponses', {}, /* authenticated = */ true,
+               getAuthUser())
+        .then(res => {
+          this._CRs = res?.[1] ?? [];
+          this._haveCRsBeenLoaded = true;
+        });
+  }
+
+  _getCRPayload(id) {
+    let maybeLoadCRsPromise;
+    if (!this._haveCRsBeenLoaded)
+      maybeLoadCRsPromise = this.loadCRs();
+    else
+      maybeLoadCRsPromise = Promise.resolve();
+
+    return maybeLoadCRsPromise.then(() => {
+      let cr = this._CRs.find(cr => cr?.[1]?.[1] == id);
+      if (!cr) throw new Error(`Couldn't find CR with id ${id}.`);
+      return cr?.[3];
+    });
+  }
+
+  execute(action, thread) {
+    let crId = action?.getCannedResponseId?.();
+    if (!crId)
+      return Promise.reject(
+          new Error('The action doesn\'t contain a valid CR id.'));
+
+    return this._getCRPayload(crId).then(payload => {
+      let subscribe = action?.getSubscribe?.() ?? false;
+      let markAsAnswer = action?.getMarkAsAnswer?.() ?? false;
+      return CCApi(
+          'CreateMessage', {
+            1: thread.forum,   // forumId
+            2: thread.thread,  // threadId
+            // message
+            3: {
+              4: payload,
+              6: {
+                1: markAsAnswer ? kType_RecommendedAnswer : kType_Reply,
+              },
+              11: kPostMethodCommunityConsole,
+            },
+            4: subscribe,
+            6: kPiiScanType_ScanNone,
+          },
+          /* authenticated = */ true, getAuthUser());
+    });
+  }
+}
diff --git a/src/contentScripts/communityConsole/workflows/components/TwptWorkflowDialog.js b/src/contentScripts/communityConsole/workflows/components/TwptWorkflowDialog.js
new file mode 100644
index 0000000..9b9525f
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/components/TwptWorkflowDialog.js
@@ -0,0 +1,76 @@
+import '@material/mwc-dialog/mwc-dialog.js';
+import '@material/web/button/text-button.js';
+
+import './TwptWorkflowProgress.js';
+
+import {css, html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import WorkflowRunner from '../runner.js';
+
+export default class TwptWorkflowDialog extends LitElement {
+  static properties = {
+    open: {type: Boolean},
+    workflow: {type: Object},
+    _runner: {type: Object, state: true},
+  };
+
+  static styles = css`
+    :host {
+      --mdc-dialog-content-ink-color: var(--mdc-theme-on-surface, #000);
+      --mdc-dialog-z-index: 200;
+    }
+
+    .workflow-name {
+      font-weight: 500;
+    }
+  `;
+
+  progressRef = createRef();
+
+  constructor() {
+    super();
+    this.open = false;
+  }
+
+  render() {
+    return html`
+      <mwc-dialog
+          ?open=${this.open}
+          @opening=${this._openingDialog}
+          @closing=${this._closingDialog}
+          heading=${'Running ' + this.workflow?.getName?.() + '...'}>
+        <twpt-workflow-progress ${ref(this.progressRef)}
+            .workflow=${this.workflow}
+            currentThread=${this._runner?.currentThreadIndex}
+            numThreads=${this._runner?.numThreads}
+            currentAction=${this._runner?.currentActionIndex}
+            status=${this._runner?.status}>
+        </twpt-workflow-progress>
+
+        <md-text-button
+            ?disabled=${this._runner?.status !== 'finished'}
+            slot="primaryAction"
+            dialogAction="cancel"
+            label="Close">
+        </md-text-button>
+      </mwc-dialog>
+    `;
+  }
+
+  start() {
+    this._runner =
+        new WorkflowRunner(this.workflow, () => this.requestUpdate());
+    this._runner.start();
+    this.open = true;
+  }
+
+  _openingDialog() {
+    this.open = true;
+  }
+
+  _closingDialog() {
+    this.open = false;
+  }
+}
+window.customElements.define('twpt-workflow-dialog', TwptWorkflowDialog);
diff --git a/src/contentScripts/communityConsole/workflows/components/TwptWorkflowProgress.js b/src/contentScripts/communityConsole/workflows/components/TwptWorkflowProgress.js
new file mode 100644
index 0000000..52245e0
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/components/TwptWorkflowProgress.js
@@ -0,0 +1,70 @@
+import '@material/mwc-dialog/mwc-dialog.js';
+import '@material/web/button/filled-button.js';
+import '@material/web/button/text-button.js';
+
+import '../../../../workflows/manager/components/ActionEditor.js';
+
+import {css, html, LitElement} from 'lit';
+import {map} from 'lit/directives/map.js';
+
+export default class TwptWorkflowProgress extends LitElement {
+  static properties = {
+    workflow: {type: Object},
+    currentThread: {type: Number},
+    numThreads: {type: Number},
+    currentAction: {type: Number},
+    status: {type: String},
+  };
+
+  static styles = css`
+    .progressbar-container {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+    }
+  `;
+
+  renderThreadProgress() {
+    // @TODO: Improve this UI when the actions section is complete
+    return html`
+      <div class="progressbar-container">
+        <progress value=${this.currentThread + 1} max=${
+        this.numThreads}></progress>
+        <span>Thread ${this.currentThread + 1}/${this.numThreads}</span>
+      </div>
+      <p style="color: gray;">(Debug information) Status: ${this.status}</p>
+    `;
+  }
+
+  renderActions() {
+    const actions = this.workflow?.getActionsList?.() ?? [];
+    return map(actions, (action, i) => {
+      let status;
+      if (i > this.currentAction)
+        status = 'idle';
+      else if (i == this.currentAction && this.status == 'running')
+        status = 'running';
+      else if (i == this.currentAction && this.status == 'error')
+        status = 'error';
+      else
+        status = 'done';
+
+      return html`
+        <wf-action-editor
+            .action=${action}
+            readOnly
+            step=${i + 1}
+            status=${status}>
+        </wf-action-editor>
+      `;
+    });
+  }
+
+  render() {
+    return [
+      this.renderThreadProgress(),
+      this.renderActions(),
+    ];
+  }
+}
+window.customElements.define('twpt-workflow-progress', TwptWorkflowProgress);
diff --git a/src/contentScripts/communityConsole/workflows/components/index.js b/src/contentScripts/communityConsole/workflows/components/index.js
index 3041e41..1822edc 100644
--- a/src/contentScripts/communityConsole/workflows/components/index.js
+++ b/src/contentScripts/communityConsole/workflows/components/index.js
@@ -1,5 +1,6 @@
-import './TwptWorkflowsMenu.js';
 import './TwptConfirmDialog.js';
+import './TwptWorkflowDialog.js';
+import './TwptWorkflowsMenu.js';
 
 import {css, html, LitElement} from 'lit';
 import {createRef, ref} from 'lit/directives/ref.js';
@@ -52,14 +53,13 @@
   }
 
   _startWorkflow() {
-    // @TODO
-    console.log(`Start workflow ${this._selectedWorkflowUuid}!`);
+    this.workflowDialogRef.value.workflow = this._selectedWorkflow.cloneMessage();
+    this.workflowDialogRef.value.start();
   }
 
   get _selectedWorkflow() {
     for (const w of this._workflows) {
-      if (w.uuid == this._selectedWorkflowUuid)
-        return w.proto;
+      if (w.uuid == this._selectedWorkflowUuid) return w.proto;
     }
 
     return null;
diff --git a/src/contentScripts/communityConsole/workflows/runner.js b/src/contentScripts/communityConsole/workflows/runner.js
new file mode 100644
index 0000000..2e90b19
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/runner.js
@@ -0,0 +1,148 @@
+import {parseUrl, recursiveParentElement} from '../../../common/commonUtils.js';
+import * as pb from '../../../workflows/proto/main_pb.js';
+
+import CRRunner from './actionRunners/replyWithCR.js';
+
+export default class WorkflowRunner {
+  constructor(workflow, updateCallback) {
+    this.workflow = workflow;
+    this._threads = [];
+    this._currentThreadIndex = 0;
+    this._currentActionIndex = 0;
+    // Can be 'waiting', 'running', 'error', 'finished'
+    this._status = 'waiting';
+    this._updateCallback = updateCallback;
+
+    // Initialize action runners:
+    this._CRRunner = new CRRunner();
+  }
+
+  start() {
+    this._getSelectedThreads();
+    this.status = 'running';
+    this._runNextAction();
+  }
+
+  _getSelectedThreads() {
+    let threads = [];
+    const checkboxes = document.querySelectorAll(
+        '.thread-group material-checkbox[aria-checked="true"]');
+
+    for (const checkbox of checkboxes) {
+      const url = recursiveParentElement(checkbox, 'EC-THREAD-SUMMARY')
+                      .querySelector('a.header-content')
+                      .href;
+      const thread = parseUrl(url);
+      if (!thread) {
+        console.error('Couldn\'t parse URL ' + url);
+        continue;
+      }
+      threads.push(thread);
+    }
+
+    this.threads = threads;
+  }
+
+  _showError(err) {
+    console.warn(
+        `An error ocurred while executing action ${this.currentActionIndex}.`,
+        err);
+    this.status = 'error';
+  }
+
+  _runAction() {
+    switch (this._currentAction?.getActionCase?.()) {
+      case pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION:
+        return this._CRRunner.execute(
+            this._currentAction?.getReplyWithCrAction?.(), this._currentThread);
+
+      default:
+        return Promise.reject(new Error('This action isn\'t supported yet.'));
+    }
+  }
+
+  _runNextAction() {
+    if (this.status !== 'running')
+      return console.error(
+          'Trying to run next action with status ' + this.status + '.');
+
+    this._runAction()
+        .then(() => {
+          if (this._nextActionIfAvailable())
+            this._runNextAction();
+          else
+            this._finish();
+        })
+        .catch(err => this._showError(err));
+  }
+
+  _nextActionIfAvailable() {
+    if (this.currentActionIndex === this._actions.length - 1) {
+      if (this.currentThreadIndex === this.numThreads - 1) return false;
+
+      this.currentThreadIndex++;
+      this.currentActionIndex = 0;
+      return true;
+    }
+
+    this.currentActionIndex++;
+    return true;
+  }
+
+  _finish() {
+    this.status = 'finished';
+  }
+
+  get numThreads() {
+    return this.threads.length ?? 0;
+  }
+
+  get _actions() {
+    return this.workflow?.getActionsList?.();
+  }
+
+  get _currentAction() {
+    return this._actions?.[this.currentActionIndex];
+  }
+
+  get _currentThread() {
+    return this._threads?.[this.currentThreadIndex];
+  }
+
+  // Setters/getters for properties, which will update the UI when changed.
+  get threads() {
+    return this._threads;
+  }
+
+  set threads(value) {
+    this._threads = value;
+    this._updateCallback();
+  }
+
+  get currentThreadIndex() {
+    return this._currentThreadIndex;
+  }
+
+  set currentThreadIndex(value) {
+    this._currentThreadIndex = value;
+    this._updateCallback();
+  }
+
+  get currentActionIndex() {
+    return this._currentActionIndex;
+  }
+
+  set currentActionIndex(value) {
+    this._currentActionIndex = value;
+    this._updateCallback();
+  }
+
+  get status() {
+    return this._status;
+  }
+
+  set status(value) {
+    this._status = value;
+    this._updateCallback();
+  }
+}