Add workflow menu button to thread lists

Bug: twpowertools:74
Change-Id: I703950394d674c2084278bf9e876014d08fa5cfb
diff --git a/src/contentScripts/communityConsole/batchLock.js b/src/contentScripts/communityConsole/batchLock.js
index 86a7057..87edc6d 100644
--- a/src/contentScripts/communityConsole/batchLock.js
+++ b/src/contentScripts/communityConsole/batchLock.js
@@ -1,19 +1,12 @@
 import {isOptionEnabled} from '../../common/optionsUtils.js';
 
-import {addButtonToThreadListActions, removeChildNodes} from './utils/common.js';
+import {addButtonToThreadListActions, removeChildNodes, shouldAddBtnToActionBar} from './utils/common.js';
+
+const lockDebugId = 'twpt-batch-lock';
 
 export var batchLock = {
-  nodeIsReadToggleBtn(node) {
-    return ('tagName' in node) && node.tagName == 'MATERIAL-BUTTON' &&
-        node.getAttribute('debugid') !== null &&
-        (node.getAttribute('debugid') == 'mark-read-button' ||
-         node.getAttribute('debugid') == 'mark-unread-button') &&
-        ('parentNode' in node) && node.parentNode !== null &&
-        ('parentNode' in node.parentNode) &&
-        node.parentNode.querySelector('[debugid="twpt-lock"]') === null &&
-        node.parentNode.parentNode !== null &&
-        ('tagName' in node.parentNode.parentNode) &&
-        node.parentNode.parentNode.tagName == 'EC-BULK-ACTIONS';
+  shouldAddButton(node) {
+    return shouldAddBtnToActionBar(lockDebugId, node);
   },
   createDialog() {
     var modal = document.querySelector('.pane[pane-id="default-1"]');
@@ -119,7 +112,7 @@
       if (isEnabled) {
         let tooltip = chrome.i18n.getMessage('inject_lockbtn');
         let btn = addButtonToThreadListActions(
-            readToggle, 'lock', 'twpt-lock', tooltip);
+            readToggle, 'lock', lockDebugId, tooltip);
         btn.addEventListener('click', () => {
           this.createDialog();
         });
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index cc55aa4..870f1c4 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -9,9 +9,10 @@
 import {applyDragAndDropFixIfEnabled} from './dragAndDropFix.js';
 // #!endif
 import {unifiedProfilesFix} from './unifiedProfiles.js';
+import Workflows from './workflows/workflows.js';
 
 var mutationObserver, intersectionObserver, intersectionOptions, options,
-    avatars;
+    avatars, workflows;
 
 const watchedNodesSelectors = [
   // App container (used to set up the intersection observer and inject the dark
@@ -112,11 +113,14 @@
     }
     // #!endif
 
-    // Inject the batch lock button in the thread list if the option is
-    // currently enabled.
-    if (batchLock.nodeIsReadToggleBtn(node)) {
+    // Inject the batch lock and workflow buttons in the thread list if the
+    // corresponding options are currently enabled.
+    // The order is the inverse because the first one will be shown last.
+    if (batchLock.shouldAddButton(node))
       batchLock.addButtonIfEnabled(node);
-    }
+
+    if (workflows.shouldAddThreadListBtn(node))
+      workflows.addThreadListBtnIfEnabled(node);
 
     // Inject avatar links to threads in the thread list. injectIfEnabled is
     // responsible of determining whether it should run or not depending on its
@@ -180,6 +184,7 @@
 
   // Initialize classes needed by the mutation observer
   avatars = new AvatarsHandler();
+  workflows = new Workflows();
 
   // autoRefresh is initialized in start.js
 
diff --git a/src/contentScripts/communityConsole/utils/common.js b/src/contentScripts/communityConsole/utils/common.js
index 648fae2..68fb736 100644
--- a/src/contentScripts/communityConsole/utils/common.js
+++ b/src/contentScripts/communityConsole/utils/common.js
@@ -34,7 +34,8 @@
 // Adds a button to the thread list actions bar next to the button given by
 // |originalBtn|. The button will have icon |icon|, when hovered it will display
 // |tooltip|, and will have a debugid attribute with value |debugId|.
-export function addButtonToThreadListActions(originalBtn, icon, debugId, tooltip) {
+export function addButtonToThreadListActions(
+    originalBtn, icon, debugId, tooltip) {
   let clone = originalBtn.cloneNode(true);
   clone.setAttribute('debugid', debugId);
   clone.classList.add('TWPT-btn--with-badge');
@@ -59,3 +60,15 @@
 
   return clone;
 }
+
+// Returns true if |node| is the "mark as read/unread" button, the parent of the
+// parent of |node| is the actions bar of the thread list, and the button with
+// debugid |debugid| is NOT part of the actions bar.
+export function shouldAddBtnToActionBar(debugid, node) {
+  return node?.tagName == 'MATERIAL-BUTTON' &&
+      (node.getAttribute?.('debugid') == 'mark-read-button' ||
+       node.getAttribute?.('debugid') == 'mark-unread-button') &&
+      node.getAttribute?.('debugid') !== null &&
+      node.parentNode?.querySelector('[debugid="' + debugid + '"]') === null &&
+      node.parentNode?.parentNode?.tagName == 'EC-BULK-ACTIONS';
+}
diff --git a/src/contentScripts/communityConsole/workflows/components/Overlay.vue b/src/contentScripts/communityConsole/workflows/components/Overlay.vue
new file mode 100644
index 0000000..304b362
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/components/Overlay.vue
@@ -0,0 +1,29 @@
+<script>
+import WfMenu from './WfMenu.vue';
+
+export default {
+  components: {
+    WfMenu,
+  },
+  data() {
+    return {
+      shown: false,
+      position: [0, 0],
+      // TODO: Get real data.
+      workflows: [
+        {name: 'Move to accounts'},
+        {name: 'Mark as spam w/ message'},
+      ],
+    };
+  },
+  methods: {
+    startWorkflow(e) {
+      console.log(e);
+    }
+  },
+}
+</script>
+
+<template>
+  <wf-menu v-model="shown" :position="position" :workflows="workflows" @select="startWorkflow" />
+</template>
diff --git a/src/contentScripts/communityConsole/workflows/components/WfMenu.vue b/src/contentScripts/communityConsole/workflows/components/WfMenu.vue
new file mode 100644
index 0000000..88528ea
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/components/WfMenu.vue
@@ -0,0 +1,39 @@
+<script>
+import {Corner} from '@material/menu-surface/constants.js';
+
+export default {
+  components: {},
+  props: {
+    modelValue: Boolean,
+    position: Array,
+    workflows: Array,
+  },
+  data() {
+    return {
+      corner: Corner.TOP_RIGHT,
+    };
+  },
+  emits: [
+    'update:modelValue',
+    'select',
+  ],
+}
+</script>
+
+<template>
+  <mcw-menu :model-value="modelValue" fixed :anchor-corner="corner"
+      :style="{ left: 'unset', right: 'calc(100% - ' + position[0] + 'px)', top: position[1] + 'px' }"
+      @update:model-value="$emit('update:modelValue', $event)" @select="$emit('select', $event)">
+    <mcw-list-item v-for="wf in workflows">{{ wf.name }}</mcw-list-item>
+  </mcw-menu>
+</template>
+
+<style scoped>
+.mdc-list-item {
+  /* These styles mimic the Community Console style. */
+  font-family: 'Google Sans Text', 'Noto', sans-serif;
+  font-size: 14px;
+  font-weight: 400;
+  height: 40px!important;
+}
+</style>
diff --git a/src/contentScripts/communityConsole/workflows/vma.js b/src/contentScripts/communityConsole/workflows/vma.js
new file mode 100644
index 0000000..89d291c
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/vma.js
@@ -0,0 +1,9 @@
+// We just import the components needed.
+import {list, menu} from 'vue-material-adapter';
+
+export default {
+  install(vm) {
+    vm.use(list);
+    vm.use(menu);
+  },
+}
diff --git a/src/contentScripts/communityConsole/workflows/workflows.js b/src/contentScripts/communityConsole/workflows/workflows.js
new file mode 100644
index 0000000..dc542fa
--- /dev/null
+++ b/src/contentScripts/communityConsole/workflows/workflows.js
@@ -0,0 +1,53 @@
+import {createApp} from 'vue';
+
+import {isOptionEnabled} from '../../../common/optionsUtils.js';
+
+import {addButtonToThreadListActions, shouldAddBtnToActionBar} from './../utils/common.js';
+import Overlay from './components/Overlay.vue';
+import VueMaterialAdapter from './vma.js';
+
+const wfDebugId = 'twpt-workflows';
+
+export default class Workflows {
+  constructor() {
+    this.overlayApp = null;
+    this.overlayVm = null;
+  }
+
+  createOverlay() {
+    let menuEl = document.createElement('div');
+    document.body.appendChild(menuEl);
+
+    this.overlayApp = createApp(Overlay);
+    this.overlayApp.use(VueMaterialAdapter);
+    this.overlayVm = this.overlayApp.mount(menuEl);
+  }
+
+  switchMenu(menuBtn) {
+    if (this.overlayApp === null) this.createOverlay();
+    if (!this.overlayVm.shown) {
+      let rect = menuBtn.getBoundingClientRect();
+      this.overlayVm.position = [rect.left + rect.width, rect.bottom];
+      this.overlayVm.shown = true;
+    } else {
+      this.overlayVm.shown = false;
+    }
+  }
+
+  addThreadListBtnIfEnabled(readToggle) {
+    isOptionEnabled('workflows').then(isEnabled => {
+      if (isEnabled) {
+        let tooltip = chrome.i18n.getMessage('inject_workflows_menubtn');
+        let btn = addButtonToThreadListActions(
+            readToggle, 'more_vert', wfDebugId, tooltip);
+        btn.addEventListener('click', () => {
+          this.switchMenu(btn);
+        });
+      }
+    });
+  }
+
+  shouldAddThreadListBtn(node) {
+    return shouldAddBtnToActionBar(wfDebugId, node);
+  }
+};
diff --git a/src/mdc/styles.scss b/src/mdc/styles.scss
index 22ecb8d..a5be23c 100644
--- a/src/mdc/styles.scss
+++ b/src/mdc/styles.scss
@@ -1 +1,9 @@
 @use "@material/tooltip/styles";
+@use "@material/list/mdc-list";
+@use "@material/menu-surface/mdc-menu-surface";
+@use "@material/menu/mdc-menu";
+
+a.mdc-list-item {
+  color: inherit!important;
+  text-decoration: none!important;
+}
diff --git a/src/static/_locales/en/messages.json b/src/static/_locales/en/messages.json
index 99a0132..1c3e75b 100644
--- a/src/static/_locales/en/messages.json
+++ b/src/static/_locales/en/messages.json
@@ -275,6 +275,10 @@
     "message": "Due to technical reasons, we can't load the avatars of threads published in private forums.",
     "description": "Helper text which appears when hovering an icon next to a thread, to explain its meaning."
   },
+  "inject_workflows_menubtn": {
+    "message": "Run a workflow...",
+    "description": "Tooltip of the icon shown above a thread or in thread lists when selecting multiple threads in the Community Console which lets the user show a menu with the worklofws they can run."
+  },
   "actionbadge_permissions_requested": {
     "message": "Some features need additional permissions to work. Click to fix it.",
     "description": "Tooltip for the extension icon when a feature is enabled but it needs several permissions to be granted."
diff --git a/src/static/css/ccdarktheme.css b/src/static/css/ccdarktheme.css
index 8a6b6ad..aa49585 100644
--- a/src/static/css/ccdarktheme.css
+++ b/src/static/css/ccdarktheme.css
@@ -31,7 +31,7 @@
   color: var(--TWPT-primary-text);
 }
 
-body.ec a {
+body.ec a:not(.mdc-list-item) {
   color: var(--TWPT-link);
 }
 
diff --git a/src/static/css/common/console.css b/src/static/css/common/console.css
index 96cb5b9..04c9648 100644
--- a/src/static/css/common/console.css
+++ b/src/static/css/common/console.css
@@ -23,7 +23,7 @@
 
 .TWPT-btn--with-badge {
   position: relative;
-  padding: 4px;
+  padding: 0 4px 4px 0;
   cursor: pointer;
 }