Ahmad Kholid 2 years ago
parent
commit
28d6c1432a
100 changed files with 4097 additions and 2897 deletions
  1. 1 0
      .eslintrc.js
  2. 8 5
      package.json
  3. 1 0
      postcss.config.js
  4. 47 0
      src/assets/css/flow.css
  5. 0 0
      src/assets/images/default.png
  6. BIN
      src/assets/images/smooth-step.png
  7. 0 0
      src/assets/images/step.png
  8. BIN
      src/assets/images/straight.png
  9. 34 30
      src/background/index.js
  10. 2 5
      src/background/workflowEngine/blocksHandler/handlerActiveTab.js
  11. 30 26
      src/background/workflowEngine/blocksHandler/handlerBlocksGroup.js
  12. 14 23
      src/background/workflowEngine/blocksHandler/handlerBrowserEvent.js
  13. 50 23
      src/background/workflowEngine/blocksHandler/handlerClipboard.js
  14. 13 22
      src/background/workflowEngine/blocksHandler/handlerCloseTab.js
  15. 7 8
      src/background/workflowEngine/blocksHandler/handlerConditions.js
  16. 1 3
      src/background/workflowEngine/blocksHandler/handlerDelay.js
  17. 2 4
      src/background/workflowEngine/blocksHandler/handlerDeleteData.js
  18. 4 9
      src/background/workflowEngine/blocksHandler/handlerElementExists.js
  19. 58 57
      src/background/workflowEngine/blocksHandler/handlerExecuteWorkflow.js
  20. 36 45
      src/background/workflowEngine/blocksHandler/handlerExportData.js
  21. 7 16
      src/background/workflowEngine/blocksHandler/handlerForwardPage.js
  22. 7 16
      src/background/workflowEngine/blocksHandler/handlerGoBack.js
  23. 34 44
      src/background/workflowEngine/blocksHandler/handlerGoogleSheets.js
  24. 3 5
      src/background/workflowEngine/blocksHandler/handlerHandleDialog.js
  25. 2 3
      src/background/workflowEngine/blocksHandler/handlerHandleDownload.js
  26. 28 36
      src/background/workflowEngine/blocksHandler/handlerHoverElement.js
  27. 2 3
      src/background/workflowEngine/blocksHandler/handlerInsertData.js
  28. 18 4
      src/background/workflowEngine/blocksHandler/handlerInteractionBlock.js
  29. 36 46
      src/background/workflowEngine/blocksHandler/handlerJavascriptCode.js
  30. 34 0
      src/background/workflowEngine/blocksHandler/handlerLogData.js
  31. 2 4
      src/background/workflowEngine/blocksHandler/handlerLoopBreakpoint.js
  32. 3 8
      src/background/workflowEngine/blocksHandler/handlerLoopData.js
  33. 75 88
      src/background/workflowEngine/blocksHandler/handlerNewTab.js
  34. 14 23
      src/background/workflowEngine/blocksHandler/handlerNewWindow.js
  35. 27 36
      src/background/workflowEngine/blocksHandler/handlerNotification.js
  36. 2 3
      src/background/workflowEngine/blocksHandler/handlerProxy.js
  37. 7 16
      src/background/workflowEngine/blocksHandler/handlerReloadTab.js
  38. 3 5
      src/background/workflowEngine/blocksHandler/handlerRepeatTask.js
  39. 35 44
      src/background/workflowEngine/blocksHandler/handlerSaveAssets.js
  40. 3 3
      src/background/workflowEngine/blocksHandler/handlerSwitchTab.js
  41. 2 2
      src/background/workflowEngine/blocksHandler/handlerSwitchTo.js
  42. 29 0
      src/background/workflowEngine/blocksHandler/handlerTabUrl.js
  43. 7 7
      src/background/workflowEngine/blocksHandler/handlerTakeScreenshot.js
  44. 1 5
      src/background/workflowEngine/blocksHandler/handlerTrigger.js
  45. 9 6
      src/background/workflowEngine/blocksHandler/handlerWaitConnections.js
  46. 3 4
      src/background/workflowEngine/blocksHandler/handlerWebhook.js
  47. 6 4
      src/background/workflowEngine/blocksHandler/handlerWhileLoop.js
  48. 138 55
      src/background/workflowEngine/engine.js
  49. 14 7
      src/background/workflowEngine/helper.js
  50. 26 30
      src/background/workflowEngine/worker.js
  51. 4 7
      src/components/block/BlockBase.vue
  52. 62 86
      src/components/block/BlockBasic.vue
  53. 81 22
      src/components/block/BlockBasicWithFallback.vue
  54. 50 107
      src/components/block/BlockConditions.vue
  55. 24 18
      src/components/block/BlockDelay.vue
  56. 25 21
      src/components/block/BlockElementExists.vue
  57. 0 64
      src/components/block/BlockExportData.vue
  58. 65 46
      src/components/block/BlockGroup.vue
  59. 20 9
      src/components/block/BlockLoopBreakpoint.vue
  60. 25 10
      src/components/block/BlockRepeatTask.vue
  61. 0 38
      src/components/block/BlockWebhook.vue
  62. 12 10
      src/components/newtab/app/AppSidebar.vue
  63. 100 0
      src/components/newtab/app/AppSurvey.vue
  64. 25 21
      src/components/newtab/settings/SettingsCloudBackup.vue
  65. 63 0
      src/components/newtab/shared/SharedPermissionsModal.vue
  66. 174 0
      src/components/newtab/storage/StorageEditTable.vue
  67. 153 0
      src/components/newtab/storage/StorageTables.vue
  68. 168 0
      src/components/newtab/storage/StorageVariables.vue
  69. 0 225
      src/components/newtab/workflow/WorkflowActions.vue
  70. 0 980
      src/components/newtab/workflow/WorkflowBuilder.vue
  71. 119 26
      src/components/newtab/workflow/WorkflowDataTable.vue
  72. 10 18
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  73. 35 163
      src/components/newtab/workflow/WorkflowEditBlock.vue
  74. 221 0
      src/components/newtab/workflow/WorkflowEditor.vue
  75. 5 5
      src/components/newtab/workflow/WorkflowSettings.vue
  76. 7 15
      src/components/newtab/workflow/WorkflowShare.vue
  77. 46 0
      src/components/newtab/workflow/edit/BlockSetting/BlockSettingGeneral.vue
  78. 89 0
      src/components/newtab/workflow/edit/BlockSetting/BlockSettingLines.vue
  79. 116 0
      src/components/newtab/workflow/edit/BlockSetting/BlockSettingOnError.vue
  80. 131 0
      src/components/newtab/workflow/edit/EditBlockSettings.vue
  81. 33 4
      src/components/newtab/workflow/edit/EditClipboard.vue
  82. 17 5
      src/components/newtab/workflow/edit/EditConditions.vue
  83. 1 1
      src/components/newtab/workflow/edit/EditDeleteData.vue
  84. 5 5
      src/components/newtab/workflow/edit/EditExecuteWorkflow.vue
  85. 4 1
      src/components/newtab/workflow/edit/EditGetText.vue
  86. 2 2
      src/components/newtab/workflow/edit/EditInsertData.vue
  87. 61 0
      src/components/newtab/workflow/edit/EditLogData.vue
  88. 45 0
      src/components/newtab/workflow/edit/EditTabURL.vue
  89. 1 1
      src/components/newtab/workflow/edit/EditTakeScreenshot.vue
  90. 3 3
      src/components/newtab/workflow/edit/InsertWorkflowData.vue
  91. 0 145
      src/components/newtab/workflow/edit/OnBlockError.vue
  92. 1 1
      src/components/newtab/workflow/edit/Trigger/TriggerSpecificDay.vue
  93. 436 0
      src/components/newtab/workflow/editor/EditorLocalActions.vue
  94. 142 0
      src/components/newtab/workflow/editor/EditorLocalCtxMenu.vue
  95. 53 0
      src/components/newtab/workflow/editor/EditorLogs.vue
  96. 39 35
      src/components/newtab/workflow/editor/EditorSearchBlocks.vue
  97. 19 20
      src/components/newtab/workflows/WorkflowsFolder.vue
  98. 72 0
      src/components/newtab/workflows/WorkflowsHosted.vue
  99. 399 0
      src/components/newtab/workflows/WorkflowsLocal.vue
  100. 49 0
      src/components/newtab/workflows/WorkflowsShared.vue

+ 1 - 0
.eslintrc.js

@@ -34,6 +34,7 @@ module.exports = {
   rules: {
     camelcase: 'off',
     'no-await-in-loop': 'off',
+    'no-alert': 'off',
     'import/no-import-module-exports': 'off',
     'no-console': ['warn', { allow: ['warn', 'error'] }],
     'no-underscore-dangle': 'off',

+ 8 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.13.2",
+  "version": "1.14.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -30,7 +30,7 @@
     "*.{js,ts,vue}": "eslint --fix"
   },
   "dependencies": {
-    "@braks/vue-flow": "^0.4.23",
+    "@braks/vue-flow": "^0.4.27",
     "@codemirror/lang-javascript": "^6.0.0",
     "@codemirror/lang-json": "^6.0.0",
     "@codemirror/language": "^6.0.0",
@@ -49,23 +49,25 @@
     "compare-versions": "^4.1.2",
     "crypto-js": "^4.1.1",
     "css-selector-generator": "^3.6.1",
+    "dagre": "^0.8.5",
     "dayjs": "^1.10.7",
     "defu": "^6.0.0",
     "dexie": "^3.2.2",
     "drawflow": "^0.0.58",
     "idb": "^7.0.2",
     "lodash.clonedeep": "^4.5.0",
+    "lodash.merge": "^4.6.2",
     "mitt": "^3.0.0",
     "mousetrap": "^1.6.5",
-    "nanoid": "^3.2.0",
+    "nanoid": "^4.0.0",
     "object-path": "^0.11.8",
     "papaparse": "^5.3.1",
+    "pinia": "^2.0.14",
     "rxjs": "^7.5.5",
     "tippy.js": "^6.3.1",
     "v-remixicon": "^0.1.1",
     "vue": "^3.2.37",
     "vue-i18n": "^9.2.0-beta.29",
-    "vue-inspector-agnostic": "^1.0.0",
     "vue-router": "^4.0.11",
     "vue-toastification": "^2.0.0-rc.5",
     "vuedraggable": "^4.1.0",
@@ -110,8 +112,9 @@
     "tailwindcss": "^3.0.7",
     "terser-webpack-plugin": "^5.3.3",
     "vue-loader": "^17.0.0",
+    "web-worker": "^1.2.0",
     "webpack": "^5.73.0",
     "webpack-cli": "^4.10.0",
     "webpack-dev-server": "^4.9.2"
   }
-}
+}

+ 1 - 0
postcss.config.js

@@ -1,5 +1,6 @@
 module.exports = {
   plugins: {
+    'tailwindcss/nesting': {},
     tailwindcss: {},
     autoprefixer: {},
   },

+ 47 - 0
src/assets/css/flow.css

@@ -0,0 +1,47 @@
+.vue-flow__minimap {
+	@apply rounded-lg dark:bg-gray-800;
+}
+
+.vue-flow__node {
+	& > div {
+		@apply rounded-lg transition;
+	}
+	&.selected > div {
+		@apply ring-2 ring-accent;
+	}
+	&:hover,
+	&.selected {
+		.menu {
+			@apply translate-y-11;
+		}
+	}
+
+	.vue-flow__handle {
+		@apply h-4 w-4 rounded-full border-0;
+		&.target {
+			@apply bg-accent -ml-4;
+		}
+		&.source {
+			border-width: 3px;
+			@apply border-accent -mr-4 bg-white dark:bg-black;
+		}
+	}
+}
+
+.vue-flow.disabled {
+	.vue-flow__handle {
+		pointer-events: none;
+	}
+}
+
+.vue-flow__edge {
+	cursor: pointer;
+	&.selected .vue-flow__edge-path {
+		stroke: theme('colors.green.300');
+	}
+}
+
+.vue-flow__edge-path {
+  stroke: theme('colors.accent');
+  stroke-width: 3;
+}

+ 0 - 0
src/assets/images/curvature.png → src/assets/images/default.png


BIN
src/assets/images/smooth-step.png


+ 0 - 0
src/assets/images/no-curvature.png → src/assets/images/step.png


BIN
src/assets/images/straight.png


+ 34 - 30
src/background/index.js

@@ -5,6 +5,7 @@ import { parseJSON, findTriggerBlock, sleep } from '@/utils/helper';
 import { fetchApi } from '@/utils/api';
 import getFile from '@/utils/getFile';
 import decryptFlow, { getWorkflowPass } from '@/utils/decryptFlow';
+import convertWorkflowData from '@/utils/convertWorkflowData';
 import {
   registerSpecificDay,
   registerContextMenu,
@@ -56,7 +57,9 @@ const workflow = {
       'workflows',
       'workflowHosts',
     ]);
-    let findWorkflow = workflows.find(({ id }) => id === workflowId);
+    let findWorkflow = Array.isArray(workflows)
+      ? workflows.find(({ id }) => id === workflowId)
+      : workflows[workflowId];
 
     if (!findWorkflow) {
       findWorkflow = Object.values(workflowHosts || {}).find(
@@ -70,7 +73,6 @@ const workflow = {
   },
   execute(workflowData, options) {
     if (workflowData.isDisabled) return null;
-
     if (workflowData.isProtected) {
       const flow = parseJSON(workflowData.drawflow, null);
 
@@ -81,7 +83,8 @@ const workflow = {
       }
     }
 
-    const engine = new WorkflowEngine(workflowData, {
+    const convertedWorkflow = convertWorkflowData(workflowData);
+    const engine = new WorkflowEngine(convertedWorkflow, {
       options,
       blocksHandler,
       logger: this.logger,
@@ -166,30 +169,6 @@ async function openDashboard(url) {
     console.error(error);
   }
 }
-async function checkWorkflowStates() {
-  const states = await workflow.states.get();
-  // const sessionStates = parseJSON(sessionStorage.getItem('workflowState'), {});
-
-  states.forEach((state) => {
-    /* Enable when using manifest 3 */
-    // const resumeWorkflow =
-    //   !state.isDestroyed && objectHasKey(sessionStates, state.id);
-
-    if (false) {
-      workflow.get(state.workflowId).then((workflowData) => {
-        workflow.execute(workflowData, {
-          state,
-          resume: true,
-        });
-      });
-    } else {
-      workflow.states.states.delete(state.id);
-    }
-  });
-
-  await browserStorage.set('workflowState', states);
-}
-checkWorkflowStates();
 async function checkVisitWebTriggers(tabId, tabUrl) {
   const workflowState = await workflow.states.get(({ state }) =>
     state.tabIds.includes(tabId)
@@ -406,10 +385,13 @@ browser.runtime.onInstalled.addListener(async ({ reason }) => {
     }
 
     if (reason === 'update') {
-      const { workflows } = await browser.storage.local.get('workflows');
+      let { workflows } = await browser.storage.local.get('workflows');
       const alarmTypes = ['specific-day', 'date', 'interval'];
 
-      for (const { trigger, drawflow, id } of workflows) {
+      workflows = Array.isArray(workflows)
+        ? workflows
+        : Object.values(workflows);
+      workflows.forEach(({ trigger, drawflow, id }) => {
         let workflowTrigger = trigger?.data || trigger;
 
         if (!trigger) {
@@ -424,7 +406,7 @@ browser.runtime.onInstalled.addListener(async ({ reason }) => {
         } else if (triggerType === 'context-menu') {
           registerContextMenu(id, workflowTrigger);
         }
-      }
+      });
     }
   } catch (error) {
     console.error(error);
@@ -540,5 +522,27 @@ message.on('workflow:execute', (workflowData, sender) => {
   workflow.execute(workflowData, workflowData?.options || {});
 });
 message.on('workflow:stop', (id) => workflow.states.stop(id));
+message.on('workflow:added', (workflowId) => {
+  browser.tabs
+    .query({ url: browser.runtime.getURL('/newtab.html') })
+    .then((tabs) => {
+      if (tabs.length >= 1) {
+        const lastTab = tabs.at(-1);
+
+        tabs.forEach((tab) => {
+          browser.tabs.sendMessage(tab.id, {
+            data: { workflowId },
+            type: 'workflow:added',
+          });
+        });
+
+        browser.tabs.update(lastTab.id, {
+          active: true,
+        });
+      } else {
+        openDashboard(`/workflows/${workflowId}?permission=true`);
+      }
+    });
+});
 
 browser.runtime.onMessage.addListener(message.listener());

+ 2 - 5
src/background/workflowEngine/blocksHandler/handlerActiveTab.js

@@ -1,13 +1,11 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection, attachDebugger } from '../helper';
+import { attachDebugger } from '../helper';
 
 async function activeTab(block) {
-  const nextBlockId = getBlockConnection(block);
-
   try {
     const data = {
-      nextBlockId,
       data: '',
+      nextBlockId: this.getBlockConnections(block.id),
     };
 
     if (this.activeTab.id) {
@@ -51,7 +49,6 @@ async function activeTab(block) {
     return data;
   } catch (error) {
     console.error(error);
-    error.nextBlockId = nextBlockId;
     error.data = error.data || {};
 
     throw error;

+ 30 - 26
src/background/workflowEngine/blocksHandler/handlerBlocksGroup.js

@@ -1,8 +1,6 @@
-import { getBlockConnection } from '../helper';
-
-function blocksGroup({ data, outputs }, { prevBlockData }) {
+function blocksGroup({ data, id }, { prevBlockData }) {
   return new Promise((resolve) => {
-    const nextBlockId = getBlockConnection({ outputs });
+    const nextBlockId = this.getBlockConnections(id);
 
     if (data.blocks.length === 0) {
       resolve({
@@ -13,34 +11,40 @@ function blocksGroup({ data, outputs }, { prevBlockData }) {
       return;
     }
 
-    const blocks = data.blocks.reduce((acc, block, index) => {
-      let nextBlock = {
-        connections: [{ node: data.blocks[index + 1]?.itemId }],
-      };
-
-      if (index === data.blocks.length - 1) {
-        nextBlock = nextBlockId;
-      }
-
-      acc[block.itemId] = {
-        ...block,
-        id: block.itemId,
-        name: block.id,
-        outputs: {
-          output_1: nextBlock,
+    if (!this.engine.extractedGroup[id]) {
+      const { blocks, connections } = data.blocks.reduce(
+        (acc, block, index) => {
+          const nextBlock = data.blocks[index + 1]?.itemId;
+
+          acc.blocks[block.itemId] = {
+            label: block.id,
+            data: block.data,
+            id: nextBlock ? block.itemId : id,
+          };
+
+          if (nextBlock) {
+            const outputId = `${block.itemId}-output-1`;
+
+            if (!acc.connections[outputId]) {
+              acc.connections[outputId] = [];
+            }
+            acc.connections[outputId].push(nextBlock);
+          }
+
+          return acc;
         },
-      };
+        { blocks: {}, connections: {} }
+      );
 
-      return acc;
-    }, {});
+      Object.assign(this.engine.blocks, blocks);
+      Object.assign(this.engine.connectionsMap, connections);
 
-    Object.assign(this.engine.blocks, blocks);
+      this.engine.extractedGroup[id] = true;
+    }
 
     resolve({
       data: prevBlockData,
-      nextBlockId: {
-        connections: [{ node: data.blocks[0].itemId }],
-      },
+      nextBlockId: [data.blocks[0].itemId],
     });
   });
 }

+ 14 - 23
src/background/workflowEngine/blocksHandler/handlerBrowserEvent.js

@@ -1,6 +1,5 @@
 import browser from 'webextension-polyfill';
 import { isWhitespace } from '@/utils/helper';
-import { getBlockConnection } from '../helper';
 
 function handleEventListener(target, validate) {
   return (data, activeTab) => {
@@ -103,30 +102,22 @@ const events = {
   'window:close': handleEventListener(browser.windows.onRemoved),
 };
 
-export default async function ({ data, outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+export default async function ({ data, id }) {
+  const currentEvent = events[data.eventName];
 
-  try {
-    const currentEvent = events[data.eventName];
-
-    if (!currentEvent) {
-      throw new Error(`Can't find ${data.eventName} event`);
-    }
-
-    const result = await currentEvent(data, this.activeTab);
+  if (!currentEvent) {
+    throw new Error(`Can't find ${data.eventName} event`);
+  }
 
-    if (data.eventName === 'tab:create' && data.setAsActiveTab) {
-      this.activeTab.id = result.tabId;
-      this.activeTab.url = result.url;
-    }
+  const result = await currentEvent(data, this.activeTab);
 
-    return {
-      nextBlockId,
-      data: result || '',
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
+  if (data.eventName === 'tab:create' && data.setAsActiveTab) {
+    this.activeTab.id = result.tabId;
+    this.activeTab.url = result.url;
   }
+
+  return {
+    data: result || '',
+    nextBlockId: this.getBlockConnections(id),
+  };
 }

+ 50 - 23
src/background/workflowEngine/blocksHandler/handlerClipboard.js

@@ -1,24 +1,41 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 
-export default async function ({ data, outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+function doCommand(command, value) {
+  const textarea = document.createElement('textarea');
+  document.body.appendChild(textarea);
 
-  try {
-    const hasPermission = await browser.permissions.contains({
-      permissions: ['clipboardRead'],
-    });
-
-    if (!hasPermission) {
-      throw new Error('no-clipboard-acces');
-    }
-
-    const textarea = document.createElement('textarea');
-    document.body.appendChild(textarea);
+  if (command === 'paste') {
     textarea.focus();
     document.execCommand('paste');
+    textarea.remove();
+
+    return textarea.value;
+  }
+  if (command === 'copy') {
+    textarea.value = value;
+    textarea.select();
+    document.execCommand('copy');
+    textarea.blur();
+    textarea.remove();
+  }
+
+  return '';
+}
+
+export default async function ({ data, id, label }) {
+  const hasPermission = await browser.permissions.contains({
+    permissions: ['clipboardRead'],
+  });
+
+  if (!hasPermission) {
+    throw new Error('no-clipboard-acces');
+  }
 
-    const copiedText = textarea.value;
+  let valueToReturn = '';
+
+  if (!data.type || data.type === 'get') {
+    const copiedText = doCommand('paste');
+    valueToReturn = copiedText;
 
     if (data.assignVariable) {
       this.setVariable(data.variableName, copiedText);
@@ -26,16 +43,26 @@ export default async function ({ data, outputs }) {
     if (data.saveData) {
       this.addDataToColumn(data.dataColumn, copiedText);
     }
+  } else if (data.type === 'insert') {
+    let text = '';
 
-    document.body.removeChild(textarea);
+    if (data.copySelectedText) {
+      if (!this.activeTab.id) throw new Error('no-tab');
 
-    return {
-      nextBlockId,
-      data: copiedText,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
+      text = await this._sendMessageToTab({
+        id,
+        label,
+      });
+    } else {
+      text = data.dataToCopy;
+    }
 
-    throw error;
+    valueToReturn = text;
+    doCommand('copy', text);
   }
+
+  return {
+    data: valueToReturn,
+    nextBlockId: this.getBlockConnections(id),
+  };
 }

+ 13 - 22
src/background/workflowEngine/blocksHandler/handlerCloseTab.js

@@ -1,5 +1,4 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 
 async function closeWindow(data, windowId) {
   const windowIds = [];
@@ -37,29 +36,21 @@ async function closeTab(data, tabId) {
   if (tabIds) await browser.tabs.remove(tabIds);
 }
 
-export default async function ({ data, outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+export default async function ({ data, id }) {
+  if (data.closeType === 'window') {
+    await closeWindow(data, this.windowId);
 
-  try {
-    if (data.closeType === 'window') {
-      await closeWindow(data, this.windowId);
-
-      this.windowId = null;
-    } else {
-      await closeTab(data, this.activeTab.id);
+    this.windowId = null;
+  } else {
+    await closeTab(data, this.activeTab.id);
 
-      if (data.activeTab) {
-        this.activeTab.id = null;
-      }
+    if (data.activeTab) {
+      this.activeTab.id = null;
     }
-
-    return {
-      nextBlockId,
-      data: '',
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
   }
+
+  return {
+    data: '',
+    nextBlockId: this.getBlockConnections(id),
+  };
 }

+ 7 - 8
src/background/workflowEngine/blocksHandler/handlerConditions.js

@@ -1,7 +1,6 @@
 import compareBlockValue from '@/utils/compareBlockValue';
 import mustacheReplacer from '@/utils/referenceData/mustacheReplacer';
 import testConditions from '@/utils/testConditions';
-import { getBlockConnection } from '../helper';
 
 function checkConditions(data, conditionOptions) {
   return new Promise((resolve, reject) => {
@@ -42,14 +41,14 @@ function checkConditions(data, conditionOptions) {
   });
 }
 
-async function conditions({ data, outputs, id }, { prevBlockData, refData }) {
+async function conditions({ data, id }, { prevBlockData, refData }) {
   if (data.conditions.length === 0) {
     throw new Error('conditions-empty');
   }
 
   let resultData = '';
   let isConditionMet = false;
-  let outputIndex = data.conditions.length + 1;
+  let outputId = 'fallback';
 
   const replacedValue = {};
   const condition = data.conditions[0];
@@ -62,7 +61,7 @@ async function conditions({ data, outputs, id }, { prevBlockData, refData }) {
       refData,
       activeTab: this.activeTab.id,
       sendMessage: (payload) =>
-        this._sendMessageToTab({ ...payload.data, name: 'conditions', id }),
+        this._sendMessageToTab({ ...payload.data, label: 'conditions', id }),
     };
 
     const conditionsResult = await checkConditions(data, conditionPayload);
@@ -72,10 +71,10 @@ async function conditions({ data, outputs, id }, { prevBlockData, refData }) {
     }
     if (conditionsResult.match) {
       isConditionMet = true;
-      outputIndex = conditionsResult.index + 1;
+      outputId = data.conditions[conditionsResult.index].id;
     }
   } else {
-    data.conditions.forEach(({ type, value, compareValue }, index) => {
+    data.conditions.forEach(({ type, value, compareValue, id: itemId }) => {
       if (isConditionMet) return;
 
       const firstValue = mustacheReplacer(
@@ -93,8 +92,8 @@ async function conditions({ data, outputs, id }, { prevBlockData, refData }) {
       );
 
       if (isMatch) {
+        outputId = itemId;
         resultData = value;
-        outputIndex = index + 1;
         isConditionMet = true;
       }
     });
@@ -103,7 +102,7 @@ async function conditions({ data, outputs, id }, { prevBlockData, refData }) {
   return {
     replacedValue,
     data: resultData,
-    nextBlockId: getBlockConnection({ outputs }, outputIndex),
+    nextBlockId: this.getBlockConnections(id, outputId),
   };
 }
 

+ 1 - 3
src/background/workflowEngine/blocksHandler/handlerDelay.js

@@ -1,5 +1,3 @@
-import { getBlockConnection } from '../helper';
-
 function delay(block) {
   return new Promise((resolve) => {
     const delayTime = +block.data.time || 500;
@@ -7,7 +5,7 @@ function delay(block) {
     setTimeout(() => {
       resolve({
         data: '',
-        nextBlockId: getBlockConnection(block),
+        nextBlockId: this.getBlockConnections(block.id),
       });
     }, delayTime);
   });

+ 2 - 4
src/background/workflowEngine/blocksHandler/handlerDeleteData.js

@@ -1,6 +1,4 @@
-import { getBlockConnection } from '../helper';
-
-function deleteData({ data, outputs }) {
+function deleteData({ data, id }) {
   return new Promise((resolve) => {
     data.deleteList.forEach((item) => {
       if (item.type === 'table') {
@@ -31,7 +29,7 @@ function deleteData({ data, outputs }) {
 
     resolve({
       data: '',
-      nextBlockId: getBlockConnection({ outputs }),
+      nextBlockId: this.getBlockConnections(id),
     });
   });
 }

+ 4 - 9
src/background/workflowEngine/blocksHandler/handlerElementExists.js

@@ -1,29 +1,24 @@
-import { getBlockConnection } from '../helper';
-
 function elementExists(block) {
   return new Promise((resolve, reject) => {
     this._sendMessageToTab(block)
       .then((data) => {
-        const nextBlockId = getBlockConnection(block, data ? 1 : 2);
-
         if (!data && block.data.throwError) {
           const error = new Error('element-not-found');
-          error.nextBlockId = nextBlockId;
           error.data = { selector: block.data.selector };
 
           reject(error);
-
           return;
         }
 
         resolve({
           data,
-          nextBlockId,
+          nextBlockId: this.getBlockConnections(
+            block.id,
+            data ? 1 : 'fallback'
+          ),
         });
       })
       .catch((error) => {
-        error.nextBlockId = getBlockConnection(block);
-
         reject(error);
       });
   });

+ 58 - 57
src/background/workflowEngine/blocksHandler/handlerExecuteWorkflow.js

@@ -1,8 +1,8 @@
 import browser from 'webextension-polyfill';
 import { isWhitespace, parseJSON } from '@/utils/helper';
 import decryptFlow, { getWorkflowPass } from '@/utils/decryptFlow';
+import convertWorkflowData from '@/utils/convertWorkflowData';
 import WorkflowEngine from '../engine';
-import { getBlockConnection } from '../helper';
 
 function workflowListener(workflow, options) {
   return new Promise((resolve, reject) => {
@@ -36,70 +36,71 @@ function workflowListener(workflow, options) {
   });
 }
 
-async function executeWorkflow({ outputs, data }) {
-  const nextBlockId = getBlockConnection({ outputs });
+async function executeWorkflow({ id: blockId, data }) {
+  if (data.workflowId === '') throw new Error('empty-workflow');
 
-  try {
-    if (data.workflowId === '') throw new Error('empty-workflow');
+  const { workflows } = await browser.storage.local.get('workflows');
+  let workflow = Array.isArray(workflows)
+    ? workflows.find(({ id }) => id === data.workflowId)
+    : workflows[data.workflowId];
+  if (!workflow) {
+    const errorInstance = new Error('no-workflow');
+    errorInstance.data = { workflowId: data.workflowId };
 
-    const { workflows } = await browser.storage.local.get('workflows');
-    const workflow = workflows.find(({ id }) => id === data.workflowId);
+    throw errorInstance;
+  }
 
-    if (!workflow) {
-      const errorInstance = new Error('no-workflow');
-      errorInstance.data = { workflowId: data.workflowId };
+  workflow = convertWorkflowData(workflow);
 
-      throw errorInstance;
-    }
-    const options = {
-      options: {
-        data: {
-          globalData: isWhitespace(data.globalData) ? null : data.globalData,
-        },
-        parentWorkflow: {
-          id: this.engine.id,
-          name: this.engine.workflow.name,
-        },
+  const options = {
+    options: {
+      data: {
+        globalData: isWhitespace(data.globalData) ? null : data.globalData,
       },
-      events: {
-        onInit: (engine) => {
-          this.childWorkflowId = engine.id;
-        },
-        onDestroyed: (engine) => {
-          if (data.executeId) {
-            const { dataColumns, globalData, googleSheets, table } =
-              engine.referenceData;
-
-            this.engine.referenceData.workflow[data.executeId] = {
-              globalData,
-              dataColumns,
-              googleSheets,
-              table: table || dataColumns,
-            };
-          }
-        },
+      parentWorkflow: {
+        id: this.engine.id,
+        name: this.engine.workflow.name,
       },
-      states: this.engine.states,
-      logger: this.engine.logger,
-      blocksHandler: this.engine.blocksHandler,
-    };
-
-    if (workflow.drawflow.includes(this.engine.workflow.id)) {
-      throw new Error('workflow-infinite-loop');
-    }
-
-    const result = await workflowListener(workflow, options);
+    },
+    events: {
+      onInit: (engine) => {
+        this.childWorkflowId = engine.id;
+      },
+      onDestroyed: (engine) => {
+        if (data.executeId) {
+          const { dataColumns, globalData, googleSheets, table } =
+            engine.referenceData;
+
+          this.engine.referenceData.workflow[data.executeId] = {
+            globalData,
+            dataColumns,
+            googleSheets,
+            table: table || dataColumns,
+          };
+        }
+      },
+    },
+    states: this.engine.states,
+    logger: this.engine.logger,
+    blocksHandler: this.engine.blocksHandler,
+  };
+
+  const isWorkflowIncluded = workflow.drawflow.nodes.some(
+    (node) =>
+      node.label === 'execute-workflow' &&
+      node.data.workflowId === this.engine.workflow.id
+  );
+  if (isWorkflowIncluded) {
+    throw new Error('workflow-infinite-loop');
+  }
 
-    return {
-      data: '',
-      logId: result.id,
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
+  const result = await workflowListener(workflow, options);
 
-    throw error;
-  }
+  return {
+    data: '',
+    logId: result.id,
+    nextBlockId: this.getBlockConnections(blockId),
+  };
 }
 
 export default executeWorkflow;

+ 36 - 45
src/background/workflowEngine/blocksHandler/handlerExportData.js

@@ -1,60 +1,51 @@
 import browser from 'webextension-polyfill';
 import { default as dataExporter, files } from '@/utils/dataExporter';
-import { getBlockConnection } from '../helper';
 
-async function exportData({ data, outputs }, { refData }) {
-  const nextBlockId = getBlockConnection({ outputs });
+async function exportData({ data, id }, { refData }) {
+  const dataToExport = data.dataToExport || 'data-columns';
+  let payload = refData.table;
 
-  try {
-    const dataToExport = data.dataToExport || 'data-columns';
-    let payload = refData.table;
+  if (dataToExport === 'google-sheets') {
+    payload = refData.googleSheets[data.refKey] || [];
+  } else if (dataToExport === 'variable') {
+    payload = refData.variables[data.variableName] || [];
 
-    if (dataToExport === 'google-sheets') {
-      payload = refData.googleSheets[data.refKey] || [];
-    } else if (dataToExport === 'variable') {
-      payload = refData.variables[data.variableName] || [];
+    if (!Array.isArray(payload)) {
+      payload = [payload];
 
-      if (!Array.isArray(payload)) {
+      if (data.type === 'csv' && typeof payload[0] !== 'object')
         payload = [payload];
-
-        if (data.type === 'csv' && typeof payload[0] !== 'object')
-          payload = [payload];
-      }
-    }
-
-    const hasDownloadAccess = await browser.permissions.contains({
-      permissions: ['downloads'],
-    });
-    const blobUrl = dataExporter(payload, {
-      ...data,
-      csvOptions: {
-        delimiter: data.csvDelimiter || ',',
-      },
-      returnUrl: hasDownloadAccess,
-    });
-
-    if (hasDownloadAccess) {
-      const filename = `${data.name || 'unnamed'}${files[data.type].ext}`;
-      const options = {
-        filename,
-        conflictAction: data.onConflict || 'uniquify',
-      };
-
-      await browser.downloads.download({
-        ...options,
-        url: blobUrl,
-      });
     }
+  }
 
-    return {
-      data: '',
-      nextBlockId,
+  const hasDownloadAccess = await browser.permissions.contains({
+    permissions: ['downloads'],
+  });
+  const blobUrl = dataExporter(payload, {
+    ...data,
+    csvOptions: {
+      delimiter: data.csvDelimiter || ',',
+    },
+    returnUrl: hasDownloadAccess,
+  });
+
+  if (hasDownloadAccess) {
+    const filename = `${data.name || 'unnamed'}${files[data.type].ext}`;
+    const options = {
+      filename,
+      conflictAction: data.onConflict || 'uniquify',
     };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
 
-    throw error;
+    await browser.downloads.download({
+      ...options,
+      url: blobUrl,
+    });
   }
+
+  return {
+    data: '',
+    nextBlockId: this.getBlockConnections(id),
+  };
 }
 
 export default exportData;

+ 7 - 16
src/background/workflowEngine/blocksHandler/handlerForwardPage.js

@@ -1,23 +1,14 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 
-export async function goBack({ outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+export async function goBack({ id }) {
+  if (!this.activeTab.id) throw new Error('no-tab');
 
-  try {
-    if (!this.activeTab.id) throw new Error('no-tab');
+  await browser.tabs.goForward(this.activeTab.id);
 
-    await browser.tabs.goForward(this.activeTab.id);
-
-    return {
-      data: '',
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
+  return {
+    data: '',
+    nextBlockId: this.getBlockConnections(id),
+  };
 }
 
 export default goBack;

+ 7 - 16
src/background/workflowEngine/blocksHandler/handlerGoBack.js

@@ -1,23 +1,14 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 
-export async function goBack({ outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+export async function goBack({ id }) {
+  if (!this.activeTab.id) throw new Error('no-tab');
 
-  try {
-    if (!this.activeTab.id) throw new Error('no-tab');
+  await browser.tabs.goBack(this.activeTab.id);
 
-    await browser.tabs.goBack(this.activeTab.id);
-
-    return {
-      data: '',
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
+  return {
+    data: '',
+    nextBlockId: this.getBlockConnections(id),
+  };
 }
 
 export default goBack;

+ 34 - 44
src/background/workflowEngine/blocksHandler/handlerGoogleSheets.js

@@ -5,7 +5,6 @@ import {
   isWhitespace,
   parseJSON,
 } from '@/utils/helper';
-import { getBlockConnection } from '../helper';
 
 async function getSpreadsheetValues({ spreadsheetId, range, firstRowAsKey }) {
   const response = await googleSheets.getValues({ spreadsheetId, range });
@@ -93,50 +92,41 @@ async function updateSpreadsheetValues(
   }
 }
 
-export default async function ({ data, outputs }, { refData }) {
-  const nextBlockId = getBlockConnection({ outputs });
-
-  try {
-    if (isWhitespace(data.spreadsheetId))
-      throw new Error('empty-spreadsheet-id');
-    if (isWhitespace(data.range)) throw new Error('empty-spreadsheet-range');
-
-    let result = [];
-
-    if (data.type === 'get') {
-      const spreadsheetValues = await getSpreadsheetValues(data);
-
-      result = spreadsheetValues;
-
-      if (data.refKey && !isWhitespace(data.refKey)) {
-        refData.googleSheets[data.refKey] = spreadsheetValues;
-      }
-    } else if (data.type === 'getRange') {
-      result = await getSpreadsheetRange(data);
-
-      if (data.assignVariable) {
-        this.setVariable(data.variableName, result);
-      }
-      if (data.saveData) {
-        this.addDataToColumn(data.dataColumn, result);
-      }
-    } else if (['update', 'append'].includes(data.type)) {
-      result = await updateSpreadsheetValues(
-        {
-          ...data,
-          append: data.type === 'append',
-        },
-        refData.table
-      );
-    }
+export default async function ({ data, id }, { refData }) {
+  if (isWhitespace(data.spreadsheetId)) throw new Error('empty-spreadsheet-id');
+  if (isWhitespace(data.range)) throw new Error('empty-spreadsheet-range');
+
+  let result = [];
+
+  if (data.type === 'get') {
+    const spreadsheetValues = await getSpreadsheetValues(data);
+
+    result = spreadsheetValues;
 
-    return {
-      nextBlockId,
-      data: result,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
+    if (data.refKey && !isWhitespace(data.refKey)) {
+      refData.googleSheets[data.refKey] = spreadsheetValues;
+    }
+  } else if (data.type === 'getRange') {
+    result = await getSpreadsheetRange(data);
 
-    throw error;
+    if (data.assignVariable) {
+      this.setVariable(data.variableName, result);
+    }
+    if (data.saveData) {
+      this.addDataToColumn(data.dataColumn, result);
+    }
+  } else if (['update', 'append'].includes(data.type)) {
+    result = await updateSpreadsheetValues(
+      {
+        ...data,
+        append: data.type === 'append',
+      },
+      refData.table
+    );
   }
+
+  return {
+    data: result,
+    nextBlockId: this.getBlockConnections(id),
+  };
 }

+ 3 - 5
src/background/workflowEngine/blocksHandler/handlerHandleDialog.js

@@ -1,4 +1,4 @@
-import { getBlockConnection, sendDebugCommand } from '../helper';
+import { sendDebugCommand } from '../helper';
 
 const overwriteDialog = (accept, promptText) => `
   const realConfirm = window.confirm;
@@ -17,9 +17,7 @@ const overwriteDialog = (accept, promptText) => `
   }
 `;
 
-function handleDialog({ data, outputs, id: blockId }) {
-  const nextBlockId = getBlockConnection({ outputs });
-
+function handleDialog({ data, id: blockId }) {
   return new Promise((resolve) => {
     if (!this.settings.debugMode || BROWSER_TYPE !== 'chrome') {
       const isScriptExist = this.preloadScripts.find(
@@ -57,7 +55,7 @@ function handleDialog({ data, outputs, id: blockId }) {
 
     resolve({
       data: '',
-      nextBlockId,
+      nextBlockId: this.getBlockConnections(blockId),
     });
   });
 }

+ 2 - 3
src/background/workflowEngine/blocksHandler/handlerHandleDownload.js

@@ -1,5 +1,4 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 
 const getFileExtension = (str) => /(?:\.([^.]+))?$/.exec(str)[1];
 function determineFilenameListener(item, suggest) {
@@ -28,8 +27,8 @@ function determineFilenameListener(item, suggest) {
   return false;
 }
 
-function handleDownload({ data, outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+function handleDownload({ data, id: blockId }) {
+  const nextBlockId = this.getBlockConnections(blockId);
   const getFilesname = () =>
     JSON.parse(sessionStorage.getItem('rename-downloaded-files')) || {};
 

+ 28 - 36
src/background/workflowEngine/blocksHandler/handlerHoverElement.js

@@ -1,44 +1,36 @@
-import { getBlockConnection, attachDebugger } from '../helper';
+import { attachDebugger } from '../helper';
 
 export async function hoverElement(block) {
-  const nextBlockId = getBlockConnection(block);
-
-  try {
-    if (!this.activeTab.id) throw new Error('no-tab');
-    if (BROWSER_TYPE !== 'chrome') {
-      const error = new Error('browser-not-supported');
-      error.data = { browser: BROWSER_TYPE };
-
-      throw error;
-    }
-
-    const { debugMode, executedBlockOnWeb } = this.settings;
-
-    if (!debugMode) {
-      await attachDebugger(this.activeTab.id);
-    }
-
-    await this._sendMessageToTab({
-      ...block,
-      debugMode,
-      executedBlockOnWeb,
-      activeTabId: this.activeTab.id,
-      frameSelector: this.frameSelector,
-    });
-
-    if (!debugMode) {
-      chrome.debugger.detach({ tabId: this.activeTab.id });
-    }
-
-    return {
-      data: '',
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
+  if (!this.activeTab.id) throw new Error('no-tab');
+  if (BROWSER_TYPE !== 'chrome') {
+    const error = new Error('browser-not-supported');
+    error.data = { browser: BROWSER_TYPE };
 
     throw error;
   }
+
+  const { debugMode, executedBlockOnWeb } = this.settings;
+
+  if (!debugMode) {
+    await attachDebugger(this.activeTab.id);
+  }
+
+  await this._sendMessageToTab({
+    ...block,
+    debugMode,
+    executedBlockOnWeb,
+    activeTabId: this.activeTab.id,
+    frameSelector: this.frameSelector,
+  });
+
+  if (!debugMode) {
+    chrome.debugger.detach({ tabId: this.activeTab.id });
+  }
+
+  return {
+    data: '',
+    nextBlockId: this.getBlockConnections(block.id),
+  };
 }
 
 export default hoverElement;

+ 2 - 3
src/background/workflowEngine/blocksHandler/handlerInsertData.js

@@ -1,8 +1,7 @@
 import { parseJSON } from '@/utils/helper';
 import mustacheReplacer from '@/utils/referenceData/mustacheReplacer';
-import { getBlockConnection } from '../helper';
 
-function insertData({ outputs, data }, { refData }) {
+function insertData({ id, data }, { refData }) {
   return new Promise((resolve) => {
     const replacedValueList = {};
     data.dataList.forEach(({ name, value, type }) => {
@@ -21,7 +20,7 @@ function insertData({ outputs, data }, { refData }) {
     resolve({
       data: '',
       replacedValue: replacedValueList,
-      nextBlockId: getBlockConnection({ outputs }),
+      nextBlockId: this.getBlockConnections(id),
     });
   });
 }

+ 18 - 4
src/background/workflowEngine/blocksHandler/handlerInteractionBlock.js

@@ -1,6 +1,6 @@
 import browser from 'webextension-polyfill';
 import { objectHasKey } from '@/utils/helper';
-import { getBlockConnection } from '../helper';
+import { attachDebugger } from '../helper';
 
 async function checkAccess(blockName) {
   if (blockName === 'upload-file') {
@@ -25,9 +25,16 @@ async function checkAccess(blockName) {
 async function interactionHandler(block) {
   await checkAccess(block.name);
 
-  const nextBlockId = getBlockConnection(block);
+  const debugMode =
+    (block.data.settings?.debugMode ?? false) && !this.settings.debugMode;
+  const isChrome = BROWSER_TYPE === 'chrome';
 
   try {
+    if (debugMode && isChrome) {
+      await attachDebugger(this.activeTab.id);
+      block.debugMode = true;
+    }
+
     const data = await this._sendMessageToTab(block, {
       frameId: this.activeTab.frameId || 0,
     });
@@ -65,12 +72,19 @@ async function interactionHandler(block) {
       this.setVariable(block.data.variableName, data);
     }
 
+    if (debugMode && isChrome) {
+      chrome.debugger.detach({ tabId: this.activeTab.id });
+    }
+
     return {
       data,
-      nextBlockId,
+      nextBlockId: this.getBlockConnections(block.id),
     };
   } catch (error) {
-    error.nextBlockId = nextBlockId;
+    if (debugMode && isChrome) {
+      chrome.debugger.detach({ tabId: this.activeTab.id });
+    }
+
     error.data = {
       name: block.name,
       selector: block.data.selector,

+ 36 - 46
src/background/workflowEngine/blocksHandler/handlerJavascriptCode.js

@@ -1,62 +1,52 @@
-import { getBlockConnection } from '../helper';
-
 export async function javascriptCode({ outputs, data, ...block }, { refData }) {
-  const nextBlockId = getBlockConnection({ outputs });
+  const nextBlockId = this.getBlockConnections(block.id);
 
-  try {
-    if (data.everyNewTab) {
-      const isScriptExist = this.preloadScripts.find(
-        ({ id }) => id === block.id
-      );
+  if (data.everyNewTab) {
+    const isScriptExist = this.preloadScripts.find(({ id }) => id === block.id);
 
-      if (!isScriptExist) {
-        this.preloadScripts.push({ ...block, data });
-      }
+    if (!isScriptExist) {
+      this.preloadScripts.push({ ...block, data });
     }
-    if (!this.activeTab.id) {
-      if (!data.everyNewTab) {
-        throw new Error('no-tab');
-      } else {
-        return { data: '', nextBlockId };
-      }
+  }
+  if (!this.activeTab.id) {
+    if (!data.everyNewTab) {
+      throw new Error('no-tab');
+    } else {
+      return { data: '', nextBlockId };
     }
+  }
 
-    const payload = { ...block, data, refData: { variables: {} } };
-    if (data.code.includes('automaRefData')) payload.refData = refData;
-
-    if (!data.code.includes('automaNextBlock'))
-      payload.data.code += `\nautomaNextBlock()`;
-
-    const result = await this._sendMessageToTab(payload);
-    if (result) {
-      if (result.columns.data?.$error) {
-        throw new Error(result.columns.data.message);
-      }
+  const payload = { ...block, data, refData: { variables: {} } };
+  if (data.code.includes('automaRefData')) payload.refData = refData;
 
-      if (result.variables) {
-        Object.keys(result.variables).forEach((varName) => {
-          this.setVariable(varName, result.variables[varName]);
-        });
-      }
+  if (!data.code.includes('automaNextBlock'))
+    payload.data.code += `\nautomaNextBlock()`;
 
-      if (result.columns.insert && result.columns.data) {
-        const params = Array.isArray(result.columns.data)
-          ? result.columns.data
-          : [result.columns.data];
+  const result = await this._sendMessageToTab(payload);
+  if (result) {
+    if (result.columns.data?.$error) {
+      throw new Error(result.columns.data.message);
+    }
 
-        this.addDataToColumn(params);
-      }
+    if (result.variables) {
+      Object.keys(result.variables).forEach((varName) => {
+        this.setVariable(varName, result.variables[varName]);
+      });
     }
 
-    return {
-      nextBlockId,
-      data: result?.columns.data || {},
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
+    if (result.columns.insert && result.columns.data) {
+      const params = Array.isArray(result.columns.data)
+        ? result.columns.data
+        : [result.columns.data];
 
-    throw error;
+      this.addDataToColumn(params);
+    }
   }
+
+  return {
+    nextBlockId,
+    data: result?.columns.data || {},
+  };
 }
 
 export default javascriptCode;

+ 34 - 0
src/background/workflowEngine/blocksHandler/handlerLogData.js

@@ -0,0 +1,34 @@
+import dbLogs from '@/db/logs';
+
+export async function logData({ id, data }) {
+  if (!data.workflowId) {
+    throw new Error('No workflow is selected');
+  }
+
+  const [workflowLog] = await dbLogs.items
+    .where('workflowId')
+    .equals(data.workflowId)
+    .reverse()
+    .sortBy('endedAt');
+  let workflowLogData = null;
+
+  if (workflowLog) {
+    workflowLogData = (
+      await dbLogs.logsData.where('logId').equals(workflowLog.id).first()
+    ).data;
+
+    if (data.assignVariable) {
+      this.setVariable(data.variableName, workflowLogData);
+    }
+    if (data.saveData) {
+      this.addDataToColumn(data.dataColumn, workflowLogData);
+    }
+  }
+
+  return {
+    data: workflowLogData,
+    nextBlockId: this.getBlockConnections(id),
+  };
+}
+
+export default logData;

+ 2 - 4
src/background/workflowEngine/blocksHandler/handlerLoopBreakpoint.js

@@ -1,5 +1,3 @@
-import { getBlockConnection } from '../helper';
-
 function loopBreakpoint(block, { prevBlockData }) {
   const currentLoop = this.loopList[block.data.loopId];
 
@@ -20,7 +18,7 @@ function loopBreakpoint(block, { prevBlockData }) {
     ) {
       resolve({
         data: '',
-        nextBlockId: currentLoop.blockId,
+        nextBlockId: [currentLoop.blockId],
       });
     } else {
       if (currentLoop.type === 'elements') {
@@ -36,7 +34,7 @@ function loopBreakpoint(block, { prevBlockData }) {
 
       resolve({
         data: prevBlockData,
-        nextBlockId: getBlockConnection(block),
+        nextBlockId: this.getBlockConnections(block.id),
       });
     }
   });

+ 3 - 8
src/background/workflowEngine/blocksHandler/handlerLoopData.js

@@ -1,9 +1,6 @@
 import { parseJSON, isXPath } from '@/utils/helper';
-import { getBlockConnection } from '../helper';
-
-async function loopData({ data, id, outputs }, { refData }) {
-  const nextBlockId = getBlockConnection({ outputs });
 
+async function loopData({ data, id }, { refData }) {
   try {
     if (this.loopList[data.loopId]) {
       const index = this.loopList[data.loopId].index + 1;
@@ -43,7 +40,7 @@ async function loopData({ data, id, outputs }, { refData }) {
             : 'cssSelector';
           const { elements, url, loopId } = await this._sendMessageToTab({
             id,
-            name: 'loop-data',
+            label: 'loop-data',
             data: {
               max,
               findBy,
@@ -103,12 +100,10 @@ async function loopData({ data, id, outputs }, { refData }) {
     localStorage.setItem(`index:${id}`, this.loopList[data.loopId].index);
 
     return {
-      nextBlockId,
       data: refData.loopData[data.loopId],
+      nextBlockId: this.getBlockConnections(id),
     };
   } catch (error) {
-    error.nextBlockId = nextBlockId;
-
     if (data.loopThrough === 'elements') {
       error.data = { selector: data.elementSelector };
     }

+ 75 - 88
src/background/workflowEngine/blocksHandler/handlerNewTab.js

@@ -1,13 +1,8 @@
 import browser from 'webextension-polyfill';
 import { isWhitespace, sleep } from '@/utils/helper';
-import {
-  waitTabLoaded,
-  attachDebugger,
-  sendDebugCommand,
-  getBlockConnection,
-} from '../helper';
-
-async function newTab({ outputs, data }) {
+import { waitTabLoaded, attachDebugger, sendDebugCommand } from '../helper';
+
+async function newTab({ id, data }) {
   if (this.windowId) {
     try {
       await browser.windows.get(this.windowId);
@@ -16,104 +11,96 @@ async function newTab({ outputs, data }) {
     }
   }
 
-  const nextBlockId = getBlockConnection({ outputs });
-
-  try {
-    const isInvalidUrl = !/^https?/.test(data.url);
-
-    if (isInvalidUrl) {
-      const error = new Error(
-        isWhitespace(data.url) ? 'url-empty' : 'invalid-active-tab'
-      );
-      error.data = { url: data.url };
-
-      throw error;
-    }
-
-    let tab = null;
-    const isChrome = BROWSER_TYPE === 'chrome';
-
-    if (data.updatePrevTab && this.activeTab.id) {
-      tab = await browser.tabs.update(this.activeTab.id, {
-        url: data.url,
-        active: data.active,
-      });
-    } else {
-      tab = await browser.tabs.create({
-        url: data.url,
-        active: data.active,
-        windowId: this.windowId,
-      });
-    }
+  const isInvalidUrl = !/^https?/.test(data.url);
 
-    this.activeTab.url = data.url;
-    if (tab) {
-      if (this.settings.debugMode || data.customUserAgent) {
-        await attachDebugger(tab.id, this.activeTab.id);
-        this.debugAttached = true;
-
-        if (data.customUserAgent && isChrome) {
-          await sendDebugCommand(tab.id, 'Network.setUserAgentOverride', {
-            userAgent: data.userAgent,
-          });
-          await browser.tabs.reload(tab.id);
-          await sleep(1000);
-        }
-      }
+  if (isInvalidUrl) {
+    const error = new Error(
+      isWhitespace(data.url) ? 'url-empty' : 'invalid-active-tab'
+    );
+    error.data = { url: data.url };
 
-      this.activeTab.id = tab.id;
-      this.windowId = tab.windowId;
-    }
+    throw error;
+  }
 
-    if (data.inGroup && !data.updatePrevTab) {
-      const options = {
-        groupId: this.activeTab.groupId,
-        tabIds: this.activeTab.id,
-      };
+  let tab = null;
+  const isChrome = BROWSER_TYPE === 'chrome';
+
+  if (data.updatePrevTab && this.activeTab.id) {
+    tab = await browser.tabs.update(this.activeTab.id, {
+      url: data.url,
+      active: data.active,
+    });
+  } else {
+    tab = await browser.tabs.create({
+      url: data.url,
+      active: data.active,
+      windowId: this.windowId,
+    });
+  }
 
-      if (!this.activeTab.groupId) {
-        options.createProperties = {
-          windowId: this.windowId,
-        };
-      }
+  this.activeTab.url = data.url;
+  if (tab) {
+    if (this.settings.debugMode || data.customUserAgent) {
+      await attachDebugger(tab.id, this.activeTab.id);
+      this.debugAttached = true;
 
-      if (isChrome) {
-        chrome.tabs.group(options, (tabGroupId) => {
-          this.activeTab.groupId = tabGroupId;
+      if (data.customUserAgent && isChrome) {
+        await sendDebugCommand(tab.id, 'Network.setUserAgentOverride', {
+          userAgent: data.userAgent,
         });
+        await browser.tabs.reload(tab.id);
+        await sleep(1000);
       }
     }
 
-    this.activeTab.frameId = 0;
+    this.activeTab.id = tab.id;
+    this.windowId = tab.windowId;
+  }
 
-    if (isChrome && !this.settings.debugMode && data.customUserAgent) {
-      chrome.debugger.detach({ tabId: tab.id });
-    }
+  if (data.inGroup && !data.updatePrevTab) {
+    const options = {
+      groupId: this.activeTab.groupId,
+      tabIds: this.activeTab.id,
+    };
 
-    if (this.preloadScripts.length > 0) {
-      const preloadScripts = this.preloadScripts.map((script) =>
-        this._sendMessageToTab(script)
-      );
-      await Promise.allSettled(preloadScripts);
+    if (!this.activeTab.groupId) {
+      options.createProperties = {
+        windowId: this.windowId,
+      };
     }
 
-    if (data.waitTabLoaded) {
-      await waitTabLoaded({
-        listenError: true,
-        tabId: this.activeTab.id,
-        ms: this.settings?.tabLoadTimeout ?? 30000,
+    if (isChrome) {
+      chrome.tabs.group(options, (tabGroupId) => {
+        this.activeTab.groupId = tabGroupId;
       });
     }
+  }
 
-    return {
-      nextBlockId,
-      data: data.url,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
+  this.activeTab.frameId = 0;
 
-    throw error;
+  if (isChrome && !this.settings.debugMode && data.customUserAgent) {
+    chrome.debugger.detach({ tabId: tab.id });
+  }
+
+  if (this.preloadScripts.length > 0) {
+    const preloadScripts = this.preloadScripts.map((script) =>
+      this._sendMessageToTab(script)
+    );
+    await Promise.allSettled(preloadScripts);
   }
+
+  if (data.waitTabLoaded) {
+    await waitTabLoaded({
+      listenError: true,
+      tabId: this.activeTab.id,
+      ms: this.settings?.tabLoadTimeout ?? 30000,
+    });
+  }
+
+  return {
+    data: data.url,
+    nextBlockId: this.getBlockConnections(id),
+  };
 }
 
 export default newTab;

+ 14 - 23
src/background/workflowEngine/blocksHandler/handlerNewWindow.js

@@ -1,33 +1,24 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 
 export async function newWindow(block) {
-  const nextBlockId = getBlockConnection(block);
+  const { incognito, windowState } = block.data;
+  const windowOptions = { incognito, state: windowState };
 
-  try {
-    const { incognito, windowState } = block.data;
-    const windowOptions = { incognito, state: windowState };
+  if (windowState === 'normal') {
+    ['top', 'left', 'height', 'width'].forEach((key) => {
+      if (block.data[key] <= 0) return;
 
-    if (windowState === 'normal') {
-      ['top', 'left', 'height', 'width'].forEach((key) => {
-        if (block.data[key] <= 0) return;
-
-        windowOptions[key] = block.data[key];
-      });
-    }
-
-    const { id } = await browser.windows.create(windowOptions);
-    this.windowId = id;
+      windowOptions[key] = block.data[key];
+    });
+  }
 
-    return {
-      data: id,
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
+  const { id } = await browser.windows.create(windowOptions);
+  this.windowId = id;
 
-    throw error;
-  }
+  return {
+    data: id,
+    nextBlockId: this.getBlockConnections(block.id),
+  };
 }
 
 export default newWindow;

+ 27 - 36
src/background/workflowEngine/blocksHandler/handlerNotification.js

@@ -1,47 +1,38 @@
 import { nanoid } from 'nanoid';
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 
-export default async function ({ data, outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+export default async function ({ data, id }) {
+  const hasPermission = await browser.permissions.contains({
+    permissions: ['notifications'],
+  });
 
-  try {
-    const hasPermission = await browser.permissions.contains({
-      permissions: ['notifications'],
-    });
+  if (!hasPermission) {
+    const error = new Error('no-permission');
+    error.data = { permission: 'notifications' };
 
-    if (!hasPermission) {
-      const error = new Error('no-permission');
-      error.data = { permission: 'notifications' };
-
-      throw error;
-    }
-
-    const options = {
-      title: data.title,
-      message: data.message,
-      iconUrl: browser.runtime.getURL('icon-128.png'),
-    };
+    throw error;
+  }
 
-    ['iconUrl', 'imageUrl'].forEach((key) => {
-      const url = data[key];
-      if (!url || !url.startsWith('http')) return;
+  const options = {
+    title: data.title,
+    message: data.message,
+    iconUrl: browser.runtime.getURL('icon-128.png'),
+  };
 
-      options[key] = url;
-    });
+  ['iconUrl', 'imageUrl'].forEach((key) => {
+    const url = data[key];
+    if (!url || !url.startsWith('http')) return;
 
-    await browser.notifications.create(nanoid(), {
-      ...options,
-      type: options.imageUrl ? 'image' : 'basic',
-    });
+    options[key] = url;
+  });
 
-    return {
-      data: '',
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
+  await browser.notifications.create(nanoid(), {
+    ...options,
+    type: options.imageUrl ? 'image' : 'basic',
+  });
 
-    throw error;
-  }
+  return {
+    data: '',
+    nextBlockId: this.getBlockConnections(id),
+  };
 }

+ 2 - 3
src/background/workflowEngine/blocksHandler/handlerProxy.js

@@ -1,9 +1,8 @@
 import browser from 'webextension-polyfill';
 import { isWhitespace } from '@/utils/helper';
-import { getBlockConnection } from '../helper';
 
-function setProxy({ data, outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+function setProxy({ data, id }) {
+  const nextBlockId = this.getBlockConnections(id);
 
   return new Promise((resolve, reject) => {
     if (data.clearProxy) {

+ 7 - 16
src/background/workflowEngine/blocksHandler/handlerReloadTab.js

@@ -1,23 +1,14 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 
-export async function reloadTab({ outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+export async function reloadTab({ id }) {
+  if (!this.activeTab.id) throw new Error('no-tab');
 
-  try {
-    if (!this.activeTab.id) throw new Error('no-tab');
+  await browser.tabs.reload(this.activeTab.id);
 
-    await browser.tabs.reload(this.activeTab.id);
-
-    return {
-      data: '',
-      nextBlockId,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
-
-    throw error;
-  }
+  return {
+    data: '',
+    nextBlockId: this.getBlockConnections(id),
+  };
 }
 
 export default reloadTab;

+ 3 - 5
src/background/workflowEngine/blocksHandler/handlerRepeatTask.js

@@ -1,20 +1,18 @@
-import { getBlockConnection } from '../helper';
-
-function repeatTask({ data, id, outputs }) {
+function repeatTask({ data, id }) {
   return new Promise((resolve) => {
     if (this.repeatedTasks[id] >= data.repeatFor) {
       delete this.repeatedTasks[id];
 
       resolve({
         data: data.repeatFor,
-        nextBlockId: getBlockConnection({ outputs }),
+        nextBlockId: this.getBlockConnections(id),
       });
     } else {
       this.repeatedTasks[id] = (this.repeatedTasks[id] || 1) + 1;
 
       resolve({
         data: data.repeatFor,
-        nextBlockId: getBlockConnection({ outputs }, 2),
+        nextBlockId: this.getBlockConnections(id, 2),
       });
     }
   });

+ 35 - 44
src/background/workflowEngine/blocksHandler/handlerSaveAssets.js

@@ -1,5 +1,4 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection } from '../helper';
 
 function getFilename(url) {
   try {
@@ -14,56 +13,48 @@ function getFilename(url) {
   }
 }
 
-export default async function ({ data, id, name, outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+export default async function ({ data, id, name }) {
+  const hasPermission = await browser.permissions.contains({
+    permissions: ['downloads'],
+  });
 
-  try {
-    const hasPermission = await browser.permissions.contains({
-      permissions: ['downloads'],
-    });
-
-    if (!hasPermission) {
-      throw new Error('no-permission');
-    }
-
-    let sources = [data.url];
-    let index = 0;
-    const downloadFile = (url) => {
-      const options = { url, conflictAction: data.onConflict };
-      let filename = data.filename || getFilename(url);
+  if (!hasPermission) {
+    throw new Error('no-permission');
+  }
 
-      if (filename) {
-        if (data.onConflict === 'overwrite' && index !== 0) {
-          filename = `(${index}) ${filename}`;
-        }
+  let sources = [data.url];
+  let index = 0;
+  const downloadFile = (url) => {
+    const options = { url, conflictAction: data.onConflict };
+    let filename = data.filename || getFilename(url);
 
-        options.filename = filename;
-        index += 1;
+    if (filename) {
+      if (data.onConflict === 'overwrite' && index !== 0) {
+        filename = `(${index}) ${filename}`;
       }
 
-      return browser.downloads.download(options);
-    };
-
-    if (data.type === 'element') {
-      sources = await this._sendMessageToTab({
-        id,
-        name,
-        data,
-        tabId: this.activeTab.id,
-      });
-
-      await Promise.all(sources.map((url) => downloadFile(url)));
-    } else if (data.type === 'url') {
-      await downloadFile(data.url);
+      options.filename = filename;
+      index += 1;
     }
 
-    return {
-      nextBlockId,
-      data: sources,
-    };
-  } catch (error) {
-    error.nextBlockId = nextBlockId;
+    return browser.downloads.download(options);
+  };
 
-    throw error;
+  if (data.type === 'element') {
+    sources = await this._sendMessageToTab({
+      id,
+      name,
+      data,
+      tabId: this.activeTab.id,
+    });
+
+    await Promise.all(sources.map((url) => downloadFile(url)));
+  } else if (data.type === 'url') {
+    await downloadFile(data.url);
   }
+
+  return {
+    data: sources,
+    nextBlockId: this.getBlockConnections(id),
+  };
 }

+ 3 - 3
src/background/workflowEngine/blocksHandler/handlerSwitchTab.js

@@ -1,8 +1,8 @@
 import browser from 'webextension-polyfill';
-import { getBlockConnection, attachDebugger } from '../helper';
+import { attachDebugger } from '../helper';
 
-export default async function ({ data, outputs }) {
-  const nextBlockId = getBlockConnection({ outputs });
+export default async function ({ data, id }) {
+  const nextBlockId = this.getBlockConnections(id);
   const generateError = (message, errorData) => {
     const error = new Error(message);
     error.nextBlockId = nextBlockId;

+ 2 - 2
src/background/workflowEngine/blocksHandler/handlerSwitchTo.js

@@ -1,8 +1,8 @@
 import { objectHasKey, sleep } from '@/utils/helper';
-import { getBlockConnection, getFrames } from '../helper';
+import { getFrames } from '../helper';
 
 async function switchTo(block) {
-  const nextBlockId = getBlockConnection(block);
+  const nextBlockId = this.getBlockConnections(block.id);
 
   try {
     if (block.data.windowType === 'main-window') {

+ 29 - 0
src/background/workflowEngine/blocksHandler/handlerTabUrl.js

@@ -0,0 +1,29 @@
+import browser from 'webextension-polyfill';
+
+export async function logData({ id, data }) {
+  let urls = [];
+
+  if (data.type === 'active-tab') {
+    if (!this.activeTab.id) throw new Error('no-tab');
+
+    const tab = await browser.tabs.get(this.activeTab.id);
+    urls = tab.url || tab.pendingUrl || '';
+  } else {
+    const tabs = await browser.tabs.query({});
+    urls = tabs.map((tab) => tab.url);
+  }
+
+  if (data.assignVariable) {
+    this.setVariable(data.variableName, urls);
+  }
+  if (data.saveData) {
+    this.addDataToColumn(data.dataColumn, urls);
+  }
+
+  return {
+    data: urls,
+    nextBlockId: this.getBlockConnections(id),
+  };
+}
+
+export default logData;

+ 7 - 7
src/background/workflowEngine/blocksHandler/handlerTakeScreenshot.js

@@ -1,6 +1,6 @@
 import browser from 'webextension-polyfill';
 import { fileSaver } from '@/utils/helper';
-import { getBlockConnection, waitTabLoaded } from '../helper';
+import { waitTabLoaded } from '../helper';
 
 async function saveImage({ filename, uri, ext }) {
   const hasDownloadAccess = await browser.permissions.contains({
@@ -33,8 +33,7 @@ async function saveImage({ filename, uri, ext }) {
   image.src = uri;
 }
 
-async function takeScreenshot({ data, outputs, name }) {
-  const nextBlockId = getBlockConnection({ outputs });
+async function takeScreenshot({ data, id, label }) {
   const saveToComputer =
     typeof data.saveToComputer === 'undefined' || data.saveToComputer;
 
@@ -85,7 +84,7 @@ async function takeScreenshot({ data, outputs, name }) {
       screenshot = await (data.fullPage ||
       ['element', 'fullpage'].includes(data.type)
         ? this._sendMessageToTab({
-            name,
+            label,
             options,
             data: {
               type: data.type,
@@ -107,10 +106,11 @@ async function takeScreenshot({ data, outputs, name }) {
       await saveScreenshot(screenshot);
     }
 
-    return { data: screenshot, nextBlockId };
+    return {
+      data: screenshot,
+      nextBlockId: this.getBlockConnections(id),
+    };
   } catch (error) {
-    error.nextBlockId = nextBlockId;
-
     if (data.type === 'element') error.data = { selector: data.selector };
 
     throw error;

+ 1 - 5
src/background/workflowEngine/blocksHandler/handlerTrigger.js

@@ -1,12 +1,8 @@
-import { getBlockConnection } from '../helper';
-
 async function trigger(block) {
   return new Promise((resolve) => {
-    const nextBlockId = getBlockConnection(block);
-
     resolve({
       data: '',
-      nextBlockId,
+      nextBlockId: this.getBlockConnections(block.id),
     });
   });
 }

+ 9 - 6
src/background/workflowEngine/blocksHandler/handlerWaitConnections.js

@@ -1,17 +1,20 @@
-import { getBlockConnection } from '../helper';
-
-async function waitConnections({ data, outputs, inputs, id }, { prevBlock }) {
+async function waitConnections({ data, id }, { prevBlock }) {
   return new Promise((resolve) => {
     let timeout;
     let resolved = false;
 
-    const nextBlockId = getBlockConnection({ outputs });
+    const nextBlockId = this.getBlockConnections(id);
     const destroyWorker =
       data.specificFlow && prevBlock?.id !== data.flowBlockId;
 
     const registerConnections = () => {
-      inputs.input_1.connections.forEach(({ node }) => {
-        this.engine.waitConnections[id][node] = {
+      const connections = this.engine.connectionsMap;
+      Object.keys(connections).forEach((key) => {
+        const isConnected = connections[key].includes(id);
+        if (!isConnected) return;
+
+        const prevBlockId = key.slice(0, key.indexOf('-output'));
+        this.engine.waitConnections[id][prevBlockId] = {
           isHere: false,
           isContinue: false,
         };

+ 3 - 4
src/background/workflowEngine/blocksHandler/handlerWebhook.js

@@ -2,11 +2,10 @@ import objectPath from 'object-path';
 import { isWhitespace } from '@/utils/helper';
 import { executeWebhook } from '@/utils/webhookUtil';
 import mustacheReplacer from '@/utils/referenceData/mustacheReplacer';
-import { getBlockConnection } from '../helper';
 
-export async function webhook({ data, outputs }, { refData }) {
-  const nextBlockId = getBlockConnection({ outputs });
-  const fallbackOutput = getBlockConnection({ outputs }, 2);
+export async function webhook({ data, id }, { refData }) {
+  const nextBlockId = this.getBlockConnections(id);
+  const fallbackOutput = this.getBlockConnections(id, 'fallback');
 
   try {
     if (isWhitespace(data.url)) throw new Error('url-empty');

+ 6 - 4
src/background/workflowEngine/blocksHandler/handlerWhileLoop.js

@@ -1,15 +1,17 @@
 import testConditions from '@/utils/testConditions';
-import { getBlockConnection } from '../helper';
 
-async function whileLoop({ data, outputs, id }, { refData }) {
+async function whileLoop({ data, id }, { refData }) {
   const conditionPayload = {
     refData,
     activeTab: this.activeTab.id,
     sendMessage: (payload) =>
-      this._sendMessageToTab({ ...payload.data, name: 'conditions', id }),
+      this._sendMessageToTab({ ...payload.data, label: 'conditions', id }),
   };
   const result = await testConditions(data.conditions, conditionPayload);
-  const nextBlockId = getBlockConnection({ outputs }, result.isMatch ? 1 : 2);
+  const nextBlockId = this.getBlockConnections(
+    id,
+    result.isMatch ? 1 : 'fallback'
+  );
 
   return {
     data: '',

+ 138 - 55
src/background/workflowEngine/engine.js

@@ -2,6 +2,7 @@ import browser from 'webextension-polyfill';
 import { nanoid } from 'nanoid';
 import { tasks } from '@/utils/shared';
 import { clearCache, sleep, parseJSON, isObject } from '@/utils/helper';
+import dbStorage from '@/db/storage';
 import Worker from './worker';
 
 class WorkflowEngine {
@@ -16,11 +17,16 @@ class WorkflowEngine {
 
     this.workerId = 0;
     this.workers = new Map();
+
+    this.extractedGroup = {};
+    this.connectionsMap = {};
     this.waitConnections = {};
 
     this.isDestroyed = false;
     this.isUsingProxy = false;
 
+    this.triggerBlockId = null;
+
     this.blocks = {};
     this.history = [];
     this.columnsId = {};
@@ -80,77 +86,114 @@ class WorkflowEngine {
     };
   }
 
-  init() {
-    if (this.workflow.isDisabled) return;
+  async init() {
+    try {
+      if (this.workflow.isDisabled) return;
 
-    if (!this.states) {
-      console.error(`"${this.workflow.name}" workflow doesn't have states`);
-      this.destroy('error');
-      return;
-    }
+      if (!this.states) {
+        console.error(`"${this.workflow.name}" workflow doesn't have states`);
+        this.destroy('error');
+        return;
+      }
 
-    const flow = this.workflow.drawflow;
-    const parsedFlow = typeof flow === 'string' ? parseJSON(flow, {}) : flow;
-    const blocks = parsedFlow?.drawflow?.Home.data;
+      const { nodes, edges } = this.workflow.drawflow;
+      if (!nodes || nodes.length === 0) {
+        console.error(`${this.workflow.name} doesn't have blocks`);
+        return;
+      }
 
-    if (!blocks) {
-      console.error(`${this.workflow.name} doesn't have blocks`);
-      return;
-    }
+      const triggerBlock = nodes.find((node) => node.label === 'trigger');
+      if (!triggerBlock) {
+        console.error(`${this.workflow.name} doesn't have a trigger block`);
+        return;
+      }
 
-    const triggerBlock = Object.values(blocks).find(
-      ({ name }) => name === 'trigger'
-    );
-    if (!triggerBlock) {
-      console.error(`${this.workflow.name} doesn't have a trigger block`);
-      return;
-    }
+      this.triggerBlockId = triggerBlock.id;
+
+      this.blocks = nodes.reduce((acc, node) => {
+        acc[node.id] = node;
+
+        return acc;
+      }, {});
+      this.connectionsMap = edges.reduce((acc, { sourceHandle, target }) => {
+        if (!acc[sourceHandle]) acc[sourceHandle] = [];
+
+        acc[sourceHandle].push(target);
+
+        return acc;
+      }, {});
+
+      const workflowTable = this.workflow.table || this.workflow.dataColumns;
+      let columns = Array.isArray(workflowTable)
+        ? workflowTable
+        : Object.values(workflowTable);
+
+      if (this.workflow.connectedTable) {
+        const connectedTable = await dbStorage.tablesItems
+          .where('id')
+          .equals(this.workflow.connectedTable)
+          .first();
+        const connectedTableData = await dbStorage.tablesData
+          .where('tableId')
+          .equals(connectedTable?.id)
+          .first();
+        if (connectedTable && connectedTableData) {
+          columns = Object.values(connectedTable.columns);
+          Object.assign(this.columns, connectedTableData.columnsIndex);
+          this.referenceData.table = connectedTableData.items || [];
+        } else {
+          this.workflow.connectedTable = null;
+        }
+      }
 
-    const workflowTable = this.workflow.table || this.workflow.dataColumns;
-    const columns = Array.isArray(workflowTable)
-      ? workflowTable
-      : Object.values(workflowTable);
+      const variables = await dbStorage.variables.toArray();
+      variables.forEach(({ name, value }) => {
+        this.referenceData.variables[`$$${name}`] = value;
+      });
 
-    columns.forEach(({ name, type, id }) => {
-      const columnId = id || name;
+      columns.forEach(({ name, type, id }) => {
+        const columnId = id || name;
 
-      this.columnsId[name] = columnId;
-      this.columns[columnId] = { index: 0, name, type };
-    });
+        this.columnsId[name] = columnId;
+        if (!this.columns[columnId])
+          this.columns[columnId] = { index: 0, name, type };
+      });
 
-    if (BROWSER_TYPE !== 'chrome') {
-      this.workflow.settings.debugMode = false;
-    }
-    if (this.workflow.settings.debugMode) {
-      chrome.debugger.onEvent.addListener(this.onDebugEvent);
-    }
-    if (this.workflow.settings.reuseLastState) {
-      const lastStateKey = `state:${this.workflow.id}`;
-      browser.storage.local.get(lastStateKey).then((value) => {
+      if (BROWSER_TYPE !== 'chrome') {
+        this.workflow.settings.debugMode = false;
+      }
+      if (this.workflow.settings.debugMode) {
+        chrome.debugger.onEvent.addListener(this.onDebugEvent);
+      }
+      if (
+        this.workflow.settings.reuseLastState &&
+        !this.workflow.connectedTable
+      ) {
+        const lastStateKey = `state:${this.workflow.id}`;
+        const value = await browser.storage.local.get(lastStateKey);
         const lastState = value[lastStateKey];
-        if (!lastState) return;
 
-        Object.assign(this.columns, lastState.columns);
-        Object.assign(this.referenceData, lastState.referenceData);
-      });
-    }
+        if (lastState) {
+          Object.assign(this.columns, lastState.columns);
+          Object.assign(this.referenceData, lastState.referenceData);
+        }
+      }
 
-    this.blocks = blocks;
-    this.startedTimestamp = Date.now();
-    this.workflow.table = columns;
+      this.workflow.table = columns;
+      this.startedTimestamp = Date.now();
 
-    this.states.on('stop', this.onWorkflowStopped);
+      this.states.on('stop', this.onWorkflowStopped);
 
-    this.states
-      .add(this.id, {
+      await this.states.add(this.id, {
         id: this.id,
         state: this.state,
         workflowId: this.workflow.id,
         parentState: this.parentWorkflow,
-      })
-      .then(() => {
-        this.addWorker({ blockId: triggerBlock.id });
       });
+      this.addWorker({ blockId: triggerBlock.id });
+    } catch (error) {
+      console.error(error);
+    }
   }
 
   resume({ id, state }) {
@@ -330,7 +373,47 @@ class WorkflowEngine {
         clearCache(this.workflow);
       }
 
+      const { table, variables } = this.referenceData;
+      const tableId = this.workflow.connectedTable;
+
+      await dbStorage.transaction(
+        'rw',
+        dbStorage.tablesItems,
+        dbStorage.tablesData,
+        dbStorage.variables,
+        async () => {
+          if (tableId) {
+            await dbStorage.tablesItems.update(tableId, {
+              modifiedAt: Date.now(),
+              rowsCount: table.length,
+            });
+            await dbStorage.tablesData.where('tableId').equals(tableId).modify({
+              items: table,
+              columnsIndex: this.columns,
+            });
+          }
+
+          for (const key in variables) {
+            if (key.startsWith('$$')) {
+              const varName = key.slice(2);
+              const varValue = variables[key];
+
+              const variable =
+                (await dbStorage.variables
+                  .where('name')
+                  .equals(varName)
+                  .first()) || {};
+              variable.name = varName;
+              variable.value = varValue;
+
+              await dbStorage.variables.put(variable);
+            }
+          }
+        }
+      );
+
       this.isDestroyed = true;
+      this.referenceData = {};
       this.eventListeners = {};
     } catch (error) {
       console.error(error);
@@ -348,9 +431,9 @@ class WorkflowEngine {
     };
 
     this.workers.forEach((worker) => {
-      const { id, name, startedAt } = worker.currentBlock;
+      const { id, label, startedAt } = worker.currentBlock;
 
-      state.currentBlock.push({ id, name, startedAt });
+      state.currentBlock.push({ id, name: label, startedAt });
       state.tabIds.push(worker.activeTab.id);
     });
 

+ 14 - 7
src/background/workflowEngine/helper.js

@@ -29,8 +29,17 @@ export function attachDebugger(tabId, prevTab) {
     if (prevTab && tabId !== prevTab)
       chrome.debugger.detach({ tabId: prevTab });
 
-    chrome.debugger.attach({ tabId }, '1.3', () => {
-      chrome.debugger.sendCommand({ tabId }, 'Page.enable', resolve);
+    chrome.debugger.getTargets((targets) => {
+      targets.forEach((target) => {
+        if (target.attached || target.tabId !== tabId) {
+          resolve();
+          return;
+        }
+
+        chrome.debugger.attach({ tabId }, '1.3', () => {
+          chrome.debugger.sendCommand({ tabId }, 'Page.enable', resolve);
+        });
+      });
     });
   });
 }
@@ -58,7 +67,7 @@ export function waitTabLoaded({ tabId, listenError = false, ms = 10000 }) {
         reject(new Error('Timeout'));
       }, ms);
     }
-    if (listenError)
+    if (listenError && BROWSER_TYPE === 'chrome')
       browser.webNavigation.onErrorOccurred.addListener(onErrorOccurred);
 
     const activeTabStatus = () => {
@@ -111,8 +120,6 @@ export function convertData(data, type) {
   return result;
 }
 
-export function getBlockConnection(block, index = 1) {
-  const blockId = block.outputs[`output_${index}`];
-
-  return blockId;
+export function getBlockConnection(blockId, outputId = 1) {
+  return `${blockId}-output-${outputId}`;
 }

+ 26 - 30
src/background/workflowEngine/worker.js

@@ -3,7 +3,7 @@ import { toCamelCase, sleep, objectHasKey, isObject } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
 import referenceData from '@/utils/referenceData';
 import injectContentScript from './injectContentScript';
-import { convertData, waitTabLoaded, getBlockConnection } from './helper';
+import { convertData, waitTabLoaded } from './helper';
 
 class Worker {
   constructor(id, engine) {
@@ -79,10 +79,15 @@ class Worker {
     this.engine.referenceData.variables[name] = value;
   }
 
+  getBlockConnections(blockId, outputIndex = 1) {
+    const outputId = `${blockId}-output-${outputIndex}`;
+    return this.engine.connectionsMap[outputId] || null;
+  }
+
   executeNextBlocks(connections, prevBlockData) {
-    connections.forEach(({ node }, index) => {
+    connections.forEach((nodeId, index) => {
       if (index === 0) {
-        this.executeBlock(this.engine.blocks[node], prevBlockData);
+        this.executeBlock(this.engine.blocks[nodeId], prevBlockData);
       } else {
         const state = structuredClone({
           windowId: this.windowId,
@@ -96,7 +101,7 @@ class Worker {
         this.engine.addWorker({
           state,
           prevBlockData,
-          blockId: node,
+          blockId: nodeId,
         });
       }
     });
@@ -123,9 +128,9 @@ class Worker {
       });
     }
 
-    const blockHandler = this.engine.blocksHandler[toCamelCase(block.name)];
+    const blockHandler = this.engine.blocksHandler[toCamelCase(block.label)];
     const handler =
-      !blockHandler && tasks[block.name].category === 'interaction'
+      !blockHandler && tasks[block.label].category === 'interaction'
         ? this.engine.blocksHandler.interactionBlock
         : blockHandler;
 
@@ -139,20 +144,21 @@ class Worker {
       ...this.engine.referenceData,
       activeTabUrl: this.activeTab.url,
     };
+
     const replacedBlock = referenceData({
       block,
       data: refData,
       refKeys:
         isRetry || block.data.disableBlock
           ? null
-          : tasks[block.name].refDataKeys,
+          : tasks[block.label].refDataKeys,
     });
     const blockDelay = this.settings?.blockDelay || 0;
     const addBlockLog = (status, obj = {}) => {
       this.engine.addLogHistory({
         prevBlockData,
         type: status,
-        name: block.name,
+        name: block.label,
         blockId: block.id,
         workerId: this.id,
         description: block.data.description,
@@ -168,7 +174,7 @@ class Worker {
       if (block.data.disableBlock) {
         result = {
           data: '',
-          nextBlockId: getBlockConnection(block),
+          nextBlockId: this.getBlockConnections(block.id),
         };
       } else {
         result = await handler.call(this, replacedBlock, {
@@ -186,17 +192,9 @@ class Worker {
         });
       }
 
-      let nodeConnections = null;
-
-      if (typeof result.nextBlockId === 'string') {
-        nodeConnections = [{ node: result.nextBlockId }];
-      } else {
-        nodeConnections = result.nextBlockId.connections;
-      }
-
-      if (nodeConnections.length > 0 && !result.destroyWorker) {
+      if (result.nextBlockId && !result.destroyWorker) {
         setTimeout(() => {
-          this.executeNextBlocks(nodeConnections, result.data);
+          this.executeNextBlocks(result.nextBlockId, result.data);
         }, blockDelay);
       } else {
         this.engine.destroyWorker(this.id);
@@ -213,17 +211,17 @@ class Worker {
           return;
         }
 
-        const nextBlocks = getBlockConnection(
-          block,
-          blockOnError.toDo === 'continue' ? 1 : 2
+        const nextBlocks = this.getBlockConnections(
+          block.id,
+          blockOnError.toDo === 'continue' ? 1 : 'fallback'
         );
-        if (blockOnError.toDo !== 'error' && nextBlocks?.connections) {
+        if (blockOnError.toDo !== 'error' && nextBlocks) {
           addBlockLog('error', {
             message: error.message,
             ...(error.data || {}),
           });
 
-          this.executeNextBlocks(nextBlocks.connections, prevBlockData);
+          this.executeNextBlocks(nextBlocks, prevBlockData);
 
           return;
         }
@@ -235,7 +233,7 @@ class Worker {
       });
 
       const { onError } = this.settings;
-      const nodeConnections = error.nextBlockId?.connections;
+      const nodeConnections = this.getBlockConnections(block.id);
 
       if (onError === 'keep-running' && nodeConnections) {
         setTimeout(() => {
@@ -248,15 +246,13 @@ class Worker {
 
         if (restartCount >= maxRestart) {
           localStorage.removeItem(restartKey);
-          this.engine.destroy();
+          this.engine.destroy('error');
           return;
         }
 
         this.reset();
 
-        const triggerBlock = Object.values(this.engine.blocks).find(
-          ({ name }) => name === 'trigger'
-        );
+        const triggerBlock = this.engine.blocks[this.engine.triggerBlockId];
         this.executeBlock(triggerBlock);
 
         localStorage.setItem(restartKey, restartCount + 1);
@@ -296,7 +292,7 @@ class Worker {
       loopData: {},
       workflow: {},
       googleSheets: {},
-      variables: this.engine.options.variables,
+      variables: this.engine.options?.variables || {},
       globalData: this.engine.referenceData.globalData,
     };
   }

+ 4 - 7
src/components/block/BlockBase.vue

@@ -1,12 +1,9 @@
 <template>
-  <div class="block-base relative" @dblclick="$emit('edit')">
+  <div class="block-base relative w-48" @dblclick.stop="$emit('edit')">
     <slot name="prepend" />
-    <div
-      :class="contentClass"
-      class="z-10 bg-white dark:bg-gray-800 relative rounded-lg overflow-hidden w-full p-4 block-base__content"
-    >
+    <ui-card :class="contentClass" class="z-10 relative block-base__content">
       <slot></slot>
-    </div>
+    </ui-card>
     <slot name="append" />
     <div
       v-if="!minimap"
@@ -22,7 +19,7 @@
           v-if="!hideDelete && !hideEdit"
           class="border-r border-gray-600 h-5 mx-3"
         />
-        <button v-if="!hideDelete" @click="$emit('delete')">
+        <button v-if="!hideDelete" @click.stop="$emit('delete')">
           <v-remixicon size="20" name="riDeleteBin7Line" />
         </button>
         <slot name="action" />

+ 62 - 86
src/components/block/BlockBasic.vue

@@ -3,42 +3,51 @@
     :id="componentId"
     :hide-edit="block.details.disableEdit"
     :hide-delete="block.details.disableDelete"
-    :minimap="editor.minimap"
-    class="block-basic"
-    @edit="editBlock"
-    @delete="editor.removeNodeId(`node-${block.id}`)"
+    :data-position="JSON.stringify(position)"
+    class="block-basic group"
+    @edit="$emit('edit')"
+    @delete="$emit('delete', id)"
   >
+    <Handle
+      v-if="label !== 'trigger'"
+      :id="`${id}-input-1`"
+      type="target"
+      :position="Position.Left"
+    />
     <div class="flex items-center">
       <span
-        :class="
-          block.data.disableBlock ? 'bg-box-transparent' : block.category.color
-        "
+        :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
         class="inline-block p-2 mr-2 rounded-lg dark:text-black"
       >
         <v-remixicon :name="block.details.icon || 'riGlobalLine'" />
       </span>
-      <div style="max-width: 200px">
+      <div class="overflow-hidden flex-1">
         <p
           v-if="block.details.id"
-          class="font-semibold leading-none whitespace-nowrap"
+          class="font-semibold leading-tight text-overflow whitespace-nowrap"
         >
           {{ t(`workflow.blocks.${block.details.id}.name`) }}
         </p>
         <p class="text-gray-600 dark:text-gray-200 text-overflow leading-tight">
-          {{ block.data.description }}
+          {{ data.description }}
         </p>
-        <input
-          type="text"
-          class="hidden trigger"
-          disabled="true"
-          @change="handleDataChange"
-        />
       </div>
     </div>
+    <slot :block="block"></slot>
+    <template #prepend>
+      <div
+        v-if="block.details.id !== 'trigger'"
+        :title="t('workflow.blocks.base.moveToGroup')"
+        draggable="true"
+        class="bg-white dark:bg-gray-700 invisible group-hover:visible z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
+        @dragstart="handleStartDrag"
+        @mousedown.stop
+      >
+        <v-remixicon name="riDragDropLine" size="20" />
+      </div>
+    </template>
     <div
-      v-if="
-        block.data.onError?.enable && block.data.onError?.toDo === 'fallback'
-      "
+      v-if="data.onError?.enable && data.onError?.toDo === 'fallback'"
       class="fallback flex items-center justify-end"
     >
       <v-remixicon
@@ -51,96 +60,63 @@
         {{ t('common.fallback') }}
       </span>
     </div>
-    <slot :block="block"></slot>
-    <template #prepend>
-      <div
-        v-if="!editor.minimap && block.details.id !== 'trigger'"
-        :title="t('workflow.blocks.base.moveToGroup')"
-        draggable="true"
-        class="bg-white dark:bg-gray-700 invisible move-to-group z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
-        @dragstart="handleStartDrag"
-        @mousedown.stop
-      >
-        <v-remixicon name="riDragDropLine" size="20" />
-      </div>
-    </template>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+    <Handle
+      v-if="data.onError?.enable && data.onError?.toDo === 'fallback'"
+      :id="`${id}-output-fallback`"
+      type="source"
+      :position="Position.Right"
+      style="top: auto; bottom: 10px"
+    />
   </block-base>
 </template>
 <script setup>
-import { watch } from 'vue';
+import { Handle, Position } from '@braks/vue-flow';
 import { useI18n } from 'vue-i18n';
-import emitter from '@/lib/mitt';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useComponentId } from '@/composable/componentId';
 import BlockBase from './BlockBase.vue';
 
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  position: {
+    type: Object,
+    default: () => ({}),
+  },
+  events: {
+    type: Object,
+    default: () => ({}),
+  },
+  dimensions: {
     type: Object,
     default: () => ({}),
   },
 });
+defineEmits(['delete', 'edit', 'update']);
 
 const { t } = useI18n();
+const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-base');
-const block = useEditorBlock(`#${componentId}`, props.editor);
 
-function editBlock() {
-  emitter.emit('editor:edit-block', {
-    ...block.details,
-    data: block.data,
-    blockId: block.id,
-  });
-}
-function handleDataChange() {
-  const { data } = props.editor.getNodeFromId(block.id);
-
-  block.data = data;
-}
 function handleStartDrag(event) {
   const payload = {
-    data: block.data,
+    data: props.data,
     id: block.details.id,
-    blockId: block.id,
+    blockId: props.id,
     fromBlockBasic: true,
   };
 
   event.dataTransfer.setData('block', JSON.stringify(payload));
 }
-
-watch(
-  () => block.data.onError,
-  (onError) => {
-    if (!onError) return;
-
-    const blockDetail = props.editor.getNodeFromId(block.id);
-    const outputLen = Object.keys(blockDetail.outputs).length;
-
-    if (!onError.enable || onError.toDo !== 'fallback') {
-      block.containerEl.classList.toggle('block-basic-fallback', false);
-
-      if (outputLen > 1) props.editor.removeNodeOutput(block.id, 'output_2');
-
-      return;
-    }
-
-    block.containerEl.classList.toggle('block-basic-fallback', true);
-
-    if (outputLen < 2) {
-      props.editor.addNodeOutput(block.id);
-    }
-
-    props.editor.updateConnectionNodes(`node-${block.id}`);
-  },
-  { deep: true }
-);
 </script>
-<style>
-.drawflow-node.selected .move-to-group,
-.block-basic:hover .move-to-group {
-  visibility: visible;
-}
-.block-basic-fallback .output_2 {
-  top: 11px;
-}
-</style>

+ 81 - 22
src/components/block/BlockBasicWithFallback.vue

@@ -1,9 +1,37 @@
 <template>
-  <block-basic v-slot="{ block }" :editor="editor" class="block-with-fallback">
-    <div class="fallback flex items-center pb-2 justify-end">
+  <block-base
+    :id="componentId"
+    :hide-edit="block.details.disableEdit"
+    :hide-delete="block.details.disableDelete"
+    class="block-basic group"
+    @edit="$emit('edit')"
+    @delete="$emit('delete', id)"
+  >
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
+    <div class="flex items-center">
+      <span
+        :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
+        class="inline-block p-2 mr-2 rounded-lg dark:text-black"
+      >
+        <v-remixicon :name="block.details.icon || 'riGlobalLine'" />
+      </span>
+      <div class="overflow-hidden flex-1">
+        <p
+          v-if="block.details.id"
+          class="font-semibold leading-tight text-overflow whitespace-nowrap"
+        >
+          {{ t(`workflow.blocks.${block.details.id}.name`) }}
+        </p>
+        <p class="text-gray-600 dark:text-gray-200 text-overflow leading-tight">
+          {{ data.description }}
+        </p>
+      </div>
+    </div>
+    <slot :block="block"></slot>
+    <div class="fallback flex items-center justify-end">
       <v-remixicon
         v-if="block"
-        :title="t(`workflow.blocks.${block.details.id}.fallback`)"
+        :title="t('workflow.blocks.base.onError.fallbackTitle')"
         name="riInformationLine"
         size="18"
       />
@@ -11,31 +39,62 @@
         {{ t('common.fallback') }}
       </span>
     </div>
-  </block-basic>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+    <Handle
+      :id="`${id}-output-fallback`"
+      type="source"
+      :position="Position.Right"
+      style="top: auto; bottom: 10px"
+    />
+    <template #prepend>
+      <div
+        v-if="block.details.id !== 'trigger'"
+        :title="t('workflow.blocks.base.moveToGroup')"
+        draggable="true"
+        class="bg-white dark:bg-gray-700 invisible group-hover:visible z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
+        @dragstart="handleStartDrag"
+        @mousedown.stop
+      >
+        <v-remixicon name="riDragDropLine" size="20" />
+      </div>
+    </template>
+  </block-base>
 </template>
 <script setup>
+import { Handle, Position } from '@braks/vue-flow';
 import { useI18n } from 'vue-i18n';
-import BlockBasic from './BlockBasic.vue';
-
-const { t } = useI18n();
+import { useEditorBlock } from '@/composable/editorBlock';
+import { useComponentId } from '@/composable/componentId';
+import BlockBase from './BlockBase.vue';
 
-defineProps({
-  editor: {
+const props = defineProps({
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     default: () => ({}),
   },
 });
-</script>
-<style>
-.block-with-fallback .block-base__content {
-  padding-bottom: 0;
-}
-.drawflow-node.webhook .outputs,
-.drawflow-node.while-loop .outputs {
-  top: 64%;
-}
-.drawflow-node.webhook .outputs .output_1,
-.drawflow-node.while-loop .outputs .output_1 {
-  margin-bottom: 14px;
+defineEmits(['delete', 'edit', 'update']);
+
+const { t } = useI18n();
+const block = useEditorBlock(props.label);
+const componentId = useComponentId('block-base');
+
+function handleStartDrag(event) {
+  const payload = {
+    data: block.data,
+    id: block.details.id,
+    blockId: block.id,
+    fromBlockBasic: true,
+  };
+
+  event.dataTransfer.setData('block', JSON.stringify(payload));
 }
-</style>
+</script>

+ 50 - 107
src/components/block/BlockConditions.vue

@@ -1,37 +1,38 @@
 <template>
-  <div :id="componentId" class="p-4" @dblclick="editBlock">
+  <ui-card
+    :id="componentId"
+    class="w-64 relative"
+    @dblclick.stop="$emit('edit')"
+  >
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center">
       <div
-        :class="
-          block.data.disableBlock ? 'bg-box-transparent' : block.category.color
-        "
+        :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
         class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
       >
         <v-remixicon name="riAB" size="20" class="inline-block mr-1" />
         <span>{{ t('workflow.blocks.conditions.name') }}</span>
       </div>
       <div class="flex-grow"></div>
-      <template v-if="!editor.minimap">
-        <v-remixicon
-          name="riDeleteBin7Line"
-          class="cursor-pointer mr-2"
-          @click="editor.removeNodeId(`node-${block.id}`)"
-        />
-        <v-remixicon
-          name="riPencilLine"
-          class="inline-block cursor-pointer"
-          @click="editBlock"
-        />
-      </template>
+      <v-remixicon
+        name="riDeleteBin7Line"
+        class="cursor-pointer mr-2"
+        @click.stop="$emit('delete', id)"
+      />
+      <v-remixicon
+        name="riPencilLine"
+        class="inline-block cursor-pointer"
+        @click="$emit('edit')"
+      />
     </div>
     <ul
-      v-if="block.data.conditions && block.data.conditions.length !== 0"
+      v-if="data.conditions && data.conditions.length !== 0"
       class="mt-4 space-y-2"
     >
       <li
-        v-for="item in block.data.conditions"
+        v-for="item in data.conditions"
         :key="item.id"
-        class="flex items-center flex-1 p-2 bg-box-transparent rounded-lg overflow-hidden w-44"
+        class="flex items-center flex-1 p-2 bg-box-transparent rounded-lg w-full relative"
       >
         <p
           v-if="item.name"
@@ -51,118 +52,60 @@
             {{ item.value || '_____' }}
           </p>
         </template>
+        <Handle
+          :id="`${id}-output-${item.id}`"
+          :position="Position.Right"
+          style="margin-right: -33px"
+          type="source"
+        />
       </li>
       <p
-        v-if="block.data.conditions && block.data.conditions.length !== 0"
+        v-if="data.conditions && data.conditions.length !== 0"
         class="text-right text-gray-600 dark:text-gray-200"
       >
         <span title="Fallback"> &#9432; </span>
         Fallback
       </p>
     </ul>
-    <input class="trigger hidden" @change="onChange" />
-  </div>
+    <Handle
+      v-if="data.conditions.length > 0"
+      :id="`${id}-output-fallback`"
+      :position="Position.Right"
+      type="source"
+      style="top: auto; bottom: 10px"
+    />
+  </ui-card>
 </template>
 <script setup>
-import { watch, onBeforeUnmount } from 'vue';
 import { useI18n } from 'vue-i18n';
-import emitter from '@/lib/mitt';
-import { debounce } from '@/utils/helper';
+import { Handle, Position } from '@braks/vue-flow';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     default: () => ({}),
   },
 });
+defineEmits(['delete', 'edit']);
 
 const { t } = useI18n();
 const componentId = useComponentId('block-conditions');
-const block = useEditorBlock(`#${componentId}`, props.editor);
-
-function onChange({ detail }) {
-  block.data.disableBlock = detail.disableBlock;
-
-  if (detail.conditions) {
-    block.data.conditions = detail.conditions;
-  }
-}
-function editBlock() {
-  emitter.emit('editor:edit-block', {
-    ...block.details,
-    data: block.data,
-    blockId: block.id,
-  });
-}
-function addConditionEmit({ id }) {
-  if (id !== block.id) return;
-
-  const { length } = block.data.conditions;
-
-  if (length >= 20) return;
-  if (length === 0) props.editor.addNodeOutput(block.id);
-
-  props.editor.addNodeOutput(block.id);
-}
-function deleteConditionEmit({ index, id }) {
-  if (id !== block.id) return;
-
-  props.editor.removeNodeOutput(block.id, `output_${index + 1}`);
-
-  if (block.data.conditions.length === 0)
-    props.editor.removeNodeOutput(block.id, `output_1`);
-}
-function refreshConnections({ id }) {
-  if (id !== block.id) return;
-
-  const node = props.editor.getNodeFromId(block.id);
-  const outputs = Object.keys(node.outputs);
-  const conditionsLen = block.data.conditions.length + 1;
-
-  if (outputs.length > conditionsLen) {
-    const diff = outputs.length - conditionsLen;
-
-    for (let index = 0; index < diff; index += 1) {
-      const output = outputs[outputs.length - 2 - index];
-
-      props.editor.removeNodeOutput(block.id, output);
-    }
-  }
-}
-
-watch(
-  () => block.data.conditions,
-  debounce((newValue, oldValue) => {
-    props.editor.updateConnectionNodes(`node-${block.id}`);
-
-    if (!oldValue) return;
-
-    emitter.emit('editor:data-changed', block.id);
-  }, 250),
-  { deep: true }
-);
-
-emitter.on('conditions-block:add', addConditionEmit);
-emitter.on('conditions-block:delete', deleteConditionEmit);
-emitter.on('conditions-block:refresh', refreshConnections);
-
-onBeforeUnmount(() => {
-  emitter.off('conditions-block:add', addConditionEmit);
-  emitter.off('conditions-block:delete', deleteConditionEmit);
-  emitter.off('conditions-block:refresh', refreshConnections);
-});
+const block = useEditorBlock(props.label);
 </script>
 <style>
-.drawflow .drawflow-node.conditions .outputs {
+.condition-handle {
+  position: relative !important;
   top: 82px !important;
-  transform: none !important;
-}
-.drawflow .drawflow-node.conditions .output {
-  margin-bottom: 30px;
-}
-.drawflow .drawflow-node.conditions .output:nth-last-child(2) {
-  margin-bottom: 22px;
+  margin-bottom: 32px !important;
 }
 </style>

+ 24 - 18
src/components/block/BlockDelay.vue

@@ -1,5 +1,6 @@
 <template>
-  <div :id="componentId" class="p-4 block-basic">
+  <ui-card :id="componentId" class="p-4 w-48 block-basic">
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center mb-2">
       <div
         :class="block.category.color"
@@ -10,24 +11,23 @@
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
-        v-if="!editor.minimap"
         name="riDeleteBin7Line"
         class="cursor-pointer"
-        @click="editor.removeNodeId(`node-${block.id}`)"
+        @click.stop="$emit('delete', id)"
       />
     </div>
     <input
-      :value="block.data.time"
+      :value="data.time"
       min="0"
       :title="t('workflow.blocks.delay.input.title')"
       :placeholder="t('workflow.blocks.delay.input.placeholder')"
-      class="px-4 py-2 rounded-lg w-36 bg-input"
+      class="px-4 py-2 w-full rounded-lg bg-input"
       type="text"
       required
-      @input="handleInput"
+      @input="$emit('update', { time: $event.target.value })"
     />
     <div
-      v-if="!editor.minimap && block.details.id !== 'trigger'"
+      v-if="block.details.id !== 'trigger'"
       :title="t('workflow.blocks.base.moveToGroup')"
       draggable="true"
       class="bg-white dark:bg-gray-700 invisible move-to-group z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
@@ -36,34 +36,40 @@
     >
       <v-remixicon name="riDragDropLine" size="20" />
     </div>
-  </div>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+  </ui-card>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
-import emitter from '@/lib/mitt';
+import { Handle, Position } from '@braks/vue-flow';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     default: () => ({}),
   },
 });
+defineEmits(['update', 'delete']);
 
 const { t } = useI18n();
+const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-delay');
-const block = useEditorBlock(`#${componentId}`, props.editor);
 
-function handleInput({ target }) {
-  props.editor.updateNodeDataFromId(block.id, { time: target.value });
-  emitter.emit('editor:data-changed', block.id);
-}
 function handleStartDrag(event) {
   const payload = {
-    data: block.data,
-    id: block.details.id,
-    blockId: block.id,
+    id: props.label,
+    data: props.data,
+    blockId: props.id,
     fromBlockBasic: true,
   };
 

+ 25 - 21
src/components/block/BlockElementExists.vue

@@ -1,16 +1,14 @@
 <template>
   <block-base
     :id="componentId"
-    :minimap="editor.minimap"
     class="element-exists"
     style="width: 195px"
     @edit="editBlock"
-    @delete="editor.removeNodeId(`node-${block.id}`)"
+    @delete="$emit('delete', id)"
   >
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div
-      :class="
-        block.data.disableBlock ? 'bg-box-transparent' : block.category.color
-      "
+      :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
       class="inline-block text-sm mb-2 p-2 rounded-lg dark:text-black"
     >
       <v-remixicon name="riFocus3Line" size="20" class="inline-block mr-1" />
@@ -18,13 +16,13 @@
     </div>
     <p
       :title="t('workflow.blocks.element-exists.selector')"
-      :class="{ 'font-mono': !block.data.description }"
+      :class="{ 'font-mono': !data.description }"
       class="text-overflow p-2 rounded-lg bg-box-transparent text-sm text-right mb-2"
       style="max-width: 200px"
     >
       {{
-        block.data.description ||
-        block.data.selector ||
+        data.description ||
+        data.selector ||
         t('workflow.blocks.element-exists.selector')
       }}
     </p>
@@ -34,44 +32,50 @@
       </span>
       {{ t('common.fallback') }}
     </p>
-    <input
-      type="text"
-      class="hidden trigger"
-      disabled="true"
-      @change="handleDataChanged"
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+    <Handle
+      :id="`${id}-output-2`"
+      type="source"
+      :position="Position.Right"
+      style="top: auto; bottom: 12px"
     />
   </block-base>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
+import { Handle, Position } from '@braks/vue-flow';
 import emitter from '@/lib/mitt';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 import BlockBase from './BlockBase.vue';
 
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     default: () => ({}),
   },
 });
+defineEmits(['delete']);
 
 const { t } = useI18n();
+const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-delay');
-const block = useEditorBlock(`#${componentId}`, props.editor);
 
 function editBlock() {
   emitter.emit('editor:edit-block', {
     ...block.details,
-    data: block.data,
+    data: props.data,
     blockId: block.id,
   });
 }
-function handleDataChanged() {
-  const { data } = props.editor.getNodeFromId(block.id);
-
-  block.data = data;
-}
 </script>
 <style>
 .drawflow .element-exists .outputs {

+ 0 - 64
src/components/block/BlockExportData.vue

@@ -1,64 +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="riDownloadLine"
-          size="20"
-          class="inline-block mr-1"
-        />
-        <span>{{ t('workflow.blocks.export-data.name') }}</span>
-      </div>
-      <div class="flex-grow"></div>
-      <v-remixicon
-        name="riDeleteBin7Line"
-        class="cursor-pointer"
-        @click="editor.removeNodeId(`node-${block.id}`)"
-      />
-    </div>
-    <input
-      v-model="block.data.name"
-      :placeholder="t('common.fileName')"
-      class="bg-input rounded-lg transition w-40 mb-2 py-2 px-4 block"
-    />
-    <ui-select v-model="block.data.type" class="w-40" placeholder="Export as">
-      <option v-for="type in dataExportTypes" :key="type.id" :value="type.id">
-        {{ type.name }}
-      </option>
-    </ui-select>
-  </div>
-</template>
-<script setup>
-import { useI18n } from 'vue-i18n';
-import { watch } from 'vue';
-import emitter from '@/lib/mitt';
-import { dataExportTypes } from '@/utils/shared';
-import { debounce } from '@/utils/helper';
-import { useComponentId } from '@/composable/componentId';
-import { useEditorBlock } from '@/composable/editorBlock';
-
-const props = defineProps({
-  editor: {
-    type: Object,
-    default: () => ({}),
-  },
-});
-
-const { t } = useI18n();
-const componentId = useComponentId('block-delay');
-const block = useEditorBlock(`#${componentId}`, props.editor);
-
-watch(
-  () => block.data,
-  debounce((value, oldValue) => {
-    if (Object.keys(oldValue).length === 0) return;
-
-    props.editor.updateNodeDataFromId(block.id, value);
-    emitter.emit('editor:data-changed', block.id);
-  }, 250),
-  { deep: true }
-);
-</script>

+ 65 - 46
src/components/block/BlockGroup.vue

@@ -1,5 +1,6 @@
 <template>
-  <div :id="componentId" class="w-64">
+  <ui-card :id="componentId" class="w-64" padding="p-0">
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="p-4">
       <div class="flex items-center mb-2">
         <div
@@ -17,20 +18,21 @@
         <v-remixicon
           name="riDeleteBin7Line"
           class="cursor-pointer"
-          @click="editor.removeNodeId(`node-${block.id}`)"
+          @click.stop="emit('delete', id)"
         />
       </div>
       <input
-        v-model="block.data.name"
+        :model-value="data.name"
         :placeholder="t('workflow.blocks.blocks-group.groupName')"
         type="text"
         class="bg-transparent w-full focus:ring-0"
+        @input="$emit('update', { name: $event.target.value })"
       />
     </div>
     <draggable
-      v-model="block.data.blocks"
+      v-model="state.blocks"
       item-key="itemId"
-      class="px-4 mb-4 overflow-auto scroll text-sm space-y-1 max-h-60"
+      class="px-4 pb-4 overflow-auto nowheel scroll text-sm space-y-1 max-h-60"
       @mousedown.stop
       @dragover.prevent
       @drop="handleDrop"
@@ -58,7 +60,7 @@
               {{ element.data.description }}
             </p>
           </div>
-          <div v-if="!editor.minimap" class="invisible group-hover:visible">
+          <div class="invisible group-hover:visible">
             <v-remixicon
               name="riPencilLine"
               size="20"
@@ -82,31 +84,40 @@
         </div>
       </template>
     </draggable>
-    <input class="hidden trigger" @change="handleDataChange" />
-  </div>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+  </ui-card>
 </template>
 <script setup>
-import { watch } from 'vue';
+import { watch, reactive, onMounted, inject } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { nanoid } from 'nanoid';
 import { useToast } from 'vue-toastification';
+import { Handle, Position } from '@braks/vue-flow';
+import cloneDeep from 'lodash.clonedeep';
 import draggable from 'vuedraggable';
-import emitter from '@/lib/mitt';
 import { tasks } from '@/utils/shared';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 
 const props = defineProps({
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
   editor: {
     type: Object,
     default: () => ({}),
   },
 });
-
-const { t } = useI18n();
-const toast = useToast();
-const componentId = useComponentId('blocks-group');
-const block = useEditorBlock(`#${componentId}`, props.editor);
+const emit = defineEmits(['update', 'delete', 'edit']);
 
 const excludeBlocks = [
   'trigger',
@@ -115,9 +126,22 @@ const excludeBlocks = [
   'loop-breakpoint',
   'blocks-group',
   'conditions',
+  'webhook',
   'element-exists',
 ];
 
+const { t } = useI18n();
+const toast = useToast();
+const componentId = useComponentId('blocks-group');
+const block = useEditorBlock(props.label);
+
+const state = reactive({
+  blocks: [],
+  retrieved: false,
+});
+
+const workflow = inject('workflow', {});
+
 function onDragStart(item, event) {
   event.dataTransfer.setData(
     'block',
@@ -129,46 +153,32 @@ function onDragEnd(itemId) {
     const blockEl = document.querySelector(`[group-item-id="${itemId}"]`);
 
     if (blockEl) {
-      const blockIndex = block.data.blocks.findIndex(
+      const blockIndex = state.blocks.findIndex(
         (item) => item.itemId === itemId
       );
 
       if (blockIndex !== -1) {
-        emitter.emit('editor:delete-block', { itemId, isInGroup: true });
-        block.data.blocks.splice(blockIndex, 1);
+        state.blocks.splice(blockIndex, 1);
       }
     }
   }, 200);
 }
-function handleDataChange({ detail }) {
-  if (!detail) return;
-
-  const itemIndex = block.data.blocks.findIndex(
-    ({ itemId }) => itemId === detail.itemId
-  );
-
-  if (itemIndex === -1) return;
-
-  block.data.blocks[itemIndex].data = detail.data;
-}
 function editBlock(payload) {
-  emitter.emit('editor:edit-block', {
-    ...tasks[payload.id],
-    ...payload,
-    isInGroup: true,
-    blockId: block.id,
-  });
+  emit('edit', payload);
 }
 function deleteItem(index, itemId) {
-  emitter.emit('editor:delete-block', { itemId, isInGroup: true });
-  block.data.blocks.splice(index, 1);
+  if (workflow.editState.blockData.itemId === itemId) {
+    workflow.editState.editing = false;
+    workflow.editState.blockData = false;
+  }
+
+  state.blocks.splice(index, 1);
 }
 function handleDrop(event) {
   event.preventDefault();
   event.stopPropagation();
 
   const droppedBlock = JSON.parse(event.dataTransfer.getData('block') || null);
-
   if (!droppedBlock || droppedBlock.fromGroup) return;
 
   const { id, data, blockId } = droppedBlock;
@@ -184,20 +194,29 @@ function handleDrop(event) {
   }
 
   if (blockId) {
-    props.editor.removeNodeId(`node-${blockId}`);
+    emit('delete', blockId);
   }
 
-  block.data.blocks.push({ id, data, itemId: nanoid(5) });
+  state.blocks.push({ id, data, itemId: nanoid(5) });
 }
 
 watch(
-  () => block.data,
-  (value, oldValue) => {
-    if (Object.keys(oldValue).length === 0) return;
-
-    props.editor.updateNodeDataFromId(block.id, value);
-    emitter.emit('editor:data-changed', block.id);
+  () => state.blocks,
+  () => {
+    if (!state.retrieved) return;
+    emit('update', { blocks: state.blocks });
   },
   { deep: true }
 );
+
+onMounted(() => {
+  const copiedBlocks = cloneDeep(props.data.blocks);
+  state.blocks = Array.isArray(copiedBlocks)
+    ? copiedBlocks
+    : Object.values(copiedBlocks);
+
+  setTimeout(() => {
+    state.retrieved = true;
+  }, 500);
+});
 </script>

+ 20 - 9
src/components/block/BlockLoopBreakpoint.vue

@@ -1,47 +1,58 @@
 <template>
-  <div :id="componentId" class="p-4">
+  <ui-card :id="componentId" class="w-48">
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center mb-2">
       <div
         :class="block.category.color"
-        class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
+        class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black text-overflow"
       >
         <v-remixicon name="riStopLine" size="20" class="inline-block mr-1" />
         <span>{{ t('workflow.blocks.loop-breakpoint.name') }}</span>
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
-        v-if="!editor.minimap"
         name="riDeleteBin7Line"
         class="cursor-pointer"
-        @click="editor.removeNodeId(`node-${block.id}`)"
+        @click.stop="$emit('delete', id)"
       />
     </div>
     <input
-      :value="block.data.loopId"
-      class="px-4 py-2 rounded-lg w-48 bg-input"
+      :value="data.loopId"
+      class="px-4 py-2 rounded-lg w-full bg-input"
       placeholder="Loop ID"
       type="text"
       required
       @input="handleInput"
     />
-  </div>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+  </ui-card>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
 import emitter from '@/lib/mitt';
+import { Handle, Position } from '@braks/vue-flow';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     default: () => ({}),
   },
 });
+defineEmits(['delete']);
 
 const { t } = useI18n();
+const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-delay');
-const block = useEditorBlock(`#${componentId}`, props.editor);
 
 function handleInput({ target }) {
   const loopId = target.value.replace(/\s/g, '');

+ 25 - 10
src/components/block/BlockRepeatTask.vue

@@ -1,5 +1,6 @@
 <template>
-  <div :id="componentId" class="p-4 repeat-task">
+  <ui-card :id="componentId" class="p-4 repeat-task">
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center mb-2">
       <div
         :class="block.category.color"
@@ -10,17 +11,16 @@
       </div>
       <div class="flex-grow"></div>
       <v-remixicon
-        v-if="!editor.minimap"
         name="riDeleteBin7Line"
         class="cursor-pointer"
-        @click="editor.removeNodeId(`node-${block.id}`)"
+        @click="$emit('delete', id)"
       />
     </div>
     <label
       class="mb-2 block bg-input focus-within:bg-input pr-4 transition rounded-lg"
     >
       <input
-        :value="block.data.repeatFor || 0"
+        :value="data.repeatFor || 0"
         min="0"
         class="pl-4 py-2 bg-transparent rounded-l-lg w-24 mr-2"
         type="number"
@@ -34,24 +34,40 @@
     <p class="text-right text-gray-600 dark:text-gray-200">
       {{ t('workflow.blocks.repeat-task.repeatFrom') }}
     </p>
-  </div>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+    <Handle
+      :id="`${id}-output-2`"
+      type="source"
+      :position="Position.Right"
+      style="top: auto; bottom: 12px"
+    />
+  </ui-card>
 </template>
 <script setup>
 import { useI18n } from 'vue-i18n';
-import emitter from '@/lib/mitt';
+import { Handle, Position } from '@braks/vue-flow';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 
 const { t } = useI18n();
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     default: () => ({}),
   },
 });
+const emit = defineEmits(['delete', 'update']);
 
+const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-delay');
-const block = useEditorBlock(`#${componentId}`, props.editor);
 
 function handleInput({ target }) {
   target.reportValidity();
@@ -60,8 +76,7 @@ function handleInput({ target }) {
 
   if (repeatFor < 0) return;
 
-  props.editor.updateNodeDataFromId(block.id, { repeatFor });
-  emitter.emit('editor:data-changed', block.id);
+  emit('update', { repeatFor });
 }
 </script>
 <style>

+ 0 - 38
src/components/block/BlockWebhook.vue

@@ -1,38 +0,0 @@
-<template>
-  <block-basic :editor="editor" class="block-webhook">
-    <div class="fallback flex items-center pb-2 justify-end">
-      <v-remixicon
-        :title="t('workflow.blocks.webhook.fallback')"
-        name="riInformationLine"
-        size="18"
-      />
-      <span class="ml-1">
-        {{ t('common.fallback') }}
-      </span>
-    </div>
-  </block-basic>
-</template>
-<script setup>
-import { useI18n } from 'vue-i18n';
-import BlockBasic from './BlockBasic.vue';
-
-const { t } = useI18n();
-
-defineProps({
-  editor: {
-    type: Object,
-    default: () => ({}),
-  },
-});
-</script>
-<style>
-.block-webhook .block-base__content {
-  padding-bottom: 0;
-}
-.drawflow-node.webhook .outputs {
-  top: 64%;
-}
-.drawflow-node.webhook .outputs .output_1 {
-  margin-bottom: 14px;
-}
-</style>

+ 12 - 10
src/components/newtab/app/AppSidebar.vue

@@ -47,18 +47,18 @@
       </router-link>
     </div>
     <div class="flex-grow"></div>
-    <ui-popover v-if="store.state.user" trigger="mouseenter" placement="right">
+    <ui-popover v-if="userStore.user" trigger="mouseenter" placement="right">
       <template #trigger>
         <span class="inline-block p-1 bg-box-transparent rounded-full">
           <img
-            :src="store.state.user.avatar_url"
+            :src="userStore.user.avatar_url"
             height="32"
             width="32"
             class="rounded-full"
           />
         </span>
       </template>
-      {{ store.state.user.username }}
+      {{ userStore.user.username }}
     </ui-popover>
     <ui-popover trigger="mouseenter" placement="right" class="my-4">
       <template #trigger>
@@ -87,10 +87,11 @@
 </template>
 <script setup>
 import { ref, computed } from 'vue';
-import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
 import browser from 'webextension-polyfill';
+import { useUserStore } from '@/stores/user';
+import { useWorkflowStore } from '@/stores/workflow';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { communities } from '@/utils/shared';
@@ -98,8 +99,9 @@ import { communities } from '@/utils/shared';
 useGroupTooltip();
 
 const { t } = useI18n();
-const store = useStore();
 const router = useRouter();
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
 
 const extensionVersion = browser.runtime.getManifest().version;
 const tabs = [
@@ -116,10 +118,10 @@ const tabs = [
     shortcut: getShortcut('page:schedule', '/triggers'),
   },
   {
-    id: 'collection',
-    icon: 'riFolderLine',
-    path: '/collections',
-    shortcut: getShortcut('page:collections', '/collections'),
+    id: 'storage',
+    icon: 'riHardDrive2Line',
+    path: '/storage',
+    shortcut: getShortcut('page:storage', '/storage'),
   },
   {
     id: 'log',
@@ -136,7 +138,7 @@ const tabs = [
 ];
 const hoverIndicator = ref(null);
 const showHoverIndicator = ref(false);
-const runningWorkflowsLen = computed(() => store.state.workflowState.length);
+const runningWorkflowsLen = computed(() => workflowStore.states.length);
 
 useShortcut(
   tabs.map(({ shortcut }) => shortcut),

+ 100 - 0
src/components/newtab/app/AppSurvey.vue

@@ -0,0 +1,100 @@
+<template>
+  <ui-card
+    v-if="modalState.show"
+    class="fixed bottom-8 right-8 shadow-2xl border-2 w-72 group"
+  >
+    <button
+      class="absolute bg-white shadow-md rounded-full -right-2 -top-2 transition scale-0 group-hover:scale-100"
+      @click="closeModal"
+    >
+      <v-remixicon class="text-gray-600" name="riCloseLine" />
+    </button>
+    <h2 class="font-semibold text-lg">
+      {{ activeModal.title }}
+    </h2>
+    <p class="mt-1 dark:text-gray-100 text-gray-700">
+      {{ activeModal.body }}
+    </p>
+    <div class="space-y-2 mt-4">
+      <ui-button
+        :href="activeModal.url"
+        tag="a"
+        target="_blank"
+        rel="noopener"
+        class="w-full block"
+        variant="accent"
+      >
+        {{ activeModal.button }}
+      </ui-button>
+    </div>
+  </ui-card>
+</template>
+<script setup>
+import { shallowReactive, computed, onMounted } from 'vue';
+import browser from 'webextension-polyfill';
+import dayjs from '@/lib/dayjs';
+
+const modalTypes = {
+  testimonial: {
+    title: 'Hi There 👋',
+    body: 'Thank you for using Automa, and if you have a great experience. Would you like to give us a testimonial?',
+    button: 'Give Testimonial',
+    url: 'https://testimonial.to/automa',
+  },
+  survey: {
+    title: "How do you think we're doing?",
+    body: 'To help us make Automa as best it can be, we need a few minutes of your time to get your feedback.',
+    button: 'Take Survey',
+    url: 'https://www.automa.site/survey',
+  },
+};
+
+const modalState = shallowReactive({
+  show: true,
+  type: 'survey',
+});
+
+function closeModal() {
+  let value = true;
+
+  if (modalState.type === 'survey') {
+    value = new Date().toString();
+  }
+
+  modalState.show = false;
+  localStorage.setItem(`has-${modalState.type}`, value);
+}
+async function checkModal() {
+  try {
+    const { isFirstTime } = await browser.storage.local.get('isFirstTime');
+
+    if (isFirstTime) {
+      modalState.show = false;
+      localStorage.setItem('has-testimonial', true);
+      localStorage.setItem('has-survey', Date.now());
+      return;
+    }
+
+    const survey = localStorage.getItem('has-survey');
+
+    if (!survey) return;
+
+    const daysDiff = dayjs().diff(survey, 'day');
+    const showTestimonial =
+      daysDiff >= 2 && !localStorage.getItem('has-testimonial');
+
+    if (showTestimonial) {
+      modalState.show = true;
+      modalState.type = 'testimonial';
+    } else {
+      modalState.show = false;
+    }
+  } catch (error) {
+    console.error(error);
+  }
+}
+
+const activeModal = computed(() => modalTypes[modalState.type]);
+
+onMounted(checkModal);
+</script>

+ 25 - 21
src/components/newtab/settings/SettingsCloudBackup.vue

@@ -123,21 +123,22 @@
 <script setup>
 import { computed, reactive, onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { useStore } from 'vuex';
 import { useToast } from 'vue-toastification';
 import browser from 'webextension-polyfill';
 import { fetchApi, cacheApi } from '@/utils/api';
 import { convertWorkflow } from '@/utils/workflowData';
 import { parseJSON } from '@/utils/helper';
+import { useUserStore } from '@/stores/user';
+import { useWorkflowStore } from '@/stores/workflow';
 import dayjs from '@/lib/dayjs';
-import Workflow from '@/models/workflow';
 import SettingsBackupItems from './SettingsBackupItems.vue';
 
 defineEmits(['close']);
 
 const { t } = useI18n();
-const store = useStore();
 const toast = useToast();
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
 
 const state = reactive({
   query: '',
@@ -154,8 +155,8 @@ const backupState = reactive({
 });
 
 const workflows = computed(() =>
-  Workflow.query()
-    .where(({ name, id }) => {
+  workflowStore.getWorkflows
+    .filter(({ name, id }) => {
       const isInCloud = state.cloudWorkflows.some(
         (workflow) => workflow.id === id
       );
@@ -165,8 +166,7 @@ const workflows = computed(() =>
         !isInCloud
       );
     })
-    .orderBy('createdAt', 'desc')
-    .get()
+    .sort((a, b) => a.createdAt - b.createdAt)
 );
 const backupWorkflows = computed(() =>
   state.cloudWorkflows.filter(({ name }) =>
@@ -174,7 +174,7 @@ const backupWorkflows = computed(() =>
   )
 );
 const workflowLimit = computed(() => {
-  const maxWorkflow = store.state.user.limit.backupWorkflow;
+  const maxWorkflow = userStore.user.limit.backupWorkflow;
 
   return maxWorkflow - state.cloudWorkflows.length;
 });
@@ -277,23 +277,19 @@ async function backupWorkflowsToCloud(workflowId) {
 
     const workflowIds = workflowId ? [workflowId] : state.selectedWorkflows;
     const workflowsPayload = workflowIds.reduce((acc, id) => {
-      const findWorkflow = Workflow.find(id);
+      const findWorkflow = workflowStore.getById(id);
 
       if (!findWorkflow) return acc;
 
-      const workflow = convertWorkflow(findWorkflow, [
-        'dataColumns',
-        'id',
-        '__id',
-      ]);
+      const workflow = convertWorkflow(findWorkflow, ['dataColumns', 'id']);
+
       delete workflow.extVersion;
+
       workflow.drawflow =
         typeof workflow.drawflow === 'string'
-          ? parseJSON(workflow.drawflow, { drawflow: { Home: { data: {} } } })
+          ? parseJSON(workflow.drawflow, { drawflow: { nodes: [], edges: [] } })
           : workflow.drawflow;
 
-      if (!workflow.__id) workflow.__id = null;
-
       acc.push(workflow);
 
       return acc;
@@ -303,12 +299,13 @@ async function backupWorkflowsToCloud(workflowId) {
       method: 'POST',
       body: JSON.stringify({ workflows: workflowsPayload }),
     });
+    const result = await response.json();
 
     if (!response.ok) {
-      throw new Error(response.statusText);
+      throw new Error(result.message);
     }
 
-    const { lastBackup, data, ids } = await response.json();
+    const { lastBackup, data, ids } = result;
 
     backupState.uploading = false;
     backupState.workflowId = '';
@@ -319,7 +316,7 @@ async function backupWorkflowsToCloud(workflowId) {
       );
       if (isExists) return;
 
-      state.cloudWorkflows.push(Workflow.find(id));
+      state.cloudWorkflows.push(workflowStore.getById(id));
     });
 
     state.lastSync = lastBackup;
@@ -333,7 +330,14 @@ async function backupWorkflowsToCloud(workflowId) {
     userWorkflows.backup = state.cloudWorkflows;
     sessionStorage.setItem('user-workflows', JSON.stringify(userWorkflows));
 
-    await Workflow.insertOrUpdate({ data });
+    await Promise.allSettled(
+      data.map(async () => {
+        workflowStore.update({
+          data,
+          id: data.id,
+        });
+      })
+    );
     await browser.storage.local.set({
       lastBackup,
       backupIds: ids,

+ 63 - 0
src/components/newtab/shared/SharedPermissionsModal.vue

@@ -0,0 +1,63 @@
+<template>
+  <ui-modal :title="t('workflowPermissions.title')" persist>
+    <p class="font-semibold">
+      {{ t('workflowPermissions.description') }}
+    </p>
+    <ui-list class="mt-2 space-y-1">
+      <ui-list-item
+        v-for="permission in permissions"
+        :key="permission"
+        small
+        style="align-items: flex-start"
+      >
+        <v-remixicon :name="icons[permission]" class="mt-1" />
+        <div class="ml-4 flex-1 overflow-hidden">
+          <p class="leading-tight">
+            {{ t(`workflowPermissions.${permission}.title`) }}
+          </p>
+          <p class="text-gray-600 dark:text-gray-200 leading-tight">
+            {{ t(`workflowPermissions.${permission}.description`) }}
+          </p>
+        </div>
+      </ui-list-item>
+    </ui-list>
+    <div class="text-right mt-8">
+      <ui-button class="mr-2" @click="emit('update:modelValue', false)">
+        {{ t('common.cancel') }}
+      </ui-button>
+      <ui-button variant="accent" @click="requestPermission">
+        {{ t('workflow.blocks.clipboard.grantPermission') }}
+      </ui-button>
+    </div>
+  </ui-modal>
+</template>
+<script setup>
+import { toRaw } from 'vue';
+import { useI18n } from 'vue-i18n';
+import browser from 'webextension-polyfill';
+
+const props = defineProps({
+  permissions: {
+    type: Array,
+    default: () => [],
+  },
+});
+const emit = defineEmits(['update:modelValue']);
+
+const { t } = useI18n();
+
+const icons = {
+  downloads: 'riDownloadLine',
+  cliboards: 'riClipboardLine',
+  contextMenus: 'riFileListLine',
+  notifications: 'riNotification3Line',
+};
+
+function requestPermission() {
+  browser.permissions
+    .request({ permissions: toRaw(props.permissions) })
+    .then(() => {
+      emit('update:modelValue', false);
+    });
+}
+</script>

+ 174 - 0
src/components/newtab/storage/StorageEditTable.vue

@@ -0,0 +1,174 @@
+<template>
+  <ui-modal :model-value="modelValue" persist custom-content>
+    <ui-card
+      padding="p-0"
+      class="max-w-xl w-full flex flex-col"
+      style="height: 600px"
+    >
+      <p class="p-4 font-semibold">
+        {{ t('storage.table.add') }}
+      </p>
+      <div class="overflow-auto scroll px-4 pb-4 flex-1">
+        <ui-input
+          v-model="state.name"
+          class="w-full -mt-1"
+          label="Table name"
+          placeholder="My table"
+        />
+        <div class="flex items-center mt-4">
+          <p class="flex-1">Columns</p>
+          <ui-button icon :title="t('common.add')" @click="addColumn">
+            <v-remixicon name="riAddLine" />
+          </ui-button>
+        </div>
+        <p
+          v-if="state.columns && state.columns.length === 0"
+          class="text-center my-4 text-gray-600 dark:text-gray-300"
+        >
+          {{ t('message.noData') }}
+        </p>
+        <ul class="mt-4 space-y-2">
+          <li
+            v-for="(column, index) in state.columns"
+            :key="column.id"
+            class="flex items-center space-x-2"
+          >
+            <ui-input
+              :model-value="column.name"
+              :placeholder="t('workflow.table.column.name')"
+              class="flex-1"
+              @blur="updateColumnName(index, $event.target)"
+            />
+            <ui-select
+              v-model="column.type"
+              class="flex-1"
+              :placeholder="t('workflow.table.column.type')"
+            >
+              <option v-for="type in dataTypes" :key="type.id" :value="type.id">
+                {{ type.name }}
+              </option>
+            </ui-select>
+            <button @click="deleteColumn(index)">
+              <v-remixicon name="riDeleteBin7Line" />
+            </button>
+          </li>
+        </ul>
+      </div>
+      <div class="text-right p-4">
+        <ui-button class="mr-4" @click="clearTempTables(true)">
+          {{ t('common.cancel') }}
+        </ui-button>
+        <ui-button
+          :disabled="!state.name || state.columns.length === 0"
+          variant="accent"
+          @click="saveTable"
+        >
+          {{ t('common.save') }}
+        </ui-button>
+      </div>
+    </ui-card>
+  </ui-modal>
+</template>
+<script setup>
+import { reactive, toRaw, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
+import cloneDeep from 'lodash.clonedeep';
+import { dataTypes } from '@/utils/constants/table';
+
+const props = defineProps({
+  modelValue: {
+    type: Boolean,
+    default: false,
+  },
+  name: {
+    type: String,
+    default: '',
+  },
+  columns: {
+    type: Array,
+    default: () => [],
+  },
+});
+const emit = defineEmits(['update:modelValue', 'save']);
+
+const { t } = useI18n();
+
+let changes = {};
+const state = reactive({
+  name: '',
+  columns: [],
+});
+
+function getColumnName(name) {
+  const columnName = name.replace(/[\s@[\]]/g, '');
+  const isColumnExists = state.columns.some(
+    (column) => column.name === columnName
+  );
+
+  if (isColumnExists || columnName.trim() === '') return '';
+
+  return columnName;
+}
+function updateColumnName(index, target) {
+  const columnName = getColumnName(target.value);
+  const { id, name } = state.columns[index];
+  if (!columnName) {
+    target.value = name;
+    return;
+  }
+
+  changes[id] = { type: 'rename', id, oldValue: name, newValue: columnName };
+  state.columns[index].name = columnName;
+}
+function saveTable() {
+  const rawState = toRaw(state);
+
+  emit('save', { ...rawState, changes });
+}
+function addColumn() {
+  const columnId = nanoid(5);
+  const columnName = `column_${columnId}`;
+
+  changes[columnId] = {
+    type: 'add',
+    id: columnId,
+    name: columnName,
+  };
+
+  state.columns.push({
+    id: columnId,
+    type: 'string',
+    name: columnName,
+  });
+}
+function clearTempTables(close = false) {
+  state.name = '';
+  state.columns = [];
+  changes = {};
+
+  if (close) {
+    emit('update:modelValue', false);
+  }
+}
+function deleteColumn(index) {
+  const column = state.columns[index];
+  changes[column.id] = { type: 'delete', id: column.id, name: column.name };
+
+  state.columns.splice(index, 1);
+}
+
+watch(
+  () => props.modelValue,
+  () => {
+    if (props.modelValue) {
+      Object.assign(state, {
+        name: `${props.name}`,
+        columns: cloneDeep(props.columns),
+      });
+    } else {
+      clearTempTables();
+    }
+  }
+);
+</script>

+ 153 - 0
src/components/newtab/storage/StorageTables.vue

@@ -0,0 +1,153 @@
+<template>
+  <div class="flex mt-6">
+    <ui-input
+      v-model="state.query"
+      :placeholder="t('common.search')"
+      prepend-icon="riSearch2Line"
+    />
+    <div class="flex-grow"></div>
+    <ui-button variant="accent" @click="state.showAddTable = true">
+      {{ t('storage.table.add') }}
+    </ui-button>
+  </div>
+  <ui-table
+    item-key="id"
+    :headers="tableHeaders"
+    :items="items"
+    :search="state.query"
+    class="w-full mt-4"
+  >
+    <template #item-name="{ item }">
+      <router-link
+        :to="`/storage/tables/${item.id}`"
+        class="w-full block"
+        style="min-height: 29px"
+      >
+        {{ item.name }}
+      </router-link>
+    </template>
+    <template #item-createdAt="{ item }">
+      {{ formatDate(item.createdAt) }}
+    </template>
+    <template #item-modifiedAt="{ item }">
+      {{ formatDate(item.modifiedAt) }}
+    </template>
+    <template #item-actions="{ item }">
+      <v-remixicon
+        name="riDeleteBin7Line"
+        class="cursor-pointer"
+        @click="deleteTable(item)"
+      />
+    </template>
+  </ui-table>
+  <storage-edit-table v-model="state.showAddTable" @save="saveTable" />
+</template>
+<script setup>
+import { reactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
+import { useDialog } from '@/composable/dialog';
+import { useWorkflowStore } from '@/stores/workflow';
+import { useLiveQuery } from '@/composable/liveQuery';
+import dbStorage from '@/db/storage';
+import StorageEditTable from './StorageEditTable.vue';
+
+const { t } = useI18n();
+const dialog = useDialog();
+const workflowStore = useWorkflowStore();
+
+const state = reactive({
+  query: '',
+  showAddTable: false,
+});
+
+const tableHeaders = [
+  {
+    value: 'name',
+    filterable: true,
+    text: t('common.name'),
+    attrs: {
+      class: 'w-4/12',
+    },
+  },
+  {
+    align: 'center',
+    value: 'createdAt',
+    text: t('storage.table.createdAt'),
+  },
+  {
+    align: 'center',
+    value: 'modifiedAt',
+    text: t('storage.table.modifiedAt'),
+  },
+  {
+    value: 'rowsCount',
+    align: 'center',
+    text: t('storage.table.rowsCount'),
+  },
+  {
+    value: 'actions',
+    align: 'right',
+    text: '',
+    sortable: false,
+  },
+];
+const items = useLiveQuery(() => dbStorage.tablesItems.reverse().toArray());
+
+function formatDate(date) {
+  return dayjs(date).format('DD MMM YYYY, hh:mm:ss A');
+}
+async function saveTable({ columns, name }) {
+  try {
+    const columnsIndex = columns.reduce(
+      (acc, column) => {
+        acc[column.id] = {
+          index: 0,
+          type: column.type,
+          name: column.name,
+        };
+
+        return acc;
+      },
+      { column: { index: 0, type: 'any', name: 'column' } }
+    );
+
+    const tableId = await dbStorage.tablesItems.add({
+      rowsCount: 0,
+      name,
+      createdAt: Date.now(),
+      modifiedAt: Date.now(),
+      columns,
+    });
+    await dbStorage.tablesData.add({
+      tableId,
+      items: [],
+      columnsIndex,
+    });
+
+    state.showAddTable = false;
+  } catch (error) {
+    console.error(error);
+  }
+}
+function deleteTable(table) {
+  dialog.confirm({
+    title: t('storage.table.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name: table.name }),
+    onConfirm: async () => {
+      try {
+        await dbStorage.tablesItems.where('id').equals(table.id).delete();
+        await dbStorage.tablesData.where('tableId').equals(table.id).delete();
+
+        await workflowStore.update({
+          id: (workflow) => workflow.connectedTable === table.id,
+          data: { connectedTable: null },
+        });
+      } catch (error) {
+        console.error(error);
+      }
+    },
+  });
+}
+</script>

+ 168 - 0
src/components/newtab/storage/StorageVariables.vue

@@ -0,0 +1,168 @@
+<template>
+  <div class="flex mt-6">
+    <ui-input
+      v-model="state.query"
+      :placeholder="t('common.search')"
+      prepend-icon="riSearch2Line"
+    />
+    <div class="flex-grow"></div>
+    <ui-button variant="accent" @click="editState.show = true">
+      Add variable
+    </ui-button>
+  </div>
+  <ui-table
+    item-key="id"
+    :headers="tableHeaders"
+    :items="variables"
+    :search="state.query"
+    class="w-full mt-4"
+  >
+    <template #item-actions="{ item }">
+      <v-remixicon
+        name="riPencilLine"
+        class="cursor-pointer inline-block mr-4"
+        @click="editVariable(item)"
+      />
+      <v-remixicon
+        name="riDeleteBin7Line"
+        class="cursor-pointer inline-block"
+        @click="deleteVariable(item)"
+      />
+    </template>
+  </ui-table>
+  <ui-modal
+    v-model="editState.show"
+    :title="`${editState.type === 'edit' ? 'Edit' : 'Add'} variable`"
+  >
+    <ui-input v-model="editState.name" placeholder="Name" class="w-full" />
+    <ui-textarea
+      v-model="editState.value"
+      placeholder="value"
+      class="w-full mt-4"
+    />
+    <div class="text-right mt-8">
+      <ui-button class="mr-4" @click="editState.show = false">
+        {{ t('common.cancel') }}
+      </ui-button>
+      <ui-button
+        :disabled="!editState.name || editState.disabled"
+        variant="accent"
+        @click="saveVariable"
+      >
+        {{ t('common.save') }}
+      </ui-button>
+    </div>
+  </ui-modal>
+</template>
+<script setup>
+import { shallowReactive, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import { parseJSON } from '@/utils/helper';
+import { useLiveQuery } from '@/composable/liveQuery';
+import dbStorage from '@/db/storage';
+
+const { t } = useI18n();
+const toast = useToast();
+const variables = useLiveQuery(() => dbStorage.variables.toArray());
+
+const tableHeaders = [
+  {
+    value: 'name',
+    filterable: true,
+    text: t('common.name'),
+    attrs: {
+      class: 'w-3/12 text-overflow',
+    },
+  },
+  {
+    value: 'value',
+    filterable: false,
+    text: 'Value',
+    attrs: {
+      class: 'flex-1 line-clamp',
+    },
+  },
+  {
+    value: 'actions',
+    filterable: false,
+    sortable: false,
+    text: '',
+    attrs: {
+      class: 'w-24',
+    },
+  },
+];
+
+const state = shallowReactive({
+  id: '',
+  query: '',
+});
+const editState = shallowReactive({
+  type: '',
+  name: '',
+  value: '',
+  show: false,
+});
+
+function deleteVariable({ id }) {
+  dbStorage.variables.delete(id);
+}
+function editVariable({ id, name, value }) {
+  state.id = id;
+  editState.name = name;
+  editState.value = value;
+  editState.type = 'edit';
+  editState.show = true;
+}
+function saveVariable() {
+  if (!editState.name) return;
+
+  const trimmedName = editState.name.trim();
+  const duplicateName = variables.value.some(
+    ({ name, id }) => name.trim() === trimmedName && id !== state.id
+  );
+
+  if (duplicateName) {
+    toast.error(`You alread add "${trimmedName}" variable`);
+    return;
+  }
+
+  const varValue = parseJSON(editState.value, editState.value);
+
+  if (editState.type === 'edit') {
+    dbStorage.variables
+      .update(state.id, {
+        value: varValue,
+        name: trimmedName,
+      })
+      .then(() => {
+        editState.show = false;
+      });
+  } else {
+    dbStorage.variables
+      .add({
+        value: varValue,
+        name: trimmedName,
+      })
+      .then(() => {
+        editState.show = false;
+      });
+  }
+}
+
+watch(
+  () => editState.show,
+  (value) => {
+    if (value) return;
+
+    state.id = '';
+    Object.assign(editState, {
+      name: '',
+      type: '',
+      value: '',
+      show: false,
+    });
+  }
+);
+</script>

+ 0 - 225
src/components/newtab/workflow/WorkflowActions.vue

@@ -1,225 +0,0 @@
-<template>
-  <ui-card v-if="!workflow.isProtected" padding="p-1 flex items-center">
-    <ui-popover>
-      <template #trigger>
-        <button
-          v-tooltip.group="t('workflow.host.title')"
-          class="hoverable p-2 rounded-lg"
-        >
-          <v-remixicon
-            :class="{ 'text-primary': data.isHost }"
-            name="riBaseStationLine"
-          />
-        </button>
-      </template>
-      <div :class="{ 'text-center': data.loadingHost }" class="w-64">
-        <div class="flex items-center text-gray-600 dark:text-gray-200">
-          <p>
-            {{ t('workflow.host.set') }}
-          </p>
-          <a
-            :title="t('common.docs')"
-            href="https://docs.automa.site/guide/host-workflow.html"
-            target="_blank"
-            class="ml-1"
-          >
-            <v-remixicon name="riInformationLine" size="20" />
-          </a>
-          <div class="flex-grow"></div>
-          <template v-if="$store.state.user">
-            <ui-spinner v-if="data.loadingHost" color="text-accent" />
-            <ui-switch
-              v-else
-              :model-value="data.isHost"
-              @change="$emit('host', $event)"
-            />
-          </template>
-          <ui-switch v-else v-close-popover @click="$emit('host', 'auth')" />
-        </div>
-        <transition-expand>
-          <ui-input
-            v-if="data.isHost"
-            v-tooltip:bottom="t('workflow.host.id')"
-            :model-value="host.hostId"
-            prepend-icon="riLinkM"
-            readonly
-            class="mt-4 block w-full"
-            @click="$event.target.select()"
-          />
-        </transition-expand>
-      </div>
-    </ui-popover>
-    <button
-      v-tooltip.group="t('workflow.share.title')"
-      :class="{ 'text-primary': data.hasShared }"
-      class="hoverable p-2 rounded-lg"
-      @click="$emit('share')"
-    >
-      <v-remixicon name="riShareLine" />
-    </button>
-  </ui-card>
-  <ui-card padding="p-1 ml-4">
-    <button
-      v-for="item in modalActions"
-      :key="item.id"
-      v-tooltip.group="item.name"
-      class="hoverable p-2 rounded-lg"
-      @click="$emit('showModal', item.id)"
-    >
-      <v-remixicon :name="item.icon" />
-    </button>
-  </ui-card>
-  <ui-card padding="p-1 ml-4 flex items-center">
-    <button
-      v-if="!workflow.isDisabled"
-      v-tooltip.group="
-        `${t('common.execute')} (${
-          shortcuts['editor:execute-workflow'].readable
-        })`
-      "
-      class="hoverable p-2 rounded-lg"
-      @click="$emit('execute')"
-    >
-      <v-remixicon name="riPlayLine" />
-    </button>
-    <button
-      v-else
-      v-tooltip="t('workflow.clickToEnable')"
-      class="p-2"
-      @click="$emit('update', { isDisabled: false })"
-    >
-      {{ t('common.disabled') }}
-    </button>
-  </ui-card>
-  <ui-card padding="p-1 ml-4 space-x-1">
-    <ui-popover>
-      <template #trigger>
-        <button class="rounded-lg p-2 hoverable">
-          <v-remixicon name="riMore2Line" />
-        </button>
-      </template>
-      <ui-list class="w-36">
-        <ui-list-item
-          class="cursor-pointer"
-          @click="$emit('update', { isDisabled: !workflow.isDisabled })"
-        >
-          <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
-          {{ t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`) }}
-        </ui-list-item>
-        <ui-list-item
-          v-for="item in moreActions"
-          :key="item.id"
-          v-close-popover
-          class="cursor-pointer"
-          @click="$emit(item.id)"
-        >
-          <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
-          {{ item.name }}
-        </ui-list-item>
-      </ui-list>
-    </ui-popover>
-    <ui-button
-      :title="shortcuts['editor:save'].readable"
-      variant="accent"
-      class="relative"
-      @click="$emit('save')"
-    >
-      <span
-        v-if="isDataChanged"
-        class="flex h-3 w-3 absolute top-0 left-0 -ml-1 -mt-1"
-      >
-        <span
-          class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
-        ></span>
-        <span
-          class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
-        ></span>
-      </span>
-      <v-remixicon name="riSaveLine" class="mr-2 -ml-1 my-1" />
-      {{ t('common.save') }}
-    </ui-button>
-  </ui-card>
-</template>
-<script setup>
-import { useI18n } from 'vue-i18n';
-import { useGroupTooltip } from '@/composable/groupTooltip';
-import { useShortcut, getShortcut } from '@/composable/shortcut';
-
-defineProps({
-  isDataChanged: {
-    type: Boolean,
-    default: false,
-  },
-  workflow: {
-    type: Object,
-    default: () => ({}),
-  },
-  host: {
-    type: Object,
-    default: () => ({}),
-  },
-  data: {
-    type: Object,
-    default: () => ({}),
-  },
-});
-const emit = defineEmits([
-  'showModal',
-  'execute',
-  'rename',
-  'delete',
-  'save',
-  'export',
-  'update',
-  'share',
-  'host',
-]);
-
-useGroupTooltip();
-
-const { t } = useI18n();
-const shortcuts = useShortcut(
-  [
-    getShortcut('editor:save', 'save'),
-    getShortcut('editor:execute-workflow', 'execute'),
-  ],
-  ({ data }) => {
-    emit(data);
-  }
-);
-
-const modalActions = [
-  {
-    id: 'table',
-    name: t('workflow.table.title'),
-    icon: 'riTable2',
-  },
-  {
-    id: 'global-data',
-    name: t('common.globalData'),
-    icon: 'riDatabase2Line',
-  },
-  {
-    id: 'settings',
-    name: t('common.settings'),
-    icon: 'riSettings3Line',
-  },
-];
-const moreActions = [
-  {
-    id: 'export',
-    name: t('common.export'),
-    icon: 'riDownloadLine',
-  },
-  {
-    id: 'rename',
-    name: t('common.rename'),
-    icon: 'riPencilLine',
-  },
-  {
-    id: 'delete',
-    name: t('common.delete'),
-    icon: 'riDeleteBin7Line',
-  },
-];
-</script>

+ 0 - 980
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -1,980 +0,0 @@
-<template>
-  <div
-    v-bind="{ arrow: $store.state.settings.editor.arrow }"
-    id="drawflow"
-    class="parent-drawflow relative"
-    @drop="dropHandler"
-    @dragover.prevent="handleDragOver"
-  >
-    <div
-      class="flex items-end absolute w-full p-4 left-0 bottom-0 justify-between z-10"
-    >
-      <div id="zoom">
-        <button
-          v-tooltip.group="t('workflow.editor.resetZoom')"
-          class="p-2 rounded-lg bg-white dark:bg-gray-800 mr-2"
-          @click="editor.zoom_reset()"
-        >
-          <v-remixicon name="riFullscreenLine" />
-        </button>
-        <div class="rounded-lg bg-white dark:bg-gray-800 inline-block">
-          <button
-            v-tooltip.group="t('workflow.editor.zoomOut')"
-            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
-            v-tooltip.group="t('workflow.editor.zoomIn')"
-            class="p-2 rounded-lg"
-            @click="editor.zoom_in()"
-          >
-            <v-remixicon name="riAddLine" />
-          </button>
-        </div>
-        <workflow-builder-search-blocks :editor="editor" />
-      </div>
-      <slot v-bind="{ editor }"></slot>
-    </div>
-    <ui-popover
-      v-model="contextMenu.show"
-      :options="contextMenu.position"
-      padding="p-3"
-      @close="clearContextMenu"
-    >
-      <ui-list class="space-y-1 w-52">
-        <ui-list-item
-          v-for="item in contextMenu.items"
-          :key="item.id"
-          v-close-popover
-          class="cursor-pointer justify-between"
-          @click="contextMenuHandler[item.event]"
-        >
-          <span>
-            {{ item.name }}
-          </span>
-          <span
-            v-if="item.shortcut"
-            class="text-sm capitalize text-gray-600 dark:text-gray-200"
-          >
-            {{ item.shortcut }}
-          </span>
-        </ui-list-item>
-      </ui-list>
-    </ui-popover>
-  </div>
-</template>
-<script>
-/* eslint-disable camelcase */
-import {
-  onMounted,
-  shallowRef,
-  reactive,
-  getCurrentInstance,
-  watch,
-  onBeforeUnmount,
-} from 'vue';
-import { useStore } from 'vuex';
-import { useRoute } from 'vue-router';
-import { useI18n } from 'vue-i18n';
-import { compare } from 'compare-versions';
-import defu from 'defu';
-import SelectionArea from '@viselect/vanilla';
-import browser from 'webextension-polyfill';
-import emitter from '@/lib/mitt';
-import {
-  useShortcut,
-  getShortcut,
-  getReadableShortcut,
-} from '@/composable/shortcut';
-import { tasks, excludeOnError } from '@/utils/shared';
-import { parseJSON } from '@/utils/helper';
-import { useGroupTooltip } from '@/composable/groupTooltip';
-import drawflow from '@/lib/drawflow';
-import WorkflowBuilderSearchBlocks from './WorkflowBuilderSearchBlocks.vue';
-
-export default {
-  components: { WorkflowBuilderSearchBlocks },
-  props: {
-    data: {
-      type: [Object, String],
-      default: null,
-    },
-    isShared: {
-      type: Boolean,
-      default: false,
-    },
-    version: {
-      type: [String, Boolean],
-      default: '',
-    },
-    mode: {
-      type: String,
-      default: 'edit',
-    },
-  },
-  emits: ['load', 'loaded', 'deleteBlock', 'update', 'save'],
-  setup(props, { emit }) {
-    useGroupTooltip();
-
-    const { t } = useI18n();
-    const route = useRoute();
-    const store = useStore();
-
-    const contextMenuItems = {
-      common: [
-        {
-          id: 'paste',
-          name: t('workflow.editor.paste'),
-          icon: 'riFileCopyLine',
-          event: 'pasteBlocks',
-          shortcut: getReadableShortcut('mod+v'),
-        },
-      ],
-      block: [
-        {
-          id: 'copy',
-          name: t('workflow.editor.copy'),
-          icon: 'riFileCopyLine',
-          event: 'copyBlocks',
-          shortcut: getReadableShortcut('mod+c'),
-        },
-        {
-          id: 'duplicate',
-          name: t('workflow.editor.duplicate'),
-          icon: 'riFileCopyLine',
-          event: 'duplicateBlock',
-          shortcut: getShortcut('editor:duplicate-block').readable,
-        },
-        {
-          id: 'delete',
-          name: t('common.delete'),
-          icon: 'riDeleteBin7Line',
-          event: 'deleteBlock',
-          shortcut: 'Del',
-        },
-      ],
-    };
-
-    let activeNode = null;
-    let hasDragged = false;
-    let isDragging = false;
-    let selectedElements = [];
-
-    const selection = shallowRef(null);
-    const editor = shallowRef(null);
-    const contextMenu = reactive({
-      items: [],
-      data: null,
-      show: false,
-      position: {},
-    });
-
-    const workflowId = route.params.id;
-
-    const prevSelectedEl = {
-      output: null,
-      connection: null,
-      nodeContent: null,
-    };
-    const isOutputEl = (el) => el.classList.contains('output');
-    const isConnectionEl = (el) =>
-      el.matches('path.main-path') ||
-      el.parentElement.classList.contains('connection');
-
-    function toggleHoverClass({ target, name, active, classes }) {
-      const prev = prevSelectedEl[name];
-
-      if (active) {
-        if (prev === target) return;
-
-        target.classList.toggle(classes, true);
-      } else if (prev) {
-        prev.classList.toggle(classes, false);
-      }
-
-      prevSelectedEl[name] = target;
-    }
-    function handleDragOver({ target }) {
-      toggleHoverClass({
-        target,
-        name: 'connection',
-        classes: 'selected',
-        active: isConnectionEl(target),
-      });
-      toggleHoverClass({
-        target,
-        name: 'output',
-        classes: 'ring-4',
-        active: isOutputEl(target),
-      });
-
-      const nodeContent = target.closest(
-        '.drawflow-node:not(.blocks-group) .drawflow_content_node'
-      );
-      toggleHoverClass({
-        classes: 'ring-4',
-        target: nodeContent,
-        name: 'nodeContent',
-        active: nodeContent,
-      });
-    }
-    function getRelativePosToEditor(clientX, clientY) {
-      const { x, y } = editor.value.precanvas.getBoundingClientRect();
-      const { clientWidth, clientHeight } = editor.value.precanvas;
-      const { zoom } = editor.value;
-
-      const xPosition =
-        clientX * (clientWidth / (clientWidth * zoom)) -
-        x * (clientWidth / (clientWidth * zoom));
-      const yPosition =
-        clientY * (clientHeight / (clientHeight * zoom)) -
-        y * (clientHeight / (clientHeight * zoom));
-
-      return { xPosition, yPosition };
-    }
-    function dropHandler({ dataTransfer, clientX, clientY, target }) {
-      const block = JSON.parse(dataTransfer.getData('block') || null);
-
-      if (!block) return;
-
-      const highlightedEls = document.querySelectorAll(
-        '.drawflow_content_node.ring-4'
-      );
-      highlightedEls.forEach((el) => {
-        el.classList.remove('ring-4');
-      });
-
-      const isTriggerExists =
-        block.id === 'trigger' &&
-        editor.value.getNodesFromName('trigger').length !== 0;
-      if (isTriggerExists) return;
-
-      if (target.closest('.drawflow_content_node')) {
-        const targetNodeId = target
-          .closest('.drawflow-node')
-          .id.replace(/node-/, '');
-        const targetNode = editor.value.getNodeFromId(targetNodeId);
-        editor.value.removeNodeId(`node-${targetNodeId}`);
-
-        if (targetNode.name === 'blocks-group') return;
-
-        let targetBlock = block;
-        if (block.fromBlockBasic) {
-          targetBlock = { ...tasks[block.id], id: block.id };
-        }
-
-        const onErrorEnabled =
-          targetNode.data?.onError?.enable &&
-          !excludeOnError.includes(targetBlock.id);
-        const newNodeData = onErrorEnabled
-          ? { ...targetBlock.data, onError: targetNode.data.onError }
-          : targetBlock.data;
-
-        const newNodeId = editor.value.addNode(
-          targetBlock.id,
-          targetBlock.inputs,
-          targetBlock.outputs,
-          targetNode.pos_x,
-          targetNode.pos_y,
-          targetBlock.id,
-          newNodeData,
-          targetBlock.component,
-          'vue'
-        );
-
-        if (onErrorEnabled && targetNode.data.onError.toDo === 'fallback') {
-          editor.value.addNodeOutput(newNodeId);
-        }
-
-        const duplicateConnections = (nodeIO, type) => {
-          if (block[type] === 0) return;
-
-          Object.keys(nodeIO).forEach((name) => {
-            const { connections } = nodeIO[name];
-
-            connections.forEach(({ node, input, output }) => {
-              if (node === targetNodeId) return;
-
-              if (type === 'inputs') {
-                editor.value.addConnection(node, newNodeId, input, name);
-              } else if (type === 'outputs') {
-                editor.value.addConnection(newNodeId, node, name, output);
-              }
-            });
-          });
-        };
-
-        duplicateConnections(targetNode.inputs, 'inputs');
-        duplicateConnections(targetNode.outputs, 'outputs');
-
-        emitter.emit('editor:data-changed');
-
-        return;
-      }
-
-      if (block.fromBlockBasic) return;
-
-      const { xPosition, yPosition } = getRelativePosToEditor(clientX, clientY);
-
-      const blockId = editor.value.addNode(
-        block.id,
-        block.inputs,
-        block.outputs,
-        xPosition + 25,
-        yPosition - 25,
-        block.id,
-        block.data,
-        block.component,
-        'vue'
-      );
-
-      if (block.fromGroup) {
-        const blockEl = document.getElementById(`node-${blockId}`);
-
-        blockEl.setAttribute('group-item-id', block.itemId);
-      }
-
-      if (isConnectionEl(target)) {
-        target.classList.remove('selected');
-
-        const classes = target.parentElement.classList.toString();
-        const result = {};
-        const items = [
-          { str: 'node_in_', key: 'inputId' },
-          { str: 'input_', key: 'inputClass' },
-          { str: 'node_out_', key: 'outputId' },
-          { str: 'output_', key: 'outputClass' },
-        ];
-
-        items.forEach(({ key, str }) => {
-          result[key] = classes
-            .match(new RegExp(`${str}[^\\s]*`))[0]
-            ?.replace(/node_in_node-|node_out_node-/, '');
-        });
-
-        try {
-          editor.value.removeSingleConnection(
-            result.outputId,
-            result.inputId,
-            result.outputClass,
-            result.inputClass
-          );
-          editor.value.addConnection(
-            result.outputId,
-            blockId,
-            result.outputClass,
-            'input_1'
-          );
-          editor.value.addConnection(
-            blockId,
-            result.inputId,
-            'output_1',
-            result.inputClass
-          );
-        } catch (error) {
-          console.error(error);
-        }
-      } else if (isOutputEl(target)) {
-        prevSelectedEl.output?.classList.remove('ring-4');
-
-        const targetNodeId = target
-          .closest('.drawflow-node')
-          .id.replace(/node-/, '');
-        const outputClass = target.classList[1];
-
-        editor.value.addConnection(
-          targetNodeId,
-          blockId,
-          outputClass,
-          'input_1'
-        );
-      }
-
-      emitter.emit('editor:data-changed');
-    }
-    function isInputAllowed(allowedInputs, input) {
-      if (typeof allowedInputs === 'boolean') return allowedInputs;
-
-      return allowedInputs.some((item) => {
-        if (item.startsWith('#')) {
-          return tasks[input].category === item.substr(1);
-        }
-
-        return item === input;
-      });
-    }
-    function deleteBlock() {
-      editor.value.removeNodeId(contextMenu.data);
-    }
-    function clearSelectedElements() {
-      selection.value.clearSelection();
-      selectedElements.forEach(({ el }) => {
-        if (!el) return;
-
-        el.classList.remove('selected-list');
-      });
-      selectedElements = [];
-      activeNode = null;
-    }
-    function duplicateBlock(nodeId, isPaste = false) {
-      let initialPos = null;
-      const nodes = new Map();
-
-      const addNode = (id) => {
-        const node = editor.value.getNodeFromId(id);
-
-        if (node.name === 'trigger') return;
-
-        nodes.set(node.id, node);
-      };
-
-      if (isPaste) {
-        store.state.copiedNodes.forEach((node) => {
-          nodes.set(node.id, node);
-        });
-
-        const pos = contextMenu?.position?.getReferenceClientRect?.() ?? null;
-        if (pos) {
-          const { xPosition, yPosition } = getRelativePosToEditor(
-            pos.left,
-            pos.top
-          );
-          initialPos = { x: xPosition, y: yPosition };
-        }
-      } else {
-        if (nodeId) addNode(nodeId);
-        else if (activeNode) addNode(activeNode.id);
-
-        selectedElements.forEach((node) => {
-          if (activeNode?.id === node.id || nodeId === node.id) return;
-
-          addNode(node.id);
-        });
-      }
-
-      clearSelectedElements();
-
-      const nodesOutputs = [];
-      let firstNodePos = null;
-      let index = 0;
-
-      nodes.forEach((node) => {
-        const { outputs, inputs } = tasks[node.name];
-
-        const inputsLen = Object.keys(node.inputs).length;
-        const outputsLen = Object.keys(node.outputs).length;
-
-        const blockInputs = inputsLen || inputs;
-        const blockOutputs = outputsLen || outputs;
-
-        let nodePosX = node.pos_x;
-        let nodePosY = node.pos_y;
-
-        if (initialPos && index === 0) {
-          firstNodePos = { x: nodePosX, y: nodePosY };
-
-          nodePosX = initialPos.x;
-          nodePosY = initialPos.y;
-        } else if (firstNodePos) {
-          const xDistance = nodePosX - firstNodePos.x;
-          const yDistance = nodePosY - firstNodePos.y;
-
-          nodePosX = initialPos.x + xDistance;
-          nodePosY = initialPos.y + yDistance;
-        }
-
-        const newNodeId = editor.value.addNode(
-          node.name,
-          blockInputs,
-          blockOutputs,
-          nodePosX + 25,
-          nodePosY + 70,
-          node.name,
-          node.data,
-          node.html,
-          'vue'
-        );
-
-        nodes.set(node.id, { ...nodes.get(node.id), newId: newNodeId });
-
-        const nodeElement = document.querySelector(`#node-${newNodeId}`);
-        nodeElement.classList.add('selected-list');
-        selectedElements.push({
-          id: newNodeId,
-          el: nodeElement,
-          posY: parseInt(nodeElement.style.top, 10),
-          posX: parseInt(nodeElement.style.left, 10),
-        });
-
-        emitter.emit('editor:data-changed');
-
-        if (outputsLen > 0) {
-          nodesOutputs.push({ id: newNodeId, outputs: node.outputs });
-        }
-
-        index += 1;
-      });
-
-      if (nodesOutputs.length < 1) return;
-
-      nodesOutputs.forEach(({ id, outputs }) => {
-        Object.keys(outputs).forEach((key) => {
-          outputs[key].connections.forEach((connection) => {
-            const node = nodes.get(connection.node);
-
-            if (!node) return;
-
-            editor.value.addConnection(id, node.newId, key, 'input_1');
-          });
-        });
-      });
-    }
-    function checkWorkflowData() {
-      if (!editor.value) return;
-
-      editor.value.editor_mode = props.isShared ? 'fixed' : 'edit';
-      editor.value.container.classList.toggle('is-shared', props.isShared);
-    }
-    function saveEditorState() {
-      const editorStates =
-        parseJSON(localStorage.getItem('editor-states'), {}) || {};
-      editorStates[workflowId] = {
-        zoom: editor.value.zoom,
-        canvas_x: editor.value.canvas_x,
-        canvas_y: editor.value.canvas_y,
-      };
-
-      localStorage.setItem('editor-states', JSON.stringify(editorStates));
-    }
-    function initSelectArea() {
-      selection.value = new SelectionArea({
-        container: '#drawflow',
-        startareas: ['#drawflow'],
-        boundaries: ['#drawflow'],
-        selectables: ['.drawflow-node'],
-        features: {
-          singleTap: {
-            allow: false,
-          },
-        },
-      });
-
-      selection.value.on('beforestart', ({ event }) => {
-        if (!event.ctrlKey && !event.metaKey) return false;
-
-        editor.value.editor_mode = 'fixed';
-        editor.value.editor_selected = false;
-
-        return true;
-      });
-      selection.value.on('move', () => {
-        hasDragged = true;
-      });
-      selection.value.on('stop', (event) => {
-        event.store.selected.forEach((el) => {
-          const isExists = selectedElements.some((item) =>
-            item.el.isEqualNode(el)
-          );
-
-          if (isExists) return;
-
-          el.classList.toggle('selected-list', true);
-
-          selectedElements.push({
-            el,
-            id: el.id.slice(5),
-            posY: parseInt(el.style.top, 10),
-            posX: parseInt(el.style.left, 10),
-          });
-        });
-
-        setTimeout(() => {
-          hasDragged = false;
-        }, 500);
-      });
-    }
-    function onMouseup({ target }) {
-      editor.value.editor_mode = 'edit';
-
-      const isNodeEl = target.closest('.drawflow-node');
-      if (!isNodeEl) return;
-
-      const getPosition = (el) => {
-        return {
-          posY: parseInt(el.style.top, 10),
-          posX: parseInt(el.style.left, 10),
-        };
-      };
-
-      selectedElements.forEach(({ el }, index) => {
-        Object.assign(selectedElements[index], getPosition(el));
-      });
-
-      if (activeNode) Object.assign(activeNode, getPosition(activeNode.el));
-
-      isDragging = false;
-    }
-    function onMousedown({ target }) {
-      const nodeEl = target.closest('.drawflow-node');
-      if (!nodeEl) return;
-
-      if (nodeEl.classList.contains('selected-list')) {
-        activeNode = {
-          el: nodeEl,
-          id: nodeEl.id.slice(5),
-          posY: parseInt(nodeEl.style.top, 10),
-          posX: parseInt(nodeEl.style.left, 10),
-        };
-      }
-
-      isDragging = true;
-    }
-    function onClick({ ctrlKey, metaKey, target }) {
-      const nodeEl = target.closest('.drawflow-node');
-      if (!nodeEl) {
-        if (!hasDragged) clearSelectedElements();
-        return;
-      }
-
-      const nodeProperties = {
-        el: nodeEl,
-        id: nodeEl.id.slice(5),
-        posY: parseInt(nodeEl.style.top, 10),
-        posX: parseInt(nodeEl.style.left, 10),
-      };
-
-      if (!ctrlKey && !metaKey && !hasDragged) {
-        clearSelectedElements();
-
-        activeNode = nodeProperties;
-        nodeEl.classList.add('selected-list');
-        selectedElements = [nodeProperties];
-        hasDragged = false;
-
-        return;
-      }
-      hasDragged = false;
-
-      if (!ctrlKey && !metaKey) return;
-
-      const nodeIndex = selectedElements.findIndex(({ el }) =>
-        nodeEl.isEqualNode(el)
-      );
-      if (nodeIndex !== -1) {
-        setTimeout(() => {
-          nodeEl.classList.remove('selected-list', 'selected');
-        }, 400);
-        selectedElements.splice(nodeIndex, 1);
-      } else {
-        nodeEl.classList.add('selected-list');
-        selectedElements.push(nodeProperties);
-      }
-    }
-    function clearContextMenu() {
-      Object.assign(contextMenu, {
-        items: [],
-        data: null,
-        show: false,
-        position: {},
-      });
-    }
-    function copyBlocks() {
-      let nodes = selectedElements;
-
-      if (nodes.length === 0) {
-        const selectedEl = document.querySelector('.drawflow-node.selected');
-
-        if (selectedEl) {
-          nodes.push({ id: selectedEl.id.substr(5) });
-        }
-      }
-
-      nodes = nodes.map((node) => editor.value.getNodeFromId(node.id));
-
-      store.commit('updateState', {
-        key: 'copiedNodes',
-        value: nodes,
-      });
-    }
-    function onKeyup({ key, target, ctrlKey, metaKey }) {
-      if (ctrlKey || metaKey) {
-        if (key === 'c') {
-          copyBlocks();
-        } else if (key === 'v') {
-          duplicateBlock(null, true);
-        }
-      }
-
-      const isAnInput =
-        ['INPUT', 'TEXTAREA'].includes(target.tagName) ||
-        target.isContentEditable;
-
-      if (key !== 'Delete' || isAnInput) return;
-
-      selectedElements.forEach(({ id }) => {
-        const nodeId = `node-${id}`;
-        const isNodeExists = document.querySelector(`#${nodeId}`);
-
-        if (!isNodeExists) return;
-
-        editor.value.removeNodeId(nodeId);
-      });
-
-      selectedElements = [];
-      activeNode = null;
-    }
-
-    useShortcut('editor:duplicate-block', () => {
-      if (!activeNode && selectedElements.length <= 0) return;
-
-      duplicateBlock();
-    });
-
-    watch(() => props.isShared, checkWorkflowData);
-
-    onMounted(() => {
-      const context = getCurrentInstance().appContext.app._context;
-      const element = document.querySelector('#drawflow');
-
-      element.addEventListener('mousedown', onMousedown);
-      element.addEventListener('mouseup', onMouseup);
-      element.addEventListener('click', onClick);
-      element.addEventListener('keyup', onKeyup);
-
-      editor.value = drawflow(element, {
-        context,
-        options: {
-          reroute: true,
-          ...store.state.settings.editor,
-        },
-      });
-      editor.value.start();
-
-      emit('load', editor.value);
-
-      if (props.data) {
-        let data =
-          typeof props.data === 'string'
-            ? parseJSON(props.data, null)
-            : props.data;
-
-        if (!data || !data?.drawflow?.Home) return;
-
-        const currentExtVersion = browser.runtime.getManifest().version;
-        const isOldWorkflow = compare(
-          currentExtVersion,
-          props.version || '0.0.0',
-          '>'
-        );
-
-        if (isOldWorkflow && typeof props.version !== 'boolean') {
-          const newDrawflowData = Object.entries(
-            data.drawflow.Home.data
-          ).reduce((obj, [key, value]) => {
-            obj[key] = {
-              ...value,
-              html: tasks[value.name].component,
-              data: defu({}, value.data, tasks[value.name].data),
-            };
-
-            return obj;
-          }, {});
-
-          data = {
-            drawflow: { Home: { data: newDrawflowData } },
-          };
-
-          emit('update', { version: currentExtVersion });
-        }
-
-        editor.value.import(data);
-
-        if (isOldWorkflow) {
-          setTimeout(() => {
-            emit('save');
-          }, 200);
-        }
-      } else if (!props.isShared) {
-        editor.value.addNode(
-          'trigger',
-          0,
-          1,
-          50,
-          300,
-          'trigger',
-          tasks.trigger.data,
-          'BlockBasic',
-          'vue'
-        );
-      }
-
-      editor.value.on('mouseMove', () => {
-        if (!activeNode || !isDragging) return;
-
-        const xDistance =
-          parseInt(activeNode.el.style.left, 10) - activeNode.posX;
-        const yDistance =
-          parseInt(activeNode.el.style.top, 10) - activeNode.posY;
-
-        selectedElements.forEach(({ el, posX, posY }) => {
-          if (el.isEqualNode(activeNode.el)) return;
-
-          const nodeId = el.id.slice(5);
-          const node = editor.value.drawflow.drawflow.Home.data[nodeId];
-
-          const newPosX = posX + xDistance;
-          const newPosY = posY + yDistance;
-
-          node.pos_x = newPosX;
-          node.pos_y = newPosY;
-          el.style.top = `${newPosY}px`;
-          el.style.left = `${newPosX}px`;
-
-          editor.value.updateConnectionNodes(el.id);
-        });
-
-        hasDragged = true;
-      });
-      editor.value.on('nodeRemoved', (id) => {
-        emit('deleteBlock', id);
-      });
-      editor.value.on(
-        'connectionCreated',
-        ({ output_id, input_id, output_class, input_class }) => {
-          const { name: inputName } = editor.value.getNodeFromId(input_id);
-          const { allowedInputs } = tasks[inputName];
-          const isAllowed = isInputAllowed(allowedInputs, inputName);
-
-          if (!isAllowed) {
-            editor.value.removeSingleConnection(
-              output_id,
-              input_id,
-              output_class,
-              input_class
-            );
-          }
-
-          emitter.emit('editor:data-changed');
-        }
-      );
-      editor.value.on('connectionRemoved', () => {
-        emitter.emit('editor:data-changed');
-      });
-      editor.value.on('export', saveEditorState);
-      editor.value.on('contextmenu', ({ clientY, clientX, target }) => {
-        if (target.tagName === 'path' && target.classList.contains('main-path'))
-          return;
-
-        const isBlock = target.closest('.drawflow .drawflow-node');
-        const virtualEl = {
-          getReferenceClientRect: () => ({
-            width: 0,
-            height: 0,
-            top: clientY,
-            right: clientX,
-            bottom: clientY,
-            left: clientX,
-          }),
-        };
-
-        if (isBlock) {
-          contextMenu.data = isBlock.id;
-          contextMenu.position = virtualEl;
-          contextMenu.items = contextMenuItems.block;
-          contextMenu.show = true;
-        }
-
-        const copiedNodesLen = store.state.copiedNodes.length;
-        if (copiedNodesLen > 0) {
-          if (isBlock) {
-            contextMenuItems.common.forEach((item) => {
-              const isExists = contextMenu.items.some(
-                (menu) => menu.id === item.id
-              );
-              if (isExists) return;
-
-              contextMenu.items.unshift(item);
-            });
-          } else {
-            contextMenu.items = contextMenuItems.common;
-          }
-
-          contextMenu.position = virtualEl;
-          contextMenu.show = true;
-        }
-      });
-
-      const editorStates =
-        parseJSON(localStorage.getItem('editor-states'), {}) || {};
-      const editorState = editorStates[workflowId];
-      if (editorState) {
-        const { canvas_x, canvas_y, zoom } = editorState;
-        editor.value.translate_to(canvas_x, canvas_y, zoom);
-      }
-
-      checkWorkflowData();
-      initSelectArea();
-      emit('loaded', editor.value);
-    });
-    onBeforeUnmount(() => {
-      const element = document.querySelector('#drawflow');
-
-      if (element) {
-        element.removeEventListener('mousedown', onMousedown);
-        element.removeEventListener('mouseup', onMouseup);
-        element.removeEventListener('click', onClick);
-        element.removeEventListener('keyup', onKeyup);
-      }
-
-      saveEditorState();
-    });
-
-    return {
-      t,
-      editor,
-      contextMenu,
-      dropHandler,
-      handleDragOver,
-      clearContextMenu,
-      contextMenuHandler: {
-        copyBlocks,
-        deleteBlock,
-        pasteBlocks: () => duplicateBlock(null, true),
-        duplicateBlock: () => duplicateBlock(contextMenu.data.substr(5)),
-      },
-    };
-  },
-};
-</script>
-<style>
-#drawflow {
-  background-image: url('@/assets/images/tile.png');
-  background-size: 35px;
-  user-select: none;
-}
-.dark #drawflow {
-  background-image: url('@/assets/images/tile-white.png');
-}
-.drawflow .drawflow-node {
-  @apply dark:bg-gray-800;
-}
-#drawflow[arrow='true'] .drawflow-node .input {
-  background-color: transparent !important;
-  border-top: 10px solid transparent;
-  border-radius: 0;
-  border-left: 10px solid theme('colors.accent');
-  border-right: 10px solid transparent;
-  border-bottom: 10px solid transparent;
-}
-.selection-area {
-  background: rgba(46, 115, 252, 0.11);
-  border: 2px solid rgba(98, 155, 255, 0.81);
-  border-radius: 0.1em;
-}
-.drawflow_content_node {
-  @apply rounded-lg;
-}
-</style>

+ 119 - 26
src/components/newtab/workflow/WorkflowDataTable.vue

@@ -1,16 +1,58 @@
 <template>
-  <div class="flex mb-4">
-    <ui-input
-      v-model="state.query"
-      autofocus
-      :placeholder="t('workflow.table.placeholder')"
-      class="mr-2 flex-1"
-      @keyup.enter="addColumn"
-      @keyup.esc="$emit('close')"
+  <template v-if="!workflow.connectedTable">
+    <ui-popover class="mb-4">
+      <template #trigger>
+        <ui-button> Connect to a storage table </ui-button>
+      </template>
+      <p>Select a table</p>
+      <ui-list class="mt-2 space-y-1 max-h-80 overflow-auto w-64">
+        <p v-if="state.tableList.length === 0">
+          {{ t('message.noData') }}
+        </p>
+        <ui-list-item
+          v-for="item in state.tableList"
+          :key="item.id"
+          class="text-overflow cursor-pointer"
+          @click="connectTable(item)"
+        >
+          {{ item.name }}
+        </ui-list-item>
+      </ui-list>
+    </ui-popover>
+    <div class="flex mb-4">
+      <ui-input
+        v-model="state.query"
+        autofocus
+        :placeholder="t('workflow.table.placeholder')"
+        class="mr-2 flex-1"
+        @keyup.enter="addColumn"
+        @keyup.esc="$emit('close')"
+      />
+      <ui-button variant="accent" @click="addColumn">
+        {{ t('common.add') }}
+      </ui-button>
+    </div>
+  </template>
+  <div
+    v-else-if="state.connectedTable"
+    class="py-2 px-4 rounded-md bg-green-200 dark:bg-green-300 flex items-center mb-4"
+  >
+    <p class="mr-1 text-black">
+      This workflow is connected to the
+      <router-link
+        :to="`/storage/tables/${state.connectedTable.id}`"
+        class="underline"
+      >
+        {{ state.connectedTable.name }}
+      </router-link>
+      table
+    </p>
+    <v-remixicon
+      name="riLinkUnlinkM"
+      title="Disconnect table"
+      class="cursor-pointer"
+      @click="disconnectTable"
     />
-    <ui-button variant="accent" @click="addColumn">
-      {{ t('common.add') }}
-    </ui-button>
   </div>
   <div
     class="overflow-y-auto scroll"
@@ -26,6 +68,7 @@
         class="flex items-center space-x-2"
       >
         <ui-input
+          :disabled="Boolean(workflow.connectedTable)"
           :model-value="columns[index].name"
           :placeholder="t('workflow.table.column.name')"
           class="flex-1"
@@ -33,14 +76,18 @@
         />
         <ui-select
           v-model="columns[index].type"
-          class="flex-1"
+          :disabled="Boolean(workflow.connectedTable)"
           :placeholder="t('workflow.table.column.type')"
+          class="flex-1"
         >
           <option v-for="type in dataTypes" :key="type.id" :value="type.id">
             {{ type.name }}
           </option>
         </ui-select>
-        <button @click="state.columns.splice(index, 1)">
+        <button
+          v-if="!Boolean(workflow.connectedTable)"
+          @click="state.columns.splice(index, 1)"
+        >
           <v-remixicon name="riDeleteBin7Line" />
         </button>
       </li>
@@ -51,7 +98,10 @@
 import { computed, onMounted, watch, reactive } from 'vue';
 import { nanoid } from 'nanoid';
 import { useI18n } from 'vue-i18n';
+import dbStorage from '@/db/storage';
 import { debounce } from '@/utils/helper';
+import { dataTypes } from '@/utils/constants/table';
+import { useWorkflowStore } from '@/stores/workflow';
 
 const props = defineProps({
   workflow: {
@@ -59,25 +109,28 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-const emit = defineEmits(['update', 'close', 'change']);
+const emit = defineEmits([
+  'update',
+  'close',
+  'change',
+  'connect',
+  'disconnect',
+]);
 
 const { t } = useI18n();
-
-const dataTypes = [
-  { id: 'any', name: 'Any' },
-  { id: 'string', name: 'Text' },
-  { id: 'integer', name: 'Number' },
-  { id: 'boolean', name: 'Boolean' },
-  { id: 'array', name: 'Array' },
-];
+const workflowStore = useWorkflowStore();
 
 const state = reactive({
   query: '',
   columns: [],
+  tableList: [],
+  connectedTable: null,
+});
+const columns = computed(() => {
+  if (state.connectedTable) return state.connectedTable.columns;
+
+  return state.columns.filter(({ name }) => name.includes(state.query));
 });
-const columns = computed(() =>
-  state.columns.filter(({ name }) => name.includes(state.query))
-);
 
 function getColumnName(name) {
   const columnName = name.replace(/[\s@[\]]/g, '');
@@ -107,10 +160,36 @@ function addColumn() {
   state.columns.push({ id: nanoid(5), name: columnName, type: 'string' });
   state.query = '';
 }
+function connectTable(table) {
+  workflowStore
+    .update({
+      id: props.workflow.id,
+      data: { connectedTable: table.id },
+    })
+    .then(() => {
+      emit('connect');
+      state.query = '';
+      state.connectedTable = table;
+    });
+}
+function disconnectTable() {
+  workflowStore
+    .update({
+      id: props.workflow.id,
+      data: { connectedTable: null },
+    })
+    .then(() => {
+      state.columns = props.workflow.table;
+      state.connectedTable = null;
+      emit('disconnect');
+    });
+}
 
 watch(
   () => state.columns,
   debounce((newValue) => {
+    if (props.workflow.connectedTable) return;
+
     const data = { table: newValue };
 
     emit('update', data);
@@ -119,7 +198,21 @@ watch(
   { deep: true }
 );
 
-onMounted(() => {
+onMounted(async () => {
+  state.tableList = await dbStorage.tablesItems.toArray();
+  if (props.workflow.connectedTable) {
+    const findTable = state.tableList.find(
+      (table) => table.id === props.workflow.connectedTable
+    );
+
+    if (findTable) {
+      state.connectedTable = findTable;
+      return;
+    }
+    emit('change', { connectedTable: null });
+    emit('update', { connectedTable: null });
+  }
+
   let isChanged = false;
   state.columns =
     props.workflow.table?.map((column) => {

+ 10 - 18
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="px-4 flex items-start mb-2 mt-1">
-    <ui-popover :disabled="data.active === 'shared'" class="mr-2 h-8">
+    <ui-popover class="mr-2 h-8">
       <template #trigger>
         <span
           :title="t('workflow.sidebar.workflowIcon')"
@@ -39,7 +39,12 @@
       <p class="font-semibold mt-1 text-overflow text-lg leading-tight">
         {{ workflow.name }}
       </p>
-      <p class="line-clamp leading-tight">
+      <p
+        class="leading-tight cursor-pointer"
+        :class="descriptionCollapsed ? 'line-clamp' : null"
+        @click="descriptionCollapsed = !descriptionCollapsed"
+      >
+        <!-- description here -->
         {{ workflow.description }}
       </p>
     </div>
@@ -47,24 +52,13 @@
   <ui-input
     id="search-input"
     v-model="query"
-    :disabled="data.active === 'shared'"
     :placeholder="`${t('common.search')}... (${
       shortcut['action:search'].readable
     })`"
     prepend-icon="riSearch2Line"
     class="px-4 mt-4 mb-2"
   />
-  <div
-    :class="[data.active === 'shared' ? 'overflow-hidden' : 'overflow-auto']"
-    class="scroll bg-scroll px-4 flex-1 relative"
-  >
-    <div
-      v-show="data.active === 'shared'"
-      class="absolute h-full w-full bg-white dark:bg-black bg-opacity-10 dark:bg-opacity-10 backdrop-blur rounded-lg z-10 flex items-center justify-center"
-      style="top: 0; left: 50%; transform: translateX(-50%); width: 95%"
-    >
-      <p>{{ t('workflow.cantEdit') }}</p>
-    </div>
+  <div class="scroll bg-scroll px-4 flex-1 relative overflow-auto">
     <ui-expand
       v-for="(items, catId) in blocks"
       :key="catId"
@@ -122,10 +116,6 @@ defineProps({
     type: Object,
     default: () => ({}),
   },
-  data: {
-    type: Object,
-    default: () => ({}),
-  },
   dataChanged: {
     type: Boolean,
     default: false,
@@ -166,6 +156,8 @@ const categoriesExpand = Object.keys(categories).reduce((acc, key) => {
   return acc;
 }, {});
 
+const descriptionCollapsed = ref(true);
+
 const query = ref('');
 const expandList = ref(categoriesExpand);
 

+ 35 - 163
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -33,30 +33,30 @@
       />
     </div>
     <component
-      :is="data.editComponent"
+      :is="components[data.editComponent]"
       v-if="blockData"
       :key="data.itemId || data.blockId"
       v-model:data="blockData"
       :block-id="data.blockId"
       v-bind="{
+        editor: data.id === 'conditions' ? editor : null,
         connections: data.id === 'wait-connections' ? data.connections : null,
       }"
     />
-    <on-block-error
+    <edit-block-settings
       v-if="!excludeOnError.includes(data.id)"
       :key="data.itemId || data.blockId"
       :data="data"
       class="mt-4"
-      @change="$emit('update', { ...blockData, onError: $event })"
+      @change="$emit('update', { ...blockData, ...$event })"
     />
   </div>
 </template>
-<script>
-import { computed, provide, ref, watch } from 'vue';
+<script setup>
+import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { tasks, excludeOnError } from '@/utils/shared';
-import { parseJSON } from '@/utils/helper';
-import OnBlockError from './edit/OnBlockError.vue';
+import { excludeOnError } from '@/utils/shared';
+import EditBlockSettings from './edit/EditBlockSettings.vue';
 
 const editComponents = require.context(
   './edit',
@@ -74,165 +74,37 @@ const components = editComponents.keys().reduce((acc, key) => {
   return acc;
 }, {});
 
-export default {
-  components: { ...components, OnBlockError },
-  props: {
-    data: {
-      type: Object,
-      default: () => ({}),
-    },
-    editor: {
-      type: Object,
-      default: () => ({}),
-    },
-    workflow: {
-      type: Object,
-      default: () => ({}),
-    },
-    autocomplete: {
-      type: Object,
-      default: () => ({}),
-    },
-    dataChanged: Boolean,
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
   },
-  emits: ['close', 'update', 'update:autocomplete'],
-  setup(props, { emit }) {
-    const { t } = useI18n();
-    const autocompleteData = ref({
-      common: {
-        table: {},
-        globalData: [],
-        activeTabUrl: '',
-        prevBlockData: '',
-        $date: '',
-        $randint: '',
-        $getLength: '',
-        $randData: '',
-      },
-    });
-
-    const blockData = computed({
-      get() {
-        return props.data.data || {};
-      },
-      set(value) {
-        emit('update', value);
-      },
-    });
-    const autocompleteList = computed(() => ({
-      ...autocompleteData.value.common,
-      ...autocompleteData.value[props.data.itemId || props.data.blockId],
-    }));
-
-    provide('autocompleteData', autocompleteList);
-
-    const dataKeywords = {
-      loopId: 'loopData',
-      refKey: 'googleSheets',
-      variableName: 'variables',
-    };
-    function addAutocompleteData(id, name, data) {
-      if (!autocompleteData.value[id]) autocompleteData.value[id] = {};
-
-      if (!tasks[name].autocomplete) return;
-
-      tasks[name].autocomplete.forEach((key) => {
-        const variableNotAssigned =
-          key === 'variableName' && !data.assignVariable;
-        if (!data[key] || variableNotAssigned) return;
-
-        const keyword = dataKeywords[key];
-        if (!autocompleteData.value[id][keyword]) {
-          autocompleteData.value[id][keyword] = {};
-        }
-
-        autocompleteData.value[id][keyword][data[key]] = '';
-      });
-    }
-    function getGroupBlockData(blocks, currentItemId) {
-      let itemFound = currentItemId || true;
-      const blockId = currentItemId || props.data.blockId;
-
-      for (let index = blocks.length - 1; index > 0; index -= 1) {
-        const { id, data, itemId } = blocks[index];
-
-        if (itemFound) {
-          addAutocompleteData(blockId, id, data);
-        } else {
-          itemFound = itemId === currentItemId;
-        }
-      }
-    }
-    function traceBlockData(
-      blockId,
-      { name, inputs, data, id },
-      blocks,
-      maxDepth = 20
-    ) {
-      const notFirstDepth = maxDepth !== 20;
-
-      if (maxDepth === 0 || (blockId === id && notFirstDepth)) return;
-
-      if (notFirstDepth) {
-        if (name === 'blocks-group') getGroupBlockData(data.blocks);
-        else addAutocompleteData(props.data.blockId, name, data);
-      }
-
-      inputs?.input_1?.connections.forEach(({ node }) => {
-        traceBlockData(blockId, blocks[node], blocks, maxDepth - 1);
-      });
-    }
-
-    watch(
-      () => [props.data.blockId, props.data.itemId],
-      () => {
-        const enableAutocomplete =
-          props.workflow.settings?.inputAutocomplete ?? true;
-
-        if (!enableAutocomplete) return;
-
-        const id = props.data.blockId;
-        const isDataChanging =
-          !props.autocomplete || !props.autocomplete[id] || props.dataChanged;
-        if (isDataChanging) {
-          const blocks = props.editor.export().drawflow.Home.data;
-          const currentBlock = blocks[id];
-
-          if (Object.keys(blocks).length > 32) return;
-
-          if (props.data.isInGroup)
-            getGroupBlockData(currentBlock.data.blocks, props.data.itemId);
-
-          traceBlockData(props.data.blockId, currentBlock, blocks);
-        }
-
-        props.workflow.table?.forEach((column) => {
-          autocompleteData.value.common.table[column.name] = '';
-        });
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+  autocomplete: {
+    type: Object,
+    default: () => ({}),
+  },
+  dataChanged: Boolean,
+});
+const emit = defineEmits(['close', 'update', 'update:autocomplete']);
 
-        const workflowGlobalData = props.workflow.globalData;
-        autocompleteData.value.common.globalData = parseJSON(
-          workflowGlobalData,
-          workflowGlobalData
-        );
-      },
-      { immediate: true }
-    );
-    watch(
-      autocompleteData,
-      () => {
-        emit('update:autocomplete', autocompleteData.value);
-      },
-      { deep: true }
-    );
+const { t } = useI18n();
 
-    return {
-      t,
-      blockData,
-      excludeOnError,
-    };
+const blockData = computed({
+  get() {
+    return props.data.data;
+  },
+  set(data) {
+    emit('update', data);
   },
-};
+});
 </script>
 <style>
 #workflow-edit-block hr {

+ 221 - 0
src/components/newtab/workflow/WorkflowEditor.vue

@@ -0,0 +1,221 @@
+<template>
+  <vue-flow
+    :id="props.id"
+    :class="{ disabled: options.disabled }"
+    :default-edge-options="{
+      updatable: true,
+      selectable: true,
+      type: settings.lineType,
+      markerEnd: settings.arrow ? MarkerType.ArrowClosed : '',
+    }"
+  >
+    <Background />
+    <MiniMap v-if="minimap" :node-class-name="minimapNodeClassName" />
+    <div
+      v-if="editorControls"
+      class="flex items-end absolute p-4 left-0 bottom-0 z-10"
+    >
+      <slot name="controls-prepend" />
+      <button
+        v-tooltip.group="t('workflow.editor.resetZoom')"
+        class="control-button mr-2"
+        @click="editor.fitView()"
+      >
+        <v-remixicon name="riFullscreenLine" />
+      </button>
+      <div class="rounded-lg bg-white dark:bg-gray-800 inline-block">
+        <button
+          v-tooltip.group="t('workflow.editor.zoomOut')"
+          class="p-2 rounded-lg relative z-10"
+          @click="editor.zoomOut()"
+        >
+          <v-remixicon name="riSubtractLine" />
+        </button>
+        <hr class="h-6 border-r inline-block" />
+        <button
+          v-tooltip.group="t('workflow.editor.zoomIn')"
+          class="p-2 rounded-lg"
+          @click="editor.zoomIn()"
+        >
+          <v-remixicon name="riAddLine" />
+        </button>
+      </div>
+      <editor-search-blocks :editor="editor" />
+      <slot name="controls-append" />
+    </div>
+    <template v-for="(node, name) in nodeTypes" :key="name" #[name]="nodeProps">
+      <component
+        :is="node"
+        v-bind="nodeProps"
+        @delete="deleteBlock"
+        @edit="editBlock(nodeProps, $event)"
+        @update="updateBlockData(nodeProps.id, $event)"
+      />
+    </template>
+  </vue-flow>
+</template>
+<script setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  VueFlow,
+  MiniMap,
+  Background,
+  useVueFlow,
+  MarkerType,
+} from '@braks/vue-flow';
+import cloneDeep from 'lodash.clonedeep';
+import { useStore } from '@/stores/main';
+import { tasks, categories } from '@/utils/shared';
+import EditorSearchBlocks from './editor/EditorSearchBlocks.vue';
+
+const props = defineProps({
+  id: {
+    type: String,
+    default: 'editor',
+  },
+  data: {
+    type: Object,
+    default: () => ({
+      x: 0,
+      y: 0,
+      zoom: 0,
+      nodes: [],
+      edges: [],
+    }),
+  },
+  options: {
+    type: Object,
+    default: () => ({}),
+  },
+  editorControls: {
+    type: Boolean,
+    default: true,
+  },
+  minimap: {
+    type: Boolean,
+    default: true,
+  },
+});
+const emit = defineEmits(['edit', 'init', 'update:node', 'delete:node']);
+
+const fallbackBlocks = {
+  BlockBasic: ['BlockExportData'],
+  BlockBasicWithFallback: ['BlockWebhook'],
+};
+
+const isMac = navigator.appVersion.indexOf('Mac') !== -1;
+const blockComponents = require.context('@/components/block', false, /\.vue$/);
+const nodeTypes = blockComponents.keys().reduce((acc, key) => {
+  const name = key.replace(/(.\/)|\.vue$/g, '');
+  const component = blockComponents(key).default;
+
+  if (fallbackBlocks[name]) {
+    fallbackBlocks[name].forEach((fallbackBlock) => {
+      acc[`node-${fallbackBlock}`] = component;
+    });
+  }
+
+  acc[`node-${name}`] = component;
+
+  return acc;
+}, {});
+const getPosition = (position) => (Array.isArray(position) ? position : [0, 0]);
+
+const { t } = useI18n();
+const store = useStore();
+const editor = useVueFlow({
+  id: props.id,
+  minZoom: 0.4,
+  edgeUpdaterRadius: 20,
+  deleteKeyCode: 'Delete',
+  elevateEdgesOnSelect: true,
+  defaultZoom: props.data?.zoom ?? 0.7,
+  multiSelectionKeyCode: isMac ? 'Meta' : 'Control',
+  defaultPosition: getPosition(props.data?.position),
+  ...props.options,
+});
+editor.onConnect((params) => {
+  params.class = `source-${params.sourceHandle} target-${params.targetHandle}`;
+  params.updatable = true;
+  editor.addEdges([params]);
+});
+editor.onEdgeUpdate(({ edge, connection }) => {
+  const isBothOutput =
+    connection.sourceHandle.includes('output') &&
+    connection.targetHandle.includes('output');
+  if (isBothOutput) return;
+
+  Object.assign(edge, connection);
+});
+
+const settings = store.settings.editor;
+
+function minimapNodeClassName({ label }) {
+  const { category } = tasks[label];
+  const { color } = categories[category];
+
+  return color;
+}
+function updateBlockData(nodeId, data = {}) {
+  if (props.options.disabled) return;
+
+  const node = editor.getNode.value(nodeId);
+  node.data = { ...node.data, ...data };
+
+  emit('update:node', node);
+}
+function editBlock({ id, label, data }, additionalData = {}) {
+  if (props.options.disabled) return;
+
+  emit('edit', {
+    id: label,
+    blockId: id,
+    data: cloneDeep(data),
+    ...additionalData,
+  });
+}
+function deleteBlock(nodeId) {
+  if (props.options.disabled) return;
+
+  editor.removeNodes([nodeId]);
+  emit('delete:node', nodeId);
+}
+function onMousedown(event) {
+  if (props.options.disabled && event.shiftKey) {
+    event.stopPropagation();
+    event.preventDefault();
+  }
+}
+function applyFlowData() {
+  if (settings.snapToGrid) {
+    editor.snapToGrid.value = true;
+    editor.snapGrid.value = Object.values(settings.snapGrid);
+  }
+
+  editor.setNodes(props.data?.nodes || []);
+  editor.setEdges(props.data?.edges || []);
+  editor.setTransform({
+    x: props.data?.x || 0,
+    y: props.data?.y || 0,
+    zoom: props.data?.zoom || 1,
+  });
+}
+
+onMounted(() => {
+  applyFlowData();
+  window.addEventListener('mousedown', onMousedown, true);
+  emit('init', editor);
+});
+onBeforeUnmount(() => {
+  window.removeEventListener('mousedown', onMousedown, true);
+});
+</script>
+<style>
+@import '@braks/vue-flow/dist/style.css';
+@import '@braks/vue-flow/dist/theme-default.css';
+
+.control-button {
+  @apply p-2 rounded-lg bg-white dark:bg-gray-800 transition-colors;
+}
+</style>

+ 5 - 5
src/components/newtab/workflow/WorkflowSettings.vue

@@ -35,6 +35,7 @@
 <script setup>
 import { onMounted, ref, reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import cloneDeep from 'lodash.clonedeep';
 import { debounce } from '@/utils/helper';
 import SettingsTable from './settings/SettingsTable.vue';
 import SettingsBlocks from './settings/SettingsBlocks.vue';
@@ -81,16 +82,15 @@ const settings = reactive({
 
 watch(
   settings,
-  debounce((newSettings) => {
-    emit('update', {
-      settings: newSettings,
-    });
+  debounce(() => {
+    emit('update', { settings });
   }, 500),
   { deep: true }
 );
 
 onMounted(() => {
-  Object.assign(settings, props.workflow.settings);
+  const copySettings = cloneDeep(props.workflow.settings);
+  Object.assign(settings, copySettings);
 });
 </script>
 <style>

+ 7 - 15
src/components/newtab/workflow/WorkflowShare.vue

@@ -1,6 +1,6 @@
 <template>
   <ui-card class="w-full max-w-2xl share-workflow overflow-auto scroll">
-    <template v-if="!store.state.user?.username">
+    <template v-if="!userStore.user?.username">
       <div class="flex items-center mb-12">
         <p>{{ t('workflow.share.title') }}</p>
         <div class="flex-grow"></div>
@@ -173,10 +173,11 @@
 </template>
 <script setup>
 import { reactive, watch } from 'vue';
-import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
 import { fetchApi } from '@/utils/api';
+import { useUserStore } from '@/stores/user';
+import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
 import { workflowCategories } from '@/utils/shared';
 import { parseJSON, debounce } from '@/utils/helper';
 import { convertWorkflow } from '@/utils/workflowData';
@@ -193,7 +194,8 @@ const emit = defineEmits(['close', 'publish', 'change']);
 
 const { t } = useI18n();
 const toast = useToast();
-const store = useStore();
+const userStore = useUserStore();
+const sharedWorkflowStore = useSharedWorkflowStore();
 
 const menuItems = [
   { id: 'bold', name: 'Bold', icon: 'riBold', action: 'toggleBold' },
@@ -224,13 +226,6 @@ async function publishWorkflow() {
 
     delete workflow.extVersion;
 
-    const nodes = workflow.drawflow?.drawflow.Home.data;
-    Object.keys(nodes).forEach((nodeId) => {
-      if (nodes[nodeId].name !== 'loop-data') return;
-
-      nodes[nodeId].data.loopData = '';
-    });
-
     const response = await fetchApi('/me/workflows/shared', {
       method: 'POST',
       body: JSON.stringify({ workflow }),
@@ -246,13 +241,10 @@ async function publishWorkflow() {
 
     workflow.drawflow = props.workflow.drawflow;
 
-    store.commit('updateStateNested', {
-      path: `sharedWorkflows.${workflow.id}`,
-      value: workflow,
-    });
+    sharedWorkflowStore.insert(workflow);
     sessionStorage.setItem(
       'shared-workflows',
-      JSON.stringify(store.state.sharedWorkflows)
+      JSON.stringify(sharedWorkflowStore.shared)
     );
 
     state.isPublishing = false;

+ 46 - 0
src/components/newtab/workflow/edit/BlockSetting/BlockSettingGeneral.vue

@@ -0,0 +1,46 @@
+<template>
+  <div class="block-setting-general">
+    <ui-list>
+      <ui-list-item v-if="browserType !== 'firefox'" small>
+        <ui-switch v-model="state.debugMode" class="mr-4" />
+        <div class="flex-1 overflow-hidden">
+          <p class="text-overflow">
+            {{ t('workflow.settings.debugMode.title') }}
+          </p>
+          <p class="text-overflow">
+            Execute block using the Chrome DevTools Protocol
+          </p>
+        </div>
+      </ui-list-item>
+    </ui-list>
+  </div>
+</template>
+<script setup>
+import { reactive, watch, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['change']);
+
+const browserType = BROWSER_TYPE;
+
+const { t } = useI18n();
+const state = reactive({});
+
+watch(
+  () => state,
+  (onError) => {
+    emit('change', onError);
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  Object.assign(state, props.data);
+});
+</script>

+ 89 - 0
src/components/newtab/workflow/edit/BlockSetting/BlockSettingLines.vue

@@ -0,0 +1,89 @@
+<template>
+  <div class="block-lines max-w-xl">
+    <ui-select
+      v-model="state.activeEdge"
+      :placeholder="t('workflow.blocks.base.settings.line.select')"
+      class="w-full"
+    >
+      <option v-for="edge in state.edges" :key="edge.id" :value="edge.id">
+        {{ edge.name }}
+      </option>
+    </ui-select>
+    <div v-if="activeEdge" class="mt-4">
+      <div class="flex items-center mt-2">
+        <label class="flex items-center mr-4 mt-5">
+          <ui-switch
+            :model-value="activeEdge.animated"
+            @change="updateActiveEdge('animated', $event)"
+          />
+          <span class="ml-2">
+            {{ t('workflow.blocks.base.settings.line.animated') }}
+          </span>
+        </label>
+        <ui-input
+          :model-value="activeEdge.label"
+          :label="t('workflow.blocks.base.settings.line.label')"
+          placeholder="A label"
+          class="flex-1"
+          @change="updateActiveEdge('label', $event)"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+<script setup>
+import { inject, onMounted, reactive, computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { debounce } from '@/utils/helper';
+
+const props = defineProps({
+  blockId: {
+    type: String,
+    default: '',
+  },
+});
+
+const { t } = useI18n();
+
+const editor = inject('workflow-editor');
+const state = reactive({
+  retrieved: false,
+  edges: {},
+  activeEdge: '',
+});
+
+const activeEdge = computed(() => state.edges[state.activeEdge]);
+
+const updateActiveEdge = debounce((name, value) => {
+  const edge = editor.value.getEdge.value(state.activeEdge);
+
+  edge[name] = value;
+  state.edges[state.activeEdge][name] = value;
+}, 250);
+
+onMounted(() => {
+  state.edges = editor.value.getEdges.value.reduce(
+    (acc, { id, source, targetNode, label, animated, labelStyle }) => {
+      if (source !== props.blockId) return acc;
+
+      let name = t('workflow.blocks.base.settings.line.to', {
+        name: t(`workflow.blocks.${targetNode.label}.name`),
+      });
+      if (targetNode.data.description) {
+        name += ` (${targetNode.data.description.slice(0, 32)})`;
+      }
+
+      acc[id] = {
+        name,
+        id,
+        label: `${label || ''}`,
+        animated: animated ?? false,
+        labelStyle: labelStyle || '',
+      };
+
+      return acc;
+    },
+    {}
+  );
+});
+</script>

+ 116 - 0
src/components/newtab/workflow/edit/BlockSetting/BlockSettingOnError.vue

@@ -0,0 +1,116 @@
+<template>
+  <div class="on-block-error">
+    <div
+      class="p-4 rounded-lg bg-green-200 dark:bg-green-300 flex items-start text-black"
+    >
+      <v-remixicon name="riInformationLine" />
+      <p class="flex-1 ml-4">
+        {{ t('workflow.blocks.base.onError.info') }}
+      </p>
+    </div>
+    <div class="mt-4">
+      <label class="inline-flex">
+        <ui-switch v-model="state.enable" />
+        <span class="ml-2">
+          {{ t('common.enable') }}
+        </span>
+      </label>
+      <template v-if="state.enable">
+        <div class="mt-2">
+          <label class="inline-flex">
+            <ui-switch v-model="state.retry" />
+            <span class="ml-2">
+              {{ t('workflow.blocks.base.onError.retry') }}
+            </span>
+          </label>
+        </div>
+        <transition-expand>
+          <div v-if="state.retry" class="mt-2">
+            <div class="inline-flex items-center">
+              <span>
+                {{ t('workflow.blocks.base.onError.times.name') }}
+              </span>
+              <v-remixicon
+                :title="t('workflow.blocks.base.onError.times.description')"
+                name="riInformationLine"
+                size="20"
+                class="mr-2"
+              />
+              <ui-input
+                v-model.number="state.retryTimes"
+                type="number"
+                min="0"
+                class="w-20"
+              />
+            </div>
+            <div class="inline-flex items-center ml-12">
+              <span>
+                {{ t('workflow.blocks.base.onError.interval.name') }}
+              </span>
+              <v-remixicon
+                :title="t('workflow.blocks.base.onError.interval.description')"
+                name="riInformationLine"
+                size="20"
+                class="mr-2"
+              />
+              <ui-input
+                v-model.number="state.retryInterval"
+                type="number"
+                min="0"
+                class="w-20"
+              />
+              <span class="ml-1">
+                {{ t('workflow.blocks.base.onError.interval.second') }}
+              </span>
+            </div>
+          </div>
+        </transition-expand>
+        <ui-select v-model="state.toDo" class="mt-2 w-56">
+          <option
+            v-for="type in toDoTypes"
+            :key="type"
+            :value="type"
+            :disabled="type === 'fallback' && data.isInGroup ? true : null"
+            class="to-do-type"
+          >
+            {{ t(`workflow.blocks.base.onError.toDo.${type}`) }}
+          </option>
+        </ui-select>
+      </template>
+    </div>
+  </div>
+</template>
+<script setup>
+import { reactive, watch, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['change']);
+
+const toDoTypes = ['error', 'continue', 'fallback'];
+
+const { t } = useI18n();
+const state = reactive({});
+
+watch(
+  () => state,
+  (onError) => {
+    emit('change', onError);
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  Object.assign(state, props.data);
+});
+</script>
+<style scoped>
+.to-do-type.is-active {
+  @apply bg-accent dark:text-black text-gray-100 !important;
+}
+</style>

+ 131 - 0
src/components/newtab/workflow/edit/EditBlockSettings.vue

@@ -0,0 +1,131 @@
+<template>
+  <div class="block-settings">
+    <ui-button
+      :class="{ 'text-primary': state.onError.enable }"
+      @click="state.showModal = true"
+    >
+      <v-remixicon name="riShieldLine" class="-ml-1 mr-2" />
+      <span>
+        {{ t('workflow.blocks.base.settings.title') }}
+      </span>
+    </ui-button>
+    <ui-modal
+      v-model="state.showModal"
+      :title="t('workflow.blocks.base.settings.title')"
+      content-class="max-w-xl modal-block-settings"
+    >
+      <ui-tabs v-model="state.activeTab" class="-mt-2">
+        <ui-tab v-for="tab in tabs" :key="tab.id" :value="tab.id">
+          {{ tab.name }}
+        </ui-tab>
+      </ui-tabs>
+      <ui-tab-panels
+        v-if="state.retrieved"
+        v-model="state.activeTab"
+        class="mt-4"
+      >
+        <ui-tab-panel value="general">
+          <block-setting-general
+            v-model:data="state.settings"
+            @change="onDataChange('settings', $event)"
+          />
+        </ui-tab-panel>
+        <ui-tab-panel value="on-error">
+          <block-setting-on-error
+            :data="state.onError"
+            @change="onDataChange('onError', $event)"
+          />
+        </ui-tab-panel>
+        <ui-tab-panel value="lines">
+          <block-setting-lines :block-id="data.blockId" />
+        </ui-tab-panel>
+      </ui-tab-panels>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { reactive, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import defu from 'defu';
+import BlockSettingLines from './BlockSetting/BlockSettingLines.vue';
+import BlockSettingOnError from './BlockSetting/BlockSettingOnError.vue';
+import BlockSettingGeneral from './BlockSetting/BlockSettingGeneral.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['change']);
+
+const { t } = useI18n();
+
+const browserType = BROWSER_TYPE;
+const supportedBlocks = ['forms', 'event-click', 'trigger-event'];
+const tabs = [
+  { id: 'on-error', name: t('workflow.blocks.base.onError.button') },
+  { id: 'lines', name: t('workflow.blocks.base.settings.line.title') },
+];
+const isSupported =
+  browserType !== 'firefox' && supportedBlocks.includes(props.data.id);
+
+if (isSupported) {
+  tabs.unshift({ id: 'general', name: t('settings.menu.general') });
+}
+
+const defaultSettings = {
+  onError: {
+    retry: false,
+    enable: false,
+    retryTimes: 1,
+    retryInterval: 2,
+    toDo: 'error',
+  },
+  general: {
+    debugMode: false,
+  },
+};
+
+const state = reactive({
+  showModal: false,
+  retrieved: false,
+  onError: defaultSettings.onError,
+  settings: defaultSettings.general,
+  activeTab: !isSupported ? 'on-error' : 'general',
+});
+
+function onDataChange(key, data) {
+  if (!state.retrieved || !state.showModal) return;
+
+  state[key] = data;
+  emit('change', { [key]: data });
+}
+
+onMounted(() => {
+  const onErrorSetting = defu(
+    props.data.data.onError || {},
+    defaultSettings.onError
+  );
+  state.onError = onErrorSetting;
+
+  const generalSettings = defu(
+    props.data.data.settings,
+    defaultSettings.general
+  );
+  state.settings = generalSettings;
+
+  setTimeout(() => {
+    state.retrieved = true;
+  }, 1000);
+});
+</script>
+<style>
+.modal-block-settings {
+  min-height: 500px;
+}
+</style>

+ 33 - 4
src/components/newtab/workflow/edit/EditClipboard.vue

@@ -7,10 +7,37 @@
       @change="updateData({ description: $event })"
     />
     <template v-if="permission.has.clipboardRead">
-      <p class="mt-4">
-        {{ t('workflow.blocks.clipboard.data') }}
-      </p>
-      <insert-workflow-data :data="data" variables @update="updateData" />
+      <ui-select
+        :model-value="data.type"
+        class="mt-4 w-full"
+        @change="updateData({ type: $event })"
+      >
+        <option v-for="type in types" :key="type" :value="type">
+          {{ t(`workflow.blocks.clipboard.types.${type}`) }}
+        </option>
+      </ui-select>
+      <insert-workflow-data
+        v-if="data.type === 'get'"
+        :data="data"
+        variables
+        @update="updateData"
+      />
+      <template v-else>
+        <ui-textarea
+          v-if="!data.copySelectedText"
+          :model-value="data.dataToCopy"
+          placeholder="Text"
+          class="mt-4"
+          @change="updateData({ dataToCopy: $event })"
+        />
+        <ui-checkbox
+          :model-value="data.copySelectedText"
+          class="mt-2"
+          @change="updateData({ copySelectedText: $event })"
+        >
+          {{ t('workflow.blocks.clipboard.copySelection') }}
+        </ui-checkbox>
+      </template>
     </template>
     <template v-else>
       <p class="mt-4">
@@ -35,6 +62,8 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const types = ['get', 'insert'];
+
 const { t } = useI18n();
 const permission = useHasPermissions(['clipboardRead']);
 

+ 17 - 5
src/components/newtab/workflow/edit/EditConditions.vue

@@ -68,6 +68,7 @@
       item-key="id"
       tag="ui-list"
       class="space-y-1"
+      @end="onEnd"
     >
       <template #item="{ element, index }">
         <ui-list-item class="group cursor-move">
@@ -85,7 +86,7 @@
             name="riDeleteBin7Line"
             size="20"
             class="ml-2 -mr-1 cursor-pointer"
-            @click="deleteCondition(index)"
+            @click="deleteCondition(index, element.id)"
           />
         </ui-list-item>
       </template>
@@ -124,6 +125,7 @@ import { ref, watch, onMounted, shallowReactive } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { nanoid } from 'nanoid';
 import Draggable from 'vuedraggable';
+import { sleep } from '@/utils/helper';
 import emitter from '@/lib/mitt';
 import SharedConditionBuilder from '@/components/newtab/shared/SharedConditionBuilder/index.vue';
 
@@ -132,6 +134,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
   blockId: {
     type: String,
     default: '',
@@ -174,12 +180,13 @@ function addCondition() {
     conditions: [],
   });
 }
-function deleteCondition(index) {
+function deleteCondition(index, id) {
   conditions.value.splice(index, 1);
 
-  emitter.emit('conditions-block:delete', {
-    index,
-    id: props.blockId,
+  props.editor.removeEdges((edges) => {
+    return edges.filter(
+      (edge) => edge.sourceHandle === `${props.blockId}-output-${id}`
+    );
   });
 }
 function refreshConnections() {
@@ -190,6 +197,11 @@ function refreshConnections() {
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
+async function onEnd() {
+  props.editor.addSelectedNodes([]);
+  await sleep(1000);
+  props.editor.addSelectedNodes([props.editor.getNode.value(props.blockId)]);
+}
 
 watch(
   conditions,

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

@@ -45,7 +45,7 @@
           </option>
           <option value="column">Column</option>
           <option
-            v-for="column in workflow.data.value.table"
+            v-for="column in workflow.columns.value"
             :key="column.id"
             :value="column.id"
           >

+ 5 - 5
src/components/newtab/workflow/edit/EditExecuteWorkflow.vue

@@ -58,7 +58,7 @@
 import { computed, shallowReactive, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRoute } from 'vue-router';
-import Workflow from '@/models/workflow';
+import { useWorkflowStore } from '@/stores/workflow';
 
 const SharedCodemirror = defineAsyncComponent(() =>
   import('@/components/newtab/shared/SharedCodemirror.vue')
@@ -78,21 +78,21 @@ const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 const route = useRoute();
+const workflowStore = useWorkflowStore();
 
 const state = shallowReactive({
   showGlobalData: false,
 });
 
 const workflows = computed(() =>
-  Workflow.query()
-    .where(({ id, drawflow }) => {
+  workflowStore.getWorkflows
+    .filter(({ id, drawflow }) => {
       const flow =
         typeof drawflow === 'string' ? drawflow : JSON.stringify(drawflow);
 
       return id !== route.params.id && !flow.includes(route.params.id);
     })
-    .orderBy('name', 'asc')
-    .get()
+    .sort((a, b) => (a.name > b.name ? 1 : -1))
 );
 
 function updateData(value) {

+ 4 - 1
src/components/newtab/workflow/edit/EditGetText.vue

@@ -83,7 +83,10 @@ const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 
-const regexExp = ref([...new Set(props.data.regexExp)]);
+const regexData = Array.isArray(props.data.regexExp)
+  ? props.data.regexExp
+  : Object.values(props.data.regexExp);
+const regexExp = ref([...new Set(regexData)]);
 
 const exps = [
   { id: 'g', name: 'global' },

+ 2 - 2
src/components/newtab/workflow/edit/EditInsertData.vue

@@ -38,7 +38,7 @@
             :placeholder="t('workflow.table.select')"
           >
             <option
-              v-for="column in workflow.data.value.table"
+              v-for="column in workflow.columns.value"
               :key="column.id"
               :value="column.id"
             >
@@ -78,7 +78,7 @@ const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 
-const workflow = inject('workflow');
+const workflow = inject('workflow', {});
 const dataList = ref(JSON.parse(JSON.stringify(props.data.dataList)));
 
 function updateData(value) {

+ 61 - 0
src/components/newtab/workflow/edit/EditLogData.vue

@@ -0,0 +1,61 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
+    <ui-select
+      :model-value="data.workflowId"
+      :placeholder="t('workflow.blocks.execute-workflow.select')"
+      class="mt-4 w-full"
+      @change="updateData({ workflowId: $event })"
+    >
+      <option
+        v-for="workflow in workflows"
+        :key="workflow.id"
+        :value="workflow.id"
+      >
+        {{ workflow.name }}
+      </option>
+    </ui-select>
+    <div class="mb-8 log-data">
+      <template v-if="data.workflowId">
+        <p class="mt-4 mb-2">
+          {{ t('workflow.blocks.log-data.data') }}
+        </p>
+        <insert-workflow-data :data="data" variables @update="updateData" />
+      </template>
+    </div>
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { useWorkflowStore } from '@/stores/workflow';
+import InsertWorkflowData from './InsertWorkflowData.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const workflowStore = useWorkflowStore();
+
+const workflows = workflowStore.getWorkflows.sort((a, b) =>
+  a.name > b.name ? 1 : -1
+);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>
+<style>
+.log-data .block-variable {
+  margin-top: 4px;
+}
+</style>

+ 45 - 0
src/components/newtab/workflow/edit/EditTabURL.vue

@@ -0,0 +1,45 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
+    <ui-select
+      :model-value="data.type"
+      :label="t('workflow.blocks.tab-url.select')"
+      class="mt-4 w-full"
+      @change="updateData({ type: $event })"
+    >
+      <option v-for="type in types" :key="type" :value="type">
+        {{ t(`workflow.blocks.tab-url.types.${type}`) }}
+      </option>
+    </ui-select>
+    <insert-workflow-data :data="data" variables @update="updateData" />
+  </div>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import InsertWorkflowData from './InsertWorkflowData.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const types = ['active-tab', 'all'];
+const { t } = useI18n();
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>
+<style>
+.log-data .block-variable {
+  margin-top: 4px;
+}
+</style>

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

@@ -83,7 +83,7 @@
       @change="updateData({ dataColumn: $event })"
     >
       <option
-        v-for="column in workflow.data.value.table"
+        v-for="column in workflow.columns.value"
         :key="column.id || column.name"
         :value="column.id || column.name"
       >

+ 3 - 3
src/components/newtab/workflow/edit/InsertWorkflowData.vue

@@ -3,7 +3,7 @@
     <ui-checkbox
       :model-value="data.assignVariable"
       block
-      class="mt-4"
+      class="mt-4 block-variable"
       @change="updateData({ assignVariable: $event })"
     >
       {{ t('workflow.variables.assign') }}
@@ -33,7 +33,7 @@
     @change="updateData({ dataColumn: $event })"
   >
     <option
-      v-for="column in [...columns, ...workflow.data.value.table]"
+      v-for="column in [...columns, ...workflow.columns.value]"
       :key="column.id"
       :value="column.id"
     >
@@ -64,7 +64,7 @@
         @change="updateData({ extraRowDataColumn: $event })"
       >
         <option
-          v-for="column in workflow.data.value.table"
+          v-for="column in [...columns, ...workflow.columns.value]"
           :key="column.id"
           :value="column.id"
         >

+ 0 - 145
src/components/newtab/workflow/edit/OnBlockError.vue

@@ -1,145 +0,0 @@
-<template>
-  <div class="on-block-error">
-    <ui-button
-      :class="{ 'text-primary': state.data.enable }"
-      @click="state.showModal = true"
-    >
-      <v-remixicon name="riShieldLine" class="-ml-1 mr-2" />
-      <span>
-        {{ t('workflow.blocks.base.onError.button') }}
-      </span>
-    </ui-button>
-    <ui-modal
-      v-model="state.showModal"
-      :title="t('workflow.blocks.base.onError.title')"
-      content-class="max-w-xl"
-    >
-      <div
-        class="p-4 rounded-lg bg-green-200 dark:bg-green-300 flex items-start text-black"
-      >
-        <v-remixicon name="riInformationLine" />
-        <p class="flex-1 ml-4">
-          {{ t('workflow.blocks.base.onError.info') }}
-        </p>
-      </div>
-      <div class="mt-8">
-        <label class="inline-flex">
-          <ui-switch v-model="state.data.enable" />
-          <span class="ml-2">
-            {{ t('common.enable') }}
-          </span>
-        </label>
-        <template v-if="state.data.enable">
-          <div class="mt-4">
-            <label class="inline-flex">
-              <ui-switch v-model="state.data.retry" />
-              <span class="ml-2">
-                {{ t('workflow.blocks.base.onError.retry') }}
-              </span>
-            </label>
-          </div>
-          <transition-expand>
-            <div v-if="state.data.retry" class="mt-2">
-              <div class="inline-flex items-center">
-                <span>
-                  {{ t('workflow.blocks.base.onError.times.name') }}
-                </span>
-                <v-remixicon
-                  :title="t('workflow.blocks.base.onError.times.description')"
-                  name="riInformationLine"
-                  size="20"
-                  class="mr-2"
-                />
-                <ui-input
-                  v-model.number="state.data.retryTimes"
-                  type="number"
-                  min="0"
-                  class="w-20"
-                />
-              </div>
-              <div class="inline-flex items-center ml-12">
-                <span>
-                  {{ t('workflow.blocks.base.onError.interval.name') }}
-                </span>
-                <v-remixicon
-                  :title="
-                    t('workflow.blocks.base.onError.interval.description')
-                  "
-                  name="riInformationLine"
-                  size="20"
-                  class="mr-2"
-                />
-                <ui-input
-                  v-model.number="state.data.retryInterval"
-                  type="number"
-                  min="0"
-                  class="w-20"
-                />
-                <span class="ml-1">
-                  {{ t('workflow.blocks.base.onError.interval.second') }}
-                </span>
-              </div>
-            </div>
-          </transition-expand>
-          <ui-select v-model="state.data.toDo" class="mt-4 w-56">
-            <option
-              v-for="type in toDoTypes"
-              :key="type"
-              :value="type"
-              :disabled="type === 'fallback' && data.isInGroup ? true : null"
-              class="to-do-type"
-            >
-              {{ t(`workflow.blocks.base.onError.toDo.${type}`) }}
-            </option>
-          </ui-select>
-        </template>
-      </div>
-    </ui-modal>
-  </div>
-</template>
-<script setup>
-import { reactive, watch, onMounted } from 'vue';
-import { useI18n } from 'vue-i18n';
-
-const props = defineProps({
-  data: {
-    type: Object,
-    default: () => ({}),
-  },
-});
-const emit = defineEmits(['change']);
-
-const { t } = useI18n();
-
-const toDoTypes = ['error', 'continue', 'fallback'];
-
-const state = reactive({
-  showModal: false,
-  data: {
-    retry: false,
-    enable: false,
-    retryTimes: 1,
-    retryInterval: 2,
-    toDo: 'error',
-  },
-});
-
-watch(
-  () => state.data,
-  (onError) => {
-    if (!state.showModal) return;
-
-    emit('change', onError);
-  },
-  { deep: true }
-);
-
-onMounted(() => {
-  state.data = Object.assign(state.data, props.data.data.onError || {});
-});
-</script>
-<style scoped>
-.to-do-type.is-active {
-  @apply bg-accent dark:text-black text-gray-100 !important;
-}
-</style>

+ 1 - 1
src/components/newtab/workflow/edit/Trigger/TriggerSpecificDay.vue

@@ -70,7 +70,7 @@
             <v-remixicon
               name="riDeleteBin7Line"
               class="cursor-pointer"
-              @click="removeDayTime(index, timeIndex)"
+              @click.stop="removeDayTime(index, timeIndex)"
             />
           </div>
         </div>

+ 436 - 0
src/components/newtab/workflow/editor/EditorLocalActions.vue

@@ -0,0 +1,436 @@
+<template>
+  <ui-card
+    v-if="!workflow.isProtected"
+    padding="p-1"
+    class="flex items-center pointer-events-auto"
+  >
+    <ui-popover>
+      <template #trigger>
+        <button
+          v-tooltip.group="t('workflow.host.title')"
+          class="hoverable p-2 rounded-lg"
+        >
+          <v-remixicon
+            :class="{ 'text-primary': hosted }"
+            name="riBaseStationLine"
+          />
+        </button>
+      </template>
+      <div :class="{ 'text-center': state.isUploadingHost }" class="w-64">
+        <div class="flex items-center text-gray-600 dark:text-gray-200">
+          <p>
+            {{ t('workflow.host.set') }}
+          </p>
+          <a
+            :title="t('common.docs')"
+            href="https://docs.automa.site/guide/host-workflow.html"
+            target="_blank"
+            class="ml-1"
+          >
+            <v-remixicon name="riInformationLine" size="20" />
+          </a>
+          <div class="flex-grow"></div>
+          <ui-spinner v-if="state.isUploadingHost" color="text-accent" />
+          <ui-switch
+            v-else
+            :model-value="Boolean(hosted)"
+            @change="setAsHostWorkflow"
+          />
+        </div>
+        <transition-expand>
+          <ui-input
+            v-if="hosted"
+            v-tooltip:bottom="t('workflow.host.id')"
+            :model-value="hosted.hostId"
+            prepend-icon="riLinkM"
+            readonly
+            class="mt-4 block w-full"
+            @click="$event.target.select()"
+          />
+        </transition-expand>
+      </div>
+    </ui-popover>
+    <button
+      v-tooltip.group="t('workflow.share.title')"
+      :class="{ 'text-primary': shared }"
+      class="hoverable p-2 rounded-lg"
+      @click="shareWorkflow"
+    >
+      <v-remixicon name="riShareLine" />
+    </button>
+  </ui-card>
+  <ui-card padding="p-1 ml-4 pointer-events-auto">
+    <button
+      v-for="item in modalActions"
+      :key="item.id"
+      v-tooltip.group="item.name"
+      class="hoverable p-2 rounded-lg"
+      @click="$emit('modal', item.id)"
+    >
+      <v-remixicon :name="item.icon" />
+    </button>
+  </ui-card>
+  <ui-card padding="p-1 ml-4 flex items-center pointer-events-auto">
+    <button
+      v-if="!workflow.isDisabled"
+      v-tooltip.group="
+        `${t('common.execute')} (${
+          shortcuts['editor:execute-workflow'].readable
+        })`
+      "
+      class="hoverable p-2 rounded-lg"
+      @click="executeWorkflow"
+    >
+      <v-remixicon name="riPlayLine" />
+    </button>
+    <button
+      v-else
+      v-tooltip="t('workflow.clickToEnable')"
+      class="p-2"
+      @click="updateWorkflow({ isDisabled: false })"
+    >
+      {{ t('common.disabled') }}
+    </button>
+  </ui-card>
+  <ui-card padding="p-1 ml-4 space-x-1 pointer-events-auto">
+    <ui-popover>
+      <template #trigger>
+        <button class="rounded-lg p-2 hoverable">
+          <v-remixicon name="riMore2Line" />
+        </button>
+      </template>
+      <ui-list class="w-36">
+        <ui-list-item
+          class="cursor-pointer"
+          @click="updateWorkflow({ isDisabled: !workflow.isDisabled })"
+        >
+          <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
+          {{ t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`) }}
+        </ui-list-item>
+        <ui-list-item
+          v-for="item in moreActions"
+          :key="item.id"
+          v-close-popover
+          class="cursor-pointer"
+          @click="item.action"
+        >
+          <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+          {{ item.name }}
+        </ui-list-item>
+      </ui-list>
+    </ui-popover>
+    <ui-button
+      :title="shortcuts['editor:save'].readable"
+      variant="accent"
+      class="relative"
+      @click="saveWorkflow"
+    >
+      <span
+        v-if="isDataChanged"
+        class="flex h-3 w-3 absolute top-0 left-0 -ml-1 -mt-1"
+      >
+        <span
+          class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
+        ></span>
+        <span
+          class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
+        ></span>
+      </span>
+      <v-remixicon name="riSaveLine" class="mr-2 -ml-1 my-1" />
+      {{ t('common.save') }}
+    </ui-button>
+  </ui-card>
+  <ui-modal v-model="renameState.showModal" title="Workflow">
+    <ui-input
+      v-model="renameState.name"
+      :placeholder="t('common.name')"
+      autofocus
+      class="w-full mb-4"
+      @keyup.enter="renameWorkflow"
+    />
+    <ui-textarea
+      v-model="renameState.description"
+      :placeholder="t('common.description')"
+      height="165px"
+      class="w-full dark:text-gray-200"
+      max="300"
+      style="min-height: 140px"
+    />
+    <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
+      {{ renameState.description.length }}/300
+    </p>
+    <div class="space-x-2 flex">
+      <ui-button class="w-full" @click="clearRenameModal">
+        {{ t('common.cancel') }}
+      </ui-button>
+      <ui-button variant="accent" class="w-full" @click="renameWorkflow">
+        {{ t('common.update') }}
+      </ui-button>
+    </div>
+  </ui-modal>
+</template>
+<script setup>
+import { reactive, computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
+import { useToast } from 'vue-toastification';
+import browser from 'webextension-polyfill';
+import { sendMessage } from '@/utils/message';
+import { fetchApi } from '@/utils/api';
+import { useUserStore } from '@/stores/user';
+import { useWorkflowStore } from '@/stores/workflow';
+import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
+import { useDialog } from '@/composable/dialog';
+import { useGroupTooltip } from '@/composable/groupTooltip';
+import { useShortcut, getShortcut } from '@/composable/shortcut';
+import { parseJSON } from '@/utils/helper';
+import { exportWorkflow, convertWorkflow } from '@/utils/workflowData';
+import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
+
+const props = defineProps({
+  isDataChanged: {
+    type: Boolean,
+    default: false,
+  },
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['modal', 'change', 'update']);
+
+useGroupTooltip();
+
+const { t } = useI18n();
+const toast = useToast();
+const router = useRouter();
+const dialog = useDialog();
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
+const sharedWorkflowStore = useSharedWorkflowStore();
+const shortcuts = useShortcut([
+  /* eslint-disable-next-line */
+  getShortcut('editor:save', saveWorkflow),
+  /* eslint-disable-next-line */
+  getShortcut('editor:execute-workflow', executeWorkflow),
+]);
+
+const state = reactive({
+  isUploadingHost: false,
+});
+const renameState = reactive({
+  name: '',
+  description: '',
+  showModal: false,
+});
+
+const shared = computed(() => sharedWorkflowStore.getById(props.workflow.id));
+const hosted = computed(() => userStore.hostedWorkflows[props.workflow.id]);
+
+function updateWorkflow(data = {}, changedIndicator = false) {
+  return workflowStore
+    .update({
+      data,
+      id: props.workflow.id,
+    })
+    .then((result) => {
+      emit('update', { data, changedIndicator });
+
+      return result;
+    });
+}
+function executeWorkflow() {
+  sendMessage(
+    'workflow:execute',
+    {
+      ...props.workflow,
+      isTesting: props.isDataChanged,
+    },
+    'background'
+  );
+}
+async function setAsHostWorkflow(isHost) {
+  if (!userStore.user) {
+    dialog.custom('auth', {
+      title: t('auth.title'),
+    });
+    return;
+  }
+
+  state.isUploadingHost = true;
+
+  try {
+    let url = '/me/workflows';
+    let payload = {};
+
+    if (isHost) {
+      const workflowPaylod = convertWorkflow(props.workflow, ['id']);
+      workflowPaylod.drawflow = parseJSON(
+        props.workflow.drawflow,
+        props.workflow.drawflow
+      );
+      delete workflowPaylod.extVersion;
+
+      url += `/host`;
+      payload = {
+        method: 'POST',
+        body: JSON.stringify({
+          workflow: workflowPaylod,
+        }),
+      };
+    } else {
+      url += `?id=${props.workflow.id}&type=host`;
+      payload.method = 'DELETE';
+    }
+
+    const response = await fetchApi(url, payload);
+    const result = await response.json();
+
+    if (!response.ok) {
+      const error = new Error(result.message);
+      error.data = result.data;
+
+      throw error;
+    }
+
+    if (isHost) {
+      userStore.hostedWorkflows[props.workflow.id] = result;
+    } else {
+      delete userStore.hostedWorkflows[props.workflow.id];
+    }
+
+    // Update cache
+    const userWorkflows = parseJSON('user-workflows', {
+      backup: [],
+      hosted: {},
+    });
+    userWorkflows.hosted = userStore.hostedWorkflows;
+    sessionStorage.setItem('user-workflows', JSON.stringify(userWorkflows));
+
+    state.isUploadingHost = false;
+  } catch (error) {
+    console.error(error);
+    state.isUploadingHost = false;
+    toast.error(error.message);
+  }
+}
+function shareWorkflow() {
+  if (shared.value) {
+    router.push(`/workflows/${props.workflow.id}/shared`);
+    return;
+  }
+
+  if (userStore.user) {
+    emit('modal', 'workflow-share');
+  } else {
+    dialog.custom('auth', {
+      title: t('auth.title'),
+    });
+  }
+}
+function clearRenameModal() {
+  Object.assign(renameState, {
+    id: '',
+    name: '',
+    description: '',
+    showModal: false,
+  });
+}
+function initRenameWorkflow() {
+  Object.assign(renameState, {
+    showModal: true,
+    name: `${props.workflow.name}`,
+    description: `${props.workflow.description}`,
+  });
+}
+function renameWorkflow() {
+  updateWorkflow({
+    name: renameState.name,
+    description: renameState.description,
+  });
+  clearRenameModal();
+}
+function deleteWorkflow() {
+  dialog.confirm({
+    title: t('workflow.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name: props.workflow.name }),
+    onConfirm: async () => {
+      await workflowStore.delete(props.workflow.id);
+      router.replace('/');
+    },
+  });
+}
+async function saveWorkflow() {
+  try {
+    const flow = props.editor.toObject();
+    flow.edges = flow.edges.map((edge) => {
+      delete edge.sourceNode;
+      delete edge.targetNode;
+
+      return edge;
+    });
+
+    const triggerBlock = flow.nodes.find((node) => node.label === 'trigger');
+    if (!triggerBlock) {
+      toast.error(t('message.noTriggerBlock'));
+      return;
+    }
+
+    await updateWorkflow(
+      {
+        drawflow: flow,
+        trigger: triggerBlock.data,
+        version: browser.runtime.getManifest().version,
+      },
+      false
+    );
+    await registerWorkflowTrigger(props.workflow.id, triggerBlock);
+
+    emit('change', { drawflow: flow });
+  } catch (error) {
+    console.error(error);
+  }
+}
+const modalActions = [
+  {
+    id: 'table',
+    name: t('workflow.table.title'),
+    icon: 'riTable2',
+  },
+  {
+    id: 'global-data',
+    name: t('common.globalData'),
+    icon: 'riDatabase2Line',
+  },
+  {
+    id: 'settings',
+    name: t('common.settings'),
+    icon: 'riSettings3Line',
+  },
+];
+const moreActions = [
+  {
+    id: 'export',
+    name: t('common.export'),
+    icon: 'riDownloadLine',
+    action: () => exportWorkflow(props.workflow),
+  },
+  {
+    id: 'rename',
+    icon: 'riPencilLine',
+    name: t('common.rename'),
+    action: initRenameWorkflow,
+  },
+  {
+    id: 'delete',
+    action: deleteWorkflow,
+    name: t('common.delete'),
+    icon: 'riDeleteBin7Line',
+  },
+];
+</script>

+ 142 - 0
src/components/newtab/workflow/editor/EditorLocalCtxMenu.vue

@@ -0,0 +1,142 @@
+<template>
+  <ui-popover
+    v-model="state.show"
+    :options="state.position"
+    padding="p-3"
+    @close="clearContextMenu"
+  >
+    <ui-list class="space-y-1 w-52">
+      <ui-list-item
+        v-for="item in state.items"
+        :key="item.id"
+        v-close-popover
+        class="cursor-pointer justify-between"
+        @click="item.event"
+      >
+        <span>
+          {{ item.name }}
+        </span>
+        <span
+          v-if="item.shortcut"
+          class="text-sm capitalize text-gray-600 dark:text-gray-200"
+        >
+          {{ item.shortcut }}
+        </span>
+      </ui-list-item>
+    </ui-list>
+  </ui-popover>
+</template>
+<script setup>
+import { onMounted, reactive, markRaw } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useStore } from '@/stores/main';
+import { getReadableShortcut, getShortcut } from '@/composable/shortcut';
+
+const props = defineProps({
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['copy', 'paste', 'duplicate']);
+
+const { t } = useI18n();
+const store = useStore();
+const state = reactive({
+  show: false,
+  items: [],
+  position: {},
+});
+
+let ctxData = null;
+const menuItems = {
+  paste: {
+    id: 'paste',
+    name: t('workflow.editor.paste'),
+    icon: 'riFileCopyLine',
+    shortcut: getReadableShortcut('mod+v'),
+    event: () => emit('paste', ctxData?.position),
+  },
+  delete: {
+    id: 'delete',
+    name: t('common.delete'),
+    icon: 'riDeleteBin7Line',
+    shortcut: 'Del',
+    event: () => {
+      props.editor.removeEdges(ctxData.edges);
+      props.editor.removeNodes(ctxData.nodes);
+    },
+  },
+  copy: {
+    id: 'copy',
+    name: t('workflow.editor.copy'),
+    icon: 'riFileCopyLine',
+    event: () => emit('copy', ctxData),
+    shortcut: getReadableShortcut('mod+c'),
+  },
+  duplicate: {
+    id: 'duplicate',
+    name: t('workflow.editor.duplicate'),
+    icon: 'riFileCopyLine',
+    event: () => emit('duplicate', ctxData),
+    shortcut: getShortcut('editor:duplicate-block').readable,
+  },
+};
+
+/* eslint-disable-next-line */
+function showCtxMenu(items = [], event) {
+  event.preventDefault();
+  const { clientX, clientY } = event;
+
+  state.items = items.map((key) => markRaw(menuItems[key]));
+
+  if (store.copiedEls.nodes.length > 0) {
+    state.items.unshift(markRaw(menuItems.paste));
+  }
+
+  state.position = {
+    getReferenceClientRect: () => ({
+      width: 0,
+      height: 0,
+      top: clientY,
+      right: clientX,
+      bottom: clientY,
+      left: clientX,
+    }),
+  };
+  state.show = true;
+}
+function clearContextMenu() {
+  state.show = false;
+  state.items = [];
+  state.position = {};
+}
+
+onMounted(() => {
+  props.editor.onNodeContextMenu(({ event, node }) => {
+    showCtxMenu(['copy', 'duplicate', 'delete'], event);
+    ctxData = { nodes: [node], edges: [] };
+  });
+  props.editor.onEdgeContextMenu(({ event, edge }) => {
+    showCtxMenu(['delete'], event);
+    ctxData = { nodes: [], edges: [edge] };
+  });
+  props.editor.onPaneContextMenu((event) => {
+    if (store.copiedEls.nodes.length === 0) return;
+
+    showCtxMenu([], event);
+    ctxData = {
+      nodes: [],
+      edges: [],
+      position: { clientX: event.clientX, clientY: event.clientY },
+    };
+  });
+  props.editor.onSelectionContextMenu(({ event }) => {
+    showCtxMenu(['copy', 'duplicate', 'delete'], event);
+    ctxData = {
+      nodes: props.editor.getSelectedNodes.value,
+      edges: props.editor.getSelectedEdges.value,
+    };
+  });
+});
+</script>

+ 53 - 0
src/components/newtab/workflow/editor/EditorLogs.vue

@@ -0,0 +1,53 @@
+<template>
+  <div
+    v-if="(!logs || logs.length === 0) && workflowStates.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">{{ t('message.noData') }}</p>
+  </div>
+  <shared-logs-table
+    :logs="logs"
+    :running="workflowStates"
+    hide-select
+    class="w-full"
+  >
+    <template #item-append="{ log: itemLog }">
+      <td class="text-right">
+        <v-remixicon
+          name="riDeleteBin7Line"
+          class="inline-block text-red-500 cursor-pointer dark:text-red-400"
+          @click="deleteLog(itemLog.id)"
+        />
+      </td>
+    </template>
+  </shared-logs-table>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import dbLogs from '@/db/logs';
+import { useLiveQuery } from '@/composable/liveQuery';
+import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
+
+const props = defineProps({
+  workflowId: {
+    type: String,
+    default: '',
+  },
+  workflowStates: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const { t } = useI18n();
+
+const logs = useLiveQuery(() =>
+  dbLogs.items
+    .where('workflowId')
+    .equals(props.workflowId)
+    .reverse()
+    .limit(15)
+    .sortBy('endedAt')
+);
+</script>

+ 39 - 35
src/components/newtab/workflow/WorkflowBuilderSearchBlocks.vue → src/components/newtab/workflow/editor/EditorSearchBlocks.vue

@@ -67,11 +67,13 @@ const props = defineProps({
 const { t } = useI18n();
 
 const initialState = {
-  zoom: 1,
   rectX: 0,
   rectY: 0,
-  canvasX: 0,
-  canvasY: 0,
+  position: {
+    x: 0,
+    y: 0,
+    zoom: 1,
+  },
 };
 
 const autocompleteEl = ref(null);
@@ -103,25 +105,22 @@ function toggleActiveSearch() {
   }
 }
 function extractBlocks() {
-  const { width, height } = props.editor.container.getBoundingClientRect();
+  const editorContainer = document.querySelector('.vue-flow');
+  editorContainer.classList.add('add-transition');
+  const { width, height } = editorContainer.getBoundingClientRect();
+
   initialState.rectX = width / 2;
   initialState.rectY = height / 2;
-  initialState.zoom = props.editor.zoom;
-  initialState.canvasX = props.editor.canvas_x;
-  initialState.canvasY = props.editor.canvas_y;
+  initialState.position = props.editor.getTransform();
 
-  const { drawflow } = props.editor.export();
-  state.autocompleteItems = Object.values(drawflow.Home.data).map(
-    ({ id, name, data, pos_x, pos_y }) => ({
+  state.autocompleteItems = props.editor.getNodes.value.map(
+    ({ computedPosition, id, data, label }) => ({
       id,
-      pos_x,
-      pos_y,
+      position: computedPosition,
       description: data.description || '',
-      name: t(`workflow.blocks.${name}.name`),
+      name: t(`workflow.blocks.${label}.name`),
     })
   );
-
-  props.editor.precanvas.style.transition = 'transform 300ms ease';
 }
 function clearHighlightedNodes() {
   document.querySelectorAll('.search-select-node').forEach((el) => {
@@ -130,8 +129,7 @@ function clearHighlightedNodes() {
 }
 function clearState() {
   if (!state.selected) {
-    const { canvasX, canvasY, zoom } = initialState;
-    props.editor.translate_to(canvasX, canvasY, zoom);
+    props.editor.setTransform(initialState.position);
   }
 
   state.query = '';
@@ -139,55 +137,61 @@ function clearState() {
   state.selected = false;
 
   Object.assign(initialState, {
-    zoom: 1,
     rectX: 0,
     rectY: 0,
-    canvasX: 0,
-    canvasY: 0,
+    position: {
+      x: 0,
+      y: 0,
+      zoom: 1,
+    },
   });
 
   autocompleteEl.value.state.showPopover = false;
   clearHighlightedNodes();
 
   setTimeout(() => {
-    props.editor.precanvas.style.transition = '';
+    const editorContainer = document.querySelector('.vue-flow');
+    editorContainer.classList.remove('add-transition');
   }, 500);
 }
 function blurInput() {
   document.querySelector('#search-blocks')?.blur();
 }
 function onSelectItem({ item }) {
-  if (props.editor.zoom !== 1) {
-    /* eslint-disable-next-line */
-    props.editor.zoom = 1;
-    props.editor.zoom_refresh();
-  }
+  const { x, y } = item.position;
+  const { rectX, rectY } = initialState;
 
   clearHighlightedNodes();
   document
-    .querySelector(`#node-${item.id}`)
+    .querySelector(`[data-id="${item.id}"]`)
     ?.classList.add('search-select-node');
 
-  const { rectX, rectY } = initialState;
-  props.editor.translate_to(
-    -(item.pos_x - rectX),
-    -(item.pos_y - rectY),
-    props.editor.zoom
-  );
+  props.editor.setTransform({
+    zoom: 1,
+    x: -(x - rectX),
+    y: -(y - rectY),
+  });
 }
 function onItemSelected(event) {
   state.selected = true;
+
+  const node = props.editor.getNode.value(event.item.id);
+  props.editor.addSelectedNodes([node]);
+
   onSelectItem(event);
   blurInput();
 }
 </script>
 <style scoped>
 input {
-  transition: width 250ms ease;
+  transition: width 300ms ease;
 }
 </style>
 <style>
-.search-select-node .drawflow_content_node {
+.search-select-node > div {
   @apply ring-4;
 }
+.vue-flow.add-transition .vue-flow__transformationpane {
+  transition: transform 250ms ease;
+}
 </style>

+ 19 - 20
src/components/newtab/workflows/WorkflowsFolder.vue

@@ -81,8 +81,8 @@ import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useDialog } from '@/composable/dialog';
 import { parseJSON } from '@/utils/helper';
-import Folder from '@/models/folder';
-import Workflow from '@/models/workflow';
+import { useFolderStore } from '@/stores/folder';
+import { useWorkflowStore } from '@/stores/workflow';
 
 defineProps({
   modelValue: {
@@ -94,8 +94,10 @@ const emit = defineEmits(['update:modelValue']);
 
 const { t } = useI18n();
 const dialog = useDialog();
+const folderStore = useFolderStore();
+const workflowStore = useWorkflowStore();
 
-const folders = computed(() => Folder.query().orderBy('name', 'asc').get());
+const folders = computed(() => folderStore.items);
 
 function onDragover(event, toggle) {
   const parent = event.target.closest('.ui-list-item');
@@ -112,11 +114,7 @@ function newFolder() {
     onConfirm(value) {
       if (!value || !value.trim()) return false;
 
-      Folder.insert({
-        data: {
-          name: value,
-        },
-      });
+      folderStore.addFolder(value);
 
       return true;
     },
@@ -129,7 +127,7 @@ function deleteFolder({ name, id }) {
     okText: t('common.delete'),
     okVariant: 'danger',
     onConfirm() {
-      Folder.delete(id);
+      folderStore.deleteFolder(id);
 
       emit('update:modelValue', '');
     },
@@ -144,24 +142,25 @@ function renameFolder({ id, name }) {
     onConfirm(newName) {
       if (!newName || !newName.trim()) return false;
 
-      Folder.update({
-        where: id,
-        data: { name: newName },
-      });
+      folderStore.updateFolder(id, { name: newName });
 
       return true;
     },
   });
 }
-function onWorkflowsDrop({ dataTransfer }, folderId) {
+async function onWorkflowsDrop({ dataTransfer }, folderId) {
   const ids = parseJSON(dataTransfer.getData('workflows'), null);
   if (!ids || !Array.isArray(ids)) return;
 
-  ids.forEach((id) => {
-    Workflow.update({
-      where: id,
-      data: { folderId },
-    });
-  });
+  try {
+    for (const id of ids) {
+      await workflowStore.update({
+        id,
+        data: { folderId },
+      });
+    }
+  } catch (error) {
+    console.error(error);
+  }
 }
 </script>

+ 72 - 0
src/components/newtab/workflows/WorkflowsHosted.vue

@@ -0,0 +1,72 @@
+<template>
+  <shared-card
+    v-for="workflow in workflows"
+    :key="workflow.hostId"
+    :data="workflow"
+    :menu="menu"
+    @execute="executeWorkflow(workflow)"
+    @click="$router.push(`/workflows/${$event.hostId}/host`)"
+    @menuSelected="deleteWorkflow(workflow)"
+  />
+</template>
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useDialog } from '@/composable/dialog';
+import { sendMessage } from '@/utils/message';
+import { arraySorter } from '@/utils/helper';
+import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+
+const props = defineProps({
+  search: {
+    type: String,
+    default: '',
+  },
+  sort: {
+    type: Object,
+    default: () => ({
+      by: '',
+      order: '',
+    }),
+  },
+});
+
+const { t } = useI18n();
+const dialog = useDialog();
+const hostedWorkflowStore = useHostedWorkflowStore();
+
+const menu = [
+  { id: 'delete', name: t('common.delete'), icon: 'riDeleteBin7Line' },
+];
+
+const workflows = computed(() => {
+  const filtered = hostedWorkflowStore.toArray.filter(({ name }) =>
+    name.toLocaleLowerCase().includes(props.search.toLocaleLowerCase())
+  );
+
+  return arraySorter({
+    data: filtered,
+    key: props.sort.by,
+    order: props.sort.order,
+  });
+});
+
+async function deleteWorkflow(workflow) {
+  dialog.confirm({
+    title: t('workflow.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name: workflow.name }),
+    onConfirm: async () => {
+      try {
+        await hostedWorkflowStore.delete(workflow.hostId);
+      } catch (error) {
+        console.error(error);
+      }
+    },
+  });
+}
+function executeWorkflow(workflow) {
+  sendMessage('workflow:execute', workflow, 'background');
+}
+</script>

+ 399 - 0
src/components/newtab/workflows/WorkflowsLocal.vue

@@ -0,0 +1,399 @@
+<template>
+  <div
+    v-if="workflowStore.getWorkflows.length === 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">
+        {{ t('message.empty') }}
+      </h1>
+    </div>
+  </div>
+  <template v-else>
+    <div class="workflows-container">
+      <shared-card
+        v-for="workflow in workflows"
+        :key="workflow.id"
+        :data="workflow"
+        :data-workflow="workflow.id"
+        draggable="true"
+        class="cursor-default select-none ring-accent local-workflow"
+        @dragstart="onDragStart"
+        @click="$router.push(`/workflows/${$event.id}`)"
+      >
+        <template #header>
+          <div class="flex items-center mb-4">
+            <template v-if="workflow && !workflow.isDisabled">
+              <ui-img
+                v-if="workflow.icon.startsWith('http')"
+                :src="workflow.icon"
+                class="rounded-lg overflow-hidden"
+                style="height: 40px; width: 40px"
+                alt="Can not display"
+              />
+              <span
+                v-else
+                class="p-2 rounded-lg bg-box-transparent inline-block"
+              >
+                <v-remixicon :name="workflow.icon" />
+              </span>
+            </template>
+            <p v-else class="py-2">{{ t('common.disabled') }}</p>
+            <div class="flex-grow"></div>
+            <button
+              v-if="!workflow.isDisabled"
+              class="invisible group-hover:visible"
+              @click="executeWorkflow(workflow)"
+            >
+              <v-remixicon name="riPlayLine" />
+            </button>
+            <ui-popover class="h-6 ml-2">
+              <template #trigger>
+                <button>
+                  <v-remixicon name="riMoreLine" />
+                </button>
+              </template>
+              <ui-list class="space-y-1" style="min-width: 150px">
+                <ui-list-item
+                  class="cursor-pointer"
+                  @click="toggleDisableWorkflow(workflow)"
+                >
+                  <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
+                  <span class="capitalize">
+                    {{
+                      t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`)
+                    }}
+                  </span>
+                </ui-list-item>
+                <ui-list-item
+                  v-for="item in menu"
+                  :key="item.id"
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="item.action(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>
+        </template>
+        <template #footer-content>
+          <v-remixicon
+            v-if="sharedWorkflowStore.getById(workflow.id)"
+            v-tooltip:bottom.group="
+              t('workflow.share.sharedAs', {
+                name: sharedWorkflowStore
+                  .getById(workflow.id)
+                  ?.name.slice(0, 64),
+              })
+            "
+            name="riShareLine"
+            size="20"
+            class="ml-2"
+          />
+          <v-remixicon
+            v-if="userStore.hostedWorkflows[workflow.id]"
+            v-tooltip:bottom.group="t('workflow.host.title')"
+            name="riBaseStationLine"
+            size="20"
+            class="ml-2"
+          />
+        </template>
+      </shared-card>
+    </div>
+    <div
+      v-if="filteredWorkflows.length > 18"
+      class="flex items-center justify-between mt-8"
+    >
+      <div>
+        {{ t('components.pagination.text1') }}
+        <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
+          <option v-for="num in [18, 32, 64, 128]" :key="num" :value="num">
+            {{ num }}
+          </option>
+        </select>
+        {{
+          t('components.pagination.text2', {
+            count: filteredWorkflows.length,
+          })
+        }}
+      </div>
+      <ui-pagination
+        v-model="pagination.currentPage"
+        :per-page="pagination.perPage"
+        :records="filteredWorkflows.length"
+      />
+    </div>
+  </template>
+  <ui-modal v-model="renameState.show" title="Workflow">
+    <ui-input
+      v-model="renameState.name"
+      :placeholder="t('common.name')"
+      autofocus
+      class="w-full mb-4"
+      @keyup.enter="renameWorkflow"
+    />
+    <ui-textarea
+      v-model="renameState.description"
+      :placeholder="t('common.description')"
+      height="165px"
+      class="w-full dark:text-gray-200"
+      max="300"
+      style="min-height: 140px"
+    />
+    <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
+      {{ renameState.description.length }}/300
+    </p>
+    <div class="space-x-2 flex">
+      <ui-button class="w-full" @click="clearRenameModal">
+        {{ t('common.cancel') }}
+      </ui-button>
+      <ui-button variant="accent" class="w-full" @click="renameWorkflow">
+        {{ t('common.update') }}
+      </ui-button>
+    </div>
+  </ui-modal>
+</template>
+<script setup>
+import { shallowReactive, computed, onMounted, onBeforeUnmount } from 'vue';
+import { useI18n } from 'vue-i18n';
+import SelectionArea from '@viselect/vanilla';
+import { arraySorter } from '@/utils/helper';
+import { sendMessage } from '@/utils/message';
+import { useUserStore } from '@/stores/user';
+import { useDialog } from '@/composable/dialog';
+import { useWorkflowStore } from '@/stores/workflow';
+import { exportWorkflow } from '@/utils/workflowData';
+import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+
+const props = defineProps({
+  search: {
+    type: String,
+    default: '',
+  },
+  folderId: {
+    type: String,
+    default: '',
+  },
+  sort: {
+    type: Object,
+    default: () => ({
+      by: '',
+      order: '',
+    }),
+  },
+  perPage: {
+    type: Number,
+    default: 18,
+  },
+});
+
+const { t } = useI18n();
+const dialog = useDialog();
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
+const sharedWorkflowStore = useSharedWorkflowStore();
+
+const state = shallowReactive({
+  selectedWorkflows: [],
+});
+const renameState = shallowReactive({
+  id: '',
+  name: '',
+  show: false,
+  description: '',
+});
+const pagination = shallowReactive({
+  currentPage: 1,
+  perPage: +`${props.perPage}` || 18,
+});
+
+const selection = new SelectionArea({
+  container: '.workflows-list',
+  startareas: ['.workflows-list'],
+  boundaries: ['.workflows-list'],
+  selectables: ['.local-workflow'],
+});
+selection
+  .on('beforestart', ({ event }) => {
+    return (
+      event.target.tagName !== 'INPUT' &&
+      !event.target.closest('.local-workflow')
+    );
+  })
+  .on('start', () => {
+    /* eslint-disable-next-line */
+  clearSelectedWorkflows();
+  })
+  .on('move', (event) => {
+    event.store.changed.added.forEach((el) => {
+      el.classList.add('ring-2');
+    });
+    event.store.changed.removed.forEach((el) => {
+      el.classList.remove('ring-2');
+    });
+  })
+  .on('stop', (event) => {
+    state.selectedWorkflows = event.store.selected.map(
+      (el) => el.dataset?.workflow
+    );
+  });
+
+const filteredWorkflows = computed(() => {
+  const filtered = workflowStore.getWorkflows.filter(
+    ({ name, folderId }) =>
+      name.toLocaleLowerCase().includes(props.search.toLocaleLowerCase()) &&
+      (!props.folderId || props.folderId === folderId)
+  );
+
+  return arraySorter({
+    data: filtered,
+    key: props.sort.by,
+    order: props.sort.order,
+  });
+});
+const workflows = computed(() =>
+  filteredWorkflows.value.slice(
+    (pagination.currentPage - 1) * pagination.perPage,
+    pagination.currentPage * pagination.perPage
+  )
+);
+
+function executeWorkflow(workflow) {
+  sendMessage('workflow:execute', workflow, 'background');
+}
+function toggleDisableWorkflow({ id, isDisabled }) {
+  workflowStore.update({
+    id,
+    data: {
+      isDisabled: !isDisabled,
+    },
+  });
+}
+function clearRenameModal() {
+  Object.assign(renameState, {
+    id: '',
+    name: '',
+    show: false,
+    description: '',
+  });
+}
+function initRenameWorkflow({ name, description, id }) {
+  Object.assign(renameState, {
+    id,
+    name,
+    show: true,
+    description,
+  });
+}
+function renameWorkflow() {
+  workflowStore.update({
+    id: renameState.id,
+    data: {
+      name: renameState.name,
+      description: renameState.description,
+    },
+  });
+  clearRenameModal();
+}
+function deleteWorkflow({ name, id }) {
+  dialog.confirm({
+    title: t('workflow.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name }),
+    onConfirm: () => {
+      workflowStore.delete(id);
+    },
+  });
+}
+function deleteSelectedWorkflows({ target, key }) {
+  const excludeTags = ['INPUT', 'TEXTAREA', 'SELECT'];
+  if (
+    excludeTags.includes(target.tagName) ||
+    key !== 'Delete' ||
+    state.selectedWorkflows.length === 0
+  )
+    return;
+
+  if (state.selectedWorkflows.length === 1) {
+    const [workflowId] = state.selectedWorkflows;
+    const workflow = workflowStore.getById(workflowId);
+    deleteWorkflow(workflow);
+  } else {
+    dialog.confirm({
+      title: t('workflow.delete'),
+      okVariant: 'danger',
+      body: t('message.delete', {
+        name: `${state.selectedWorkflows.length} workflows`,
+      }),
+      onConfirm: async () => {
+        await workflowStore.delete(state.selectedWorkflows);
+      },
+    });
+  }
+}
+function duplicateWorkflow(workflow) {
+  const copyWorkflow = { ...workflow, createdAt: Date.now() };
+  const delKeys = ['$id', 'data', 'id', 'isDisabled'];
+
+  delKeys.forEach((key) => {
+    delete copyWorkflow[key];
+  });
+
+  workflowStore.insert(copyWorkflow);
+}
+function onDragStart({ dataTransfer, target }) {
+  const payload = [...state.selectedWorkflows];
+
+  const targetId = target.dataset?.workflow;
+  if (targetId && !payload.includes(targetId)) payload.push(targetId);
+
+  dataTransfer.setData('workflows', JSON.stringify(payload));
+}
+function clearSelectedWorkflows() {
+  state.selectedWorkflows = [];
+
+  selection.getSelection().forEach((el) => {
+    el.classList.remove('ring-2');
+  });
+  selection.clearSelection();
+}
+
+const menu = [
+  {
+    id: 'duplicate',
+    name: t('common.duplicate'),
+    icon: 'riFileCopyLine',
+    action: duplicateWorkflow,
+  },
+  {
+    id: 'export',
+    name: t('common.export'),
+    icon: 'riDownloadLine',
+    action: exportWorkflow,
+  },
+  {
+    id: 'rename',
+    name: t('common.rename'),
+    icon: 'riPencilLine',
+    action: initRenameWorkflow,
+  },
+  {
+    id: 'delete',
+    name: t('common.delete'),
+    icon: 'riDeleteBin7Line',
+    action: deleteWorkflow,
+  },
+];
+
+onMounted(() => {
+  window.addEventListener('keydown', deleteSelectedWorkflows);
+});
+onBeforeUnmount(() => {
+  window.removeEventListener('keydown', deleteSelectedWorkflows);
+});
+</script>

+ 49 - 0
src/components/newtab/workflows/WorkflowsShared.vue

@@ -0,0 +1,49 @@
+<template>
+  <shared-card
+    v-for="workflow in workflows"
+    :key="workflow.id"
+    :data="workflow"
+    :show-details="false"
+    @execute="executeWorkflow(workflow)"
+    @click="$router.push(`/workflows/${$event.id}/shared`)"
+  />
+</template>
+<script setup>
+import { computed } from 'vue';
+import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
+import { sendMessage } from '@/utils/message';
+import { arraySorter } from '@/utils/helper';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+
+const props = defineProps({
+  search: {
+    type: String,
+    default: '',
+  },
+  sort: {
+    type: Object,
+    default: () => ({
+      by: '',
+      order: '',
+    }),
+  },
+});
+
+const sharedWorkflowStore = useSharedWorkflowStore();
+
+const workflows = computed(() => {
+  const filtered = sharedWorkflowStore.toArray.filter(({ name }) =>
+    name.toLocaleLowerCase().includes(props.search.toLocaleLowerCase())
+  );
+
+  return arraySorter({
+    data: filtered,
+    key: props.sort.by,
+    order: props.sort.order,
+  });
+});
+
+function executeWorkflow(workflow) {
+  sendMessage('workflow:execute', workflow, 'background');
+}
+</script>

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