Show workflows in thread list menu

This CL adds the logic for displaying the list of workflows in the menu
which is added to thread lists. Before, the list included some fake
names. It also adds a button to manage workflows.

The next step is to create the components/logic which will allow the
user to execute the workflow in the selected threads.

Bug: twpowertools:74
Change-Id: I22d0be8a101f9e167b9408bb6046299f3bd3c787
diff --git a/package-lock.json b/package-lock.json
index 3a700a4..cc0eb90 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
         "css-loader": "^6.2.0",
         "json5": "^2.2.0",
         "path": "^0.12.7",
+        "raw-loader": "^4.0.2",
         "sass": "^1.38.1",
         "sass-loader": "^12.1.0",
         "style-loader": "^3.2.1",
@@ -1193,6 +1194,15 @@
         "tslib": "^2.3.1"
       }
     },
+    "node_modules/big.js": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
+      "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/binary-extensions": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -1421,6 +1431,15 @@
       "integrity": "sha512-X/6VRCXWALzdX+RjCtBU6cyg8WZgoxm9YA02COmDOiNJEZ59WkQggDbWZ4t/giHi/3GS+cvdrP6gbLISANAGYA==",
       "dev": true
     },
+    "node_modules/emojis-list": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
+      "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
     "node_modules/enhanced-resolve": {
       "version": "5.8.2",
       "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz",
@@ -1974,6 +1993,20 @@
         "node": ">=6.11.5"
       }
     },
+    "node_modules/loader-utils": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
+      "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
+      "dev": true,
+      "dependencies": {
+        "big.js": "^5.2.2",
+        "emojis-list": "^3.0.0",
+        "json5": "^2.1.2"
+      },
+      "engines": {
+        "node": ">=8.9.0"
+      }
+    },
     "node_modules/locate-path": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -2387,6 +2420,26 @@
         "safe-buffer": "^5.1.0"
       }
     },
+    "node_modules/raw-loader": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
+      "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
+      "dev": true,
+      "dependencies": {
+        "loader-utils": "^2.0.0",
+        "schema-utils": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 10.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "webpack": "^4.0.0 || ^5.0.0"
+      }
+    },
     "node_modules/readdirp": {
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -4140,6 +4193,12 @@
         "tslib": "^2.3.1"
       }
     },
+    "big.js": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
+      "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
+      "dev": true
+    },
     "binary-extensions": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -4304,6 +4363,12 @@
       "integrity": "sha512-X/6VRCXWALzdX+RjCtBU6cyg8WZgoxm9YA02COmDOiNJEZ59WkQggDbWZ4t/giHi/3GS+cvdrP6gbLISANAGYA==",
       "dev": true
     },
+    "emojis-list": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
+      "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
+      "dev": true
+    },
     "enhanced-resolve": {
       "version": "5.8.2",
       "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz",
@@ -4726,6 +4791,17 @@
       "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==",
       "dev": true
     },
+    "loader-utils": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
+      "integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
+      "dev": true,
+      "requires": {
+        "big.js": "^5.2.2",
+        "emojis-list": "^3.0.0",
+        "json5": "^2.1.2"
+      }
+    },
     "locate-path": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -5013,6 +5089,16 @@
         "safe-buffer": "^5.1.0"
       }
     },
+    "raw-loader": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
+      "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
+      "dev": true,
+      "requires": {
+        "loader-utils": "^2.0.0",
+        "schema-utils": "^3.0.0"
+      }
+    },
     "readdirp": {
       "version": "3.6.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
diff --git a/package.json b/package.json
index 8eaa3db..b033fc6 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
     "css-loader": "^6.2.0",
     "json5": "^2.2.0",
     "path": "^0.12.7",
+    "raw-loader": "^4.0.2",
     "sass": "^1.38.1",
     "sass-loader": "^12.1.0",
     "style-loader": "^3.2.1",
diff --git a/src/bg.js b/src/bg.js
index 4244e93..44b546a 100644
--- a/src/bg.js
+++ b/src/bg.js
@@ -102,6 +102,11 @@
           .catch(error => sendResponse({status: 'rejected', error}));
       break;
 
+    case 'openWorkflowsManager':
+      chrome.tabs.create({
+        url: chrome.runtime.getURL('options/workflows.html'),
+      });
+
     default:
       console.warn('Unknown message "' + msg.message + '".');
   }
diff --git a/src/contentScripts/communityConsole/workflows/components/TwptWorkflowsMenu.js b/src/contentScripts/communityConsole/workflows/components/TwptWorkflowsMenu.js
index 1399228..a706a93 100644
--- a/src/contentScripts/communityConsole/workflows/components/TwptWorkflowsMenu.js
+++ b/src/contentScripts/communityConsole/workflows/components/TwptWorkflowsMenu.js
@@ -1,40 +1,92 @@
+import '@material/web/icon/icon.js';
 import '@material/web/iconbutton/standard-icon-button.js';
-import '@material/web/menu/menu-button.js';
+import '@material/web/list/list-divider.js';
 import '@material/web/menu/menu.js';
+import '@material/web/menu/menu-button.js';
 import '@material/web/menu/menu-item.js';
 
-import {css, html, LitElement} from 'lit';
+import consoleCommonStyles from '!!raw-loader!../../../../static/css/common/console.css';
+
+import {css, html, LitElement, nothing, unsafeCSS} from 'lit';
 import {map} from 'lit/directives/map.js';
-import {range} from 'lit/directives/range.js';
 
 import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
 
 export default class TwptWorkflowsMenu extends LitElement {
+  static properties = {
+    workflows: {type: Object},
+  };
+
   static styles = [
     SHARED_MD3_STYLES,
+    css`${unsafeCSS(consoleCommonStyles)}`,
     css`
       .workflow-item {
         padding-inline: 1em;
       }
+
+      /* Custom styles to override the common button with badge styles */
+      .TWPT-btn--with-badge {
+        padding-bottom: 0!important;
+      }
+
+      .TWPT-btn--with-badge .TWPT-badge {
+        bottom: 8px!important;
+      }
     `,
   ];
 
-  renderMenuItems() {
-    return map(
-        range(8),
-        i => html`
-      <md-menu-item @click="${this._showWorkflow}" data-workflow-id="${
-            i}"><span class="workflow-item" slot="start">Workflow ${
-            i}</span></md-menu-item>
+  renderWorkflowItems() {
+    if (!this.workflows) return nothing;
+    if (this.workflows?.length == 0)
+      return html`
+        <md-menu-item disabled>
+          <span class="workflow-item" slot="start">
+            No workflows
+          </span>
+        </md-menu-item>
+      `;
+    return map(this.workflows, w => html`
+      <md-menu-item
+          @click="${() => this._showWorkflow(w.uuid)}">
+        <span class="workflow-item" slot="start">
+          ${w.proto.getName()}
+        </span>
+      </md-menu-item>
     `);
   }
 
+  renderMenuItems() {
+    return [
+      this.renderWorkflowItems(),
+      html`
+        <md-list-divider></md-list-divider>
+        <md-menu-item
+            @click="${() => this._openWorkflowManager()}">
+          <span class="workflow-item" slot="start">
+            Manage workflows...
+          </span>
+        </md-menu-item>
+      `,
+    ];
+  }
+
+  // Based on createExtBadge() in ../../utils/common.js.
+  renderBadge() {
+    return html`
+      <div class="TWPT-badge">
+        <md-icon>repeat</md-icon>
+      </div>
+    `;
+  }
+
   render() {
-    // The button is based in the button created in the
-    // addButtonToThreadListActions function in file ../../utils/common.js.
     return html`
       <md-menu-button>
-        <md-standard-icon-button slot="button" icon="more_vert"></md-standard-icon-button>
+        <div slot="button" class="TWPT-btn--with-badge">
+          <md-standard-icon-button icon="more_vert"></md-standard-icon-button>
+          ${this.renderBadge()}
+        </div>
         <md-menu slot="menu">
           ${this.renderMenuItems()}
         </md-menu>
@@ -42,9 +94,13 @@
     `;
   }
 
-  _showWorkflow(e) {
-    console.log(
-        `Clicked workflow ${e.target.getAttribute('data-workflow-id')}.`);
+  _showWorkflow(uuid) {
+    console.log(`Clicked workflow ${uuid}.`);
+  }
+
+  _openWorkflowManager() {
+    const e = new Event('twpt-open-workflow-manager');
+    document.dispatchEvent(e);
   }
 }
 window.customElements.define('twpt-workflows-menu', TwptWorkflowsMenu);
diff --git a/src/contentScripts/communityConsole/workflows/components/index.js b/src/contentScripts/communityConsole/workflows/components/index.js
new file mode 100644
index 0000000..8f73fee
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/components/index.js
@@ -0,0 +1,29 @@
+import './TwptWorkflowsMenu.js';
+
+import {css, html, LitElement} from 'lit';
+
+import WorkflowsStorage from '../../../../workflows/workflowsStorage.js';
+
+export default class TwptWorkflowsInject extends LitElement {
+  static properties = {
+    _workflows: {type: Object},
+  };
+
+  constructor() {
+    super();
+    this._workflows = null;
+    this.addEventListener('twpt-workflows-update', e => {
+      const workflows = e.detail?.workflows ?? [];
+      WorkflowsStorage.convertRawListToProtobuf(workflows);
+      this._workflows = workflows;
+    });
+  }
+
+  render() {
+    return html`
+      <twpt-workflows-menu .workflows=${this._workflows}></twpt-workflows-menu>
+      <twpt-workflow-dialog></twpt-workflow-dialog>
+    `;
+  }
+}
+window.customElements.define('twpt-workflows-inject', TwptWorkflowsInject);
diff --git a/src/contentScripts/communityConsole/workflows/workflows.js b/src/contentScripts/communityConsole/workflows/workflows.js
index 1c99729..7ba3cbc 100644
--- a/src/contentScripts/communityConsole/workflows/workflows.js
+++ b/src/contentScripts/communityConsole/workflows/workflows.js
@@ -1,16 +1,45 @@
 import {isOptionEnabled} from '../../../common/optionsUtils.js';
+import WorkflowsStorage from '../../../workflows/workflowsStorage.js';
 import {addElementToThreadListActions, shouldAddBtnToActionBar} from '../utils/common.js';
 
 const wfDebugId = 'twpt-workflows';
 
 export default class Workflows {
-  constructor() {}
+  constructor() {
+    this.menu = null;
+    this.workflows = null;
+
+    // Always keep the workflows list updated
+    WorkflowsStorage.watch(workflows => {
+      this.workflows = workflows;
+      this._emitWorkflowsUpdateEvent();
+    }, /* asProtobuf = */ false);
+
+    // Open the workflow manager when instructed to do so.
+    document.addEventListener('twpt-open-workflow-manager', () => {
+      chrome.runtime.sendMessage({
+        message: 'openWorkflowsManager',
+      });
+    });
+  }
+
+  _emitWorkflowsUpdateEvent() {
+    if (!this.menu) return;
+    const e = new CustomEvent('twpt-workflows-update', {
+      detail: {
+        workflows: this.workflows,
+      }
+    });
+    this.menu?.dispatchEvent?.(e);
+  }
 
   addThreadListBtnIfEnabled(readToggle) {
     isOptionEnabled('workflows').then(isEnabled => {
       if (isEnabled) {
-        const menu = document.createElement('twpt-workflows-menu');
-        addElementToThreadListActions(readToggle, menu);
+        this.menu = document.createElement('twpt-workflows-inject');
+        this.menu.setAttribute('debugid', wfDebugId);
+        this._emitWorkflowsUpdateEvent();
+        addElementToThreadListActions(readToggle, this.menu);
       }
     });
   }
diff --git a/src/injections/workflowComponentsInject.js b/src/injections/workflowComponentsInject.js
index c15111e..d7e788e 100644
--- a/src/injections/workflowComponentsInject.js
+++ b/src/injections/workflowComponentsInject.js
@@ -2,7 +2,7 @@
 // This is done by injecting this javascript file instead of placing this code
 // directly in the content script because `window.customElements` doesn't exist
 // in content scripts.
-import '../contentScripts/communityConsole/workflows/components/TwptWorkflowsMenu.js';
+import '../contentScripts/communityConsole/workflows/components/index.js';
 
 import {injectStylesheet} from '../common/contentScriptsUtils.js';
 
diff --git a/src/static/css/common/console.css b/src/static/css/common/console.css
index 04c9648..20cb248 100644
--- a/src/static/css/common/console.css
+++ b/src/static/css/common/console.css
@@ -17,7 +17,7 @@
   user-select: none;
 }
 
-.TWPT-badge .material-icon-i {
+.TWPT-badge :is(.material-icon-i, md-icon) {
   font-size: var(--icon-size, 16px);
 }
 
diff --git a/src/workflows/workflowsStorage.js b/src/workflows/workflowsStorage.js
index 6e10a07..3fc6f95 100644
--- a/src/workflows/workflowsStorage.js
+++ b/src/workflows/workflowsStorage.js
@@ -18,6 +18,13 @@
     callOnChanged();
   }
 
+  static convertRawListToProtobuf(workflows) {
+    workflows.forEach(w => {
+      w.proto = pb.workflows.Workflow.deserializeBinary(w?.data);
+      delete w.data;
+    });
+  }
+
   static getAll(asProtobuf = false) {
     return new Promise(res => {
       chrome.storage.local.get(kWorkflowsDataKey, items => {
@@ -25,10 +32,7 @@
         if (!Array.isArray(workflows)) return res([]);
         if (!asProtobuf) return res(workflows);
 
-        workflows.map(w => {
-          w.proto = pb.workflows.Workflow.deserializeBinary(w?.data);
-          delete w.data;
-        });
+        this.convertRawListToProtobuf(workflows);
         return res(workflows);
       });
     });