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/static/img/undraw_insert.svg b/src/static/img/undraw_insert.svg
new file mode 100644
index 0000000..1f5d4b2
--- /dev/null
+++ b/src/static/img/undraw_insert.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" width="663.67004" height="601.06409" viewBox="0 0 663.67004 601.06409" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M510.92969,611.27069H297.31787a29.01036,29.01036,0,0,1-28.97754-28.97754V281.78875c0-72.96191,59.3584-132.3208,132.32031-132.3208H510.93018a29.00984,29.00984,0,0,1,28.97705,28.977V582.29315A29.01005,29.01005,0,0,1,510.92969,611.27069Z" transform="translate(-268.16498 -149.46795)" fill="#f2f2f2"/><path d="M304.78727,410.62257a12.21859,12.21859,0,0,0-12.20459,12.20459v68.59033a12.219,12.219,0,0,0,12.20459,12.20508H512.37809a12.219,12.219,0,0,0,12.20459-12.20508V422.82716a12.2186,12.2186,0,0,0-12.20459-12.20459Z" transform="translate(-268.16498 -149.46795)" fill="#fff"/><path d="M512.8776,366.12257H305.28727A13.21927,13.21927,0,0,1,292.08268,352.918a81.88817,81.88817,0,0,1,81.79541-81.79541h139a13.21927,13.21927,0,0,1,13.20459,13.20459v68.59033A13.22011,13.22011,0,0,1,512.8776,366.12257Z" transform="translate(-268.16498 -149.46795)" fill="#fff"/><path d="M602.25412,493.09951H363.284a5.304,5.304,0,0,1-5.29794-5.29794V424.83628a5.30383,5.30383,0,0,1,5.29794-5.29774H602.25412a5.30383,5.30383,0,0,1,5.29794,5.29774v62.96529A5.304,5.304,0,0,1,602.25412,493.09951Z" transform="translate(-268.16498 -149.46795)" fill="#fff"/><path d="M602.25435,494.09957H363.28413a6.305,6.305,0,0,1-6.29785-6.29785V424.83639a6.305,6.305,0,0,1,6.29785-6.29785H602.25435a6.305,6.305,0,0,1,6.29785,6.29785v62.96533A6.305,6.305,0,0,1,602.25435,494.09957Zm-238.97022-73.561a4.303,4.303,0,0,0-4.29785,4.29785v62.96533a4.303,4.303,0,0,0,4.29785,4.29785H602.25435a4.303,4.303,0,0,0,4.29785-4.29785V424.83639a4.303,4.303,0,0,0-4.29785-4.29785Z" transform="translate(-268.16498 -149.46795)" fill="#ccc"/><circle cx="115.43944" cy="291.31876" r="8.59703" fill="#198f7f"/><path d="M587.96966,462.45069H377.56754a2.56,2.56,0,1,1,0-5.12H587.96966a2.56,2.56,0,1,1,0,5.12Z" transform="translate(-268.16498 -149.46795)" fill="#198f7f"/><path d="M567.06361,472.56661H377.56754a2.56015,2.56015,0,1,1,0-5.12029H567.06361a2.56014,2.56014,0,0,1,0,5.12029Z" transform="translate(-268.16498 -149.46795)" fill="#198f7f"/><path d="M865.95747,638.94482,673.83524,496.83309a5.304,5.304,0,0,1-1.10873-7.40993L710.171,438.80162a5.30382,5.30382,0,0,1,7.40981-1.10857L909.703,579.80478a5.30383,5.30383,0,0,1,1.10885,7.40977L873.3674,637.83609A5.304,5.304,0,0,1,865.95747,638.94482Z" transform="translate(-268.16498 -149.46795)" fill="#fff"/><path d="M865.36294,639.749,673.24059,497.63715a6.305,6.305,0,0,1-1.318-8.80845l37.44449-50.62157a6.305,6.305,0,0,1,8.80845-1.318L910.29789,579.001a6.305,6.305,0,0,1,1.318,8.80845L874.17138,638.431A6.305,6.305,0,0,1,865.36294,639.749ZM716.98618,438.49706a4.303,4.303,0,0,0-6.01117.89944l-37.44448,50.62157a4.303,4.303,0,0,0,.89943,6.01116L866.5523,638.141a4.303,4.303,0,0,0,6.01117-.89944L910.008,586.62a4.303,4.303,0,0,0-.89944-6.01116Z" transform="translate(-268.16498 -149.46795)" fill="#ccc"/><circle cx="453.11661" cy="317.39198" r="8.59703" fill="#198f7f"/><path d="M872.69972,605.80967,703.545,480.68685a2.56,2.56,0,1,1,3.04481-4.1163L875.74452,601.69337a2.56,2.56,0,1,1-3.0448,4.1163Z" transform="translate(-268.16498 -149.46795)" fill="#198f7f"/><path d="M849.87632,601.51,697.52918,488.81964a2.56015,2.56015,0,0,1,3.045-4.11651L852.92128,597.39345a2.56015,2.56015,0,1,1-3.045,4.11651Z" transform="translate(-268.16498 -149.46795)" fill="#198f7f"/><path d="M776.24473,489.33229a9.35494,9.35494,0,0,1-.41031-14.24145l-15.14595-44.09913,11.16777-7.18832,17.60612,52.50874a9.34183,9.34183,0,0,1-13.21763,13.02016Z" transform="translate(-268.16498 -149.46795)" fill="#ffb6b6"/><path d="M763.31911,367.65667c2.67511,11.31576,6.66788,21.20024,6.127,25.9472-.53526,4.734,5.24986,9.28292,3.41132,13.02489-1.83482,3.75127,3.81358,4.15275,2.28629,10.19-1.53294,6.05019-2.52858,7.34408-1.23467,8.33977,1.30321.992,8.18819,24.084,8.18819,24.084l-4.03849,1.48-11.78414,4.299-27.6919-45.54672.08382-55.906,6.87587-.22069c1.65086.619,3.47928,1.3616,5.303,2.25644C756.41515,358.2933,762.04965,362.26753,763.31911,367.65667Z" transform="translate(-268.16498 -149.46795)" fill="#e6e6e6"/><polygon points="393.183 572.925 378.783 572.924 371.932 517.38 393.185 517.381 393.183 572.925" fill="#ffb6b6"/><path d="M617.88359,743.63969a6.12512,6.12512,0,0,0,6.08628,6.09847h27.10835l.83991-1.74067,3.83439-7.91217,1.485,7.91217.32869,1.74067h10.22491l-.14606-1.75286-2.72665-32.69559-3.56655-.2191-15.41045-.91294-3.78573-.23128v9.76243C639.283,726.74415,616.73936,738.29591,617.88359,743.63969Z" transform="translate(-268.16498 -149.46795)" fill="#2f2e41"/><polygon points="561.89 557.25 549.365 564.356 515.996 519.427 534.481 508.939 561.89 557.25" fill="#ffb6b6"/><path d="M802.73637,746.64628a6.09621,6.09621,0,0,0,8.30163,2.30063l1.42424-.80336h.01219l22.14192-12.57429-.706-10.69966,6.354,7.49827,4.68649-2.6658,4.19955-2.38585-3.71269-5.42892-15.7878-23.11571-3.21357,1.58242-13.86458,6.80443-3.38394,1.66764,4.80818,8.48432C813.01,721.40037,799.10889,742.55631,802.73637,746.64628Z" transform="translate(-268.16498 -149.46795)" fill="#2f2e41"/><path d="M732.81136,449.73881C752.1083,462.526,771.21108,585.379,766.19765,583.37333l45.48327,84.76473-64.73884,29.01978L704.37515,549.356l-7.09449,54.39108L706.74,690.06335l-41.84613,10.11977-28.94394-2.06742c7.521-102.88745,26.29919-211.40192,41.86145-253.37689Z" transform="translate(-268.16498 -149.46795)" fill="#2f2e41"/><path d="M747.46268,458.86256l.21.79c-20.4,11.35-35.79,18.84-58.09,11.97l-14.82-13.34-6.91-26.65.43-67.2a18.83094,18.83094,0,0,1,20.23-18.66l14.66-10.86,20.87.72,13.96,15.42,8.34,2.98,6.48,2.31c1.31,1.18,2.74,2.54,4.11,4.04l-10.25,61-4.36,18.24Z" transform="translate(-268.16498 -149.46795)" fill="#e6e6e6"/><circle cx="713.58746" cy="305.71548" r="26.77784" transform="translate(-327.16806 232.22699) rotate(-28.80791)" fill="#ffb6b6"/><path d="M748.7966,296.387c-.07994.03023-.14987.06038-.22981.09061a3.74831,3.74831,0,0,1-.46954.17117,12.93336,12.93336,0,0,1-3.16821.728,12.68435,12.68435,0,0,1-1.38978.07346,13.08323,13.08323,0,0,1-1.78032-.11549,12.5918,12.5918,0,0,1-1.941-.39506,9.23784,9.23784,0,0,1-.89075-.28778,13.64042,13.64042,0,0,1-8.44213-8.74864l0-.01a5.90144,5.90144,0,0,1-.1715-.59954,13.53815,13.53815,0,0,1-.44873-3.44889l0-.02a13.53823,13.53823,0,0,1,2.53-7.9164c.99643-1.41255,2.14744-1.64862,1.50176-3.26381-2.05974-5.1524,6.3672-9.36425,11.34206-7.09872a4.86908,4.86908,0,0,1,.54-.02139c3.35-.00847,4.59246,4.93842,6.5895,7.72335l-.05782.01885a2.57034,2.57034,0,0,0,.03267,4.86311q1.99459.6498,4.03047,1.148C760.84975,284.56463,755.71807,300.03148,748.7966,296.387Z" transform="translate(-268.16498 -149.46795)" fill="#2f2e41"/><path d="M746.33245,310.56334c-1.47581,5.6337-4.2019,11.13066-8.72262,14.79207-4.5308,3.6615-25.83888,12.33542-27.6377,4.90995,1.20313,5.187.47816,7.1688-1.45716,9.02367-.33115-.44911-.68221-.87822-1.04332-1.29735a16.86984,16.86984,0,0,0-4.70952-3.77808,54.22682,54.22682,0,0,0,.67363,9.35831,31.64224,31.64224,0,0,0-2.27409,2.32577c-3.446-10.27129-10.99958-22.57369-5.53078-31.92612,2.17043-3.71173.3606-4.22007-1.56175-4.64608-3.35595-.74372-5.20678-14.51685-6.00522-17.86482-.9906-4.19752-1.38283-9.02656,1.21845-12.47315s9.18094-3.59324,10.51134.51339l2.77045-3.767c3.58012-7.87908,13.08414-6.30314,21.67671-5.28489,8.5926,1.0083,16.17791,7.04913,20.04748,14.78937C748.148,292.97868,748.52123,302.18773,746.33245,310.56334Z" transform="translate(-268.16498 -149.46795)" fill="#2f2e41"/><path d="M732.78645,274.57218a24.25663,24.25663,0,0,0,13.87535,22.862c1.74573.79093,3.26571-1.79769,1.5076-2.59423a21.34086,21.34086,0,0,1-12.383-20.27539c.1-1.92944-2.90027-1.916-3,.00758Z" transform="translate(-268.16498 -149.46795)" fill="#198f7f"/><path d="M608.84361,461.32458a9.35494,9.35494,0,0,0,5.997-12.92373L646.16,413.85763l-7.42474-11.012-36.89845,41.29948a9.34182,9.34182,0,0,0,7.00685,17.17947Z" transform="translate(-268.16498 -149.46795)" fill="#ffb6b6"/><path d="M697.87076,349.25943s-22.21316-3.98915-29.13592,5.36174-14.492,16.85149-15.869,21.42185-8.48447,6.45938-8.27423,10.62819-5.14392,2.30646-6.11938,8.464-.57392,7.74476-2.16112,8.14631-17.01919,18.89794-17.01919,18.89794l12.25486,11.5553L674.961,402.81128Z" transform="translate(-268.16498 -149.46795)" fill="#e6e6e6"/><circle cx="229.78778" cy="36.77667" r="22.62992" fill="#198f7f"/><path d="M497.95276,198.4184a.963.963,0,0,1-.963-.963v-22.285a.963.963,0,0,1,1.926,0v22.285A.963.963,0,0,1,497.95276,198.4184Z" transform="translate(-268.16498 -149.46795)" fill="#fff"/><path d="M509.09515,187.27577H486.81036a.963.963,0,1,1,0-1.926h22.28479a.963.963,0,1,1,0,1.926Z" transform="translate(-268.16498 -149.46795)" fill="#fff"/><path d="M849.76481,749.51808l-.1333-.71387a83.88189,83.88189,0,0,1,17.7539-68.25586A81.386,81.386,0,0,1,882.34,666.65187l.52881-.38184.23145.60938c2.75976,7.27636,6.24316,14.56347,7.83056,17.78906l1.07129-24.07813.61133-.32421a15.90842,15.90842,0,0,1,17.7334,1.83007,16.181,16.181,0,0,1,5.34326,17.32618c-.83252,2.8291-1.61865,5.76367-2.3794,8.60156-2.61279,9.75195-5.31445,19.83594-10.55029,28.32715a50.70216,50.70216,0,0,1-36.89941,23.3125Z" transform="translate(-268.16498 -149.46795)" fill="#f2f2f2"/><path d="M931.835,749.342a1.18647,1.18647,0,0,1-1.19006,1.19H269.355a1.19,1.19,0,0,1,0-2.38h661.29A1.18651,1.18651,0,0,1,931.835,749.342Z" transform="translate(-268.16498 -149.46795)" fill="#ccc"/></svg>
diff --git a/src/static/img/undraw_insert.svg.license b/src/static/img/undraw_insert.svg.license
new file mode 100644
index 0000000..fb1a091
--- /dev/null
+++ b/src/static/img/undraw_insert.svg.license
@@ -0,0 +1,7 @@
+undraw_insert.svg image obtained from https://undraw.co/. A copy of the license can be found below:
+
+Copyright 2022 Katerina Limpitsouni
+
+All images, assets and vectors published on unDraw can be used for free. You can use them for noncommercial and commercial purposes. You do not need to ask permission from or provide credit to the creator or unDraw.
+
+More precisely, unDraw grants you an nonexclusive, worldwide copyright license to download, copy, modify, distribute, perform, and use the assets provided from unDraw for free, including for commercial purposes, without permission from or attributing the creator or unDraw. This license does not include the right to compile assets, vectors or images from unDraw to replicate a similar or competing service, in any form or distribute the assets in packs or otherwise. This extends to automated and non-automated ways to link, embed, scrape, search or download the assets included on the website without our consent.
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);
+    });
+  }
+}