Browse Source

Merge pull request #74 from Kholid060/dev

v0.4.4
Ahmad Kholid 3 years ago
parent
commit
2c996ae1e6
55 changed files with 2165 additions and 401 deletions
  1. 2 0
      .gitignore
  2. 1 1
      .prettierrc
  3. 4 2
      package.json
  4. 1 0
      src/assets/css/drawflow.css
  5. 6 7
      src/assets/css/tailwind.css
  6. 51 0
      src/background/collection-engine/flow-handler.js
  7. 176 0
      src/background/collection-engine/index.js
  8. 29 3
      src/background/index.js
  9. 146 41
      src/background/workflow-engine/blocks-handler.js
  10. 3 3
      src/background/workflow-engine/error-message.js
  11. 36 17
      src/background/workflow-engine/index.js
  12. 2 2
      src/background/workflow-state.js
  13. 1 1
      src/components/block/BlockExportData.vue
  14. 49 0
      src/components/block/BlockLoopBreakpoint.vue
  15. 0 70
      src/components/block/BlockNewTab.vue
  16. 14 0
      src/components/newtab/app/AppSidebar.vue
  17. 3 1
      src/components/newtab/logs/LogsDataViewer.vue
  18. 73 0
      src/components/newtab/shared/SharedCard.vue
  19. 31 20
      src/components/newtab/shared/SharedWorkflowState.vue
  20. 21 4
      src/components/newtab/workflow/WorkflowBuilder.vue
  21. 0 62
      src/components/newtab/workflow/WorkflowCard.vue
  22. 34 8
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  23. 23 16
      src/components/newtab/workflow/WorkflowEditBlock.vue
  24. 22 12
      src/components/newtab/workflow/WorkflowSettings.vue
  25. 27 23
      src/components/newtab/workflow/edit/EditForms.vue
  26. 35 33
      src/components/newtab/workflow/edit/EditInteractionBase.vue
  27. 201 0
      src/components/newtab/workflow/edit/EditLoopData.vue
  28. 54 0
      src/components/newtab/workflow/edit/EditNewTab.vue
  29. 1 1
      src/components/newtab/workflow/edit/EditTrigger.vue
  30. 157 0
      src/components/newtab/workflow/edit/EditWebhook.vue
  31. 100 0
      src/components/ui/UiPagination.vue
  32. 1 0
      src/components/ui/UiTabs.vue
  33. 1 0
      src/content/blocks-handler.js
  34. 7 0
      src/lib/v-remixicon.js
  35. 29 0
      src/models/collection.js
  36. 1 0
      src/models/index.js
  37. 13 0
      src/models/log.js
  38. 1 1
      src/newtab/App.vue
  39. 117 0
      src/newtab/pages/Collections.vue
  40. 13 7
      src/newtab/pages/Home.vue
  41. 19 8
      src/newtab/pages/Workflows.vue
  42. 309 0
      src/newtab/pages/collections/[id].vue
  43. 29 3
      src/newtab/pages/logs.vue
  44. 26 5
      src/newtab/pages/logs/[id].vue
  45. 21 4
      src/newtab/pages/workflows/[id].vue
  46. 12 0
      src/newtab/router.js
  47. 1 1
      src/popup/pages/Home.vue
  48. 9 5
      src/store/index.js
  49. 4 2
      src/utils/handle-form-element.js
  50. 30 0
      src/utils/helper.js
  51. 21 9
      src/utils/reference-data.js
  52. 64 4
      src/utils/shared.js
  53. 72 0
      src/utils/webhookUtil.js
  54. 13 25
      src/utils/workflow-data.js
  55. 49 0
      yarn.lock

+ 2 - 0
.gitignore

@@ -22,3 +22,5 @@
 
 # secrets
 secrets.*.js
+
+.idea

+ 1 - 1
.prettierrc

@@ -2,4 +2,4 @@
   "singleQuote": true,
   "trailingComma": "es5",
   "arrowParens": "always"
-}
+}

+ 4 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.3.0",
+  "version": "0.4.4",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -28,7 +28,7 @@
     "drawflow": "^0.0.51",
     "mousetrap": "^1.6.5",
     "nanoid": "3.1.28",
-    "object-path": "^0.11.8",
+    "object-path-immutable": "^4.1.2",
     "papaparse": "^5.3.1",
     "prismjs": "^1.25.0",
     "tiny-emitter": "^2.1.0",
@@ -37,6 +37,8 @@
     "vue": "3.2.19",
     "vue-prism-editor": "^2.0.0-alpha.2",
     "vue-router": "^4.0.11",
+    "vue-virtual-scroller": "^2.0.0-alpha.1",
+    "vuedraggable": "^4.1.0",
     "vuex": "^4.0.2",
     "webextension-polyfill": "^0.8.0"
   },

+ 1 - 0
src/assets/css/drawflow.css

@@ -19,6 +19,7 @@
   width: 100%;
   height: 100%;
   user-select: none;
+  perspective: 0;
 }
 
 .drawflow .drawflow-node {

+ 6 - 7
src/assets/css/tailwind.css

@@ -3,9 +3,9 @@
 @tailwind utilities;
 
 body {
-	font-family: 'Inter var';
+  font-family: 'Inter var';
   font-size: 16px;
-  font-feature-settings: "cv02","cv03","cv04","cv11";
+  font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
   @apply bg-gray-50 dark:bg-gray-900;
 }
 table th,
@@ -16,12 +16,11 @@ input:focus,
 button:focus,
 textarea:focus,
 select:focus,
-[role="button"]:focus {
-	outline: none;
-	@apply ring-2 ring-accent dark:ring-gray-200;
+[role='button']:focus {
+  outline: none;
+  @apply ring-2 ring-accent dark:ring-gray-200;
 }
 
-
 .text-overflow {
   overflow: hidden;
   text-overflow: ellipsis;
@@ -57,6 +56,6 @@ select:focus,
     @apply bg-black bg-opacity-5 hover:bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-5 dark:hover:bg-opacity-10;
   }
   .bg-box-transparent {
-  	@apply bg-black bg-opacity-5 dark:bg-gray-200 dark:bg-opacity-5;
+    @apply bg-black bg-opacity-5 dark:bg-gray-200 dark:bg-opacity-5;
   }
 }

+ 51 - 0
src/background/collection-engine/flow-handler.js

@@ -0,0 +1,51 @@
+import WorkflowEngine from '../workflow-engine';
+import dataExporter from '@/utils/data-exporter';
+
+export function workflow(flow) {
+  return new Promise((resolve, reject) => {
+    const currentWorkflow = this.workflows.find(({ id }) => id === flow.itemId);
+
+    if (!currentWorkflow) {
+      const error = new Error(`Can't find workflow with ${flow.itemId} ID`);
+      error.name = 'Workflow';
+
+      reject(error);
+      return;
+    }
+
+    this.currentWorkflow = currentWorkflow;
+
+    const engine = new WorkflowEngine(currentWorkflow, {
+      isInCollection: true,
+      collectionLogId: this.id,
+      collectionId: this.collection.id,
+    });
+
+    this.workflowEngine = engine;
+
+    engine.init();
+    engine.on('update', (state) => {
+      this.workflowState = state;
+      this.updateState();
+    });
+    engine.on('destroyed', ({ id, status, message }) => {
+      this.data.push({
+        id,
+        status,
+        errorMessage: message,
+        workflowId: currentWorkflow.id,
+        workflowName: currentWorkflow.name,
+      });
+
+      resolve({ id, name: currentWorkflow.name, type: status, message });
+    });
+  });
+}
+
+export function exportResult() {
+  return new Promise((resolve) => {
+    dataExporter(this.data, { name: this.collection.name, type: 'json' }, true);
+
+    resolve({ name: 'Export result' });
+  });
+}

+ 176 - 0
src/background/collection-engine/index.js

@@ -0,0 +1,176 @@
+import { nanoid } from 'nanoid';
+import browser from 'webextension-polyfill';
+import { toCamelCase } from '@/utils/helper';
+import * as flowHandler from './flow-handler';
+import workflowState from '../workflow-state';
+
+class CollectionEngine {
+  constructor(collection) {
+    this.id = nanoid();
+    this.collection = collection;
+    this.workflows = [];
+    this.data = [];
+    this.logs = [];
+    this.isDestroyed = false;
+    this.currentFlow = null;
+    this.workflowState = null;
+    this.workflowEngine = null;
+    this.currentWorkflow = null;
+    this.currentIndex = 0;
+    this.eventListeners = {};
+  }
+
+  async init() {
+    try {
+      if (this.collection.flow.length === 0) return;
+
+      await workflowState.add(this.id, {
+        state: this.state,
+        isCollection: true,
+        collectionId: this.collection.id,
+      });
+
+      const { workflows } = await browser.storage.local.get('workflows');
+
+      this.workflows = workflows;
+      this.startedTimestamp = Date.now();
+      this._flowHandler(this.collection.flow[0]);
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  on(name, listener) {
+    (this.eventListeners[name] = this.eventListeners[name] || []).push(
+      listener
+    );
+  }
+
+  dispatchEvent(name, params) {
+    const listeners = this.eventListeners[name];
+
+    if (!listeners) return;
+
+    listeners.forEach((callback) => {
+      callback(params);
+    });
+  }
+
+  async destroy(status) {
+    this.isDestroyed = true;
+    this.dispatchEvent('destroyed', { id: this.id });
+
+    const { logs } = await browser.storage.local.get('logs');
+    const { name, icon } = this.collection;
+
+    logs.push({
+      name,
+      icon,
+      status,
+      id: this.id,
+      data: this.data,
+      history: this.logs,
+      endedAt: Date.now(),
+      collectionId: this.collection.id,
+      startedAt: this.startedTimestamp,
+    });
+
+    await browser.storage.local.set({ logs });
+    await workflowState.delete(this.id);
+
+    this.listeners = {};
+  }
+
+  nextFlow() {
+    this.currentIndex += 1;
+
+    if (this.currentIndex >= this.collection.flow.length) {
+      this.destroy('success');
+
+      return;
+    }
+
+    this._flowHandler(this.collection.flow[this.currentIndex]);
+  }
+
+  get state() {
+    const data = {
+      id: this.id,
+      currentBlock: [],
+      name: this.collection.name,
+      startedTimestamp: this.startedTimestamp,
+    };
+
+    if (this.currentWorkflow) {
+      const { name, icon } = this.currentWorkflow;
+
+      data.currentBlock.push({ name, icon });
+    }
+
+    if (this.workflowState) {
+      const { name } = this.workflowState.currentBlock;
+
+      data.currentBlock.push({ name });
+    }
+
+    return data;
+  }
+
+  updateState() {
+    workflowState.update(this.id, this.state);
+  }
+
+  stop() {
+    this.workflowEngine.stop();
+
+    this.destroy('stopped');
+  }
+
+  _flowHandler(flow) {
+    if (this.isDestroyed) return;
+
+    const handlerName =
+      flow.type === 'workflow' ? 'workflow' : toCamelCase(flow.itemId);
+    const handler = flowHandler[handlerName];
+    const started = Date.now();
+
+    this.currentFlow = flow;
+    this.updateState();
+
+    if (handler) {
+      if (flow.type !== 'workflow') {
+        this.workflowState = null;
+        this.currentWorkflow = null;
+        this.workflowEngine = null;
+      }
+
+      handler
+        .call(this, flow)
+        .then((data) => {
+          this.logs.push({
+            type: data.type || 'success',
+            name: data.name,
+            logId: data.id,
+            duration: Math.round(Date.now() - started),
+          });
+
+          this.nextFlow();
+        })
+        .catch((error) => {
+          this.logs.push({
+            type: 'error',
+            name: error.name,
+            logId: error.id,
+            message: error.message,
+            duration: Math.round(Date.now() - started),
+          });
+
+          this.nextFlow();
+        });
+    } else {
+      console.error(`"${flow.type}" flow doesn't have a handler`);
+    }
+  }
+}
+
+export default CollectionEngine;

+ 29 - 3
src/background/index.js

@@ -2,6 +2,7 @@ import browser from 'webextension-polyfill';
 import { MessageListener } from '@/utils/message';
 import workflowState from './workflow-state';
 import WorkflowEngine from './workflow-engine';
+import CollectionEngine from './collection-engine';
 
 function getWorkflow(workflowId) {
   return new Promise((resolve) => {
@@ -14,15 +15,16 @@ function getWorkflow(workflowId) {
 }
 
 const runningWorkflows = {};
+const runningCollections = {};
 
 async function executeWorkflow(workflow, tabId) {
   try {
-    const engine = new WorkflowEngine(workflow, tabId);
+    const engine = new WorkflowEngine(workflow, { tabId });
 
     runningWorkflows[engine.id] = engine;
 
     engine.init();
-    engine.on('destroyed', (id) => {
+    engine.on('destroyed', ({ id }) => {
       delete runningWorkflows[id];
     });
 
@@ -32,6 +34,18 @@ async function executeWorkflow(workflow, tabId) {
     return error;
   }
 }
+function executeCollection(collection) {
+  const engine = new CollectionEngine(collection);
+
+  runningCollections[engine.id] = engine;
+
+  engine.init();
+  engine.on('destroyed', (id) => {
+    delete runningWorkflows[id];
+  });
+
+  return true;
+}
 
 browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
   if (changeInfo.status === 'complete') {
@@ -65,8 +79,9 @@ chrome.runtime.onInstalled.addListener((details) => {
     browser.storage.local
       .set({
         logs: [],
-        workflows: [],
         shortcuts: {},
+        workflows: [],
+        collections: [],
         workflowState: [],
         isFirstTime: true,
         visitWebTriggers: [],
@@ -86,6 +101,17 @@ chrome.runtime.onInstalled.addListener((details) => {
 
 const message = new MessageListener('background');
 
+message.on('collection:execute', executeCollection);
+message.on('collection:stop', (id) => {
+  const collection = runningCollections[id];
+  if (!collection) {
+    workflowState.delete(id);
+    return;
+  }
+
+  collection.stop();
+});
+
 message.on('workflow:execute', (workflow) => executeWorkflow(workflow));
 message.on('workflow:stop', (id) => {
   const workflow = runningWorkflows[id];

+ 146 - 41
src/background/blocks-handler.js → src/background/workflow-engine/blocks-handler.js

@@ -2,9 +2,10 @@
 import browser from 'webextension-polyfill';
 import { objectHasKey, fileSaver, isObject } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
-import dataExporter from '@/utils/data-exporter';
+import dataExporter, { generateJSON } from '@/utils/data-exporter';
 import compareBlockValue from '@/utils/compare-block-value';
 import errorMessage from './error-message';
+import { executeWebhook } from '@/utils/webhookUtil';
 
 function getBlockConnection(block, index = 1) {
   const blockId = block.outputs[`output_${index}`]?.connections[0]?.node;
@@ -82,6 +83,60 @@ export async function trigger(block) {
   }
 }
 
+export function loopBreakpoint(block, prevBlockData) {
+  return new Promise((resolve) => {
+    const currentLoop = this.loopList[block.data.loopId];
+
+    if (
+      currentLoop &&
+      currentLoop.index < currentLoop.maxLoop - 1 &&
+      currentLoop.index <= currentLoop.data.length - 1
+    ) {
+      resolve({
+        data: '',
+        nextBlockId: currentLoop.blockId,
+      });
+    } else {
+      resolve({
+        data: prevBlockData,
+        nextBlockId: getBlockConnection(block),
+      });
+    }
+  });
+}
+
+export function loopData(block) {
+  return new Promise((resolve) => {
+    const { data } = block;
+
+    if (this.loopList[data.loopId]) {
+      this.loopList[data.loopId].index += 1;
+      this.loopData[data.loopId] =
+        this.loopList[data.loopId].data[this.loopList[data.loopId].index];
+    } else {
+      const currLoopData =
+        data.loopThrough === 'data-columns'
+          ? generateJSON(Object.keys(this.data), this.data)
+          : JSON.parse(data.loopData);
+
+      this.loopList[data.loopId] = {
+        index: 0,
+        data: currLoopData,
+        id: data.loopId,
+        blockId: block.id,
+        maxLoop: data.maxLoop || currLoopData.length,
+      };
+      /* eslint-disable-next-line */
+      this.loopData[data.loopId] = currLoopData[0];
+    }
+
+    resolve({
+      data: this.loopData[data.loopId],
+      nextBlockId: getBlockConnection(block),
+    });
+  });
+}
+
 export function goBack(block) {
   return new Promise((resolve, reject) => {
     const nextBlockId = getBlockConnection(block);
@@ -132,44 +187,56 @@ export function forwardPage(block) {
   });
 }
 
-export function newTab(block) {
+function tabUpdatedListener(tab) {
   return new Promise((resolve, reject) => {
-    browser.tabs
-      .create(block.data)
-      .then((tab) => {
-        this._listener({
-          name: 'tab-updated',
-          id: tab.id,
-          once: true,
-          callback: async (tabId, changeInfo, deleteListener) => {
-            if (changeInfo.status !== 'complete') return;
-
-            try {
-              await browser.tabs.executeScript(tabId, {
-                file: './contentScript.bundle.js',
-              });
+    this._listener({
+      name: 'tab-updated',
+      id: tab.id,
+      once: true,
+      callback: async (tabId, changeInfo, deleteListener) => {
+        if (changeInfo.status !== 'complete') return;
 
-              this.tabId = tabId;
-              this._connectTab(tabId);
+        try {
+          await browser.tabs.executeScript(tabId, {
+            file: './contentScript.bundle.js',
+          });
 
-              deleteListener();
+          deleteListener();
+          this._connectTab(tabId);
 
-              resolve({
-                nextBlockId: getBlockConnection(block),
-                data: block.data.url,
-              });
-            } catch (error) {
-              console.error(error);
-            }
-          },
-        });
-      })
-      .catch((error) => {
-        console.error(error);
-        reject(error);
-      });
+          resolve();
+        } catch (error) {
+          console.error(error);
+          reject(error);
+        }
+      },
+    });
   });
 }
+export async function newTab(block) {
+  try {
+    const { updatePrevTab, url, active } = block.data;
+
+    if (updatePrevTab && this.tabId) {
+      await browser.tabs.update(this.tabId, { url, active });
+    } else {
+      const { id, windowId } = await browser.tabs.create({ url, active });
+
+      this.tabId = id;
+      this.windowId = windowId;
+    }
+
+    await tabUpdatedListener.call(this, { id: this.tabId });
+
+    return {
+      data: url,
+      nextBlockId: getBlockConnection(block),
+    };
+  } catch (error) {
+    console.error(error);
+    throw error;
+  }
+}
 
 export async function activeTab(block) {
   const nextBlockId = getBlockConnection(block);
@@ -186,13 +253,17 @@ export async function activeTab(block) {
       return data;
     }
 
-    const [tab] = await browser.tabs.query({ active: true });
+    const [tab] = await browser.tabs.query({
+      active: true,
+      currentWindow: true,
+    });
 
     await browser.tabs.executeScript(tab.id, {
       file: './contentScript.bundle.js',
     });
 
     this.tabId = tab.id;
+    this.windowId = tab.windowId;
     this._connectTab(tab.id);
 
     return data;
@@ -200,6 +271,7 @@ export async function activeTab(block) {
     console.error(error);
     return {
       data: '',
+      message: error.message || error,
       nextBlockId,
     };
   }
@@ -238,16 +310,24 @@ export async function takeScreenshot(block) {
         throw new Error(errorMessage('no-tab', block));
       }
 
-      const [tab] = await browser.tabs.query({ active: true });
+      const [tab] = await browser.tabs.query({
+        active: true,
+        currentWindow: true,
+      });
 
+      await browser.windows.update(this.windowId, { focused: true });
       await browser.tabs.update(this.tabId, { active: true });
 
-      setTimeout(() => {
-        browser.tabs.captureVisibleTab(options).then((uri) => {
-          browser.tabs.update(tab.id, { active: true });
-          saveImage(uri);
-        });
-      }, 500);
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      const uri = await browser.tabs.captureVisibleTab(options);
+
+      if (tab) {
+        await browser.windows.update(tab.windowId, { focused: true });
+        await browser.tabs.update(tab.id, { active: true });
+      }
+
+      saveImage(uri);
     } else {
       const uri = await browser.tabs.captureVisibleTab(options);
 
@@ -436,3 +516,28 @@ export function repeatTask({ data, id, outputs }) {
     }
   });
 }
+
+export function webhook({ data, outputs }) {
+  return new Promise((resolve, reject) => {
+    if (!data.url) {
+      reject(new Error('URL is empty'));
+      return;
+    }
+
+    if (!data.url.startsWith('http')) {
+      reject(new Error('URL is not valid'));
+      return;
+    }
+
+    executeWebhook(data)
+      .then(() => {
+        resolve({
+          data: '',
+          nextBlockId: getBlockConnection({ outputs }),
+        });
+      })
+      .catch((error) => {
+        reject(error);
+      });
+  });
+}

+ 3 - 3
src/background/error-message.js → src/background/workflow-engine/error-message.js

@@ -1,4 +1,4 @@
-import { objectHasKey } from '@/utils/helper';
+import { objectHasKey, replaceMustache } from '@/utils/helper';
 
 const messages = {
   'no-trigger-block': '"{{name}}"" workflow doesn\'t have a trigger block.',
@@ -13,8 +13,8 @@ export default function (errorId, data) {
   if (!message) return `Can't find message for this error (${errorId})`;
 
   /* eslint-disable-next-line */
-  const resultMessage = message.replace(/{{\s*[\w\.]+\s*}}/g, (match) => {
-    const key = match.replace(/{|}/g, '');
+  const resultMessage = replaceMustache(message, (match) => {
+    const key = match.slice(2, -2);
 
     return objectHasKey(data, key) ? data[key] : key;
   });

+ 36 - 17
src/background/workflow-engine.js → src/background/workflow-engine/index.js

@@ -5,7 +5,7 @@ import { toCamelCase } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
 import referenceData from '@/utils/reference-data';
 import errorMessage from './error-message';
-import workflowState from './workflow-state';
+import workflowState from '../workflow-state';
 import * as blocksHandler from './blocks-handler';
 
 let reloadTimeout;
@@ -31,7 +31,7 @@ function tabRemovedHandler(tabId) {
   delete this.tabId;
 
   if (tasks[this.currentBlock.name].category === 'interaction') {
-    this.destroy('error');
+    this.destroy('error', 'Current active tab is removed');
   }
 
   workflowState.update(this.id, this.state);
@@ -74,19 +74,24 @@ function tabUpdatedHandler(tabId, changeInfo) {
 }
 
 class WorkflowEngine {
-  constructor(workflow, tabId = null) {
+  constructor(workflow, { tabId = null, isInCollection, collectionLogId }) {
     this.id = nanoid();
     this.tabId = tabId;
     this.workflow = workflow;
+    this.isInCollection = isInCollection;
+    this.collectionLogId = collectionLogId;
     this.data = {};
     this.blocks = {};
     this.eventListeners = {};
     this.repeatedTasks = {};
+    this.loopList = {};
+    this.loopData = {};
     this.logs = [];
     this.isPaused = false;
     this.isDestroyed = false;
     this.currentBlock = null;
     this.workflowTimeout = null;
+    this.windowId = null;
 
     this.tabMessageListeners = {};
     this.tabUpdatedListeners = {};
@@ -136,8 +141,9 @@ class WorkflowEngine {
 
     workflowState
       .add(this.id, {
-        workflowId: this.workflow.id,
         state: this.state,
+        workflowId: this.workflow.id,
+        isInCollection: this.isInCollection,
       })
       .then(() => {
         this._blockHandler(triggerBlock);
@@ -165,8 +171,10 @@ class WorkflowEngine {
     this.destroy('stopped');
   }
 
-  async destroy(status) {
+  async destroy(status, message) {
     try {
+      this.dispatchEvent('destroyed', { id: this.id, status, message });
+
       this.eventListeners = {};
       this.tabMessageListeners = {};
       this.tabUpdatedListeners = {};
@@ -194,12 +202,12 @@ class WorkflowEngine {
           history: this.logs,
           endedAt: this.endedTimestamp,
           startedAt: this.startedTimestamp,
+          isInCollection: this.isInCollection,
+          collectionLogId: this.collectionLogId,
         });
 
         await browser.storage.local.set({ logs });
       }
-
-      this.dispatchEvent('destroyed', this.id);
     } catch (error) {
       console.error(error);
     }
@@ -221,6 +229,7 @@ class WorkflowEngine {
       'isPaused',
       'isDestroyed',
       'currentBlock',
+      'isInCollection',
       'startedTimestamp',
     ];
     const state = keys.reduce((acc, key) => {
@@ -245,12 +254,18 @@ class WorkflowEngine {
       return;
     }
 
-    this.workflowTimeout = setTimeout(() => {
-      if (!this.isDestroyed) this.stop('Workflow stopped because of timeout');
-    }, this.workflow.settings.timeout || 120000);
+    const disableTimeoutKeys = ['delay', 'javascript-code'];
+
+    if (!disableTimeoutKeys.includes(block.name)) {
+      this.workflowTimeout = setTimeout(() => {
+        if (!this.isDestroyed) this.stop('Workflow stopped because of timeout');
+      }, this.workflow.settings.timeout || 120000);
+    }
+
     this.currentBlock = block;
 
     workflowState.update(this.id, this.state);
+    this.dispatchEvent('update', this.state);
 
     const started = Date.now();
     const isInteraction = tasks[block.name].category === 'interaction';
@@ -260,11 +275,18 @@ class WorkflowEngine {
     const handler = blocksHandler[handlerName];
 
     if (handler) {
-      referenceData(block, { data: this.data, prevBlockData });
+      const replacedBlock = referenceData(block, {
+        prevBlockData,
+        data: this.data,
+        loopData: this.loopData,
+      });
 
       handler
-        .call(this, block, prevBlockData)
+        .call(this, replacedBlock, prevBlockData)
         .then((result) => {
+          clearTimeout(this.workflowTimeout);
+          this.workflowTimeout = null;
+
           if (result.nextBlockId) {
             this.logs.push({
               type: 'success',
@@ -283,9 +305,6 @@ class WorkflowEngine {
             this.dispatchEvent('finish');
             this.destroy('success');
           }
-
-          clearTimeout(this.workflowTimeout);
-          this.workflowTimeout = null;
         })
         .catch((error) => {
           this.logs.push({
@@ -293,7 +312,7 @@ class WorkflowEngine {
             message: error.message,
             name: tasks[block.name].name,
           });
-          console.dir(error);
+
           if (
             this.workflow.settings.onError === 'keep-running' &&
             error.nextBlockId
@@ -303,7 +322,7 @@ class WorkflowEngine {
               error.data || ''
             );
           } else {
-            this.destroy('error');
+            this.destroy('error', error.message);
           }
 
           clearTimeout(this.workflowTimeout);

+ 2 - 2
src/background/workflow-state.js

@@ -22,11 +22,11 @@ class WorkflowState {
     try {
       let { workflowState } = await browser.storage.local.get('workflowState');
 
-      if (filter) {
+      if (workflowState && filter) {
         workflowState = workflowState.filter(filter);
       }
 
-      return workflowState;
+      return workflowState || [];
     } catch (error) {
       console.error(error);
 

+ 1 - 1
src/components/block/BlockExportData.vue

@@ -21,7 +21,7 @@
     </div>
     <input
       v-model="block.data.name"
-      class="w-full bg-input rounded-lg transition mb-2 py-2 px-4 block"
+      class="bg-input rounded-lg transition w-40 mb-2 py-2 px-4 block"
       placeholder="File name"
     />
     <ui-select v-model="block.data.type" class="w-40" placeholder="Export as">

+ 49 - 0
src/components/block/BlockLoopBreakpoint.vue

@@ -0,0 +1,49 @@
+<template>
+  <div :id="componentId" class="p-4">
+    <div class="flex items-center mb-2">
+      <div
+        :class="block.category.color"
+        class="inline-block text-sm mr-4 p-2 rounded-lg"
+      >
+        <v-remixicon name="riStopLine" size="20" class="inline-block mr-1" />
+        <span>Loop breakpoint</span>
+      </div>
+      <div class="flex-grow"></div>
+      <v-remixicon
+        name="riDeleteBin7Line"
+        class="cursor-pointer"
+        @click="editor.removeNodeId(`node-${block.id}`)"
+      />
+    </div>
+    <input
+      :value="block.data.loopId"
+      class="px-4 py-2 rounded-lg w-48 bg-input"
+      placeholder="Loop ID"
+      type="text"
+      required
+      @input="handleInput"
+    />
+  </div>
+</template>
+<script setup>
+import emitter from 'tiny-emitter/instance';
+import { useComponentId } from '@/composable/componentId';
+import { useEditorBlock } from '@/composable/editorBlock';
+
+const props = defineProps({
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const componentId = useComponentId('block-delay');
+const block = useEditorBlock(`#${componentId}`, props.editor);
+
+function handleInput({ target }) {
+  const loopId = target.value.replace(/\s/g, '');
+
+  props.editor.updateNodeDataFromId(block.id, { loopId });
+  emitter.emit('editor:data-changed', block.id);
+}
+</script>

+ 0 - 70
src/components/block/BlockNewTab.vue

@@ -1,70 +0,0 @@
-<template>
-  <div :id="componentId" class="p-4">
-    <div class="flex items-center mb-2">
-      <div
-        :class="block.category.color"
-        class="inline-block text-sm mr-4 p-2 rounded-lg"
-      >
-        <v-remixicon name="riGlobalLine" size="20" class="inline-block mr-1" />
-        <span>{{ block.details.name }}</span>
-      </div>
-      <div class="flex-grow"></div>
-      <v-remixicon
-        name="riDeleteBin7Line"
-        class="cursor-pointer"
-        @click="editor.removeNodeId(`node-${block.id}`)"
-      />
-    </div>
-    <input
-      :value="block.data.url"
-      class="px-4 py-2 mb-1 rounded-lg block w-48 bg-input"
-      placeholder="http://example.com"
-      type="url"
-      required
-      @blur="checkInputValue"
-      @input="handleInput"
-    />
-    <ui-checkbox :model-value="block.data.active" @change="handleCheckbox">
-      Set as active tab
-    </ui-checkbox>
-  </div>
-</template>
-<script setup>
-import emitter from 'tiny-emitter/instance';
-import { debounce } from '@/utils/helper';
-import { useComponentId } from '@/composable/componentId';
-import { useEditorBlock } from '@/composable/editorBlock';
-
-const props = defineProps({
-  editor: {
-    type: Object,
-    default: () => ({}),
-  },
-});
-
-const componentId = useComponentId('new-tab');
-const block = useEditorBlock(`#${componentId}`, props.editor);
-
-const isValidURL = (url) => /^(https?):\/\//i.test(url);
-const handleInput = debounce(({ target }) => {
-  target.reportValidity();
-
-  block.data.url = isValidURL(target.value) ? target.value : '';
-
-  props.editor.updateNodeDataFromId(block.id, {
-    ...block.data,
-    url: block.data.url,
-  });
-  emitter.emit('editor:data-changed', block.id);
-}, 250);
-function handleCheckbox(value) {
-  props.editor.updateNodeDataFromId(block.id, { ...block.data, active: value });
-  block.data.active = value;
-  emitter.emit('editor:data-changed', block.id);
-}
-function checkInputValue({ target }) {
-  if (!isValidURL(target.value)) {
-    target.value = '';
-  }
-}
-</script>

+ 14 - 0
src/components/newtab/app/AppSidebar.vue

@@ -64,6 +64,15 @@
       </router-link>
     </div>
     <div class="flex-grow"></div>
+    <a
+      v-tooltip:right="'Documentation'"
+      href="https://github.com/kholid060/automa/wiki"
+      rel="noopener"
+      class="mb-8"
+      target="_blank"
+    >
+      <v-remixicon name="riBookOpenLine" />
+    </a>
     <a
       v-tooltip:right="'Github'"
       href="https://github.com/kholid060/automa"
@@ -91,6 +100,11 @@ const tabs = [
     icon: 'riFlowChart',
     path: '/workflows',
   },
+  {
+    name: 'Collections',
+    icon: 'riFolderLine',
+    path: '/collections',
+  },
   {
     name: 'Logs',
     icon: 'riHistoryLine',

+ 3 - 1
src/components/newtab/logs/LogsDataViewer.vue

@@ -48,7 +48,9 @@ const props = defineProps({
   },
 });
 
-const data = generateJSON(Object.keys(props.log.data), props.log.data);
+const data = Array.isArray(props.log.data)
+  ? props.log.data
+  : generateJSON(Object.keys(props.log.data), props.log.data);
 const dataStr = JSON.stringify(data, null, 2);
 
 const fileName = ref(props.log.name);

+ 73 - 0
src/components/newtab/shared/SharedCard.vue

@@ -0,0 +1,73 @@
+<template>
+  <ui-card class="hover:ring-2 group hover:ring-accent">
+    <div class="flex items-center mb-4">
+      <span class="p-2 rounded-lg bg-box-transparent">
+        <v-remixicon :name="data.icon || icon" />
+      </span>
+      <div class="flex-grow"></div>
+      <button
+        class="invisible group-hover:visible"
+        @click="$emit('execute', data)"
+      >
+        <v-remixicon name="riPlayLine" />
+      </button>
+      <ui-popover v-if="showDetails" class="h-6 ml-2">
+        <template #trigger>
+          <button>
+            <v-remixicon name="riMoreLine" />
+          </button>
+        </template>
+        <ui-list class="w-36 space-y-1">
+          <ui-list-item
+            v-for="item in menu"
+            :key="item.name"
+            v-close-popover
+            class="cursor-pointer"
+            @click="$emit('menuSelected', { name: item.name, data })"
+          >
+            <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+            <span class="capitalize">{{ item.name }}</span>
+          </ui-list-item>
+        </ui-list>
+      </ui-popover>
+    </div>
+    <div class="cursor-pointer" @click="$emit('click', data)">
+      <p class="line-clamp font-semibold leading-tight">
+        {{ data.name }}
+      </p>
+      <p class="text-gray-600 dark:text-gray-200">{{ formatDate() }}</p>
+    </div>
+  </ui-card>
+</template>
+<script setup>
+import dayjs from '@/lib/dayjs';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  icon: {
+    type: String,
+    default: 'riGlobalLine',
+  },
+  showDetails: {
+    type: Boolean,
+    default: true,
+  },
+  menu: {
+    type: Array,
+    default: () => [],
+  },
+});
+defineEmits(['execute', 'click', 'menuSelected']);
+
+let formattedDate = null;
+const formatDate = () => {
+  if (formattedDate) return formattedDate;
+
+  formattedDate = dayjs(props.data.createdAt).fromNow();
+
+  return formattedDate;
+};
+</script>

+ 31 - 20
src/components/newtab/shared/SharedWorkflowState.vue

@@ -2,19 +2,19 @@
   <ui-card>
     <div class="flex items-center mb-4">
       <div class="flex-1 text-overflow mr-4">
-        <p class="w-full mr-2 text-overflow">{{ state.name }}</p>
+        <p class="w-full mr-2 text-overflow">{{ data.state.name }}</p>
         <p
           class="w-full mr-2 text-gray-600 leading-tight text-overflow"
           :title="`Started at: ${formatDate(
-            state.startedTimestamp,
+            data.state.startedTimestamp,
             'DD MMM, hh:mm A'
           )}`"
         >
-          {{ formatDate(state.startedTimestamp, 'relative') }}
+          {{ formatDate(data.state.startedTimestamp, 'relative') }}
         </p>
       </div>
       <ui-button
-        v-if="state.tabId"
+        v-if="data.state.tabId"
         icon
         class="mr-2"
         title="Open tab"
@@ -22,18 +22,21 @@
       >
         <v-remixicon name="riExternalLinkLine" />
       </ui-button>
-      <ui-button variant="accent" @click="stopWorkflow(item)">
+      <ui-button variant="accent" @click="stopWorkflow">
         <v-remixicon name="riStopLine" class="mr-2 -ml-1" />
         <span>Stop</span>
       </ui-button>
     </div>
-    <div class="flex items-center bg-box-transparent px-4 py-2 rounded-lg">
-      <template v-if="state.currentBlock">
-        <v-remixicon :name="getBlock().icon" />
-        <p class="flex-1 ml-2 mr-4">{{ getBlock().name }}</p>
+    <div class="divide-y bg-box-transparent divide-y px-4 rounded-lg">
+      <div
+        v-for="block in getBlock()"
+        :key="block.name"
+        class="flex items-center py-2"
+      >
+        <v-remixicon :name="block.icon" />
+        <p class="flex-1 ml-2 mr-4">{{ block.name }}</p>
         <ui-spinner color="text-accnet" size="20" />
-      </template>
-      <p v-else>No block</p>
+      </div>
     </div>
   </ui-card>
 </template>
@@ -44,20 +47,24 @@ import { tasks } from '@/utils/shared';
 import dayjs from '@/lib/dayjs';
 
 const props = defineProps({
-  id: {
-    type: String,
-    default: '',
-  },
-  state: {
+  data: {
     type: Object,
     default: () => ({}),
   },
 });
 
 function getBlock() {
-  if (!props.state.currentBlock) return {};
+  if (!props.data.state.currentBlock) return [];
+
+  if (Array.isArray(props.data.state.currentBlock)) {
+    return props.data.state.currentBlock.map((item) => {
+      if (tasks[item.name]) return tasks[item.name];
+
+      return item;
+    });
+  }
 
-  return tasks[props.state.currentBlock.name];
+  return [tasks[props.data.state.currentBlock.name]];
 }
 function formatDate(date, format) {
   if (format === 'relative') return dayjs(date).fromNow();
@@ -65,9 +72,13 @@ function formatDate(date, format) {
   return dayjs(date).format(format);
 }
 function openTab() {
-  browser.tabs.update(props.state.tabId, { active: true });
+  browser.tabs.update(props.data.state.tabId, { active: true });
 }
 function stopWorkflow() {
-  sendMessage('workflow:stop', props.id, 'background');
+  sendMessage(
+    props.data.isCollection ? 'collection:stop' : 'workflow:stop',
+    props.data.id,
+    'background'
+  );
 }
 </script>

+ 21 - 4
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -7,15 +7,27 @@
   >
     <slot></slot>
     <div class="absolute z-10 p-4 bottom-0 left-0">
-      <button class="p-2 rounded-lg bg-white mr-2" @click="editor.zoom_reset()">
+      <button
+        v-tooltip.group="'Reset zoom'"
+        class="p-2 rounded-lg bg-white mr-2"
+        @click="editor.zoom_reset()"
+      >
         <v-remixicon name="riFullscreenLine" />
       </button>
       <div class="rounded-lg bg-white inline-block">
-        <button class="p-2 rounded-lg relative z-10" @click="editor.zoom_out()">
+        <button
+          v-tooltip.group="'Zoom out'"
+          class="p-2 rounded-lg relative z-10"
+          @click="editor.zoom_out()"
+        >
           <v-remixicon name="riSubtractLine" />
         </button>
         <hr class="h-6 border-r inline-block" />
-        <button class="p-2 rounded-lg" @click="editor.zoom_in()">
+        <button
+          v-tooltip.group="'Zoom in'"
+          class="p-2 rounded-lg"
+          @click="editor.zoom_in()"
+        >
           <v-remixicon name="riAddLine" />
         </button>
       </div>
@@ -27,6 +39,7 @@
 import { onMounted, shallowRef, getCurrentInstance } from 'vue';
 import emitter from 'tiny-emitter/instance';
 import { tasks } from '@/utils/shared';
+import { useGroupTooltip } from '@/composable/groupTooltip';
 import drawflow from '@/lib/drawflow';
 
 export default {
@@ -38,6 +51,8 @@ export default {
   },
   emits: ['load', 'deleteBlock'],
   setup(props, { emit }) {
+    useGroupTooltip();
+
     const editor = shallowRef(null);
 
     function dropHandler({ dataTransfer, clientX, clientY }) {
@@ -100,7 +115,9 @@ export default {
 
       if (props.data) {
         const data =
-          typeof props.data === 'string' ? JSON.parse(props.data) : props.data;
+          typeof props.data === 'string'
+            ? JSON.parse(props.data.replace(/BlockNewTab/g, 'BlockBasic'))
+            : props.data;
 
         editor.value.import(data);
       } else {

+ 0 - 62
src/components/newtab/workflow/WorkflowCard.vue

@@ -1,62 +0,0 @@
-<template>
-  <ui-card class="hover:ring-accent hover:ring-2">
-    <div class="mb-4 flex items-center">
-      <span class="p-2 rounded-lg bg-box-transparent inline-block">
-        <v-remixicon :name="workflow.icon" />
-      </span>
-      <div class="flex-grow"></div>
-      <button @click="$emit('execute', workflow)">
-        <v-remixicon name="riPlayLine" />
-      </button>
-      <ui-popover v-if="showDetails" class="ml-2 h-6">
-        <template #trigger>
-          <button>
-            <v-remixicon name="riMoreLine" />
-          </button>
-        </template>
-        <ui-list class="w-36 space-y-1">
-          <ui-list-item
-            v-for="item in menu"
-            :key="item.name"
-            v-close-popover
-            class="cursor-pointer"
-            @click="$emit(item.name, workflow)"
-          >
-            <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
-            <span class="capitalize">{{ item.name }}</span>
-          </ui-list-item>
-        </ui-list>
-      </ui-popover>
-    </div>
-    <router-link :to="`/workflows/${workflow.id}`">
-      <p class="line-clamp leading-tight font-semibold" :title="workflow.name">
-        {{ workflow.name }}
-      </p>
-      <p class="text-gray-600 dark:text-gray-200 leading-tight text-overflow">
-        {{ formatDate() }}
-      </p>
-    </router-link>
-  </ui-card>
-</template>
-<script setup>
-import dayjs from '@/lib/dayjs';
-
-const props = defineProps({
-  workflow: {
-    type: Object,
-    default: () => ({}),
-  },
-  showDetails: {
-    type: Boolean,
-    default: true,
-  },
-});
-defineEmits(['delete', 'export', 'rename', 'execute']);
-
-const formatDate = () => dayjs(props.workflow.createdAt).fromNow();
-const menu = [
-  { name: 'export', icon: 'riDownloadLine' },
-  { name: 'rename', icon: 'riPencilLine' },
-  { name: 'delete', icon: 'riDeleteBin7Line' },
-];
-</script>

+ 34 - 8
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -122,7 +122,12 @@
       </ui-list>
     </ui-popover>
   </div>
-  <hr class="m-4 border-gray-100" />
+  <ui-input
+    v-model="query"
+    prepend-icon="riSearch2Line"
+    class="px-4 mt-4 mb-2"
+    placeholder="Search blocks"
+  />
   <div class="scroll bg-scroll overflow-auto px-4 flex-1 overflow-auto">
     <template v-for="(items, catId) in taskList" :key="catId">
       <div class="flex items-center top-0 space-x-2 mb-2">
@@ -139,6 +144,7 @@
           :title="block.description || block.name"
           draggable="true"
           class="
+            transform
             select-none
             cursor-move
             relative
@@ -151,6 +157,16 @@
             $event.dataTransfer.setData('block', JSON.stringify(block))
           "
         >
+          <a
+            v-if="block.docs"
+            :href="`https://github.com/Kholid060/automa/wiki/Blocks#${block.id}`"
+            target="_blank"
+            title="Documentation"
+            rel="noopener"
+            class="absolute top-px right-2"
+          >
+            &#128712;
+          </a>
           <v-remixicon :name="block.icon" size="24" class="mb-2" />
           <p class="leading-tight text-overflow">
             {{ block.name }}
@@ -161,6 +177,7 @@
   </div>
 </template>
 <script setup>
+import { computed, ref } from 'vue';
 import { tasks, categories } from '@/utils/shared';
 
 defineProps({
@@ -184,13 +201,6 @@ defineEmits([
   'showDataColumns',
 ]);
 
-const taskList = Object.keys(tasks).reduce((arr, key) => {
-  const task = tasks[key];
-
-  (arr[task.category] = arr[task.category] || []).push({ id: key, ...task });
-
-  return arr;
-}, {});
 const icons = [
   'riGlobalLine',
   'riFileTextLine',
@@ -205,4 +215,20 @@ const icons = [
   'riDownloadLine',
   'riCommandLine',
 ];
+
+const query = ref('');
+const taskList = computed(() =>
+  Object.keys(tasks).reduce((arr, key) => {
+    const task = tasks[key];
+
+    if (tasks[key].name.toLowerCase().includes(query.value.toLowerCase())) {
+      (arr[task.category] = arr[task.category] || []).push({
+        id: key,
+        ...task,
+      });
+    }
+
+    return arr;
+  }, {})
+);
 </script>

+ 23 - 16
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -11,7 +11,9 @@
     <component
       :is="data.editComponent"
       v-if="blockData"
+      :key="data.blockId"
       v-model:data="blockData"
+      :block-id="data.blockId"
     />
   </div>
 </template>
@@ -23,6 +25,8 @@ const editComponents = require.context(
   false,
   /^(?:.*\/)?Edit[^/]*\.vue$/
 );
+
+/* eslint-disable-next-line */
 const components = editComponents.keys().reduce((acc, key) => {
   const name = key.replace(/(.\/)|\.vue$/g, '');
   const componentObj = editComponents(key)?.default ?? {};
@@ -34,23 +38,26 @@ const components = editComponents.keys().reduce((acc, key) => {
 
 export default {
   components,
-};
-</script>
-<script setup>
-const props = defineProps({
-  data: {
-    type: Object,
-    default: () => ({}),
+  props: {
+    data: {
+      type: Object,
+      default: () => ({}),
+    },
   },
-});
-const emit = defineEmits(['close', 'update']);
+  emits: ['close', 'update'],
+  setup(props, { emit }) {
+    const blockData = computed({
+      get() {
+        return props.data.data || {};
+      },
+      set(value) {
+        emit('update', value);
+      },
+    });
 
-const blockData = computed({
-  get() {
-    return props.data.data || {};
+    return {
+      blockData,
+    };
   },
-  set(value) {
-    emit('update', value);
-  },
-});
+};
 </script>

+ 22 - 12
src/components/newtab/workflow/WorkflowSettings.vue

@@ -1,16 +1,26 @@
 <template>
-  <p class="font-semibold mb-2">On workflow error</p>
-  <div class="space-x-4">
-    <ui-radio
-      v-for="item in onError"
-      :key="item.id"
-      :model-value="workflow.settings.onError"
-      :value="item.id"
-      class="mr-4"
-      @change="updateWorkflow({ onError: $event })"
-    >
-      {{ item.name }}
-    </ui-radio>
+  <div class="mb-4">
+    <p class="mb-1">On workflow error</p>
+    <div class="space-x-4">
+      <ui-radio
+        v-for="item in onError"
+        :key="item.id"
+        :model-value="workflow.settings.onError"
+        :value="item.id"
+        class="mr-4"
+        @change="updateWorkflow({ onError: $event })"
+      >
+        {{ item.name }}
+      </ui-radio>
+    </div>
+  </div>
+  <div>
+    <p class="mb-1">Workflow timeout (milliseconds)</p>
+    <ui-input
+      :model-value="workflow.settings.timeout"
+      type="number"
+      @change="updateWorkflow({ timeout: +$event })"
+    />
   </div>
 </template>
 <script setup>

+ 27 - 23
src/components/newtab/workflow/edit/EditForms.vue

@@ -1,40 +1,50 @@
 <template>
-  <edit-interaction-base v-model:data="state">
+  <edit-interaction-base v-bind="{ data }" @change="updateData">
     <ui-select
-      v-model="state.type"
+      :model-value="data.type"
       class="block w-full mt-4 mb-3"
       placeholder="Form type"
+      @change="updateData({ type: $event })"
     >
       <option v-for="form in forms" :key="form.id" :value="form.id">
         {{ form.name }}
       </option>
     </ui-select>
     <ui-checkbox
-      v-if="state.type === 'checkbox' || state.type === 'radio'"
-      v-model="state.selected"
+      v-if="data.type === 'checkbox' || data.type === 'radio'"
+      :model-value="data.selected"
+      @change="updateData({ selected: $event })"
     >
       Selected
     </ui-checkbox>
-    <ui-textarea
-      v-if="state.type === 'text-field' || state.type === 'select'"
-      v-model="state.value"
-      placeholder="Value"
-      class="w-full"
-    />
+    <template v-if="data.type === 'text-field' || data.type === 'select'">
+      <ui-textarea
+        :model-value="data.value"
+        placeholder="Value"
+        class="w-full"
+        @change="updateData({ value: $event })"
+      />
+      <ui-checkbox
+        :model-value="data.clearValue"
+        class="mb-1 ml-1"
+        @change="updateData({ clearValue: $event })"
+      >
+        Clear form value
+      </ui-checkbox>
+    </template>
     <ui-input
-      v-if="state.type === 'text-field'"
-      v-model="state.delay"
+      v-if="data.type === 'text-field'"
+      :model-value="data.delay"
       label="Typing delay (millisecond)(0 to disable)"
       placeholder="Delay"
       class="w-full"
       min="0"
       type="number"
+      @change="updateData({ delay: +$event })"
     />
   </edit-interaction-base>
 </template>
 <script setup>
-import { ref, watch } from 'vue';
-import { debounce } from '@/utils/helper';
 import EditInteractionBase from './EditInteractionBase.vue';
 
 const props = defineProps({
@@ -52,13 +62,7 @@ const forms = [
   { id: 'radio', name: 'Radio' },
 ];
 
-const state = ref(props.data);
-
-watch(
-  state,
-  debounce((value) => {
-    emit('update:data', value);
-  }, 250),
-  { deep: true }
-);
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
 </script>

+ 35 - 33
src/components/newtab/workflow/edit/EditInteractionBase.vue

@@ -1,37 +1,39 @@
 <template>
-  <slot name="prepend" />
-  <ui-textarea
-    :model-value="data.description"
-    autoresize
-    placeholder="Description"
-    class="w-full mb-2"
-    @change="updateData({ description: $event })"
-  />
-  <ui-input
-    v-if="!hideSelector"
-    :model-value="data.selector"
-    placeholder="Element selector"
-    class="mb-1 w-full"
-    @change="updateData({ selector: $event })"
-  />
-  <template v-if="!hideSelector">
-    <ui-checkbox
-      v-if="!data.disableMultiple && !hideMultiple"
-      class="mr-6"
-      :model-value="data.multiple"
-      @change="updateData({ multiple: $event })"
-    >
-      Multiple
-    </ui-checkbox>
-    <ui-checkbox
-      :model-value="data.markEl"
-      title="An element will not be selected after marked"
-      @change="updateData({ markEl: $event })"
-    >
-      Mark element
-    </ui-checkbox>
-  </template>
-  <slot></slot>
+  <div>
+    <slot name="prepend" />
+    <ui-textarea
+      :model-value="data.description"
+      autoresize
+      placeholder="Description"
+      class="w-full mb-2"
+      @change="updateData({ description: $event })"
+    />
+    <ui-input
+      v-if="!hideSelector"
+      :model-value="data.selector"
+      placeholder="Element selector"
+      class="mb-1 w-full"
+      @change="updateData({ selector: $event })"
+    />
+    <template v-if="!hideSelector">
+      <ui-checkbox
+        v-if="!data.disableMultiple && !hideMultiple"
+        class="mr-6"
+        :model-value="data.multiple"
+        @change="updateData({ multiple: $event })"
+      >
+        Multiple
+      </ui-checkbox>
+      <ui-checkbox
+        :model-value="data.markEl"
+        title="An element will not be selected if have been selected before"
+        @change="updateData({ markEl: $event })"
+      >
+        Mark element
+      </ui-checkbox>
+    </template>
+    <slot></slot>
+  </div>
 </template>
 <script setup>
 const props = defineProps({

+ 201 - 0
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -0,0 +1,201 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      placeholder="Description"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
+    <ui-input
+      :model-value="data.loopId"
+      class="w-full mb-3"
+      label="Loop ID"
+      placeholder="Loop ID"
+      @change="updateLoopID"
+    />
+    <ui-select
+      :model-value="data.loopThrough"
+      placeholder="Loop through"
+      class="w-full mb-2"
+      @change="
+        updateData({
+          loopThrough: $event,
+          loopData: $event === 'custom-data' ? data.loopData : '[]',
+        })
+      "
+    >
+      <option v-for="type in loopTypes" :key="type.id" :value="type.id">
+        {{ type.name }}
+      </option>
+    </ui-select>
+    <ui-input
+      :model-value="data.maxLoop"
+      class="w-full mb-4"
+      min="0"
+      type="number"
+      label="Max data to loop (0 to disable)"
+      title="Max numbers of data to loop"
+      @change="updateData({ maxLoop: +$event || 0 })"
+    />
+    <ui-button
+      v-if="data.loopThrough === 'custom-data'"
+      class="w-full"
+      variant="accent"
+      @click="state.showDataModal = true"
+    >
+      Insert data
+    </ui-button>
+    <ui-modal
+      v-model="state.showDataModal"
+      title="Data"
+      content-class="max-w-3xl"
+    >
+      <div class="flex mb-4 items-center">
+        <ui-button variant="accent" @click="importFile">
+          Import file
+        </ui-button>
+        <ui-button
+          v-tooltip="'Options'"
+          :class="{ 'text-primary': state.showOptions }"
+          icon
+          class="ml-2"
+          @click="state.showOptions = !state.showOptions"
+        >
+          <v-remixicon name="riSettings3Line" />
+        </ui-button>
+        <p class="flex-1 text-overflow mx-4">{{ file.name }}</p>
+        <template v-if="data.loopData.length > maxStrLength">
+          <p class="mr-2">File too large to edit</p>
+          <ui-button @click="updateData({ loopData: '[]' })">
+            Clear data
+          </ui-button>
+        </template>
+        <p v-else>Max file size is 1MB</p>
+      </div>
+      <div style="height: calc(100vh - 11rem)">
+        <prism-editor
+          v-show="!state.showOptions"
+          v-model="state.tempLoopData"
+          :highlight="highlighter('json')"
+          :readonly="data.loopData.length > maxStrLength"
+          class="py-4"
+          @input="updateData({ loopData: $event.target.value })"
+        />
+        <div v-show="state.showOptions">
+          <p class="font-semibold mb-2">CSV</p>
+          <ui-checkbox v-model="options.header">
+            Use the first row as keys
+          </ui-checkbox>
+        </div>
+      </div>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { onMounted, shallowReactive } from 'vue';
+import { nanoid } from 'nanoid';
+import { PrismEditor } from 'vue-prism-editor';
+import Papa from 'papaparse';
+import { highlighter } from '@/lib/prism';
+import { openFilePicker } from '@/utils/helper';
+
+const props = defineProps({
+  blockId: {
+    type: String,
+    default: '',
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const maxStrLength = 5e4;
+const maxFileSize = 1024 * 1024;
+const loopTypes = [
+  { id: 'data-columns', name: 'Data columns' },
+  { id: 'custom-data', name: 'Custom data' },
+];
+const tempLoopData =
+  props.data.loopData.length > maxStrLength
+    ? props.data.loopData.slice(0, maxStrLength)
+    : props.data.loopData;
+
+const state = shallowReactive({
+  tempLoopData,
+  showOptions: false,
+  showDataModal: false,
+  workflowLoopData: {},
+});
+const options = shallowReactive({
+  header: true,
+});
+const file = shallowReactive({
+  size: 0,
+  name: '',
+  type: '',
+});
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+function updateLoopID(id) {
+  let loopId = id.replace(/\s/g, '');
+
+  if (!loopId) {
+    loopId = nanoid(6);
+  }
+
+  updateData({ loopId });
+}
+function importFile() {
+  openFilePicker(['application/json', 'text/csv', 'application/vnd.ms-excel'])
+    .then(async (fileObj) => {
+      if (fileObj.size > maxFileSize) {
+        alert('The file size is the exceeded maximum allowed');
+        return;
+      }
+
+      file.name = fileObj.name;
+      file.type = fileObj.type;
+
+      const csvTypes = ['text/csv', 'application/vnd.ms-excel'];
+
+      const reader = new FileReader();
+
+      reader.onload = ({ target }) => {
+        let loopData;
+
+        if (fileObj.type === 'application/json') {
+          const result = JSON.parse(target.result);
+          loopData = Array.isArray(result) ? result : [result];
+        } else if (csvTypes.includes(fileObj.type)) {
+          loopData = Papa.parse(target.result, options).data;
+        }
+
+        if (Array.isArray(loopData)) {
+          const loopDataStr = JSON.stringify(loopData);
+
+          state.tempLoopData =
+            loopDataStr.length > maxStrLength
+              ? loopDataStr.slice(0, maxStrLength)
+              : loopDataStr;
+          updateData({ loopData: loopDataStr });
+        }
+      };
+
+      reader.readAsText(fileObj);
+    })
+    .catch((error) => {
+      console.error(error);
+      if (error.message.startsWith('invalid')) alert(error.message);
+    });
+}
+
+onMounted(() => {
+  if (!props.data.loopId) {
+    updateData({ loopId: nanoid(6) });
+  }
+});
+</script>

+ 54 - 0
src/components/newtab/workflow/edit/EditNewTab.vue

@@ -0,0 +1,54 @@
+<!-- use the current active tab optoin?  -->
+<template>
+  <div class="mb-2 mt-4">
+    <ui-textarea
+      :model-value="data.description"
+      class="w-full mb-2"
+      placeholder="Description"
+      @change="updateData({ description: $event })"
+    />
+    <ui-input
+      v-if="!data.activeTab"
+      :model-value="data.url"
+      title="URL"
+      class="w-full"
+      placeholder="http://example.com/"
+      @change="updateData({ url: $event })"
+    />
+    <a
+      href="https://github.com/Kholid060/automa/wiki/Features#reference-data"
+      rel="noopener"
+      class="text-primary inline-block mb-2 text-sm"
+      target="_blank"
+    >
+      Learn how to add dynamic data
+    </a>
+    <ui-checkbox
+      :model-value="data.updatePrevTab"
+      class="mb-2 leading-tight"
+      title="Use the previously opened new tab instead of creating a new one"
+      @change="updateData({ updatePrevTab: $event })"
+    >
+      Update previously opened tab
+    </ui-checkbox>
+    <ui-checkbox
+      :model-value="data.active"
+      @change="updateData({ active: $event })"
+    >
+      Set as active tab
+    </ui-checkbox>
+  </div>
+</template>
+<script setup>
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 1 - 1
src/components/newtab/workflow/edit/EditTrigger.vue

@@ -27,7 +27,7 @@
         min="10"
         max="120"
         @change="
-          updateIntervalInput($event, { key: 'interval', min: 10, max: 120 })
+          updateIntervalInput($event, { key: 'interval', min: 1, max: 120 })
         "
       />
       <ui-input

+ 157 - 0
src/components/newtab/workflow/edit/EditWebhook.vue

@@ -0,0 +1,157 @@
+<template>
+  <div class="mb-2 mt-4">
+    <ui-textarea
+      :model-value="data.description"
+      placeholder="Description"
+      class="w-full mb-2"
+      @change="updateData({ description: $event })"
+    />
+    <ui-input
+      :model-value="data.url"
+      class="mb-2 w-full"
+      placeholder="https://example.com/postreceive"
+      required
+      title="The Post receive URL"
+      type="url"
+      @change="updateData({ url: $event })"
+    />
+    <ui-select
+      :model-value="data.contentType"
+      placeholder="Select a content type"
+      class="mb-2 w-full"
+      @change="updateData({ contentType: $event })"
+    >
+      <option
+        v-for="type in contentTypes"
+        :key="type.value"
+        :value="type.value"
+      >
+        {{ type.name }}
+      </option>
+    </ui-select>
+    <ui-input
+      :model-value="data.timeout"
+      class="mb-2 w-full"
+      placeholder="Timeout"
+      title="Http request execution timeout(ms)"
+      type="number"
+      @change="updateData({ timeout: +$event })"
+    />
+    <ui-tabs v-model="activeTab" fill class="mb-4">
+      <ui-tab value="headers">Headers</ui-tab>
+      <ui-tab value="body">Content body</ui-tab>
+    </ui-tabs>
+    <ui-tab-panels :model-value="activeTab">
+      <ui-tab-panel
+        value="headers"
+        class="grid grid-cols-7 justify-items-center gap-2"
+      >
+        <template v-for="(items, index) in headerRef" :key="index">
+          <ui-input
+            v-model="items.name"
+            :placeholder="`Header ${index + 1}`"
+            type="text"
+            class="col-span-3"
+          />
+          <ui-input
+            v-model="items.value"
+            placeholder="Value"
+            type="text"
+            class="col-span-3"
+          />
+          <button @click="removeHeader(index)">
+            <v-remixicon name="riCloseCircleLine" size="20" />
+          </button>
+        </template>
+        <ui-button
+          class="col-span-4 mt-4 block w-full"
+          variant="accent"
+          @click="addHeader"
+        >
+          <span> Add Header </span>
+        </ui-button>
+      </ui-tab-panel>
+      <ui-tab-panel value="body">
+        <prism-editor
+          v-if="!showContentModalRef"
+          :highlight="highlighter('json')"
+          :model-value="data.body"
+          class="p-4 max-h-80 mb-2"
+          readonly
+          @click="showContentModalRef = true"
+        />
+      </ui-tab-panel>
+    </ui-tab-panels>
+    <ui-modal
+      v-model="showContentModalRef"
+      content-class="max-w-3xl"
+      title="Content Body"
+    >
+      <prism-editor
+        v-model="contentRef"
+        :highlight="highlighter('json')"
+        class="py-4"
+        line-numbers
+        style="height: calc(100vh - 18rem)"
+      />
+      <div class="mt-3">
+        <a
+          href="https://github.com/Kholid060/automa/wiki/Features#reference-data"
+          rel="noopener"
+          class="border-b text-primary"
+          target="_blank"
+        >
+          Click here to learn how to add dynamic data
+        </a>
+      </div>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+import { PrismEditor } from 'vue-prism-editor';
+import { highlighter } from '@/lib/prism';
+import { contentTypes } from '@/utils/shared';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const activeTab = ref('headers');
+const contentRef = ref(props.data.body);
+const headerRef = ref(props.data.headers);
+const showContentModalRef = ref(false);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+
+watch(contentRef, (value) => {
+  updateData({ body: value });
+});
+
+function removeHeader(index) {
+  headerRef.value.splice(index, 1);
+}
+
+function addHeader() {
+  headerRef.value.push({ name: '', value: '' });
+}
+
+watch(
+  headerRef,
+  (value) => {
+    updateData({ headers: value });
+  },
+  { deep: true }
+);
+</script>
+<style scoped>
+code {
+  @apply bg-gray-900 text-sm text-white p-1 rounded-md;
+}
+</style>

+ 100 - 0
src/components/ui/UiPagination.vue

@@ -0,0 +1,100 @@
+<template>
+  <div class="flex items-center">
+    <ui-button
+      v-tooltip="'Previous page'"
+      :disabled="modelValue <= 1"
+      icon
+      @click="updatePage(modelValue - 1)"
+    >
+      <v-remixicon name="riArrowLeftSLine" />
+    </ui-button>
+    <div class="mx-4">
+      <input
+        ref="inputEl"
+        v-tooltip="'Current page'"
+        :value="modelValue"
+        :max="maxPage"
+        min="0"
+        class="
+          p-2
+          text-center
+          transition
+          w-10
+          appearance-none
+          bg-input
+          rounded-lg
+        "
+        type="number"
+        @click="$event.target.select()"
+        @input="updatePage(+$event.target.value, $event.target)"
+      />
+      of
+      {{ maxPage }}
+    </div>
+    <ui-button
+      v-tooltip="'Next page'"
+      :disabled="modelValue >= maxPage"
+      icon
+      @click="updatePage(modelValue + 1)"
+    >
+      <v-remixicon rotate="180" name="riArrowLeftSLine" />
+    </ui-button>
+  </div>
+</template>
+<script setup>
+import { computed, ref, watch } from 'vue';
+
+const props = defineProps({
+  modelValue: {
+    type: Number,
+    default: 1,
+  },
+  records: {
+    type: Number,
+    default: 10,
+  },
+  perPage: {
+    type: Number,
+    default: 10,
+  },
+});
+const emit = defineEmits(['update:modelValue', 'paginate']);
+
+const inputEl = ref(null);
+
+const maxPage = computed(() => Math.round(props.records / props.perPage));
+
+function emitEvent(page) {
+  emit('update:modelValue', page);
+  emit('paginate', page);
+}
+function updatePage(page, element) {
+  let currentPage = page;
+
+  if (currentPage > maxPage.value || currentPage < 1) {
+    if (!element) return;
+
+    currentPage = currentPage > maxPage.value ? maxPage.value : 1;
+  }
+
+  emitEvent(currentPage);
+}
+
+watch(
+  () => [props.perPage, props.records],
+  () => {
+    emitEvent(1);
+  }
+);
+</script>
+<style scoped>
+input::-webkit-outer-spin-button,
+input::-webkit-inner-spin-button {
+  -webkit-appearance: none;
+  margin: 0;
+}
+
+input[type='number'] {
+  -moz-appearance: textfield;
+}
+</style>

+ 1 - 0
src/components/ui/UiTabs.vue

@@ -7,6 +7,7 @@
       dark:text-gray-200
       border-b
       flex
+      space-x-1
       items-center
       relative
     "

+ 1 - 0
src/content/blocks-handler.js

@@ -174,6 +174,7 @@ export function forms(block) {
       if (isElementUnique(elements, block))
         handleFormElement(elements, data, resolve);
     } else {
+      alert('else?');
       resolve('');
     }
   });

File diff suppressed because it is too large
+ 7 - 0
src/lib/v-remixicon.js


+ 29 - 0
src/models/collection.js

@@ -0,0 +1,29 @@
+import { Model } from '@vuex-orm/core';
+import { nanoid } from 'nanoid';
+
+class Collection extends Model {
+  static entity = 'collections';
+
+  static primaryKey = 'id';
+
+  static autoSave = true;
+
+  static fields() {
+    return {
+      id: this.uid(() => nanoid()),
+      name: this.string(''),
+      flow: this.attr([]),
+      createdAt: this.number(),
+    };
+  }
+
+  static async insert(payload) {
+    const res = await super.insert(payload);
+
+    await this.store().dispatch('saveToStorage', 'collections');
+
+    return res;
+  }
+}
+
+export default Collection;

+ 1 - 0
src/models/index.js

@@ -1,2 +1,3 @@
 export { default as Workflow } from './workflow';
+export { default as Collection } from './collection';
 export { default as Log } from './log';

+ 13 - 0
src/models/log.js

@@ -13,10 +13,23 @@ class Log extends Model {
       endedAt: this.number(0),
       startedAt: this.number(0),
       workflowId: this.attr(null),
+      collectionId: this.attr(null),
       status: this.string('success'),
+      collectionLogId: this.attr(null),
       icon: this.string('riGlobalLine'),
+      isInCollection: this.boolean(false),
     };
   }
+
+  static afterDelete(item) {
+    const logs = this.query().where('collectionLogId', item.id).get();
+
+    if (logs.length !== 0) {
+      Promise.allSettled(logs.map(({ id }) => this.delete(id))).then(() => {
+        this.store().dispatch('saveToStorage', 'workflows');
+      });
+    }
+  }
 }
 
 export default Log;

+ 1 - 1
src/newtab/App.vue

@@ -17,7 +17,7 @@ const retrieved = ref(false);
 
 store.dispatch('retrieveWorkflowState');
 store
-  .dispatch('retrieve', ['workflows', 'logs'])
+  .dispatch('retrieve', ['workflows', 'logs', 'collections'])
   .then(() => {
     retrieved.value = true;
   })

+ 117 - 0
src/newtab/pages/Collections.vue

@@ -0,0 +1,117 @@
+<template>
+  <div class="container pt-8 pb-4">
+    <h1 class="text-2xl font-semibold">Collections</h1>
+    <p class="text-gray-600 dark:text-gray-200">
+      Execute your workflows continuously
+    </p>
+    <div class="flex items-center my-6 space-x-4">
+      <ui-input
+        v-model="query"
+        prepend-icon="riSearch2Line"
+        placeholder="Search..."
+        class="flex-1"
+      />
+      <ui-button variant="accent" @click="newCollection">
+        New collection
+      </ui-button>
+    </div>
+    <div
+      v-if="Collection.query().count() === 0"
+      class="py-12 flex items-center"
+    >
+      <img src="@/assets/svg/alien.svg" class="w-96" />
+      <div class="ml-4">
+        <h1 class="text-2xl font-semibold max-w-md mb-6">
+          Oppss... It's looks like you don't have any collections.
+        </h1>
+        <ui-button variant="accent" @click="newCollection">
+          New collection
+        </ui-button>
+      </div>
+    </div>
+    <div class="grid gap-4 grid-cols-5 2xl:grid-cols-6">
+      <shared-card
+        v-for="collection in collections"
+        :key="collection.id"
+        :data="collection"
+        :menu="collectionCardMenu"
+        icon="riFolderLine"
+        @click="$router.push(`/collections/${$event.id}`)"
+        @execute="executeCollection"
+        @menuSelected="menuHandlers[$event.name]($event.data)"
+      />
+    </div>
+  </div>
+</template>
+<script setup>
+import { ref, computed } from 'vue';
+import { sendMessage } from '@/utils/message';
+import { useDialog } from '@/composable/dialog';
+import Collection from '@/models/collection';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+
+const dialog = useDialog();
+
+const collectionCardMenu = [
+  { name: 'rename', icon: 'riPencilLine' },
+  { name: 'delete', icon: 'riDeleteBin7Line' },
+];
+
+const query = ref('');
+
+const collections = computed(() =>
+  Collection.query()
+    .where(({ name }) =>
+      name.toLocaleLowerCase().includes(query.value.toLocaleLowerCase())
+    )
+    .orderBy('createdAt', 'desc')
+    .get()
+);
+
+function executeCollection(collection) {
+  sendMessage('collection:execute', collection, 'background');
+}
+function newCollection() {
+  dialog.prompt({
+    title: 'New collection',
+    placeholder: 'Collection name',
+    okText: 'Add collection',
+    onConfirm: (name) => {
+      Collection.insert({
+        data: {
+          name: name || 'Unnamed',
+          createdAt: Date.now(),
+        },
+      });
+    },
+  });
+}
+function renameCollection({ id, name }) {
+  dialog.prompt({
+    title: 'Rename collection',
+    placeholder: 'Collection name',
+    okText: 'Rename',
+    inputValue: name,
+    onConfirm: (newName) => {
+      Collection.update({
+        where: id,
+        data: {
+          name: newName,
+        },
+      });
+    },
+  });
+}
+function deleteCollection({ name, id }) {
+  dialog.confirm({
+    title: 'Delete collection',
+    okVariant: 'danger',
+    body: `Are you sure you want to delete "${name}" collection?`,
+    onConfirm: () => {
+      Collection.delete(id);
+    },
+  });
+}
+
+const menuHandlers = { rename: renameCollection, delete: deleteCollection };
+</script>

+ 13 - 7
src/newtab/pages/Home.vue

@@ -7,13 +7,14 @@
           <p v-if="workflows.length === 0" class="text-center text-gray-600">
             No data
           </p>
-          <workflow-card
+          <shared-card
             v-for="workflow in workflows"
             :key="workflow.id"
-            :workflow="workflow"
+            :data="workflow"
             :show-details="false"
             style="max-width: 250px"
             @execute="executeWorkflow"
+            @click="$router.push(`/workflows/${$event.id}`)"
           />
         </div>
         <div>
@@ -38,9 +39,8 @@
         </p>
         <shared-workflow-state
           v-for="item in workflowState"
-          :id="item.id"
+          v-bind="{ data: item }"
           :key="item.id"
-          :state="item.state"
           class="w-full"
         />
       </div>
@@ -53,7 +53,7 @@ import { useStore } from 'vuex';
 import { sendMessage } from '@/utils/message';
 import Log from '@/models/log';
 import Workflow from '@/models/workflow';
-import WorkflowCard from '@/components/newtab/workflow/WorkflowCard.vue';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
 import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
 
@@ -63,9 +63,15 @@ const workflows = computed(() =>
   Workflow.query().orderBy('createdAt', 'desc').limit(3).get()
 );
 const logs = computed(() =>
-  Log.query().orderBy('startedAt', 'desc').limit(10).get()
+  Log.query()
+    .where('isInCollection', false)
+    .orderBy('startedAt', 'desc')
+    .limit(10)
+    .get()
+);
+const workflowState = computed(() =>
+  store.state.workflowState.filter(({ isInCollection }) => !isInCollection)
 );
-const workflowState = computed(() => store.state.workflowState);
 
 function executeWorkflow(workflow) {
   sendMessage('workflow:execute', workflow, 'background');

+ 19 - 8
src/newtab/pages/Workflows.vue

@@ -43,15 +43,14 @@
         >
       </div>
     </div>
-    <div v-else class="grid gap-4 grid-cols-5">
-      <workflow-card
+    <div v-else class="grid gap-4 grid-cols-5 2xl:grid-cols-6">
+      <shared-card
         v-for="workflow in workflows"
+        v-bind="{ data: workflow, menu }"
         :key="workflow.id"
-        v-bind="{ workflow }"
-        @export="exportWorkflow"
-        @delete="deleteWorkflow"
-        @rename="renameWorkflow"
+        @click="$router.push(`/workflows/${$event.id}`)"
         @execute="executeWorkflow"
+        @menuSelected="menuHandlers[$event.name]($event.data)"
       />
     </div>
   </div>
@@ -61,7 +60,7 @@ import { computed, shallowReactive } from 'vue';
 import { useDialog } from '@/composable/dialog';
 import { sendMessage } from '@/utils/message';
 import { exportWorkflow, importWorkflow } from '@/utils/workflow-data';
-import WorkflowCard from '@/components/newtab/workflow/WorkflowCard.vue';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import Workflow from '@/models/workflow';
 
 const dialog = useDialog();
@@ -70,6 +69,12 @@ const sorts = [
   { name: 'Name', id: 'name' },
   { name: 'Created date', id: 'createdAt' },
 ];
+const menu = [
+  { name: 'export', icon: 'riDownloadLine' },
+  { name: 'rename', icon: 'riPencilLine' },
+  { name: 'delete', icon: 'riDeleteBin7Line' },
+];
+
 const state = shallowReactive({
   query: '',
   sortBy: 'createdAt',
@@ -96,7 +101,7 @@ function newWorkflow() {
     onConfirm: (name) => {
       Workflow.insert({
         data: {
-          name,
+          name: name || 'Unnamed',
           createdAt: Date.now(),
         },
       });
@@ -129,6 +134,12 @@ function renameWorkflow({ id, name }) {
     },
   });
 }
+
+const menuHandlers = {
+  export: exportWorkflow,
+  rename: renameWorkflow,
+  delete: deleteWorkflow,
+};
 </script>
 <style>
 .workflow-sort select {

+ 309 - 0
src/newtab/pages/collections/[id].vue

@@ -0,0 +1,309 @@
+<template>
+  <div class="container pt-8 pb-4">
+    <div class="flex items-center mb-8">
+      <input
+        :value="collection.name"
+        placeholder="Collection name"
+        class="
+          text-2xl
+          hover:ring-2 hover:ring-accent
+          font-semibold
+          bg-transparent
+        "
+        @blur="updateCollection({ name: $event.target.value || 'Unnamed' })"
+      />
+      <div class="flex-grow"></div>
+      <ui-button variant="accent" class="mr-4" @click="executeCollection">
+        Execute
+      </ui-button>
+      <ui-button class="text-red-500" @click="deleteCollection">
+        Delete
+      </ui-button>
+    </div>
+    <div class="flex items-start">
+      <div
+        class="w-80 border-r pr-6 mr-6 p-1 scroll overflow-auto"
+        style="max-height: calc(100vh - 8rem)"
+      >
+        <ui-input
+          v-model="state.query"
+          placeholder="Search workflows"
+          class="w-full space-x-1 mb-3"
+          prepend-icon="riSearch2Line"
+        />
+        <ui-tabs v-model="state.sidebarTab" fill class="w-full mb-4">
+          <ui-tab value="workflows">Workflows</ui-tab>
+          <ui-tab value="blocks">Blocks</ui-tab>
+        </ui-tabs>
+        <draggable
+          :list="state.sidebarTab === 'workflows' ? workflows : blocksArr"
+          :group="{ name: 'collection', pull: 'clone', put: false }"
+          :sort="false"
+          item-key="id"
+        >
+          <template #item="{ element }">
+            <ui-card
+              v-bind="{
+                title: element.description ? element.description : element.name,
+              }"
+              class="mb-2 cursor-move flex items-center"
+            >
+              <v-remixicon :name="element.icon" class="mr-2" />
+              <p class="flex-1 text-overflow">{{ element.name }}</p>
+            </ui-card>
+          </template>
+        </draggable>
+      </div>
+      <div class="flex-1 relative">
+        <div class="px-1 mb-4 inline-block rounded-lg bg-white">
+          <ui-tabs
+            v-model="state.activeTab"
+            class="border-none h-full space-x-1"
+          >
+            <ui-tab value="flow">Flow</ui-tab>
+            <ui-tab value="logs">Logs</ui-tab>
+            <ui-tab value="running">
+              Running
+              <span
+                v-if="runningCollection.length > 0"
+                class="
+                  ml-2
+                  p-1
+                  text-center
+                  inline-block
+                  text-xs
+                  rounded-full
+                  bg-black
+                  text-white
+                "
+                style="min-width: 25px"
+              >
+                {{ runningCollection.length }}
+              </span>
+            </ui-tab>
+          </ui-tabs>
+        </div>
+        <ui-tab-panels v-model="state.activeTab">
+          <ui-tab-panel class="relative" value="flow">
+            <div
+              v-if="collection.flow.length === 0"
+              class="
+                border
+                text-gray-600
+                absolute
+                top-0
+                w-full
+                z-0
+                dark:text-gray-200
+                rounded-lg
+                border-dashed
+                text-center
+                p-4
+              "
+            >
+              Drop a workflow or block in here
+            </div>
+            <draggable
+              :model-value="collectionFlow"
+              item-key="id"
+              group="collection"
+              style="min-height: 200px"
+              @update:modelValue="updateCollectionFlow"
+            >
+              <template #item="{ element, index }">
+                <ui-card class="group flex cursor-move mb-2 items-center">
+                  <v-remixicon :name="element.icon" class="mr-4" />
+                  <p class="flex-1 text-overflow">{{ element.name }}</p>
+                  <router-link
+                    v-if="element.type !== 'block'"
+                    :to="'/workflows/' + element.id"
+                    title="Open workflow"
+                    class="mr-4 group group-hover:visible invisible"
+                  >
+                    <v-remixicon name="riExternalLinkLine" />
+                  </router-link>
+                  <v-remixicon
+                    name="riDeleteBin7Line"
+                    class="cursor-pointer"
+                    @click="deleteCollectionFlow(index)"
+                  />
+                </ui-card>
+              </template>
+            </draggable>
+          </ui-tab-panel>
+          <ui-tab-panel value="logs">
+            <div v-if="logs.length === 0" class="text-center">
+              <img
+                src="@/assets/svg/files-and-folder.svg"
+                class="mx-auto max-w-sm"
+              />
+              <p class="text-xl font-semibold">No data to show</p>
+            </div>
+            <shared-logs-table :logs="logs" class="w-full">
+              <template #item-append="{ log }">
+                <td class="text-right">
+                  <v-remixicon
+                    name="riDeleteBin7Line"
+                    class="inline-block text-red-500 cursor-pointer"
+                    @click="deleteLog(log.id)"
+                  />
+                </td>
+              </template>
+            </shared-logs-table>
+          </ui-tab-panel>
+          <ui-tab-panel value="running">
+            <div v-if="runningCollection.length === 0" class="text-center">
+              <img
+                src="@/assets/svg/files-and-folder.svg"
+                class="mx-auto max-w-sm"
+              />
+              <p class="text-xl font-semibold">No data to show</p>
+            </div>
+            <div class="grid grid-cols-2 gap-4">
+              <shared-workflow-state
+                v-for="item in runningCollection"
+                :key="item.id"
+                :data="item"
+              />
+            </div>
+          </ui-tab-panel>
+        </ui-tab-panels>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup>
+import { computed, shallowReactive, onMounted } from 'vue';
+import { nanoid } from 'nanoid';
+import { useStore } from 'vuex';
+import { useRoute, useRouter } from 'vue-router';
+import Draggable from 'vuedraggable';
+import { useDialog } from '@/composable/dialog';
+import { sendMessage } from '@/utils/message';
+import Log from '@/models/log';
+import Workflow from '@/models/workflow';
+import Collection from '@/models/collection';
+import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
+import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
+
+const blocks = {
+  'export-result': {
+    type: 'block',
+    id: 'export-result',
+    icon: 'riDownloadLine',
+    name: 'Export result',
+    description: 'Export the collection result as JSON',
+    data: {
+      type: 'json',
+    },
+  },
+};
+const blocksArr = Object.entries(blocks).map(([id, value]) => ({
+  ...value,
+  id,
+}));
+
+const store = useStore();
+const route = useRoute();
+const router = useRouter();
+const dialog = useDialog();
+
+const state = shallowReactive({
+  query: '',
+  activeTab: 'flow',
+  sidebarTab: 'workflows',
+});
+
+const runningCollection = computed(() =>
+  store.state.workflowState.filter(
+    ({ collectionId }) => collectionId === route.params.id
+  )
+);
+const logs = computed(() =>
+  Log.query()
+    .where(
+      ({ collectionId, isInCollection }) =>
+        collectionId === route.params.id && !isInCollection
+    )
+    .orderBy('startedAt', 'desc')
+    .limit(10)
+    .get()
+);
+const workflows = computed(() =>
+  Workflow.query()
+    .where(({ name }) =>
+      name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase())
+    )
+    .orderBy('createdAt', 'desc')
+    .get()
+);
+const collection = computed(() => Collection.find(route.params.id));
+const collectionFlow = computed(() => {
+  if (!collection.value) return [];
+
+  return collection.value.flow.map(({ itemId, type }) => {
+    if (type === 'workflow') return Workflow.find(itemId) || { type };
+
+    return blocks[itemId];
+  });
+});
+
+function deleteLog(logId) {
+  Log.delete(logId).then(() => {
+    store.dispatch('saveToStorage', 'logs');
+  });
+}
+function executeCollection() {
+  sendMessage('collection:execute', collection.value, 'background');
+}
+function updateCollection(data) {
+  Collection.update({
+    where: route.params.id,
+    data,
+  });
+}
+function updateCollectionFlow(event) {
+  const flow = event.map(({ type, id, flowId, data }) => {
+    const itemFlowId = flowId || nanoid();
+
+    return type === 'block'
+      ? { type, itemId: id, id: itemFlowId, data }
+      : { type: 'workflow', itemId: id, id: itemFlowId };
+  });
+
+  updateCollection({ flow });
+}
+function deleteCollectionFlow(index) {
+  const flow = [...collection.value.flow];
+
+  flow.splice(index, 1);
+
+  updateCollection({ flow });
+}
+function deleteCollection() {
+  dialog.confirm({
+    title: 'Delete collection',
+    okVariant: 'danger',
+    body: 'Are you sure you want to delete this collection?',
+    onConfirm: () => {
+      Collection.delete(route.params.id).then(() => {
+        router.replace('/collections');
+      });
+    },
+  });
+}
+
+onMounted(() => {
+  collectionFlow.value.forEach((item, index) => {
+    if (!item.itemId && item.type === 'workflow') {
+      deleteCollectionFlow(index);
+    }
+  });
+});
+</script>
+<style>
+.ghost {
+  position: relative;
+  z-index: 100;
+}
+</style>

+ 29 - 3
src/newtab/pages/logs.vue

@@ -39,6 +39,22 @@
         </td>
       </template>
     </shared-logs-table>
+    <div class="flex items-center justify-between mt-4">
+      <div>
+        Showing
+        <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
+          <option v-for="num in [10, 15, 25, 50, 100]" :key="num" :value="num">
+            {{ num }}
+          </option>
+        </select>
+        items out of {{ filteredLogs.length }}
+      </div>
+      <ui-pagination
+        v-model="pagination.currentPage"
+        :per-page="pagination.perPage"
+        :records="filteredLogs.length"
+      />
+    </div>
     <ui-card
       v-if="selectedLogs.length !== 0"
       class="fixed right-0 bottom-0 m-5 shadow-xl space-x-2"
@@ -73,6 +89,10 @@ const store = useStore();
 const dialog = useDialog();
 
 const selectedLogs = ref([]);
+const pagination = shallowReactive({
+  perPage: 10,
+  currentPage: 1,
+});
 const filtersBuilder = shallowReactive({
   query: '',
   byDate: 0,
@@ -87,9 +107,9 @@ const exportDataModal = shallowReactive({
   log: {},
 });
 
-const logs = computed(() =>
+const filteredLogs = computed(() =>
   Log.query()
-    .where(({ name, status, startedAt }) => {
+    .where(({ name, status, startedAt, isInCollection }) => {
       let statusFilter = true;
       let dateFilter = true;
       const searchFilter = name
@@ -106,11 +126,17 @@ const logs = computed(() =>
         dateFilter = date <= startedAt;
       }
 
-      return searchFilter && statusFilter && dateFilter;
+      return !isInCollection && searchFilter && statusFilter && dateFilter;
     })
     .orderBy(sortsBuilder.by, sortsBuilder.order)
     .get()
 );
+const logs = computed(() =>
+  filteredLogs.value.slice(
+    (pagination.currentPage - 1) * pagination.perPage,
+    pagination.currentPage * pagination.perPage
+  )
+);
 
 function deleteLog(id) {
   Log.delete(id).then(() => {

+ 26 - 5
src/newtab/pages/logs/[id].vue

@@ -24,14 +24,23 @@
     </div>
     <div class="flex items-start">
       <ui-list class="w-7/12 mr-6">
+        <router-link
+          v-if="collectionLog"
+          :to="activeLog.collectionLogId"
+          class="mb-4 flex block"
+        >
+          <v-remixicon name="riArrowLeftLine" class="mr-2" />
+          Go back
+          <span class="font-semibold mx-1">{{ collectionLog.name }}</span> log
+        </router-link>
         <ui-list-item v-for="(item, index) in activeLog.history" :key="index">
           <span
-            :class="logsType[item.type].color"
+            :class="logsType[item.type]?.color"
             class="p-1 rounded-lg align-middle inline-block mr-2"
           >
-            <v-remixicon :name="logsType[item.type].icon" size="20" />
+            <v-remixicon :name="logsType[item.type]?.icon" size="20" />
           </span>
-          <div class="flex-1">
+          <div class="flex-1 text-overflow pr-2">
             <p class="w-full text-overflow leading-tight">
               {{ item.name }}
             </p>
@@ -41,14 +50,21 @@
               class="
                 text-sm
                 leading-tight
-                line-clamp
-                text-gray-600
+                text-overflow text-gray-600
                 dark:text-gray-200
               "
             >
               {{ item.message }}
             </p>
           </div>
+          <router-link
+            v-if="item.logId"
+            :to="'/logs/' + item.logId"
+            class="mr-4"
+            title="Open log detail"
+          >
+            <v-remixicon name="riExternalLinkLine" />
+          </router-link>
           <p class="text-gray-600">
             {{ countDuration(0, item.duration || 0) }}
           </p>
@@ -77,6 +93,10 @@ const logsType = {
     color: 'bg-yellow-200',
     icon: 'riStopLine',
   },
+  stopped: {
+    color: 'bg-yellow-200',
+    icon: 'riStopLine',
+  },
   error: {
     color: 'bg-red-200',
     icon: 'riErrorWarningLine',
@@ -91,6 +111,7 @@ const route = useRoute();
 const router = useRouter();
 
 const activeLog = computed(() => Log.find(route.params.id));
+const collectionLog = computed(() => Log.find(activeLog.value.collectionLogId));
 
 function deleteLog() {
   Log.delete(route.params.id).then(() => {

+ 21 - 4
src/newtab/pages/workflows/[id].vue

@@ -89,9 +89,8 @@
             <div class="grid grid-cols-2 gap-4">
               <shared-workflow-state
                 v-for="item in workflowState"
-                :id="item.id"
                 :key="item.id"
-                :state="item.state"
+                :data="item"
               />
             </div>
           </template>
@@ -162,7 +161,10 @@ const workflowState = computed(() =>
 );
 const workflow = computed(() => Workflow.find(workflowId) || {});
 const logs = computed(() =>
-  Log.query().where('workflowId', workflowId).orderBy('startedAt', 'desc').get()
+  Log.query()
+    .where((item) => item.workflowId === workflowId && !item.isInCollection)
+    .orderBy('startedAt', 'desc')
+    .get()
 );
 
 const updateBlockData = debounce((data) => {
@@ -195,6 +197,21 @@ function updateWorkflow(data) {
     data,
   });
 }
+function convertToTimestamp(date, hourMinutes) {
+  let timestamp = Date.now() + 60000;
+  if (date) {
+    const dateObj = new Date(date);
+    if (hourMinutes) {
+      const arr = hourMinutes.split(':');
+      dateObj.setHours(arr[0]);
+      dateObj.setMinutes(arr[1]);
+    }
+
+    timestamp = dateObj.getTime();
+  }
+
+  return timestamp;
+}
 async function handleWorkflowTrigger({ data }) {
   try {
     const workflowAlarm = await browser.alarms.get(workflowId);
@@ -225,7 +242,7 @@ async function handleWorkflowTrigger({ data }) {
 
       if (data.type === 'date') {
         alarmInfo = {
-          when: data.date ? new Date(data.date).getTime() : Date.now() + 60000,
+          when: convertToTimestamp(data.date, data.time),
         };
       } else {
         alarmInfo = {

+ 12 - 0
src/newtab/router.js

@@ -2,6 +2,8 @@ import { createRouter, createWebHashHistory } from 'vue-router';
 import Home from './pages/Home.vue';
 import Workflows from './pages/Workflows.vue';
 import WorkflowDetails from './pages/workflows/[id].vue';
+import Collections from './pages/Collections.vue';
+import CollectionsDetails from './pages/collections/[id].vue';
 import Logs from './pages/Logs.vue';
 import LogsDetails from './pages/logs/[id].vue';
 
@@ -21,6 +23,16 @@ const routes = [
     path: '/workflows/:id',
     component: WorkflowDetails,
   },
+  {
+    name: 'collections',
+    path: '/collections',
+    component: Collections,
+  },
+  {
+    name: 'collections-details',
+    path: '/collections/:id',
+    component: CollectionsDetails,
+  },
   {
     name: 'logs',
     path: '/logs',

+ 1 - 1
src/popup/pages/Home.vue

@@ -115,7 +115,7 @@ function openDashboard(url) {
     });
 }
 async function selectElement() {
-  const [tab] = await browser.tabs.query({ active: true });
+  const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
 
   try {
     await browser.tabs.sendMessage(tab.id, {

+ 9 - 5
src/store/index.js

@@ -16,7 +16,9 @@ const store = createStore({
   },
   getters: {
     getWorkflowState: (state) => (id) =>
-      (state.workflowState || []).filter(({ workflowId }) => workflowId === id),
+      (state.workflowState || []).filter(
+        ({ workflowId, isInCollection }) => workflowId === id && !isInCollection
+      ),
   },
   actions: {
     async retrieve({ dispatch, getters }, keys = 'workflows') {
@@ -41,8 +43,8 @@ const store = createStore({
           });
           await browser.storage.local.set({
             isFirstTime: false,
-            workflows: firstWorkflows,
           });
+          await dispatch('saveToStorage', 'workflows');
         }
 
         return await Promise.allSettled(promises);
@@ -73,9 +75,11 @@ const store = createStore({
         }
         const data = getters[`entities/${key}/all`]();
 
-        browser.storage.local.set({ [key]: data }).then(() => {
-          resolve();
-        });
+        browser.storage.local
+          .set({ [key]: JSON.parse(JSON.stringify(data)) })
+          .then(() => {
+            resolve();
+          });
       });
     },
   },

+ 4 - 2
src/utils/handle-form-element.js

@@ -35,10 +35,10 @@ function formEvent(element, data) {
 }
 function inputText({ data, element, index = 0, callback }) {
   const noDelay = data.delay === 0;
-  const currentChar = data.value[index];
+  const currentChar = data.value[index] ?? '';
 
   if (noDelay) {
-    element.value = data.value;
+    element.value += data.value;
   } else {
     element.value += currentChar;
   }
@@ -58,6 +58,8 @@ export default function (element, data, callback) {
   const textFields = ['INPUT', 'TEXTAREA'];
 
   if (data.type === 'text-field' && textFields.includes(element.tagName)) {
+    if (data.clearValue) element.value = '';
+
     inputText({ data, element, callback });
     return;
   }

+ 30 - 0
src/utils/helper.js

@@ -1,3 +1,33 @@
+export function replaceMustache(str, replacer) {
+  /* eslint-disable-next-line */
+  return str.replace(/{{(.*)}}/g, replacer);
+}
+
+export function openFilePicker(acceptedFileTypes = [], attrs = {}) {
+  return new Promise((resolve, reject) => {
+    const input = document.createElement('input');
+    input.type = 'file';
+    input.accept = acceptedFileTypes.join(',');
+
+    Object.entries(attrs).forEach(([key, value]) => {
+      input[key] = value;
+    });
+
+    input.onchange = (event) => {
+      const file = event.target.files[0];
+
+      if (!file || !acceptedFileTypes.includes(file.type)) {
+        reject(new Error(`Invalid ${file.type} file type`));
+        return;
+      }
+
+      resolve(file);
+    };
+
+    input.click();
+  });
+}
+
 export function fileSaver(fileName, data) {
   const anchor = document.createElement('a');
   anchor.download = fileName;

+ 21 - 9
src/utils/reference-data.js

@@ -1,10 +1,13 @@
-import objectPath from 'object-path';
-import { isObject, objectHasKey } from '@/utils/helper';
+import { get, set } from 'object-path-immutable';
+import { isObject, objectHasKey, replaceMustache } from '@/utils/helper';
+
+const objectPath = { get, set };
 
 function parseKey(key) {
   const [dataKey, path] = key.split('@');
 
-  if (dataKey === 'prevBlockData') return { dataKey, path: path || '0' };
+  if (['prevBlockData', 'loopData'].includes(dataKey))
+    return { dataKey, path: path || '' };
 
   const pathArr = path.split('.');
   let dataPath = '';
@@ -28,15 +31,16 @@ function parseKey(key) {
 }
 
 export default function (block, data) {
-  const replaceKeys = ['url', 'fileName', 'name', 'value'];
+  const replaceKeys = ['url', 'fileName', 'name', 'value', 'body', 'selector'];
+  let replacedBlock = block;
 
   replaceKeys.forEach((blockDataKey) => {
     if (!objectHasKey(block.data, blockDataKey)) return;
 
-    const newDataValue = block.data[blockDataKey].replace(
-      /\[(.+?)]/g,
+    const newDataValue = replaceMustache(
+      replacedBlock.data[blockDataKey],
       (match) => {
-        const key = match.replace(/\[|]/g, '');
+        const key = match.slice(2, -2).replace(/\s/g, '');
         const { dataKey, path } = parseKey(key);
 
         if (
@@ -46,10 +50,18 @@ export default function (block, data) {
           return data.prevBlockData;
         }
 
-        return objectPath.get(data, `${dataKey}.${path}`) || match;
+        const result = objectPath.get(data[dataKey], path) ?? match;
+
+        return isObject(result) ? JSON.stringify(result) : result;
       }
     );
 
-    block.data[blockDataKey] = newDataValue;
+    replacedBlock = objectPath.set(
+      replacedBlock,
+      `data.${blockDataKey}`,
+      newDataValue
+    );
   });
+
+  return replacedBlock;
 }

File diff suppressed because it is too large
+ 64 - 4
src/utils/shared.js


+ 72 - 0
src/utils/webhookUtil.js

@@ -0,0 +1,72 @@
+import { isObject } from './helper';
+
+const renderContent = (content, contentType) => {
+  // 1. render the content
+  // 2. if the content type is json then parse the json
+  // 3. else parse to form data
+  const renderedJson = JSON.parse(content);
+
+  if (contentType === 'form') {
+    return Object.keys(renderedJson)
+      .map(
+        (key) =>
+          `${key}=${
+            isObject(renderedJson[key])
+              ? JSON.stringify(renderedJson[key])
+              : renderedJson[key]
+          }`
+      )
+      .join('&');
+  }
+
+  return JSON.stringify(renderedJson);
+};
+
+const filterHeaders = (headers) => {
+  const filteredHeaders = {};
+  headers.forEach((item) => {
+    if (item.name && item.value) {
+      filteredHeaders[item.name] = item.value;
+    }
+  });
+  return filteredHeaders;
+};
+
+const convertContentType = (contentType) => {
+  return contentType === 'json'
+    ? 'application/json'
+    : 'application/x-www-form-urlencoded';
+};
+
+export async function executeWebhook({
+  url,
+  contentType,
+  headers,
+  timeout,
+  body,
+}) {
+  const controller = new AbortController();
+  const id = setTimeout(() => {
+    controller.abort();
+  }, timeout);
+
+  try {
+    const finalHeaders = filterHeaders(headers);
+    const finalContent = renderContent(body, contentType);
+
+    await fetch(url, {
+      method: 'POST',
+      headers: {
+        'Content-Type': convertContentType(contentType),
+        ...finalHeaders,
+      },
+      body: finalContent,
+      signal: controller.signal,
+    });
+
+    clearTimeout(id);
+  } catch (error) {
+    clearTimeout(id);
+    throw error;
+  }
+}

+ 13 - 25
src/utils/workflow-data.js

@@ -1,35 +1,23 @@
-import { fileSaver } from './helper';
+import { fileSaver, openFilePicker } from './helper';
 import Workflow from '@/models/workflow';
 
 export function importWorkflow() {
-  const input = document.createElement('input');
-  input.type = 'file';
-  input.accept = 'application/json';
+  openFilePicker(['application/json'])
+    .then((file) => {
+      const reader = new FileReader();
 
-  input.onchange = (event) => {
-    const file = event.target.files[0];
-
-    if (!file || file.type !== 'application/json') {
-      alert('Invalid file');
-      return;
-    }
-
-    const reader = new FileReader();
-
-    reader.onload = ({ target }) => {
-      try {
+      reader.onload = ({ target }) => {
         const workflow = JSON.parse(target.result);
 
-        Workflow.insert({ data: workflow, createdAt: Date.now() });
-      } catch (error) {
-        console.error(error);
-      }
-    };
-
-    reader.readAsText(file);
-  };
+        Workflow.insert({ data: { ...workflow, createdAt: Date.now() } });
+      };
 
-  input.click();
+      reader.readAsText(file);
+    })
+    .catch((error) => {
+      alert(error.message);
+      console.error(error);
+    });
 }
 
 export function exportWorkflow(workflow) {

+ 49 - 0
yarn.lock

@@ -4020,6 +4020,11 @@ is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   dependencies:
     isobject "^3.0.1"
 
+is-plain-object@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
+  integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
+
 is-regex@^1.0.4, is-regex@^1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
@@ -4566,6 +4571,11 @@ minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5:
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
+mitt@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mitt/-/mitt-2.1.0.tgz#f740577c23176c6205b121b2973514eade1b2230"
+  integrity sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==
+
 mixin-deep@^1.2.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
@@ -4806,6 +4816,14 @@ object-keys@^1.0.12, object-keys@^1.1.1:
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
 
+object-path-immutable@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/object-path-immutable/-/object-path-immutable-4.1.2.tgz#d78e3587f03c9a41f83dd6465cfef5a9eb390bb4"
+  integrity sha512-Bfrox46OegMkQXL872EzEjofMyBxk/2hgiy99NkCkYFegn6Dm9FvV2jY2Tnp9qLj2QL0TLii12CuPpzonkjJrA==
+  dependencies:
+    is-plain-object "^5.0.0"
+    object-path "^0.11.8"
+
 object-path@^0.11.8:
   version "0.11.8"
   resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.8.tgz#ed002c02bbdd0070b78a27455e8ae01fc14d4742"
@@ -6018,6 +6036,11 @@ sockjs@^0.3.21:
     uuid "^3.4.0"
     websocket-driver "^0.7.4"
 
+sortablejs@1.14.0:
+  version "1.14.0"
+  resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.14.0.tgz#6d2e17ccbdb25f464734df621d4f35d4ab35b3d8"
+  integrity sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==
+
 source-map-js@^0.6.2:
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e"
@@ -6684,11 +6707,21 @@ vue-loader@16.8.1:
     hash-sum "^2.0.0"
     loader-utils "^2.0.0"
 
+vue-observe-visibility@^2.0.0-alpha.1:
+  version "2.0.0-alpha.1"
+  resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-2.0.0-alpha.1.tgz#1e4eda7b12562161d58984b7e0dea676d83bdb13"
+  integrity sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==
+
 vue-prism-editor@^2.0.0-alpha.2:
   version "2.0.0-alpha.2"
   resolved "https://registry.yarnpkg.com/vue-prism-editor/-/vue-prism-editor-2.0.0-alpha.2.tgz#aa53a88efaaed628027cbb282c2b1d37fc7c5c69"
   integrity sha512-Gu42ba9nosrE+gJpnAEuEkDMqG9zSUysIR8SdXUw8MQKDjBnnNR9lHC18uOr/ICz7yrA/5c7jHJr9lpElODC7w==
 
+vue-resize@^2.0.0-alpha.1:
+  version "2.0.0-alpha.1"
+  resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz#43eeb79e74febe932b9b20c5c57e0ebc14e2df3a"
+  integrity sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==
+
 vue-router@^4.0.11:
   version "4.0.11"
   resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.0.11.tgz#cd649a0941c635281763a20965b599643ddc68ed"
@@ -6696,6 +6729,15 @@ vue-router@^4.0.11:
   dependencies:
     "@vue/devtools-api" "^6.0.0-beta.14"
 
+vue-virtual-scroller@^2.0.0-alpha.1:
+  version "2.0.0-alpha.1"
+  resolved "https://registry.yarnpkg.com/vue-virtual-scroller/-/vue-virtual-scroller-2.0.0-alpha.1.tgz#5b5410105b8e60ca57bbd5f2faf5ad1d8108d046"
+  integrity sha512-Mn5w3Qe06t7c3Imm2RHD43RACab1CCWplpdgzq+/FWJcpQtcGKd5vDep8i+nIwFtzFLsWAqEK0RzM7KrfAcBng==
+  dependencies:
+    mitt "^2.1.0"
+    vue-observe-visibility "^2.0.0-alpha.1"
+    vue-resize "^2.0.0-alpha.1"
+
 vue@3.2.19:
   version "3.2.19"
   resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.19.tgz#da2c80a6a0271c7097fee9e31692adfd9d569c8f"
@@ -6707,6 +6749,13 @@ vue@3.2.19:
     "@vue/server-renderer" "3.2.19"
     "@vue/shared" "3.2.19"
 
+vuedraggable@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-4.1.0.tgz#edece68adb8a4d9e06accff9dfc9040e66852270"
+  integrity sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==
+  dependencies:
+    sortablejs "1.14.0"
+
 vuex@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/vuex/-/vuex-4.0.2.tgz#f896dbd5bf2a0e963f00c67e9b610de749ccacc9"

Some files were not shown because too many files changed in this diff