Add ability to save and view a list of workflows
This CL introduces the following functionality:
- Introduces some logic to handle the persistence of workflows in the
browser storage.
- Lets the user save created workflows in the "create workflow" dialog.
- Shows a list of workflows. If the user hasn't added any workflow, a
placeholder image is shown alongside a text which invites the user to
create a new workflow.
Bug: twpowertools:74
Change-Id: Icba09d20468bafc1415b802a3c935e22669546e6
diff --git a/src/workflows/common.js b/src/workflows/common.js
new file mode 100644
index 0000000..6d6ed03
--- /dev/null
+++ b/src/workflows/common.js
@@ -0,0 +1,16 @@
+// Source: https://stackoverflow.com/a/66046176
+export const arrayBufferToBase64 = async (data) => {
+ // Use a FileReader to generate a base64 data URI
+ const base64url = await new Promise((r) => {
+ const reader = new FileReader();
+ reader.onload = () => r(reader.result);
+ reader.readAsDataURL(new Blob([data]));
+ });
+
+ /*
+ The result looks like
+ "data:application/octet-stream;base64,<your base64 data>",
+ so we split off the beginning:
+ */
+ return base64url.split(',', 2)[1]
+}
diff --git a/src/workflows/manager/components/AddDialog.js b/src/workflows/manager/components/AddDialog.js
index 8bf63db..32a2cae 100644
--- a/src/workflows/manager/components/AddDialog.js
+++ b/src/workflows/manager/components/AddDialog.js
@@ -1,6 +1,6 @@
import '@material/mwc-dialog/mwc-dialog.js';
import '@material/web/button/text-button.js';
-import '@material/web/button/tonal-button.js';
+import '@material/web/button/filled-button.js';
import './WorkflowEditor.js';
import {css, html, LitElement} from 'lit';
@@ -35,11 +35,11 @@
@closed=${this._closedDialog}>
<wf-workflow-editor ${ref(this.workflowEditorRef)}>
</wf-workflow-editor>
- <md-tonal-button
+ <md-filled-button
slot="primaryAction"
label="Add"
@click=${this._save}>
- </md-tonal-button>
+ </md-filled-button>
<md-text-button
slot="secondaryAction"
dialogAction="cancel"
diff --git a/src/workflows/manager/components/List.js b/src/workflows/manager/components/List.js
index 09220b1..a7851ee 100644
--- a/src/workflows/manager/components/List.js
+++ b/src/workflows/manager/components/List.js
@@ -1,8 +1,77 @@
-import {html, LitElement} from 'lit';
+import '@material/web/list/list.js';
+import '@material/web/list/list-item.js';
+import '@material/web/iconbutton/standard-icon-button.js';
+
+import {css, html, LitElement, nothing} from 'lit';
+import {map} from 'lit/directives/map.js';
export default class WFList extends LitElement {
+ static properties = {
+ workflows: {type: Object},
+ };
+
+ static styles = css`
+ .noworkflows {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin: 32px 0;
+ }
+
+ .noworkflows--image {
+ margin-bottom: 16px;
+ max-width: 500px;
+ }
+
+ .noworkflows--helper {
+ color: #555;
+ }
+ `;
+
+ renderListItems() {
+ return map(this.workflows, w => html`
+ <md-list-item
+ headline=${w.proto?.getName?.()}
+ @click=${() => this._show(w.uuid)}>
+ <div slot="end" class="end">
+ <md-standard-icon-button
+ icon="edit"
+ @click=${e => this._showEdit(w.uuid, e)}>
+ </md-standard-icon-button>
+ <md-standard-icon-button
+ icon="delete"
+ @click=${e => this._showDelete(w.uuid, e)}>
+ </md-standard-icon-button>
+ </div>
+ </md-list-item>
+ `);
+ }
+
render() {
- return html`<p>Temporary placeholder where the workflows list will exist.</p>`;
+ if (!this.workflows) return nothing;
+ if (this.workflows?.length === 0)
+ return html`
+ <div class="noworkflows">
+ <img class="noworkflows--image" src="/img/undraw_insert.svg">
+ <span class="noworkflows--helper">You haven't created any workflow yet! Create one by clicking the button in the bottom-right corner.</span>
+ </div>
+ `;
+
+ return html`
+ <md-list>
+ ${this.renderListItems()}
+ </md-list>
+ `;
+ }
+
+ _show(uuid) {}
+
+ _showEdit(uuid, e) {
+ e.stopPropagation();
+ }
+
+ _showDelete(uuid, e) {
+ e.stopPropagation();
}
}
window.customElements.define('wf-list', WFList);
diff --git a/src/workflows/manager/components/WorkflowEditor.js b/src/workflows/manager/components/WorkflowEditor.js
index 4a8ab90..dcd38cd 100644
--- a/src/workflows/manager/components/WorkflowEditor.js
+++ b/src/workflows/manager/components/WorkflowEditor.js
@@ -7,6 +7,7 @@
import {repeat} from 'lit/directives/repeat.js';
import * as pb from '../../proto/main_pb.js';
+import WorkflowsStorage from '../../workflowsStorage.js';
export default class WFWorkflowEditor extends LitElement {
static properties = {
@@ -82,7 +83,8 @@
const actionEditors = this.renderRoot.querySelectorAll('wf-action-editor');
for (const editor of actionEditors) allValid &&= editor.checkValidity();
- // @TODO: Save if allValid === true
+ // Save the workflow if the validation checks passed
+ if (allValid) WorkflowsStorage.add(this.workflow);
return allValid;
}
diff --git a/src/workflows/manager/index.js b/src/workflows/manager/index.js
index a9332de..af0f653 100644
--- a/src/workflows/manager/index.js
+++ b/src/workflows/manager/index.js
@@ -6,6 +6,7 @@
import {createRef, ref} from 'lit/directives/ref.js';
import {SHARED_MD3_STYLES} from '../../common/styles/md3.js';
+import {default as WorkflowsStorage, kWorkflowsDataKey} from '../workflowsStorage.js';
export default class WFApp extends LitElement {
static styles = [
@@ -32,10 +33,21 @@
addFabRef = createRef();
+ constructor() {
+ super();
+ this._workflows = undefined;
+ this._updateWorkflows();
+ chrome.storage.onChanged.addListener((changes, areaName) => {
+ if (areaName == 'local' && changes[kWorkflowsDataKey])
+ this._updateWorkflows();
+ });
+ }
+
render() {
return html`
<h1>Workflows</h1>
- <wf-list></wf-list>
+ <p>Workflows allow you to run a customized list of actions on a thread easily.</p>
+ <wf-list .workflows=${this._workflows}></wf-list>
<md-fab ${ref(this.addFabRef)}
icon="add"
@click=${this._showAddDialog}>
@@ -44,6 +56,13 @@
`;
}
+ _updateWorkflows() {
+ WorkflowsStorage.getAll(/* asProtobuf = */ true).then(workflows => {
+ this._workflows = workflows;
+ this.requestUpdate();
+ });
+ }
+
_showAddDialog() {
this.renderRoot.querySelector('wf-add-dialog').open = true;
}
diff --git a/src/workflows/workflowsStorage.js b/src/workflows/workflowsStorage.js
new file mode 100644
index 0000000..e692209
--- /dev/null
+++ b/src/workflows/workflowsStorage.js
@@ -0,0 +1,63 @@
+import {arrayBufferToBase64} from './common.js';
+import * as pb from './proto/main_pb.js';
+
+export const kWorkflowsDataKey = 'workflowsData';
+
+export default class WorkflowsStorage {
+ static getAll(asProtobuf = false) {
+ return new Promise(res => {
+ chrome.storage.local.get(kWorkflowsDataKey, items => {
+ const workflows = items[kWorkflowsDataKey];
+ 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;
+ });
+ return res(workflows);
+ });
+ });
+ }
+
+ static get(uuid, asProtobuf = false) {
+ return this.getAll(asProtobuf).then(workflows => {
+ for (const w of workflows) {
+ if (w.uuid == uuid) return w;
+ }
+ return null;
+ });
+ }
+
+ static exists(uuid) {
+ return this.get(uuid).then(w => w !== null);
+ }
+
+ static addRaw(base64Workflow) {
+ const w = {
+ uuid: self.crypto.randomUUID(),
+ data: base64Workflow,
+ };
+ return this.getAll().then(workflows => {
+ workflows.push(w);
+ const items = {};
+ items[kWorkflowsDataKey] = workflows;
+ chrome.storage.local.set(items);
+ });
+ }
+
+ static add(workflow) {
+ const binaryWorkflow = workflow.serializeBinary();
+ return arrayBufferToBase64(binaryWorkflow).then(data => {
+ return this.addRaw(data);
+ });
+ }
+
+ static remove(uuid) {
+ return this.getAll().then(workflows => {
+ const items = {};
+ items[kWorkflowsDataKey] = workflows.filter(w => w.uuid != uuid);
+ chrome.storage.local.set(items);
+ });
+ }
+}