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/common/commonUtils.js b/src/common/commonUtils.js
index 814ce29..5784da1 100644
--- a/src/common/commonUtils.js
+++ b/src/common/commonUtils.js
@@ -24,3 +24,11 @@
   a.addEventListener('click', e => e.stopPropagation(), false);
   return a;
 }
+
+export function recursiveParentElement(el, tag) {
+  while (el !== document.documentElement) {
+    el = el.parentNode;
+    if (el.tagName == tag) return el;
+  }
+  return undefined;
+}
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();
+  }
+}
diff --git a/src/injections/batchLock.js b/src/injections/batchLock.js
index 4fb3af8..6d7a1e5 100644
--- a/src/injections/batchLock.js
+++ b/src/injections/batchLock.js
@@ -1,15 +1,7 @@
 import {CCApi} from '../common/api.js';
-import {parseUrl} from '../common/commonUtils.js';
+import {parseUrl, recursiveParentElement} from '../common/commonUtils.js';
 import {getAuthUser} from '../common/communityConsoleUtils.js';
 
-function recursiveParentElement(el, tag) {
-  while (el !== document.documentElement) {
-    el = el.parentNode;
-    if (el.tagName == tag) return el;
-  }
-  return undefined;
-}
-
 // Source:
 // https://stackoverflow.com/questions/33063774/communication-from-an-injected-script-to-the-content-script-with-a-response
 var contentScriptRequest = (function() {
diff --git a/src/workflows/manager/components/ActionEditor.js b/src/workflows/manager/components/ActionEditor.js
index 971b098..c4b3faf 100644
--- a/src/workflows/manager/components/ActionEditor.js
+++ b/src/workflows/manager/components/ActionEditor.js
@@ -1,26 +1,14 @@
 import './actions/ReplyWithCR.js';
 
-import {css, html, LitElement, nothing} from 'lit';
+import '@material/mwc-circular-progress/mwc-circular-progress.js';
+
+import {html, LitElement, nothing} from 'lit';
 import {map} from 'lit/directives/map.js';
 import {createRef, ref} from 'lit/directives/ref.js';
 
 import * as pb from '../../proto/main_pb.js';
+import {kActionHeadings, kActionStyles, kSupportedActions} from '../shared/actions.js';
 
-// TODO: remove this and substitute it with proper localization.
-const kActionHeadings = {
-  0: 'Unknown action',
-  1: 'Reply',
-  2: 'Move to a forum',
-  3: 'Mark as duplicate of a thread',
-  4: 'Unmark duplicate',
-  5: 'Change thread attributes',
-  6: 'Reply with canned response',
-  16: 'Star/unstar thread',
-  17: 'Subscribe/unsubscribe to thread',
-  18: 'Vote thread',
-  19: 'Report thread',
-};
-const kSupportedActions = new Set([6]);
 const actionCases = Object.entries(pb.workflows.Action.ActionCase);
 
 export default class WFActionEditor extends LitElement {
@@ -29,50 +17,10 @@
     readOnly: {type: Boolean},
     disableRemoveButton: {type: Boolean},
     step: {type: Number},
+    status: {type: String},
   };
 
-  static styles = css`
-    .action {
-      margin-bottom: 20px;
-    }
-
-    .header {
-      display: flex;
-      align-items: center;
-      margin-bottom: 8px;
-    }
-
-    .step {
-      display: flex;
-      align-items: center;
-      justify-content: center;
-
-      min-height: 30px;
-      min-width: 30px;
-      margin-inline-end: 8px;
-
-      font-weight: 500;
-      font-size: 18px;
-
-      border-radius: 50%;
-      color: white;
-      background-color: #018786;
-    }
-
-    .title {
-      font-weight: 500;
-      margin: 0;
-      flex-grow: 1;
-    }
-
-    .header .select {
-      flex-grow: 1;
-      width: 300px;
-      padding: 4px;
-      margin-inline-end: 8px;
-      font-size: 16px;
-    }
-  `;
+  static styles = kActionStyles;
 
   selectRef = createRef();
 
@@ -119,27 +67,32 @@
   }
 
   render() {
-    return [
-      html`
-        <div class="action">
-          <div class="header">
-            <div class="step">${this.step}</div>
-            ${this.renderActionTitle()}
+    let actionClass = '';
+    if (this.readOnly && this.status) actionClass = 'action--' + this.status;
+    return html`
+      <div class="action ${actionClass}">
+        <div class="header">
+          <div class="step">
+            ${this.step}
             ${
-          !this.readOnly ?
-              html`
-                <button
-                    ?disabled=${this.disableRemoveButton}
-                    @click=${this._remove}>
-                  Remove
-                </button>
-              ` :
-              nothing}
+        this.status == 'running' ?
+            html`<mwc-circular-progress indeterminate density="-1"></mwc-circular-progress>` :
+            ''}
           </div>
-          ${this.renderSpecificActionEditor()}
+          ${this.renderActionTitle()}
+          ${
+    !this.readOnly ? html`
+              <button
+                  ?disabled=${this.disableRemoveButton}
+                  @click=${this._remove}>
+                Remove
+              </button>
+            ` :
+                     nothing}
         </div>
-      `,
-    ];
+        ${this.renderSpecificActionEditor()}
+      </div>
+    `;
   }
 
   checkValidity() {
diff --git a/src/workflows/manager/shared/actions.js b/src/workflows/manager/shared/actions.js
new file mode 100644
index 0000000..4551f1f
--- /dev/null
+++ b/src/workflows/manager/shared/actions.js
@@ -0,0 +1,76 @@
+import {css} from 'lit';
+
+// TODO: remove this and substitute it with proper localization.
+export const kActionHeadings = {
+  0: 'Unknown action',
+  1: 'Reply',
+  2: 'Move to a forum',
+  3: 'Mark as duplicate of a thread',
+  4: 'Unmark duplicate',
+  5: 'Change thread attributes',
+  6: 'Reply with canned response',
+  16: 'Star/unstar thread',
+  17: 'Subscribe/unsubscribe to thread',
+  18: 'Vote thread',
+  19: 'Report thread',
+};
+
+export const kSupportedActions = new Set([6]);
+
+export const kActionStyles = css`
+  .action {
+    margin-bottom: 20px;
+  }
+
+  .header {
+    display: flex;
+    align-items: center;
+    margin-bottom: 8px;
+  }
+
+  .step {
+    display: flex;
+    position: relative;
+    align-items: center;
+    justify-content: center;
+
+    min-height: 30px;
+    min-width: 30px;
+    margin-inline-end: 8px;
+
+    font-weight: 500;
+    font-size: 18px;
+
+    border-radius: 50%;
+    color: white;
+    background-color: #018786;
+  }
+
+  :is(.action--idle, .action--running) .step {
+    color: black;
+    background-color: #d1d1d1;
+  }
+
+  .action--error .step {
+    background-color: #c30000;
+  }
+
+  .step mwc-circular-progress {
+    position: absolute;
+    --mdc-theme-primary: #018786;
+  }
+
+  .title {
+    font-weight: 500;
+    margin: 0;
+    flex-grow: 1;
+  }
+
+  .header .select {
+    flex-grow: 1;
+    width: 300px;
+    padding: 4px;
+    margin-inline-end: 8px;
+    font-size: 16px;
+  }
+`;