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;
+ }
+`;