Add initial version of the workflows manager
The workflows manager can be accessed from the extension options, and
will let the user add custom workflows, edit and view the already
created ones, and delete them.
As of now, the only feature implemented is a functional prototype of the
"new workflow" dialog, which is missing the workflow title field and the
functionality to persist the designed workflows. The only action
supported in the workflows editor is the "reply with canned response"
action.
Bug: twpowertools:74
Change-Id: I0d93bf0fdcda92a449855c1f8470f8b7068839aa
diff --git a/src/common/styles/md3.js b/src/common/styles/md3.js
new file mode 100644
index 0000000..c712226
--- /dev/null
+++ b/src/common/styles/md3.js
@@ -0,0 +1,7 @@
+import {css} from 'lit';
+
+export const SHARED_MD3_STYLES = css`
+ :host {
+ --md-sys-color-surface: rgb(227, 255, 251);
+ }
+`;
diff --git a/src/contentScripts/communityConsole/workflows/components/TwptWorkflowsMenu.js b/src/contentScripts/communityConsole/workflows/components/TwptWorkflowsMenu.js
index d36e730..1399228 100644
--- a/src/contentScripts/communityConsole/workflows/components/TwptWorkflowsMenu.js
+++ b/src/contentScripts/communityConsole/workflows/components/TwptWorkflowsMenu.js
@@ -7,16 +7,25 @@
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 styles = css`
- .workflow-item {
- padding-inline: 1em;
- }
- `;
+ static styles = [
+ SHARED_MD3_STYLES,
+ css`
+ .workflow-item {
+ padding-inline: 1em;
+ }
+ `,
+ ];
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>
+ 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>
`);
}
@@ -34,7 +43,8 @@
}
_showWorkflow(e) {
- console.log(`Clicked workflow ${e.target.getAttribute('data-workflow-id')}.`);
+ console.log(
+ `Clicked workflow ${e.target.getAttribute('data-workflow-id')}.`);
}
}
window.customElements.define('twpt-workflows-menu', TwptWorkflowsMenu);
diff --git a/src/static/options/workflows.html b/src/static/options/workflows.html
new file mode 100644
index 0000000..c00dcda
--- /dev/null
+++ b/src/static/options/workflows.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Workflow manager</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500">
+ <style>
+ body {
+ font-size: 100%;
+ }
+ </style>
+ </head>
+ <body>
+ <wf-app></wf-app>
+ <script src="/workflowManager.bundle.js" async defer="defer"></script>
+ </body>
+</html>
diff --git a/src/workflows/manager/components/ActionEditor.js b/src/workflows/manager/components/ActionEditor.js
new file mode 100644
index 0000000..fbd0bed
--- /dev/null
+++ b/src/workflows/manager/components/ActionEditor.js
@@ -0,0 +1,253 @@
+import './actions/ReplyWithCR.js';
+
+import {css, 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';
+
+// 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 {
+ static properties = {
+ action: {type: Object},
+ readOnly: {type: Boolean},
+ disableRemoveButton: {type: Boolean},
+ step: {type: Number},
+ };
+
+ 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;
+ }
+ `;
+
+ selectRef = createRef();
+
+ constructor() {
+ super();
+ this.action = new pb.workflows.Action();
+ this.readOnly = false;
+ }
+
+ renderActionTitle() {
+ if (this.readOnly) return html`<h3 class="title">${this._stepTitle()}</h3>`;
+
+ let selectedActionCase = this._actionCase;
+
+ return html`
+ <select ${ref(this.selectRef)}
+ class="select"
+ @change=${this._actionCaseChanged}>
+ ${map(actionCases, ([actionName, num]) => {
+ if (num == 0) return nothing;
+ return html`
+ <option value=${num} ?selected=${selectedActionCase == num}>
+ ${kActionHeadings[num] ?? actionName}
+ </option>
+ `;
+ })}
+ </select>
+ `;
+ }
+
+ renderSpecificActionEditor() {
+ switch (this._actionCase) {
+ case pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION:
+ return html`
+ <wf-action-reply-with-cr
+ ?readOnly=${this.readOnly}
+ .action=${this.action.getReplyWithCrAction()}>
+ </wf-action-reply-with-cr>
+ `;
+
+ default:
+ return html`<p>This action has not yet been implemented.</p>`;
+ }
+ }
+
+ render() {
+ return [
+ html`
+ <div class="action">
+ <div class="header">
+ <div class="step">${this.step}</div>
+ ${this.renderActionTitle()}
+ ${
+ !this.readOnly ?
+ html`
+ <button
+ ?disabled=${this.disableRemoveButton}
+ @click=${this._remove}>
+ Remove
+ </button>
+ ` :
+ nothing}
+ </div>
+ ${this.renderSpecificActionEditor()}
+ </div>
+ `,
+ ];
+ }
+
+ checkValidity() {
+ if (this.readOnly || !kSupportedActions.has(this._actionCase)) return true;
+ return this._specificActionEditor().checkValidity();
+ }
+
+ _actionCaseChanged() {
+ this._actionCaseString = this.selectRef.value.value;
+ }
+
+ _dispatchUpdateEvent() {
+ // Transmit to other components that the action has changed
+ const e = new Event('action-updated', {bubbles: true, composed: true});
+ this.renderRoot.dispatchEvent(e);
+ }
+
+ _remove() {
+ // Transmit to other components that the action has to be removed
+ const e = new Event('action-removed', {bubbles: true, composed: true});
+ this.renderRoot.dispatchEvent(e);
+ }
+
+ _stepTitle() {
+ return kActionHeadings[this._actionCase] ?? this._actionCase;
+ }
+
+ get _actionCase() {
+ return this.action.getActionCase();
+ }
+
+ set _actionCase(newCase) {
+ let value;
+ switch (newCase) {
+ case pb.workflows.Action.ActionCase.REPLY_ACTION:
+ value = new pb.workflows.Action.ReplyAction;
+ this.action.setReplyAction(value);
+ break;
+ case pb.workflows.Action.ActionCase.MOVE_ACTION:
+ value = new pb.workflows.Action.MoveAction;
+ this.action.setMoveAction(value);
+ break;
+ case pb.workflows.Action.ActionCase.MARK_DUPLICATE_ACTION:
+ value = new pb.workflows.Action.MarkDuplicateAction;
+ this.action.setMarkDuplicateAction(value);
+ break;
+ case pb.workflows.Action.ActionCase.UNMARK_DUPLICATE_ACTION:
+ value = new pb.workflows.Action.UnmarkDuplicateAction;
+ this.action.setUnmarkDuplicateAction(value);
+ break;
+ case pb.workflows.Action.ActionCase.ATTRIBUTE_ACTION:
+ value = new pb.workflows.Action.AttributeAction;
+ this.action.setAttributeAction(value);
+ break;
+ case pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION:
+ value = new pb.workflows.Action.ReplyWithCRAction;
+ this.action.setReplyWithCrAction(value);
+ break;
+ case pb.workflows.Action.ActionCase.STAR_ACTION:
+ value = new pb.workflows.Action.StarAction;
+ this.action.setStarAction(value);
+ break;
+ case pb.workflows.Action.ActionCase.SUBSCRIBE_ACTION:
+ value = new pb.workflows.Action.SubscribeAction;
+ this.action.setSubscribeAction(value);
+ break;
+ case pb.workflows.Action.ActionCase.VOTE_ACTION:
+ value = new pb.workflows.Action.VoteAction;
+ this.action.setVoteAction(value);
+ break;
+ case pb.workflows.Action.ActionCase.REPORT_ACTION:
+ value = new pb.workflows.Action.ReportAction;
+ this.action.setReportAction(value);
+ break;
+ default:
+ this.action.clearReplyAction();
+ this.action.clearMoveAction();
+ this.action.clearMarkDuplicateAction();
+ this.action.clearUnmarkDuplicateAction();
+ this.action.clearAttributeAction();
+ this.action.clearReplyWithCrAction();
+ this.action.clearStarAction();
+ this.action.clearSubscribeAction();
+ this.action.clearVoteAction();
+ this.action.clearReportAction();
+ }
+
+ this.requestUpdate();
+ this._dispatchUpdateEvent();
+ }
+
+ // The same as _actionCase, but represented as a String instead of a Number
+ get _actionCaseString() {
+ return this._actionCase.toString();
+ }
+
+ set _actionCaseString(newCase) {
+ this._actionCase = parseInt(newCase);
+ }
+
+ _specificActionEditor() {
+ switch (this._actionCase) {
+ case pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION:
+ return this.renderRoot.querySelector('wf-action-reply-with-cr');
+
+ default:
+ return null;
+ }
+ }
+}
+window.customElements.define('wf-action-editor', WFActionEditor);
diff --git a/src/workflows/manager/components/AddDialog.js b/src/workflows/manager/components/AddDialog.js
new file mode 100644
index 0000000..a2b5691
--- /dev/null
+++ b/src/workflows/manager/components/AddDialog.js
@@ -0,0 +1,94 @@
+import '@material/mwc-dialog/mwc-dialog.js';
+import '@material/web/button/text-button.js';
+import '@material/web/button/tonal-button.js';
+import './WorkflowEditor.js';
+
+import {css, html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import * as pb from '../../proto/main_pb.js';
+
+export default class WFAddDialog extends LitElement {
+ static properties = {
+ open: {type: Boolean},
+ };
+
+ static styles = css`
+ :host {
+ --mdc-dialog-content-ink-color: var(--mdc-theme-on-surface, #000);
+ }
+ `;
+
+ workflowEditorRef = createRef();
+
+ constructor() {
+ super();
+ this.open = false;
+ }
+
+ render() {
+ return html`
+ <mwc-dialog
+ heading="New workflow"
+ ?open=${this.open}
+ @opening=${this._openingDialog}
+ @closing=${this._closingDialog}
+ @closed=${this._closedDialog}>
+ <wf-workflow-editor ${ref(this.workflowEditorRef)}>
+ </wf-workflow-editor>
+ <md-tonal-button
+ slot="primaryAction"
+ label="Add"
+ @click=${this._save}>
+ </md-tonal-button>
+ <md-text-button
+ slot="secondaryAction"
+ dialogAction="cancel"
+ label="Cancel">
+ </md-text-button>
+ </mwc-dialog>
+ `;
+ }
+
+ firstUpdated() {
+ this._resetWorkflow();
+ }
+
+ _resetWorkflow() {
+ this.workflowEditorRef.value.workflow = this._defaultWorkflow();
+ }
+
+ _getWorkflow() {
+ return this.workflowEditorRef.value.workflow;
+ }
+
+ _defaultWorkflow() {
+ let wf = new pb.workflows.Workflow();
+ let action = new pb.workflows.Action();
+ let rAction = new pb.workflows.Action.ReplyWithCRAction();
+ action.setReplyWithCrAction(rAction);
+ wf.addActions(action);
+ return wf;
+ }
+
+ _openingDialog() {
+ this.open = true;
+ }
+
+ _closingDialog() {
+ this.open = false;
+ }
+
+ _closedDialog(e) {
+ if (e.detail?.action === 'cancel') this._resetWorkflow();
+ }
+
+ _save() {
+ let success = this.workflowEditorRef.value.save();
+ if (success) {
+ this.open = false;
+ this._resetWorkflow();
+ }
+ }
+}
+window.customElements.define('wf-add-dialog', WFAddDialog);
diff --git a/src/workflows/manager/components/List.js b/src/workflows/manager/components/List.js
new file mode 100644
index 0000000..09220b1
--- /dev/null
+++ b/src/workflows/manager/components/List.js
@@ -0,0 +1,8 @@
+import {html, LitElement} from 'lit';
+
+export default class WFList extends LitElement {
+ render() {
+ return html`<p>Temporary placeholder where the workflows list will exist.</p>`;
+ }
+}
+window.customElements.define('wf-list', WFList);
diff --git a/src/workflows/manager/components/WorkflowEditor.js b/src/workflows/manager/components/WorkflowEditor.js
new file mode 100644
index 0000000..7da2a4d
--- /dev/null
+++ b/src/workflows/manager/components/WorkflowEditor.js
@@ -0,0 +1,97 @@
+import '@material/web/button/outlined-button.js';
+import './ActionEditor.js';
+
+import {html, LitElement, nothing} from 'lit';
+import {repeat} from 'lit/directives/repeat.js';
+
+import * as pb from '../../proto/main_pb.js';
+
+export default class WFWorkflowEditor extends LitElement {
+ static properties = {
+ workflow: {type: Object},
+ readOnly: {type: Boolean},
+ };
+
+ constructor() {
+ super();
+ this.workflow = new pb.workflows.Workflow();
+ this.readOnly = false;
+ }
+
+ renderActions() {
+ return repeat(this._actions(), (action, i) => html`
+ <wf-action-editor
+ .action=${action}
+ ?readOnly=${this.readOnly}
+ ?disableRemoveButton=${this._actions().length <= 1}
+ step=${i + 1}
+ @action-removed=${() => this._removeAction(i)}>
+ </wf-action-editor>
+ `);
+ }
+
+ renderAddActionBtn() {
+ if (this.readOnly) return nothing;
+ return html`
+ <md-outlined-button
+ icon="add"
+ label="Add another action"
+ @click=${this._addAction}>
+ </md-outlined-button>
+ `;
+ }
+
+ render() {
+ return [
+ this.renderActions(),
+ this.renderAddActionBtn(),
+ ];
+ }
+
+ save() {
+ let allValid = true;
+ const actionEditors = this.renderRoot.querySelectorAll('wf-action-editor');
+ for (const editor of actionEditors) {
+ const isValid = editor.checkValidity();
+ if (!isValid) allValid = false;
+ }
+ // @TODO: Save if allValid === true
+ return allValid;
+ }
+
+ _actions() {
+ return this.workflow.getActionsList();
+ }
+
+ _addAction() {
+ let action = new pb.workflows.Action();
+ let rAction = new pb.workflows.Action.ReplyWithCRAction();
+ action.setReplyWithCrAction(rAction);
+ this.workflow.addActions(action);
+ this._dispatchUpdateEvent();
+ }
+
+ _removeAction(index) {
+ let actions = this.workflow.getActionsList();
+ actions.splice(index, 1);
+ this.workflow.setActionsList(actions);
+ this._dispatchUpdateEvent();
+ }
+
+ _updateAction(index, action) {
+ let actions = this.workflow.getActionsList();
+ actions[index] = action;
+ this.workflow.setActionsList(actions);
+ this._dispatchUpdateEvent();
+ }
+
+ _dispatchUpdateEvent() {
+ // Request an update for this component
+ this.requestUpdate();
+
+ // Transmit to other components that the workflow has changed
+ const e = new Event('workflow-updated', {bubbles: true, composed: true});
+ this.renderRoot.dispatchEvent(e);
+ }
+}
+window.customElements.define('wf-workflow-editor', WFWorkflowEditor);
diff --git a/src/workflows/manager/components/actions/ReplyWithCR.js b/src/workflows/manager/components/actions/ReplyWithCR.js
new file mode 100644
index 0000000..614a042
--- /dev/null
+++ b/src/workflows/manager/components/actions/ReplyWithCR.js
@@ -0,0 +1,135 @@
+import '@material/web/textfield/outlined-text-field.js';
+import '@material/web/switch/switch.js';
+import '@material/web/formfield/formfield.js';
+
+import {html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import {CCApi} from '../../../../common/api.js';
+import * as pb from '../../../proto/main_pb.js';
+
+export default class WFActionReplyWithCR extends LitElement {
+ static properties = {
+ action: {type: Object},
+ readOnly: {type: Boolean},
+ };
+
+ cannedResponseRef = createRef();
+ subscribeRef = createRef();
+ markAsAnswerRef = createRef();
+
+ constructor() {
+ super();
+ this.action = new pb.workflows.Action.ReplyWithCRAction;
+ // this._loadUserCannedResponses();
+ }
+
+ render() {
+ return html`
+ <p>
+ <md-outlined-text-field ${ref(this.cannedResponseRef)}
+ type="number"
+ label="Canned response ID"
+ required
+ value=${this._cannedResponseIdString}
+ ?readonly=${this.readOnly}
+ @input=${this._cannedResponseIdChanged}>
+ </md-outlined-text-field>
+ </p>
+ <p>
+ <md-formfield label="Subscribe to thread">
+ <md-switch ${ref(this.subscribeRef)}
+ ?selected=${this.subscribe}
+ ?disabled=${this.readOnly}
+ @click=${this._subscribeChanged}/>
+ </md-formfield>
+ </p>
+ <p>
+ <md-formfield label="Mark as answer">
+ <md-switch ${ref(this.markAsAnswerRef)}
+ ?selected=${this.markAsAnswer}
+ ?disabled=${this.readOnly}
+ @click=${this._markAsAnswerChanged}/>
+ </md-formfield>
+ </p>
+ `;
+ }
+
+ checkValidity() {
+ return this.cannedResponseRef.value.reportValidity();
+ }
+
+ _loadUserCannedResponses() {
+ if (window.USER_CANNED_RESPONSES_STARTED_TO_LOAD) return;
+
+ window.USER_CANNED_RESPONSES_STARTED_TO_LOAD = true;
+ let searchParams = new URLSearchParams(document.location.search);
+ let authuser = searchParams.get('authuser') ?? 0;
+
+ // @TODO: This isn't as simple as doing this because the request contains
+ // the wrong origin and fails.
+ CCApi('ListCannedResponses', {}, true, authuser).then(res => {
+ console.log(res);
+ });
+ }
+
+ _dispatchUpdateEvent() {
+ // Request an update for this component
+ this.requestUpdate();
+
+ // Transmit to other components that the action has changed
+ const e = new Event(
+ 'replywithcr-action-updated', {bubbles: true, composed: true});
+ this.renderRoot.dispatchEvent(e);
+ }
+
+ _cannedResponseIdChanged() {
+ this._cannedResponseIdString = this.cannedResponseRef.value.value;
+ }
+
+ _subscribeChanged() {
+ this.subscribe = this.subscribeRef.value.selected;
+ }
+
+ _markAsAnswerChanged() {
+ this.markAsAnswer = this.markAsAnswerRef.value.selected;
+ }
+
+ get cannedResponseId() {
+ return this.action.getCannedResponseId() ?? 0;
+ }
+
+ set cannedResponseId(value) {
+ this.action.setCannedResponseId(value);
+ this._dispatchUpdateEvent();
+ }
+
+ get _cannedResponseIdString() {
+ let id = this.cannedResponseId;
+ if (id == 0) return '';
+ return id.toString();
+ }
+
+ set _cannedResponseIdString(value) {
+ this.cannedResponseId = parseInt(value);
+ }
+
+ get subscribe() {
+ return this.action.getSubscribe();
+ }
+
+ set subscribe(value) {
+ this.action.setSubscribe(value);
+ this._dispatchUpdateEvent();
+ }
+
+ get markAsAnswer() {
+ return this.action.getMarkAsAnswer();
+ }
+
+ set markAsAnswer(value) {
+ this.action.setMarkAsAnswer(value);
+ this._dispatchUpdateEvent();
+ }
+}
+window.customElements.define('wf-action-reply-with-cr', WFActionReplyWithCR);
diff --git a/src/workflows/manager/index.js b/src/workflows/manager/index.js
new file mode 100644
index 0000000..a9332de
--- /dev/null
+++ b/src/workflows/manager/index.js
@@ -0,0 +1,51 @@
+import '@material/web/fab/fab.js';
+import './components/List.js';
+import './components/AddDialog.js';
+
+import {css, html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import {SHARED_MD3_STYLES} from '../../common/styles/md3.js';
+
+export default class WFApp extends LitElement {
+ static styles = [
+ SHARED_MD3_STYLES,
+ css`
+ :host {
+ font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI',
+ Helvetica, Arial, sans-serif, 'Apple Color Emoji',
+ 'Segoe UI Emoji', 'Segoe UI Symbol'!important;
+
+ display: block;
+ max-width: 1024px;
+ margin: auto;
+ position: relative;
+ }
+
+ md-fab {
+ position: fixed;
+ bottom: 2em;
+ right: 2em;
+ }
+ `,
+ ];
+
+ addFabRef = createRef();
+
+ render() {
+ return html`
+ <h1>Workflows</h1>
+ <wf-list></wf-list>
+ <md-fab ${ref(this.addFabRef)}
+ icon="add"
+ @click=${this._showAddDialog}>
+ </md-fab>
+ <wf-add-dialog></wf-add-dialog>
+ `;
+ }
+
+ _showAddDialog() {
+ this.renderRoot.querySelector('wf-add-dialog').open = true;
+ }
+}
+window.customElements.define('wf-app', WFApp);