Ahmad Kholid vor 3 Jahren
Ursprung
Commit
3fae4fff32
61 geänderte Dateien mit 1589 neuen und 371 gelöschten Zeilen
  1. 2 1
      package.json
  2. 5 0
      src/background/index.js
  3. 10 6
      src/background/workflow-engine/blocks-handler/handler-google-sheets.js
  4. 3 0
      src/background/workflow-engine/blocks-handler/handler-javascript-code.js
  5. 1 1
      src/background/workflow-engine/blocks-handler/handler-save-assets.js
  6. 25 7
      src/background/workflow-engine/blocks-handler/handler-take-screenshot.js
  7. 39 16
      src/background/workflow-engine/engine.js
  8. 47 0
      src/components/block/BlockBasic.vue
  9. 20 7
      src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue
  10. 5 0
      src/components/newtab/shared/SharedConditionBuilder/index.vue
  11. 9 1
      src/components/newtab/workflow/WorkflowBuilder.vue
  12. 12 4
      src/components/newtab/workflow/WorkflowDataTable.vue
  13. 138 3
      src/components/newtab/workflow/WorkflowEditBlock.vue
  14. 20 8
      src/components/newtab/workflow/edit/EditAttributeValue.vue
  15. 32 21
      src/components/newtab/workflow/edit/EditCloseTab.vue
  16. 5 0
      src/components/newtab/workflow/edit/EditConditions.vue
  17. 20 7
      src/components/newtab/workflow/edit/EditElementExists.vue
  18. 20 7
      src/components/newtab/workflow/edit/EditExportData.vue
  19. 21 6
      src/components/newtab/workflow/edit/EditForms.vue
  20. 37 15
      src/components/newtab/workflow/edit/EditGetText.vue
  21. 56 37
      src/components/newtab/workflow/edit/EditGoogleSheets.vue
  22. 21 8
      src/components/newtab/workflow/edit/EditHandleDialog.vue
  23. 20 6
      src/components/newtab/workflow/edit/EditInteractionBase.vue
  24. 20 7
      src/components/newtab/workflow/edit/EditLoopData.vue
  25. 20 7
      src/components/newtab/workflow/edit/EditNewTab.vue
  26. 35 13
      src/components/newtab/workflow/edit/EditSaveAssets.vue
  27. 8 1
      src/components/newtab/workflow/edit/EditScrollElement.vue
  28. 48 28
      src/components/newtab/workflow/edit/EditSwitchTab.vue
  29. 19 6
      src/components/newtab/workflow/edit/EditSwitchTo.vue
  30. 19 6
      src/components/newtab/workflow/edit/EditTakeScreenshot.vue
  31. 8 1
      src/components/newtab/workflow/edit/EditTriggerEvent.vue
  32. 21 5
      src/components/newtab/workflow/edit/EditUploadFile.vue
  33. 22 9
      src/components/newtab/workflow/edit/EditWebhook.vue
  34. 5 0
      src/components/newtab/workflow/edit/EditWhileLoop.vue
  35. 142 0
      src/components/newtab/workflow/edit/OnBlockError.vue
  36. 11 3
      src/components/newtab/workflow/edit/TriggerEventWheel.vue
  37. 181 45
      src/components/ui/UiAutocomplete.vue
  38. 5 1
      src/components/ui/UiTextarea.vue
  39. 3 1
      src/composable/editorBlock.js
  40. 11 2
      src/content/blocks-handler/handler-javascript-code.js
  41. 151 44
      src/content/element-selector/App.vue
  42. 46 0
      src/content/element-selector/AppElementHighlighter.vue
  43. 33 9
      src/content/element-selector/AppSelector.vue
  44. 2 0
      src/content/element-selector/icons.js
  45. 1 1
      src/content/element-selector/index.js
  46. 84 0
      src/content/element-selector/list-selector.js
  47. 4 0
      src/lib/drawflow.js
  48. 4 0
      src/lib/v-remixicon.js
  49. 22 0
      src/locales/en/blocks.json
  50. 9 0
      src/locales/en/newtab.json
  51. 1 1
      src/newtab/pages/Settings.vue
  52. 0 0
      src/newtab/pages/settings/SettingsAbout.vue
  53. 0 0
      src/newtab/pages/settings/SettingsBackup.vue
  54. 43 3
      src/newtab/pages/settings/SettingsIndex.vue
  55. 0 0
      src/newtab/pages/settings/SettingsShortcuts.vue
  56. 17 0
      src/newtab/pages/workflows/[id].vue
  57. 4 4
      src/newtab/router.js
  58. 5 11
      src/store/index.js
  59. 2 1
      src/utils/reference-data/index.js
  60. 10 1
      src/utils/shared.js
  61. 5 0
      yarn.lock

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.6.7",
+  "version": "1.7.0",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -42,6 +42,7 @@
     "defu": "^5.0.1",
     "drawflow": "^0.0.51",
     "idb": "^7.0.0",
+    "lodash.clonedeep": "^4.5.0",
     "mitt": "^3.0.0",
     "mousetrap": "^1.6.5",
     "nanoid": "^3.2.0",

+ 5 - 0
src/background/index.js

@@ -148,6 +148,11 @@ checkWorkflowStates();
 async function checkVisitWebTriggers(changeInfo, tab) {
   if (!changeInfo.status || changeInfo.status !== 'complete') return;
 
+  const tabIsUsed = await workflow.states.get(
+    ({ state }) => state.activeTab.id === tab.id
+  );
+  if (tabIsUsed) return;
+
   const visitWebTriggers = await storage.get('visitWebTriggers');
   const triggeredWorkflow = visitWebTriggers.find(({ url, isRegex }) => {
     if (url.trim() === '') return false;

+ 10 - 6
src/background/workflow-engine/blocks-handler/handler-google-sheets.js

@@ -9,13 +9,15 @@ import { getBlockConnection } from '../helper';
 
 async function getSpreadsheetValues({ spreadsheetId, range, firstRowAsKey }) {
   const response = await googleSheets.getValues({ spreadsheetId, range });
+  const result = await response.json();
 
-  if (response.status !== 200) {
-    throw new Error(response.statusText);
+  if (!response.ok) {
+    throw new Error(result.statusMessage);
   }
 
-  const { values } = await response.json();
-  const sheetsData = firstRowAsKey ? convert2DArrayToArrayObj(values) : values;
+  const sheetsData = firstRowAsKey
+    ? convert2DArrayToArrayObj(result.values)
+    : result.values;
 
   return sheetsData;
 }
@@ -51,8 +53,10 @@ async function updateSpreadsheetValues(
     },
   });
 
-  if (response.status !== 200) {
-    throw new Error(response.statusText);
+  if (!response.ok) {
+    const error = await response.json();
+
+    throw new Error(error.statusMessage);
   }
 }
 

+ 3 - 0
src/background/workflow-engine/blocks-handler/handler-javascript-code.js

@@ -23,6 +23,9 @@ export async function javascriptCode({ outputs, data, ...block }, { refData }) {
 
     const result = await this._sendMessageToTab({ ...block, data, refData });
 
+    if (result?.columns.data.$error) {
+      throw new Error(result?.columns.data.message);
+    }
     if (result?.variables) {
       Object.keys(result.variables).forEach((varName) => {
         this.setVariable(varName, result.variables[varName]);

+ 1 - 1
src/background/workflow-engine/blocks-handler/handler-save-assets.js

@@ -52,7 +52,7 @@ export default async function ({ data, id, name, outputs }) {
         tabId: this.activeTab.id,
       });
 
-      await Promise.allSettled(sources.map((url) => downloadFile(url)));
+      await Promise.all(sources.map((url) => downloadFile(url)));
     } else if (data.type === 'url') {
       await downloadFile(data.url);
     }

+ 25 - 7
src/background/workflow-engine/blocks-handler/handler-take-screenshot.js

@@ -2,11 +2,24 @@ import browser from 'webextension-polyfill';
 import { fileSaver } from '@/utils/helper';
 import { getBlockConnection } from '../helper';
 
-function saveImage({ fileName, uri, ext }) {
+async function saveImage({ filename, uri, ext }) {
+  const hasDownloadAccess = await browser.permissions.contains({
+    permissions: ['downloads'],
+  });
+  const name = `${filename || 'Screenshot'}.${ext || 'png'}`;
+
+  if (hasDownloadAccess) {
+    await browser.downloads.download({
+      url: uri,
+      filename: name,
+    });
+
+    return;
+  }
+
   const image = new Image();
 
   image.onload = () => {
-    const name = `${fileName || 'Screenshot'}.${ext || 'png'}`;
     const canvas = document.createElement('canvas');
     canvas.width = image.width;
     canvas.height = image.height;
@@ -31,10 +44,14 @@ async function takeScreenshot({ data, outputs, name }) {
       quality: data.quality,
       format: data.ext || 'png',
     };
-    const saveScreenshot = (dataUrl) => {
+    const saveScreenshot = async (dataUrl) => {
       if (data.saveToColumn) this.addDataToColumn(data.dataColumn, dataUrl);
       if (saveToComputer)
-        saveImage({ fileName: data.fileName, uri: dataUrl, ext: data.ext });
+        await saveImage({
+          filename: data.fileName,
+          uri: dataUrl,
+          ext: data.ext,
+        });
       if (data.assignVariable) this.setVariable(data.variableName, dataUrl);
     };
 
@@ -48,7 +65,8 @@ async function takeScreenshot({ data, outputs, name }) {
         currentWindow: true,
       });
 
-      await browser.windows.update(this.windowId, { focused: true });
+      if (this.windowId)
+        await browser.windows.update(this.windowId, { focused: true });
       await browser.tabs.update(this.activeTab.id, { active: true });
 
       await new Promise((resolve) => setTimeout(resolve, 500));
@@ -66,11 +84,11 @@ async function takeScreenshot({ data, outputs, name }) {
         await browser.tabs.update(tab.id, { active: true });
       }
 
-      saveScreenshot(screenshot);
+      await saveScreenshot(screenshot);
     } else {
       screenshot = await browser.tabs.captureVisibleTab(options);
 
-      saveScreenshot(screenshot);
+      await saveScreenshot(screenshot);
     }
 
     return { data: screenshot, nextBlockId };

+ 39 - 16
src/background/workflow-engine/engine.js

@@ -10,7 +10,7 @@ import {
   objectHasKey,
 } from '@/utils/helper';
 import referenceData from '@/utils/reference-data';
-import { convertData, waitTabLoaded } from './helper';
+import { convertData, waitTabLoaded, getBlockConnection } from './helper';
 import executeContentScript from './execute-content-script';
 
 class WorkflowEngine {
@@ -371,7 +371,7 @@ class WorkflowEngine {
     }
   }
 
-  async executeBlock(block, prevBlockData) {
+  async executeBlock(block, prevBlockData, isRetry) {
     const currentState = await this.states.get(this.id);
 
     if (!currentState || currentState.isDestroyed) {
@@ -385,8 +385,10 @@ class WorkflowEngine {
     this.referenceData.prevBlockData = prevBlockData;
     this.referenceData.activeTabUrl = this.activeTab.url || '';
 
-    await this.states.update(this.id, { state: this.state });
-    this.dispatchEvent('update', { state: this.state });
+    if (!isRetry) {
+      await this.states.update(this.id, { state: this.state });
+      this.dispatchEvent('update', { state: this.state });
+    }
 
     const startExecuteTime = Date.now();
 
@@ -405,9 +407,19 @@ class WorkflowEngine {
     const replacedBlock = referenceData({
       block,
       data: this.referenceData,
-      refKeys: tasks[block.name].refDataKeys,
+      refKeys: isRetry ? null : tasks[block.name].refDataKeys,
     });
     const blockDelay = this.workflow.settings?.blockDelay || 0;
+    const addBlockLog = (status, obj = {}) => {
+      this.addLogHistory({
+        type: status,
+        name: block.name,
+        description: block.data.description,
+        replacedValue: replacedBlock.replacedValue,
+        duration: Math.round(Date.now() - startExecuteTime),
+        ...obj,
+      });
+    };
 
     try {
       const result = await handler.call(this, replacedBlock, {
@@ -418,13 +430,8 @@ class WorkflowEngine {
       if (result.replacedValue)
         replacedBlock.replacedValue = result.replacedValue;
 
-      this.addLogHistory({
-        name: block.name,
+      addBlockLog(result.status || 'success', {
         logId: result.logId,
-        type: result.status || 'success',
-        description: block.data.description,
-        replacedValue: replacedBlock.replacedValue,
-        duration: Math.round(Date.now() - startExecuteTime),
       });
 
       if (result.nextBlockId) {
@@ -440,12 +447,28 @@ class WorkflowEngine {
         this.destroy('success');
       }
     } catch (error) {
-      this.addLogHistory({
-        type: 'error',
+      const { onError: blockOnError } = replacedBlock.data;
+      if (blockOnError && blockOnError.enable) {
+        if (blockOnError.retry && blockOnError.retryTimes) {
+          await sleep(blockOnError.retryInterval * 1000);
+          blockOnError.retryTimes -= 1;
+          await this.executeBlock(replacedBlock, prevBlockData, true);
+
+          return;
+        }
+
+        const nextBlockId = getBlockConnection(
+          block,
+          blockOnError.toDo === 'continue' ? 1 : 2
+        );
+        if (blockOnError.toDo !== 'error' && nextBlockId) {
+          this.executeBlock(this.blocks[nextBlockId], '');
+          return;
+        }
+      }
+
+      addBlockLog('error', {
         message: error.message,
-        name: block.name,
-        description: block.data.description,
-        replacedValue: replacedBlock.replacedValue,
         ...(error.data || {}),
       });
 

+ 47 - 0
src/components/block/BlockBasic.vue

@@ -32,6 +32,22 @@
         />
       </div>
     </div>
+    <div
+      v-if="
+        block.data.onError?.enable && block.data.onError?.toDo === 'fallback'
+      "
+      class="fallback flex items-center justify-end"
+    >
+      <v-remixicon
+        v-if="block"
+        :title="t('workflow.blocks.base.onError.fallbackTitle')"
+        name="riInformationLine"
+        size="18"
+      />
+      <span class="ml-1">
+        {{ t('common.fallback') }}
+      </span>
+    </div>
     <slot :block="block"></slot>
     <template #prepend>
       <div
@@ -48,6 +64,7 @@
   </block-base>
 </template>
 <script setup>
+import { watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import emitter from '@/lib/mitt';
 import { useEditorBlock } from '@/composable/editorBlock';
@@ -87,10 +104,40 @@ function handleStartDrag(event) {
 
   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>

+ 20 - 7
src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue

@@ -22,14 +22,23 @@
           </option>
         </optgroup>
       </ui-select>
-      <ui-input
-        v-for="(_, name) in item.data"
-        :key="item.id + name + index"
-        v-model="inputsData[index].data[name]"
-        :title="conditionBuilder.inputTypes[name].label"
-        :placeholder="conditionBuilder.inputTypes[name].label"
+      <ui-autocomplete
+        :items="autocomplete"
+        :trigger-char="['{{', '}}']"
+        block
+        hide-empty
         class="flex-1"
-      />
+      >
+        <ui-input
+          v-for="(_, name) in item.data"
+          :key="item.id + name + index"
+          v-model="inputsData[index].data[name]"
+          :title="conditionBuilder.inputTypes[name].label"
+          :placeholder="conditionBuilder.inputTypes[name].label"
+          autocomplete="off"
+          class="w-full"
+        />
+      </ui-autocomplete>
     </div>
     <ui-select
       v-else-if="item.category === 'compare'"
@@ -56,6 +65,10 @@ const props = defineProps({
     type: Array,
     default: () => [],
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update']);
 

+ 5 - 0
src/components/newtab/shared/SharedConditionBuilder/index.vue

@@ -45,6 +45,7 @@
             </template>
             <div class="space-y-2 px-4 py-2">
               <condition-builder-inputs
+                :autocomplete="autocomplete"
                 :data="inputs.items"
                 @update="
                   conditions[index].conditions[inputsIndex].items = $event
@@ -96,6 +97,10 @@ const props = defineProps({
     type: Array,
     default: () => [],
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:modelValue', 'change']);
 

+ 9 - 1
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -73,6 +73,7 @@ import {
   watch,
   onBeforeUnmount,
 } from 'vue';
+import { useStore } from 'vuex';
 import { useRoute } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import { compare } from 'compare-versions';
@@ -109,6 +110,7 @@ export default {
 
     const { t } = useI18n();
     const route = useRoute();
+    const store = useStore();
 
     const contextMenuItems = {
       block: [
@@ -366,7 +368,13 @@ export default {
       const context = getCurrentInstance().appContext.app._context;
       const element = document.querySelector('#drawflow');
 
-      editor.value = drawflow(element, { context, options: { reroute: true } });
+      editor.value = drawflow(element, {
+        context,
+        options: {
+          reroute: true,
+          ...store.state.settings.editor,
+        },
+      });
 
       const editorStates =
         parseJSON(localStorage.getItem('editor-states'), {}) || {};

+ 12 - 4
src/components/newtab/workflow/WorkflowDataTable.vue

@@ -54,7 +54,7 @@ const props = defineProps({
     default: () => ({}),
   },
 });
-const emit = defineEmits(['update', 'close']);
+const emit = defineEmits(['update', 'close', 'change']);
 
 const { t } = useI18n();
 
@@ -105,7 +105,10 @@ function addColumn() {
 watch(
   () => state.columns,
   debounce((newValue) => {
-    emit('update', { table: newValue });
+    const data = { table: newValue };
+
+    emit('update', data);
+    emit('change', data);
   }, 250),
   { deep: true }
 );
@@ -120,8 +123,13 @@ onMounted(() => {
       }
 
       return column;
-    }) || props.workflow.table;
+    }) || [];
+
+  if (isChanged) {
+    const data = { table: state.columns };
 
-  if (isChanged) emit('update', { table: state.columns });
+    emit('change', data);
+    emit('update', data);
+  }
 });
 </script>

+ 138 - 3
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -24,12 +24,22 @@
       :key="data.blockId"
       v-model:data="blockData"
       :block-id="data.blockId"
+      :autocomplete="autocompleteList"
+    />
+    <on-block-error
+      v-if="!excludeOnError.includes(data.id)"
+      :key="data.blockId"
+      :data="data"
+      class="mt-4"
+      @change="$emit('update', { ...blockData, onError: $event })"
     />
   </div>
 </template>
 <script>
-import { computed } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { tasks } from '@/utils/shared';
+import OnBlockError from './edit/OnBlockError.vue';
 
 const editComponents = require.context(
   './edit',
@@ -48,16 +58,45 @@ const components = editComponents.keys().reduce((acc, key) => {
 }, {});
 
 export default {
-  components,
+  components: { ...components, OnBlockError },
   props: {
     data: {
       type: Object,
       default: () => ({}),
     },
+    editor: {
+      type: Object,
+      default: () => ({}),
+    },
+    workflow: {
+      type: Object,
+      default: () => ({}),
+    },
+    autocomplete: {
+      type: Object,
+      default: () => ({}),
+    },
+    dataChanged: Boolean,
   },
-  emits: ['close', 'update'],
+  emits: ['close', 'update', 'update:autocomplete'],
   setup(props, { emit }) {
+    const defaultAutocomplete = [
+      'activeTabUrl',
+      '$date',
+      '$randint',
+      '$getLength',
+      'globalData',
+    ];
+    const excludeOnError = [
+      'webhook',
+      'while-loop',
+      'element-exists',
+      'conditions',
+      'trigger',
+    ];
+
     const { t } = useI18n();
+    const autocompleteData = ref({});
 
     const blockData = computed({
       get() {
@@ -67,10 +106,106 @@ export default {
         emit('update', value);
       },
     });
+    const autocompleteList = computed(() => {
+      const blockId = props.data.itemId || props.data.blockId;
+      const arr = [
+        defaultAutocomplete,
+        autocompleteData.value.table,
+        autocompleteData.value[blockId],
+      ];
+
+      return arr.flatMap((items) => [...(items || [])]);
+    });
+
+    const dataKeywords = {
+      loopId: 'loopData',
+      refKey: 'googleSheets',
+      variableName: 'variables',
+    };
+    function addAutocompleteData(id, name, data) {
+      if (!autocompleteData.value[id]) autocompleteData.value[id] = new Set();
+
+      if (!tasks[name].autocomplete) return;
+
+      tasks[name].autocomplete.forEach((key) => {
+        if (!data[key]) return;
+
+        autocompleteData.value[id].add(`${dataKeywords[key]}@${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(
+      id,
+      { name, inputs, data },
+      blocks,
+      maxDepth = 100
+    ) {
+      if (maxDepth === 0) return;
+
+      if (maxDepth !== 100) {
+        if (name === 'blocks-group') getGroupBlockData(data.blocks);
+        else addAutocompleteData(props.data.blockId, name, data);
+      }
+
+      inputs?.input_1?.connections.forEach(({ node }) => {
+        traceBlockData(id, blocks[node], blocks, maxDepth - 1);
+      });
+    }
+
+    watch(
+      () => [props.data.blockId, props.data.itemId],
+      () => {
+        const id = props.data.blockId;
+
+        if (
+          !props.autocomplete ||
+          !props.autocomplete[id] ||
+          props.dataChanged
+        ) {
+          const blocks = props.editor.export().drawflow.Home.data;
+          const currentBlock = blocks[id];
+
+          if (props.data.isInGroup)
+            getGroupBlockData(currentBlock.data.blocks, props.data.itemId);
+
+          traceBlockData(props.data.blockId, currentBlock, blocks);
+        }
+
+        if (!autocompleteData.value.table) {
+          autocompleteData.value.table = new Set();
+          props.workflow.table?.forEach((column) => {
+            autocompleteData.value.table.add(`table@${column.name}`);
+          });
+        }
+      },
+      { immediate: true }
+    );
+    watch(
+      autocompleteData,
+      () => {
+        emit('update:autocomplete', autocompleteData.value);
+      },
+      { deep: true }
+    );
 
     return {
       t,
       blockData,
+      excludeOnError,
+      autocompleteList,
     };
   },
 };

+ 20 - 8
src/components/newtab/workflow/edit/EditAttributeValue.vue

@@ -1,13 +1,21 @@
 <template>
-  <edit-interaction-base v-bind="{ data }" @change="updateData">
+  <edit-interaction-base v-bind="{ data, autocomplete }" @change="updateData">
     <hr />
-    <ui-input
-      :model-value="data.attributeName"
-      :label="t('workflow.blocks.attribute-value.forms.name')"
-      placeholder="name"
-      class="w-full"
-      @change="updateData({ attributeName: $event })"
-    />
+    <ui-autocomplete
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
+    >
+      <ui-input
+        :model-value="data.attributeName"
+        :label="t('workflow.blocks.attribute-value.forms.name')"
+        autocomplete="off"
+        placeholder="name"
+        class="w-full"
+        @change="updateData({ attributeName: $event })"
+      />
+    </ui-autocomplete>
     <insert-workflow-data
       :data="data"
       extra-row
@@ -26,6 +34,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 32 - 21
src/components/newtab/workflow/edit/EditCloseTab.vue

@@ -30,29 +30,36 @@
           {{ t('workflow.blocks.close-tab.activeTab') }}
         </ui-checkbox>
       </div>
-      <ui-input
+      <ui-autocomplete
         v-if="!data.activeTab"
-        :model-value="data.url"
-        class="w-full mt-1"
-        placeholder="http://example.com/*"
-        @change="updateData({ url: $event })"
+        :items="autocomplete"
+        :trigger-char="['{{', '}}']"
+        block
+        hide-empty
       >
-        <template #label>
-          {{ t('workflow.blocks.close-tab.url') }}
-          <a
-            :title="t('common.example', 2)"
-            rel="noopener"
-            target="_blank"
-            href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#examples"
-          >
-            <v-remixicon
-              name="riInformationLine"
-              size="18"
-              class="inline-block"
-            />
-          </a>
-        </template>
-      </ui-input>
+        <ui-input
+          :model-value="data.url"
+          class="w-full mt-1"
+          placeholder="http://example.com/*"
+          @change="updateData({ url: $event })"
+        >
+          <template #label>
+            {{ t('workflow.blocks.close-tab.url') }}
+            <a
+              :title="t('common.example', 2)"
+              rel="noopener"
+              target="_blank"
+              href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#examples"
+            >
+              <v-remixicon
+                name="riInformationLine"
+                size="18"
+                class="inline-block"
+              />
+            </a>
+          </template>
+        </ui-input>
+      </ui-autocomplete>
     </template>
     <ui-checkbox
       v-else
@@ -72,6 +79,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

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

@@ -53,6 +53,7 @@
             class="text-xl font-semibold mb-4 bg-transparent focus:ring-0"
           />
           <shared-condition-builder
+            :autocomplete="autocomplete"
             :model-value="conditions[state.conditionsIndex].conditions"
             @change="conditions[state.conditionsIndex].conditions = $event"
           />
@@ -77,6 +78,10 @@ const props = defineProps({
     type: String,
     default: '',
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 20 - 7
src/components/newtab/workflow/edit/EditElementExists.vue

@@ -10,13 +10,22 @@
         {{ t(`workflow.blocks.base.findElement.options.${type}`) }}
       </option>
     </ui-select>
-    <ui-input
-      :model-value="data.selector"
-      :label="t('workflow.blocks.element-exists.selector')"
-      placeholder=".element"
-      class="mb-1 w-full"
-      @change="updateData({ selector: $event })"
-    />
+    <ui-autocomplete
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
+      class="mb-1"
+    >
+      <ui-input
+        :model-value="data.selector"
+        :label="t('workflow.blocks.element-exists.selector')"
+        autocomplete="off"
+        placeholder=".element"
+        class="w-full"
+        @change="updateData({ selector: $event })"
+      />
+    </ui-autocomplete>
     <ui-input
       :model-value="data.tryCount"
       :title="t('workflow.blocks.element-exists.tryFor.title')"
@@ -54,6 +63,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 20 - 7
src/components/newtab/workflow/edit/EditExportData.vue

@@ -16,13 +16,22 @@
         {{ t(`workflow.blocks.export-data.dataToExport.options.${option}`) }}
       </option>
     </ui-select>
-    <ui-input
-      :model-value="data.name"
-      label="File name"
-      class="w-full mt-2"
-      placeholder="unnamed"
-      @change="updateData({ name: $event })"
-    />
+    <ui-autocomplete
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
+      class="mt-2"
+    >
+      <ui-input
+        :model-value="data.name"
+        autocomplete="off"
+        label="File name"
+        class="w-full"
+        placeholder="unnamed"
+        @change="updateData({ name: $event })"
+      />
+    </ui-autocomplete>
     <ui-select
       v-if="permission.has.downloads"
       :model-value="data.onConflict"
@@ -72,6 +81,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 21 - 6
src/components/newtab/workflow/edit/EditForms.vue

@@ -1,5 +1,8 @@
 <template>
-  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
+  <edit-interaction-base
+    v-bind="{ data, hide: hideBase, autocomplete }"
+    @change="updateData"
+  >
     <hr />
     <ui-checkbox
       :model-value="data.getValue"
@@ -32,12 +35,20 @@
         {{ t('workflow.blocks.forms.selected') }}
       </ui-checkbox>
       <template v-if="data.type === 'text-field' || data.type === 'select'">
-        <ui-textarea
-          :model-value="data.value"
-          :placeholder="t('workflow.blocks.forms.text-field.value')"
+        <ui-autocomplete
+          :items="autocomplete"
+          :trigger-char="['{{', '}}']"
+          block
+          hide-empty
           class="w-full mb-1"
-          @change="updateData({ value: $event })"
-        />
+        >
+          <ui-textarea
+            :model-value="data.value"
+            :placeholder="t('workflow.blocks.forms.text-field.value')"
+            class="w-full"
+            @change="updateData({ value: $event })"
+          />
+        </ui-autocomplete>
         <ui-checkbox
           :model-value="data.clearValue"
           @change="updateData({ clearValue: $event })"
@@ -72,6 +83,10 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 37 - 15
src/components/newtab/workflow/edit/EditGetText.vue

@@ -1,5 +1,5 @@
 <template>
-  <edit-interaction-base v-bind="{ data }" @change="updateData">
+  <edit-interaction-base v-bind="{ data, autocomplete }" @change="updateData">
     <hr />
     <div class="flex rounded-lg bg-input px-4 items-center transition">
       <span>/</span>
@@ -27,22 +27,40 @@
       </ui-popover>
     </div>
     <div class="mt-2 flex space-x-2">
-      <ui-input
-        :model-value="data.prefixText"
-        :title="t('workflow.blocks.get-text.prefixText.title')"
-        :label="t('workflow.blocks.get-text.prefixText.placeholder')"
-        placeholder="Text"
+      <ui-autocomplete
+        :items="autocomplete"
+        :trigger-char="['{{', '}}']"
+        block
+        hide-empty
         class="w-full"
-        @change="updateData({ prefixText: $event })"
-      />
-      <ui-input
-        :model-value="data.suffixText"
-        :title="t('workflow.blocks.get-text.suffixText.title')"
-        :label="t('workflow.blocks.get-text.suffixText.placeholder')"
-        placeholder="Text"
+      >
+        <ui-input
+          :model-value="data.prefixText"
+          :title="t('workflow.blocks.get-text.prefixText.title')"
+          :label="t('workflow.blocks.get-text.prefixText.placeholder')"
+          autocomplete="off"
+          placeholder="Text"
+          class="w-full"
+          @change="updateData({ prefixText: $event })"
+        />
+      </ui-autocomplete>
+      <ui-autocomplete
+        :items="autocomplete"
+        :trigger-char="['{{', '}}']"
+        block
+        hide-empty
         class="w-full"
-        @change="updateData({ suffixText: $event })"
-      />
+      >
+        <ui-input
+          :model-value="data.suffixText"
+          :title="t('workflow.blocks.get-text.suffixText.title')"
+          :label="t('workflow.blocks.get-text.suffixText.placeholder')"
+          autocomplete="off"
+          placeholder="Text"
+          class="w-full"
+          @change="updateData({ suffixText: $event })"
+        />
+      </ui-autocomplete>
     </div>
     <ui-checkbox
       :model-value="data.includeTags"
@@ -71,6 +89,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 56 - 37
src/components/newtab/workflow/edit/EditGoogleSheets.vue

@@ -18,42 +18,56 @@
         {{ t('workflow.blocks.google-sheets.select.update') }}
       </option>
     </ui-select>
-    <ui-input
-      :model-value="data.spreadsheetId"
-      class="w-full"
-      placeholder="abcd123"
-      @change="updateData({ spreadsheetId: $event })"
+    <ui-autocomplete
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
     >
-      <template #label>
-        {{ t('workflow.blocks.google-sheets.spreadsheetId.label') }}*
-        <a
-          href="https://docs.automa.site/blocks/google-sheets.html#spreadsheet-id"
-          target="_blank"
-          rel="noopener"
-          :title="t('workflow.blocks.google-sheets.spreadsheetId.link')"
-        >
-          <v-remixicon name="riInformationLine" size="18" class="inline" />
-        </a>
-      </template>
-    </ui-input>
-    <ui-input
-      :model-value="data.range"
-      class="w-full mt-1"
-      placeholder="Sheet1!A1:B2"
-      @change="updateData({ range: $event })"
+      <ui-input
+        :model-value="data.spreadsheetId"
+        class="w-full"
+        placeholder="abcd123"
+        @change="updateData({ spreadsheetId: $event })"
+      >
+        <template #label>
+          {{ t('workflow.blocks.google-sheets.spreadsheetId.label') }}*
+          <a
+            href="https://docs.automa.site/blocks/google-sheets.html#spreadsheet-id"
+            target="_blank"
+            rel="noopener"
+            :title="t('workflow.blocks.google-sheets.spreadsheetId.link')"
+          >
+            <v-remixicon name="riInformationLine" size="18" class="inline" />
+          </a>
+        </template>
+      </ui-input>
+    </ui-autocomplete>
+    <ui-autocomplete
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
     >
-      <template #label>
-        {{ t('workflow.blocks.google-sheets.range.label') }}*
-        <a
-          href="https://docs.automa.site/blocks/google-sheets.html#range"
-          target="_blank"
-          rel="noopener"
-          :title="t('workflow.blocks.google-sheets.range.link')"
-        >
-          <v-remixicon name="riInformationLine" size="18" class="inline" />
-        </a>
-      </template>
-    </ui-input>
+      <ui-input
+        :model-value="data.range"
+        class="w-full mt-1"
+        placeholder="Sheet1!A1:B2"
+        @change="updateData({ range: $event })"
+      >
+        <template #label>
+          {{ t('workflow.blocks.google-sheets.range.label') }}*
+          <a
+            href="https://docs.automa.site/blocks/google-sheets.html#range"
+            target="_blank"
+            rel="noopener"
+            :title="t('workflow.blocks.google-sheets.range.link')"
+          >
+            <v-remixicon name="riInformationLine" size="18" class="inline" />
+          </a>
+        </template>
+      </ui-input>
+    </ui-autocomplete>
     <template v-if="data.type === 'get'">
       <ui-input
         :model-value="data.refKey"
@@ -83,8 +97,9 @@
       <shared-codemirror
         v-if="previewDataState.data && previewDataState.status !== 'error'"
         :model-value="previewDataState.data"
+        :line-numbers="false"
         readonly
-        class="mt-4 max-h-96"
+        class="mt-4 max-h-96 scroll"
       />
     </template>
     <template v-else-if="data.type === 'update'">
@@ -167,6 +182,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 
@@ -196,10 +215,10 @@ async function previewData() {
       range: props.data.range,
     });
 
-    if (response.status !== 200) {
+    if (!response.ok) {
       const error = await response.json();
 
-      throw new Error(response.statusText || error.statusMessage);
+      throw new Error(error.statusMessage || response.statusText);
     }
 
     const { values } = await response.json();

+ 21 - 8
src/components/newtab/workflow/edit/EditHandleDialog.vue

@@ -14,15 +14,24 @@
     >
       {{ t('workflow.blocks.handle-dialog.accept') }}
     </ui-checkbox>
-    <ui-input
+    <ui-autocomplete
       v-if="data.accept"
-      :model-value="data.promptText"
-      :label="t('workflow.blocks.handle-dialog.promptText.label')"
-      :title="t('workflow.blocks.handle-dialog.promptText.description')"
-      placeholder="Text"
-      class="w-full mt-1"
-      @change="updateData({ promptText: $event })"
-    />
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
+      class="mt-1"
+    >
+      <ui-input
+        :model-value="data.promptText"
+        :label="t('workflow.blocks.handle-dialog.promptText.label')"
+        :title="t('workflow.blocks.handle-dialog.promptText.description')"
+        autocomplete="off"
+        placeholder="Text"
+        class="w-full"
+        @change="updateData({ promptText: $event })"
+      />
+    </ui-autocomplete>
   </div>
 </template>
 <script setup>
@@ -33,6 +42,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 20 - 6
src/components/newtab/workflow/edit/EditInteractionBase.vue

@@ -20,13 +20,23 @@
           {{ t(`workflow.blocks.base.findElement.options.${type}`) }}
         </option>
       </ui-select>
-      <ui-input
+      <ui-autocomplete
         v-if="!hideSelector"
-        :model-value="data.selector"
-        :placeholder="t('workflow.blocks.base.selector')"
-        class="mb-1 w-full"
-        @change="updateData({ selector: $event })"
-      />
+        :items="autocomplete"
+        :trigger-char="['{{', '}}']"
+        block
+        hide-empty
+        class="mb-1"
+      >
+        <ui-input
+          v-if="!hideSelector"
+          :model-value="data.selector"
+          :placeholder="t('workflow.blocks.base.selector')"
+          autocomplete="off"
+          class="w-full"
+          @change="updateData({ selector: $event })"
+        />
+      </ui-autocomplete>
       <ui-expand
         v-if="!hideSelector"
         hide-header-icon
@@ -102,6 +112,10 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data', 'change']);
 

+ 20 - 7
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -44,14 +44,23 @@
       class="w-full mt-2"
       @change="updateData({ variableName: $event })"
     />
-    <ui-input
+    <ui-autocomplete
       v-else-if="data.loopThrough === 'elements'"
-      :model-value="data.elementSelector"
-      :label="t('workflow.blocks.base.selector')"
-      placeholder=".selector"
-      class="mt-2 w-full"
-      @change="updateData({ elementSelector: $event })"
-    />
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
+      class="mt-2"
+    >
+      <ui-input
+        :model-value="data.elementSelector"
+        :label="t('workflow.blocks.base.selector')"
+        autocomplete="off"
+        placeholder=".selector"
+        class="w-full"
+        @change="updateData({ elementSelector: $event })"
+      />
+    </ui-autocomplete>
     <ui-button
       v-else-if="data.loopThrough === 'custom-data'"
       class="w-full mt-4"
@@ -174,6 +183,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 20 - 7
src/components/newtab/workflow/edit/EditNewTab.vue

@@ -6,14 +6,23 @@
       class="w-full"
       @change="updateData({ description: $event })"
     />
-    <ui-input
+    <ui-autocomplete
       v-if="!data.activeTab"
-      :model-value="data.url"
-      :label="t('workflow.blocks.new-tab.url')"
-      class="w-full mt-2"
-      placeholder="http://example.com/"
-      @change="updateData({ url: $event })"
-    />
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
+      class="mt-2"
+    >
+      <ui-input
+        :model-value="data.url"
+        :label="t('workflow.blocks.new-tab.url')"
+        class="w-full"
+        autocomplete="off"
+        placeholder="http://example.com/"
+        @change="updateData({ url: $event })"
+      />
+    </ui-autocomplete>
     <ui-checkbox
       :model-value="data.updatePrevTab"
       class="leading-tight mt-2"
@@ -60,6 +69,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 35 - 13
src/components/newtab/workflow/edit/EditSaveAssets.vue

@@ -2,6 +2,7 @@
   <edit-interaction-base
     :data="data"
     :hide="!permission.has.downloads"
+    :autocomplete="autocomplete"
     :hide-selector="data.type !== 'element'"
     @change="updateData"
   >
@@ -27,22 +28,39 @@
         </ui-button>
       </template>
     </template>
-    <ui-input
+    <ui-autocomplete
       v-if="data.type === 'url'"
-      :model-value="data.url"
-      label="URL"
-      class="w-full"
-      placeholder="https://example.com/picture.png"
-      @change="updateData({ url: $event })"
-    />
-    <template v-if="permission.has.downloads">
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
+    >
       <ui-input
-        :model-value="data.filename"
-        :label="t('workflow.blocks.save-assets.filename')"
-        class="w-full mt-4"
-        placeholder="image.jpeg"
-        @change="updateData({ filename: $event })"
+        :model-value="data.url"
+        label="URL"
+        class="w-full"
+        autocomplete="off"
+        placeholder="https://example.com/picture.png"
+        @change="updateData({ url: $event })"
       />
+    </ui-autocomplete>
+    <template v-if="permission.has.downloads">
+      <ui-autocomplete
+        :items="autocomplete"
+        :trigger-char="['{{', '}}']"
+        block
+        hide-empty
+        class="mt-4"
+      >
+        <ui-input
+          :model-value="data.filename"
+          :label="t('workflow.blocks.save-assets.filename')"
+          class="w-full"
+          autocomplete="off"
+          placeholder="image.jpeg"
+          @change="updateData({ filename: $event })"
+        />
+      </ui-autocomplete>
       <ui-select
         :model-value="data.onConflict"
         :label="t('workflow.blocks.handle-download.onConflict')"
@@ -66,6 +84,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 8 - 1
src/components/newtab/workflow/edit/EditScrollElement.vue

@@ -1,5 +1,8 @@
 <template>
-  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
+  <edit-interaction-base
+    v-bind="{ data, autocomplete, hide: hideBase }"
+    @change="updateData"
+  >
     <div v-if="!data.scrollIntoView" class="flex items-center mt-3 space-x-2">
       <ui-input
         :model-value="data.scrollX || 0"
@@ -58,6 +61,10 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 48 - 28
src/components/newtab/workflow/edit/EditSwitchTab.vue

@@ -1,28 +1,35 @@
 <template>
   <div>
-    <ui-input
-      :model-value="data.matchPattern"
-      placeholder="https://example.com/*"
-      class="w-full"
-      @change="updateData({ matchPattern: $event })"
+    <ui-autocomplete
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
     >
-      <template #label>
-        {{ t('workflow.blocks.switch-tab.matchPattern') }}
-        <a
-          :title="t('common.example', 2)"
-          href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#examples"
-          target="_blank"
-          rel="noopener"
-          class="inline-block ml-1"
-        >
-          <v-remixicon
-            name="riInformationLine"
-            class="inline-block"
-            size="18"
-          />
-        </a>
-      </template>
-    </ui-input>
+      <ui-input
+        :model-value="data.matchPattern"
+        placeholder="https://example.com/*"
+        class="w-full"
+        @change="updateData({ matchPattern: $event })"
+      >
+        <template #label>
+          {{ t('workflow.blocks.switch-tab.matchPattern') }}
+          <a
+            :title="t('common.example', 2)"
+            href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#examples"
+            target="_blank"
+            rel="noopener"
+            class="inline-block ml-1"
+          >
+            <v-remixicon
+              name="riInformationLine"
+              class="inline-block"
+              size="18"
+            />
+          </a>
+        </template>
+      </ui-input>
+    </ui-autocomplete>
     <ui-checkbox
       :model-value="data.createIfNoMatch"
       class="mt-1"
@@ -30,14 +37,23 @@
     >
       {{ t('workflow.blocks.switch-tab.createIfNoMatch') }}
     </ui-checkbox>
-    <ui-input
+    <ui-autocomplete
       v-if="data.createIfNoMatch"
-      :model-value="data.url"
-      :label="t('workflow.blocks.switch-tab.url')"
-      placeholder="https://example.com"
-      class="w-full mt-2"
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
+      class="mt-2"
       @change="updateData({ url: $event })"
-    />
+    >
+      <ui-input
+        :model-value="data.url"
+        :label="t('workflow.blocks.switch-tab.url')"
+        placeholder="https://example.com"
+        class="w-full"
+        @change="updateData({ url: $event })"
+      />
+    </ui-autocomplete>
   </div>
 </template>
 <script setup>
@@ -48,6 +64,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 19 - 6
src/components/newtab/workflow/edit/EditSwitchTo.vue

@@ -19,13 +19,22 @@
         {{ t('workflow.blocks.switch-to.windowTypes.iframe') }}
       </option>
     </ui-select>
-    <ui-input
+    <ui-autocomplete
       v-if="data.windowType === 'iframe'"
-      :model-value="data.selector"
-      :placeholder="t('workflow.blocks.switch-to.iframeSelector')"
-      class="mb-1 w-full"
-      @change="updateData({ selector: $event })"
-    />
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
+      class="mt-2"
+    >
+      <ui-input
+        :model-value="data.selector"
+        :placeholder="t('workflow.blocks.switch-to.iframeSelector')"
+        autocomplete="off"
+        class="mb-1 w-full"
+        @change="updateData({ selector: $event })"
+      />
+    </ui-autocomplete>
   </div>
 </template>
 <script setup>
@@ -36,6 +45,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 19 - 6
src/components/newtab/workflow/edit/EditTakeScreenshot.vue

@@ -29,13 +29,22 @@
       {{ t('workflow.blocks.take-screenshot.saveToComputer') }}
     </ui-checkbox>
     <div v-if="data.saveToComputer" class="flex items-center mt-1">
-      <ui-input
-        :model-value="data.fileName"
-        :placeholder="t('common.fileName')"
+      <ui-autocomplete
+        :items="autocomplete"
+        :trigger-char="['{{', '}}']"
+        block
+        hide-empty
         class="flex-1 mr-2"
-        title="File name"
-        @change="updateData({ fileName: $event })"
-      />
+      >
+        <ui-input
+          :model-value="data.fileName"
+          :placeholder="t('common.fileName')"
+          autocomplete="off"
+          class="flex-1 mr-2"
+          title="File name"
+          @change="updateData({ fileName: $event })"
+        />
+      </ui-autocomplete>
       <ui-select
         :model-value="data.ext || 'png'"
         placeholder="Type"
@@ -95,6 +104,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 8 - 1
src/components/newtab/workflow/edit/EditTriggerEvent.vue

@@ -1,5 +1,8 @@
 <template>
-  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
+  <edit-interaction-base
+    v-bind="{ data, autocomplete, hide: hideBase }"
+    @change="updateData"
+  >
     <ui-select
       :model-value="data.eventName"
       :placeholder="t('workflow.blocks.trigger-event.selectEvent')"
@@ -78,6 +81,10 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 21 - 5
src/components/newtab/workflow/edit/EditUploadFile.vue

@@ -1,5 +1,8 @@
 <template>
-  <edit-interaction-base v-bind="{ data, hide: hideBase }" @change="updateData">
+  <edit-interaction-base
+    v-bind="{ data, autocomplete, hide: hideBase }"
+    @change="updateData"
+  >
     <template v-if="hasFileAccess">
       <div class="mt-4 space-y-2">
         <div
@@ -7,11 +10,20 @@
           :key="index"
           class="flex items-center group"
         >
-          <ui-input
-            v-model="filePaths[index]"
-            :placeholder="t('workflow.blocks.upload-file.filePath')"
+          <ui-autocomplete
+            :items="autocomplete"
+            :trigger-char="['{{', '}}']"
+            block
+            hide-empty
             class="mr-2"
-          />
+          >
+            <ui-input
+              v-model="filePaths[index]"
+              :placeholder="t('workflow.blocks.upload-file.filePath')"
+              autocomplete="off"
+              class="w-full"
+            />
+          </ui-autocomplete>
           <v-remixicon
             name="riDeleteBin7Line"
             class="invisible cursor-pointer group-hover:visible"
@@ -55,6 +67,10 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 22 - 9
src/components/newtab/workflow/edit/EditWebhook.vue

@@ -16,15 +16,24 @@
         {{ method }}
       </option>
     </ui-select>
-    <ui-input
-      :model-value="data.url"
-      :label="`${t('workflow.blocks.webhook.url')}*`"
-      placeholder="http://api.example.com"
-      class="mb-2 w-full"
-      required
-      type="url"
-      @change="updateData({ url: $event })"
-    />
+    <ui-autocomplete
+      :items="autocomplete"
+      :trigger-char="['{{', '}}']"
+      block
+      hide-empty
+      class="mb-2"
+    >
+      <ui-input
+        :model-value="data.url"
+        :label="`${t('workflow.blocks.webhook.url')}*`"
+        placeholder="http://api.example.com"
+        class="w-full"
+        autocomplete="off"
+        required
+        type="url"
+        @change="updateData({ url: $event })"
+      />
+    </ui-autocomplete>
     <ui-select
       :model-value="data.contentType"
       :label="t('workflow.blocks.webhook.contentType')"
@@ -158,6 +167,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

+ 5 - 0
src/components/newtab/workflow/edit/EditWhileLoop.vue

@@ -27,6 +27,7 @@
         </div>
         <shared-condition-builder
           :model-value="data.conditions"
+          :autocomplete="autocomplete"
           class="overflow-auto p-4 mt-4 scroll"
           style="height: calc(100vh - 8rem)"
           @change="updateData({ conditions: $event })"
@@ -46,6 +47,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  autocomplete: {
+    type: Array,
+    default: () => [],
+  },
 });
 const emit = defineEmits(['update:data']);
 

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

@@ -0,0 +1,142 @@
+<template>
+  <div class="on-block-error">
+    <ui-button @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 text-gray-100 dark:text-black">
+          {{ 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>

+ 11 - 3
src/components/newtab/workflow/edit/TriggerEventWheel.vue

@@ -1,9 +1,17 @@
 <template>
   <div class="grid gap-2 grid-cols-2">
-    <ui-input v-model="defaultParams.deltaX" type="number" label="deltaX" />
-    <ui-input v-model="defaultParams.deltaY" type="number" label="deltaY" />
     <ui-input
-      v-model="defaultParams.deltaX"
+      v-model.number="defaultParams.deltaX"
+      type="number"
+      label="deltaX"
+    />
+    <ui-input
+      v-model.number="defaultParams.deltaY"
+      type="number"
+      label="deltaY"
+    />
+    <ui-input
+      v-model.number="defaultParams.deltaZ"
       type="number"
       class="col-span-2"
       label="deltaZ"

+ 181 - 45
src/components/ui/UiAutocomplete.vue

@@ -1,21 +1,15 @@
 <template>
   <ui-popover
+    :id="componentId"
     v-model="state.showPopover"
+    :class="{ block }"
+    :padding="`p-2 max-h-56 overflow-auto scroll ${componentId}`"
     trigger-width
     trigger="manual"
-    :padding="`p-2 max-h-56 overflow-auto scroll ${componentId}`"
+    class="ui-autocomplete"
   >
     <template #trigger>
-      <ui-input
-        v-bind="{ modelValue, placeholder, label, prependIcon }"
-        autocomplete="off"
-        @focus="state.showPopover = true"
-        @blur="state.showPopover = false"
-        @keydown="handleKeydown"
-        @change="updateValue"
-        @keyup.enter="selectItem(state.activeIndex)"
-        @keyup.esc="state.showPopover = false"
-      />
+      <slot />
     </template>
     <p v-if="filteredItems.length === 0" class="text-center">No data to show</p>
     <ui-list v-else class="space-y-1">
@@ -36,7 +30,13 @@
   </ui-popover>
 </template>
 <script setup>
-import { computed, onMounted, shallowReactive, watch } from 'vue';
+import {
+  computed,
+  onMounted,
+  onBeforeUnmount,
+  shallowReactive,
+  watch,
+} from 'vue';
 import { useComponentId } from '@/composable/componentId';
 import { debounce } from '@/utils/helper';
 
@@ -53,24 +53,27 @@ const props = defineProps({
     type: String,
     default: '',
   },
-  label: {
-    type: String,
-    default: '',
+  triggerChar: {
+    type: Array,
+    default: () => [],
   },
-  placeholder: {
-    type: String,
-    default: '',
+  block: {
+    type: Boolean,
+    default: false,
   },
-  prependIcon: {
-    type: String,
-    default: '',
+  hideEmpty: {
+    type: Boolean,
+    default: false,
   },
 });
 const emit = defineEmits(['update:modelValue', 'change']);
 
+let input = null;
 const componentId = useComponentId('autocomplete');
 
 const state = shallowReactive({
+  charIndex: -1,
+  searchText: '',
   activeIndex: -1,
   showPopover: false,
   inputChanged: false,
@@ -78,32 +81,71 @@ const state = shallowReactive({
 
 const getItem = (item) => item[props.itemLabel] || item;
 
-const filteredItems = computed(() =>
-  props.items.filter(
+const filteredItems = computed(() => {
+  if (!state.showPopover) return [];
+
+  const triggerChar = props.triggerChar.length > 0;
+  const searchText = (
+    triggerChar ? state.searchText : props.modelValue
+  ).toLocaleLowerCase();
+
+  return props.items.filter(
     (item) =>
       !state.inputChanged ||
-      getItem(item)
-        ?.toLocaleLowerCase()
-        .includes(props.modelValue.toLocaleLowerCase())
-  )
-);
+      getItem(item)?.toLocaleLowerCase().includes(searchText)
+  );
+});
 
-function handleKeydown(event) {
-  if (!state.showPopover) state.showPopover = true;
+function getLastKeyBeforeCaret(caretIndex) {
+  const getPosition = (val, index) => ({
+    index,
+    charIndex: input.value.lastIndexOf(val, caretIndex - 1),
+  });
+  const [charData] = props.triggerChar
+    .map(getPosition)
+    .sort((a, b) => b.charIndex - a.charIndex);
 
-  const itemsLength = filteredItems.value.length - 1;
+  if (charData.index > 0) return -1;
 
-  if (event.key === 'ArrowUp') {
-    if (state.activeIndex <= 0) state.activeIndex = itemsLength;
-    else state.activeIndex -= 1;
+  return charData.charIndex;
+}
+function getSearchText(caretIndex, charIndex) {
+  if (charIndex !== -1) {
+    const charsLength = props.triggerChar.length;
+    const text = input.value.substring(charIndex + charsLength, caretIndex);
 
-    event.preventDefault();
-  } else if (event.key === 'ArrowDown') {
-    if (state.activeIndex >= itemsLength) state.activeIndex = 0;
-    else state.activeIndex += 1;
+    if (!/\s/.test(text)) {
+      return text;
+    }
+  }
 
-    event.preventDefault();
+  return null;
+}
+function showPopover() {
+  if (props.triggerChar.length < 1) {
+    state.showPopover = true;
+    return;
   }
+
+  const { selectionStart } = input;
+
+  if (selectionStart >= 0) {
+    const charIndex = getLastKeyBeforeCaret(selectionStart);
+    const text = getSearchText(selectionStart, charIndex);
+
+    if (charIndex >= 0 && text) {
+      state.inputChanged = true;
+      state.showPopover = true;
+      state.searchText = text;
+      state.charIndex = charIndex;
+
+      return;
+    }
+  }
+
+  state.charIndex = -1;
+  state.searchText = '';
+  state.showPopover = false;
 }
 function checkInView(container, element, partial = false) {
   const cTop = container.scrollTop;
@@ -120,21 +162,95 @@ function checkInView(container, element, partial = false) {
   return isTotal || isPartial;
 }
 function updateValue(value) {
-  if (!state.showPopover) state.showPopover = true;
-
   state.inputChanged = true;
 
   emit('change', value);
   emit('update:modelValue', value);
+
+  input.value = value;
+  input.dispatchEvent(new Event('input'));
 }
-function selectItem(index) {
-  const selectedItem = filteredItems.value[index];
+function selectItem(itemIndex) {
+  let selectedItem = filteredItems.value[itemIndex];
 
   if (!selectedItem) return;
 
-  updateValue(getItem(selectedItem));
+  selectedItem = getItem(selectedItem);
+
+  let caretPosition;
+  const isTriggerChar = state.charIndex >= 0 && state.searchText;
+
+  if (isTriggerChar) {
+    const val = input.value;
+    const index = state.charIndex;
+    const charLength = props.triggerChar[0].length;
+
+    caretPosition = index + charLength + selectedItem.length;
+    selectedItem =
+      val.slice(0, index + charLength) +
+      selectedItem +
+      val.slice(state.searchText.length + index + charLength, val.length);
+  }
+
+  updateValue(selectedItem);
+
+  if (isTriggerChar) {
+    setTimeout(() => {
+      input.selectionEnd = caretPosition;
+      const isNotTextarea = input.tagName !== 'TEXTAREA';
+
+      if (isNotTextarea) {
+        input.blur();
+        input.focus();
+      }
+    }, 300);
+  }
+}
+function handleKeydown(event) {
+  const itemsLength = filteredItems.value.length - 1;
+
+  if (event.key === 'ArrowUp') {
+    if (state.activeIndex <= 0) state.activeIndex = itemsLength;
+    else state.activeIndex -= 1;
+
+    event.preventDefault();
+  } else if (event.key === 'ArrowDown') {
+    if (state.activeIndex >= itemsLength) state.activeIndex = 0;
+    else state.activeIndex += 1;
+
+    event.preventDefault();
+  } else if (event.key === 'Enter' && state.showPopover) {
+    selectItem(state.activeIndex);
+
+    event.preventDefault();
+  } else if (event.key === 'Escape') {
+    state.showPopover = false;
+  }
+}
+function handleBlur() {
   state.showPopover = false;
 }
+function handleFocus() {
+  if (props.triggerChar.length < 1) return;
+
+  showPopover();
+}
+function attachEvents() {
+  if (!input) return;
+
+  input.addEventListener('blur', handleBlur);
+  input.addEventListener('focus', handleFocus);
+  input.addEventListener('input', showPopover);
+  input.addEventListener('keydown', handleKeydown);
+}
+function detachEvents() {
+  if (!input) return;
+
+  input.removeEventListener('blur', handleBlur);
+  input.removeEventListener('focus', handleFocus);
+  input.removeEventListener('input', showPopover);
+  input.removeEventListener('keydown', handleKeydown);
+}
 
 watch(
   () => state.activeIndex,
@@ -154,16 +270,36 @@ watch(
   () => state.showPopover,
   (value) => {
     if (!value) state.inputChanged = false;
+
+    if (props.hideEmpty && filteredItems.value.length === 0) {
+      state.showPopover = false;
+    }
   }
 );
 
 onMounted(() => {
   if (props.modelValue) {
-    const activeIndex = props.items(
+    const activeIndex = props.items.findIndex(
       (item) => getItem(item) === props.modelValue
     );
 
     if (activeIndex !== -1) state.activeIndex = activeIndex;
   }
+
+  input = document.querySelector(
+    `#${componentId} input, #${componentId} textarea`
+  );
+
+  attachEvents();
+});
+onBeforeUnmount(() => {
+  detachEvents();
 });
 </script>
+<style>
+.ui-autocomplete.block,
+.ui-autocomplete.block .ui-popover__trigger {
+  width: 100%;
+  display: block;
+}
+</style>

+ 5 - 1
src/components/ui/UiTextarea.vue

@@ -7,6 +7,10 @@
     :class="{ 'overflow-hidden resize-none': autoresize }"
     :style="{ height }"
     @input="emitValue"
+    @keyup="$emit('keyup', $event)"
+    @keydown="$emit('keydown', $event)"
+    @focus="$emit('focus', $event)"
+    @blur="$emit('blur', $event)"
   ></textarea>
 </template>
 <script>
@@ -41,7 +45,7 @@ export default {
     },
     block: Boolean,
   },
-  emits: ['update:modelValue', 'change'],
+  emits: ['update:modelValue', 'change', 'focus', 'blur', 'keyup', 'keydown'],
   setup(props, { emit }) {
     const textareaId = useComponentId('textarea');
     const textarea = ref(null);

+ 3 - 1
src/composable/editorBlock.js

@@ -8,6 +8,7 @@ export function useEditorBlock(selector, editor) {
     details: {},
     category: {},
     retrieved: false,
+    containerEl: null,
   });
 
   nextTick(() => {
@@ -15,7 +16,8 @@ export function useEditorBlock(selector, editor) {
 
     if (block.id || !element) return;
 
-    block.id = element.parentElement.parentElement.id.replace('node-', '');
+    block.containerEl = element.parentElement.parentElement;
+    block.id = block.containerEl.id.replace('node-', '');
 
     if (block.id) {
       const { name, data } = editor.getNodeFromId(block.id);

+ 11 - 2
src/content/blocks-handler/handler-javascript-code.js

@@ -124,11 +124,20 @@ function javascriptCode(block) {
 
       script.setAttribute(scriptAttr, '');
       script.classList.add('automa-custom-js');
-      script.innerHTML = `(() => {\n${automaScript} ${block.data.code}\n})()`;
+      script.innerHTML = `(() => {
+        ${automaScript}
+
+        try {
+          ${block.data.code}
+        } catch (error) {
+          console.error(error);
+          automaNextBlock({ $error: true, message: error.message });
+        }
+      })()`;
 
       if (!block.data.everyNewTab) {
         let timeout;
-        const cleanUp = (columns = '') => {
+        const cleanUp = (columns = {}) => {
           const storageKey = `automa--${block.id}`;
           const storageRefData = JSON.parse(sessionStorage.getItem(storageKey));
 

+ 151 - 44
src/content/element-selector/App.vue

@@ -33,10 +33,10 @@
         </ui-button>
       </div>
       <app-selector
+        v-model:selectorType="state.selectorType"
+        v-model:selectList="state.selectList"
         :selector="state.elSelector"
         :selected-count="state.selectedElements.length"
-        :selector-type="state.selectorType"
-        @selector="state.selectorType = $event"
         @child="selectChildElement"
         @parent="selectParentElement"
         @change="updateSelectedElements"
@@ -61,18 +61,18 @@
             >
               <template #item="{ element }">
                 <div
-                  v-for="attribute in element.attributes"
-                  :key="attribute.name"
+                  v-for="(value, name) in element.attributes"
+                  :key="name"
                   class="bg-box-transparent mb-1 rounded-lg py-2 px-3"
                 >
                   <p
                     class="text-sm text-overflow leading-tight text-gray-600"
                     title="Attribute name"
                   >
-                    {{ attribute.name }}
+                    {{ name }}
                   </p>
                   <input
-                    :value="attribute.value"
+                    :value="value"
                     readonly
                     title="Attribute value"
                     class="bg-transparent w-full"
@@ -130,24 +130,18 @@
       v-if="!state.hide"
       class="h-full w-full absolute top-0 pointer-events-none left-0 z-10"
     >
-      <rect
-        v-for="(item, index) in state.hoveredElements"
-        v-bind="item"
-        :key="index"
-        stroke-width="2"
+      <app-element-highlighter
+        :items="state.hoveredElements"
         stroke="#fbbf24"
-        fill="rgba(251, 191, 36, 0.2)"
-      ></rect>
-      <rect
-        v-for="(item, index) in state.selectedElements"
-        v-bind="item"
-        :key="index"
-        :stroke="item.highlight ? '#2563EB' : '#f87171'"
-        :fill="
-          item.highlight ? 'rgb(37, 99, 235, 0.2)' : 'rgba(248, 113, 113, 0.2)'
-        "
-        stroke-width="2"
-      ></rect>
+        fill="rgba(251, 191, 36, 0.1)"
+      />
+      <app-element-highlighter
+        :items="state.selectedElements"
+        stroke="#2563EB"
+        active-stroke="#f87171"
+        fill="rgba(37, 99, 235, 0.1)"
+        active-fill="rgba(248, 113, 113, 0.1)"
+      />
     </svg>
   </div>
 </template>
@@ -155,10 +149,13 @@
 import { reactive, ref, watch, inject, nextTick } from 'vue';
 import { getCssSelector } from 'css-selector-generator';
 import { debounce } from '@/utils/helper';
+import { finder } from '@medv/finder';
 import findElement from '@/utils/find-element';
 import AppBlocks from './AppBlocks.vue';
 import AppSelector from './AppSelector.vue';
 import AppElementList from './AppElementList.vue';
+import AppElementHighlighter from './AppElementHighlighter.vue';
+import findElementList from './list-selector';
 
 const selectedElement = {
   path: [],
@@ -174,11 +171,13 @@ const cardEl = ref('cardEl');
 const state = reactive({
   activeTab: '',
   elSelector: '',
+  listSelector: '',
   isDragging: false,
+  selectList: false,
   isExecuting: false,
   selectElements: [],
-  selectorType: 'css',
   hoveredElements: [],
+  selectorType: 'css',
   selectedElements: [],
   hide: window.self !== window.top,
 });
@@ -197,12 +196,12 @@ const getElementSelector = (element, options = {}) =>
         blacklist: [
           '[focused]',
           /focus/,
+          '[src=*]',
           '[data-*]',
           '[href=*]',
-          '[src=*]',
           '[value=*]',
+          '[automa-*]',
         ],
-        selectors: ['id', 'class', 'tag', 'attribute'],
         includeTag: true,
         ...options,
       })
@@ -235,17 +234,20 @@ function generateXPath(element) {
 function toggleHighlightElement({ index, highlight }) {
   state.selectedElements[index].highlight = highlight;
 }
-function getElementRect(target) {
+function getElementRect(target, withElement = false) {
   if (!target) return {};
 
   const { x, y, height, width } = target.getBoundingClientRect();
-
-  return {
+  const result = {
     width: width + 4,
     height: height + 4,
     x: x - 2,
     y: y - 2,
   };
+
+  if (withElement) result.element = target;
+
+  return result;
 }
 function updateSelectedElements(selector) {
   state.elSelector = selector;
@@ -259,10 +261,18 @@ function updateSelectedElements(selector) {
       elements = elements ? [elements] : [];
     }
 
-    state.selectedElements = Array.from(elements).map((element, index) => {
-      const attributes = Array.from(element.attributes).map(
-        ({ name, value }) => ({ name, value })
+    const elementsDetail = Array.from(elements).map((element, index) => {
+      const attributes = Array.from(element.attributes).reduce(
+        (acc, { name, value }) => {
+          if (name === 'automa-el-list') return acc;
+
+          acc[name] = value;
+
+          return acc;
+        },
+        {}
       );
+
       const elementProps = {
         element,
         attributes,
@@ -283,13 +293,39 @@ function updateSelectedElements(selector) {
 
       return elementProps;
     });
+
     state.selectElements = selectElements;
+    state.selectedElements = elementsDetail;
   } catch (error) {
     state.selectElements = [];
     state.selectedElements = [];
   }
 }
+function getElementList(target) {
+  const automaListEl = target.closest('[automa-el-list]');
+
+  if (automaListEl) {
+    if (target.hasAttribute('automa-el-list')) return [];
+
+    const childSelector = finder(target, {
+      root: automaListEl,
+      idName: () => false,
+    });
+    const elements = document.querySelectorAll(
+      `${state.listSelector} ${childSelector}`
+    );
+
+    return Array.from(elements);
+  }
+
+  return findElementList(target) || [target];
+}
+let prevHoverElement = null;
 function handleMouseMove({ clientX, clientY, target }) {
+  if (prevHoverElement === target) return;
+
+  prevHoverElement = target;
+
   if (state.isDragging) {
     const height = window.innerHeight;
     const width = document.documentElement.clientWidth;
@@ -309,29 +345,82 @@ function handleMouseMove({ clientX, clientY, target }) {
 
   if (state.hide || rootElement === target) return;
 
-  state.hoveredElements = [getElementRect(target)];
+  let elementsRect = [];
+
+  if (state.selectList) {
+    const elements = getElementList(target) || [];
+
+    elementsRect = elements.map((el) => getElementRect(el, true));
+  } else {
+    elementsRect = [getElementRect(target)];
+  }
+
+  state.hoveredElements = elementsRect;
 }
 function handleClick(event) {
   const { target, path, ctrlKey } = event;
 
   if (target === rootElement || state.hide || state.isExecuting) return;
-
   event.stopPropagation();
   event.preventDefault();
 
-  const attributes = Array.from(target.attributes).map(({ name, value }) => ({
-    name,
-    value,
-  }));
+  if (state.selectList) {
+    const firstElement = state.hoveredElements[0].element;
 
-  let targetElement = target;
-  const targetElementDetail = {
-    ...getElementRect(target),
-    attributes,
-    element: target,
-    highlight: false,
+    if (!firstElement) return;
+
+    const isInList = target.closest('[automa-el-list]');
+    if (isInList) {
+      const childSelector = finder(target, {
+        root: isInList,
+        idName: () => false,
+      });
+      updateSelectedElements(`${state.listSelector} ${childSelector}`, true);
+
+      return;
+    }
+
+    const prevSelectedList = document.querySelectorAll('[automa-el-list]');
+    prevSelectedList.forEach((element) => {
+      element.removeAttribute('automa-el-list');
+    });
+
+    state.hoveredElements.forEach(({ element }) => {
+      element.setAttribute('automa-el-list', '');
+    });
+
+    const parentSelector = getCssSelector(firstElement.parentElement, {
+      includeTag: true,
+    });
+    const elementSelector = `${parentSelector} > ${firstElement.tagName.toLowerCase()}`;
+
+    state.listSelector = elementSelector;
+    updateSelectedElements(elementSelector);
+
+    return;
+  }
+
+  const getElementDetail = (element) => {
+    const attributes = {};
+
+    Array.from(element.attributes).forEach(({ name, value }) => {
+      if (name === 'automa-el-list') return;
+
+      attributes[name] = value;
+    });
+
+    return {
+      ...getElementRect(element),
+      element,
+      attributes,
+      highlight: false,
+      outline: state.selectList && state.selectedElements.length,
+    };
   };
 
+  let targetElement = target;
+  const targetElementDetail = getElementDetail(target);
+
   if (state.selectorType === 'css' && ctrlKey) {
     let elementIndex = -1;
 
@@ -431,6 +520,11 @@ function destroy() {
     selectedElements: [],
   });
 
+  const prevSelectedList = document.querySelectorAll('[automa-el-list]');
+  prevSelectedList.forEach((element) => {
+    element.removeAttribute('automa-el-list');
+  });
+
   document.documentElement.style.fontSize = originalFontSize;
 }
 
@@ -446,6 +540,19 @@ watch(
   }
 );
 watch(() => [state.elSelector, state.activeTab, state.hide], updateCardSize);
+watch(
+  () => state.selectList,
+  (value) => {
+    if (value) {
+      state.selectedElements = [];
+    } else {
+      const prevSelectedList = document.querySelectorAll('[automa-el-list]');
+      prevSelectedList.forEach((element) => {
+        element.removeAttribute('automa-el-list');
+      });
+    }
+  }
+);
 
 nextTick(() => {
   setTimeout(() => {

+ 46 - 0
src/content/element-selector/AppElementHighlighter.vue

@@ -0,0 +1,46 @@
+<template>
+  <rect
+    v-for="(item, index) in items"
+    v-bind="{
+      ...item,
+      'stroke-dasharray': item.outline ? '5,5' : null,
+      fill: getFillColor(item),
+      stroke: getStrokeColor(item),
+    }"
+    :key="index"
+    stroke-width="2"
+  ></rect>
+</template>
+<script setup>
+const props = defineProps({
+  items: {
+    type: Object,
+    default: () => ({}),
+  },
+  stroke: {
+    type: String,
+    default: null,
+  },
+  activeStroke: {
+    type: String,
+    default: null,
+  },
+  fill: {
+    type: String,
+    default: null,
+  },
+  activeFill: {
+    type: String,
+    default: null,
+  },
+});
+
+function getFillColor(item) {
+  if (item.outline) return null;
+
+  return item.highlight ? props.fill : props.activeFill || props.fill;
+}
+function getStrokeColor(item) {
+  return item.highlight ? props.stroke : props.activeStroke || props.stroke;
+}
+</script>

+ 33 - 9
src/content/element-selector/AppSelector.vue

@@ -1,13 +1,26 @@
 <template>
   <div class="mt-4">
-    <ui-select
-      :model-value="selectorType"
-      class="w-full"
-      @change="$emit('selector', $event)"
-    >
-      <option value="css">CSS Selector</option>
-      <option value="xpath">XPath</option>
-    </ui-select>
+    <div class="flex items-center">
+      <ui-select
+        :model-value="selectorType"
+        :disabled="selectList"
+        class="w-full"
+        @change="$emit('update:selectorType', $event)"
+      >
+        <option value="css">CSS Selector</option>
+        <option value="xpath">XPath</option>
+      </ui-select>
+      <ui-button
+        v-if="selectorType === 'css'"
+        :class="{ 'text-primary': selectList }"
+        icon
+        class="ml-2"
+        title="Select a list of elements"
+        @click="$emit('update:selectList', !selectList)"
+      >
+        <v-remixicon name="riListUnordered" />
+      </ui-button>
+    </div>
     <div class="mt-2 flex items-center">
       <ui-input
         :model-value="selector"
@@ -54,8 +67,19 @@ const props = defineProps({
     type: String,
     default: '',
   },
+  selectList: {
+    type: Boolean,
+    default: false,
+  },
 });
-const emit = defineEmits(['change', 'parent', 'child', 'selector']);
+const emit = defineEmits([
+  'change',
+  'list',
+  'parent',
+  'child',
+  'update:selectorType',
+  'update:selectList',
+]);
 
 const rootElement = inject('rootElement');
 

+ 2 - 0
src/content/element-selector/icons.js

@@ -5,6 +5,7 @@ import {
   riEyeOffLine,
   riFileCopyLine,
   riDragMoveLine,
+  riListUnordered,
   riArrowLeftLine,
   riArrowLeftSLine,
   riInformationLine,
@@ -18,6 +19,7 @@ export default {
   riEyeOffLine,
   riFileCopyLine,
   riDragMoveLine,
+  riListUnordered,
   riArrowLeftLine,
   riArrowLeftSLine,
   riInformationLine,

+ 1 - 1
src/content/element-selector/index.js

@@ -64,7 +64,7 @@ function elementSelectorInstance() {
 
     const automaStyle = document.createElement('style');
     automaStyle.classList.add('automa-element-selector');
-    automaStyle.innerHTML = `.automa-element-selector { pointer-events: none; direction: ltr } \n [automa-isDragging] { user-select: none }`;
+    automaStyle.innerHTML = `.automa-element-selector { pointer-events: none; direction: ltr } \n [automa-isDragging] { user-select: none } \n [automa-el-list] {outline: 2px dashed #6366f1;}`;
 
     initElementSelector(rootElement);
 

+ 84 - 0
src/content/element-selector/list-selector.js

@@ -0,0 +1,84 @@
+/* eslint-disable  no-cond-assign */
+export function getAllSiblings(el, selector) {
+  const siblings = [el];
+
+  const validateElement = (element) => {
+    const isValidSelector = selector ? element.querySelector(selector) : true;
+    const isSameTag = el.tagName === element.tagName;
+
+    return isValidSelector && isSameTag;
+  };
+
+  let nextSibling = el;
+  let prevSibling = el;
+  let elementIndex = 1;
+
+  while ((prevSibling = prevSibling?.previousElementSibling)) {
+    if (validateElement(prevSibling)) {
+      elementIndex += 1;
+
+      siblings.unshift(prevSibling);
+    }
+  }
+  while ((nextSibling = nextSibling?.nextElementSibling)) {
+    if (validateElement(nextSibling)) {
+      siblings.push(nextSibling);
+    }
+  }
+
+  return {
+    elements: siblings,
+    index: elementIndex,
+  };
+}
+
+export function getCssPath(el, root = document.body) {
+  if (!(el instanceof Element)) return null;
+
+  const path = [];
+
+  while (el.nodeType === Node.ELEMENT_NODE && !el.isSameNode(root)) {
+    let selector = el.nodeName.toLowerCase();
+
+    if (el.id) {
+      selector += `#${el.id}`;
+
+      path.unshift(selector);
+    } else {
+      let nth = 1;
+      let sib = el;
+
+      while ((sib = sib.previousElementSibling)) {
+        if (sib.nodeName.toLowerCase() === selector) nth += 1;
+      }
+
+      if (nth !== 1) selector += `:nth-of-type(${nth})`;
+
+      path.unshift(selector);
+    }
+
+    el = el.parentNode;
+  }
+
+  return path.join(' > ');
+}
+
+export function getElementList(el, maxDepth = 50, paths = []) {
+  if (maxDepth === 0 || el.tagName === 'BODY') return null;
+
+  let selector = el.tagName.toLowerCase();
+  const { elements, index } = getAllSiblings(el, paths.join(' > '));
+  let siblings = elements;
+
+  if (index !== 1) selector += `:nth-of-type(${index})`;
+
+  paths.unshift(selector);
+
+  if (siblings.length === 1) {
+    siblings = getElementList(el.parentElement, maxDepth - 1, paths);
+  }
+
+  return siblings;
+}
+
+export default getElementList;

+ 4 - 0
src/lib/drawflow.js

@@ -8,6 +8,10 @@ export default function (element, { context, options = {} }) {
   const editor = new Drawflow(element, { render, version: 3, h }, context);
 
   editor.useuuid = true;
+  editor.curvature = 0;
+  editor.reroute_curvature = 0;
+  editor.reroute_curvature_start_end = 0;
+  editor.reroute_fix_curvature = true;
 
   Object.entries(options).forEach(([key, value]) => {
     editor[key] = value;

+ 4 - 0
src/lib/v-remixicon.js

@@ -11,6 +11,7 @@ import {
   riEyeLine,
   riAddLine,
   riSortAsc,
+  riMindMap,
   riKey2Line,
   riTBoxLine,
   riSaveLine,
@@ -44,6 +45,7 @@ import {
   riWindowLine,
   riPencilLine,
   riGlobalLine,
+  riShieldLine,
   riCursorLine,
   riUploadLine,
   riFocus3Line,
@@ -118,6 +120,7 @@ export const icons = {
   riEyeLine,
   riAddLine,
   riSortAsc,
+  riMindMap,
   riKey2Line,
   riTBoxLine,
   riSaveLine,
@@ -151,6 +154,7 @@ export const icons = {
   riWindowLine,
   riPencilLine,
   riGlobalLine,
+  riShieldLine,
   riCursorLine,
   riUploadLine,
   riFocus3Line,

+ 22 - 0
src/locales/en/blocks.json

@@ -12,6 +12,28 @@
       "base": {
         "moveToGroup": "Move block to blocks group",
         "selector": "Element selector",
+        "onError": {
+          "info": "These rules will apply when an error occurs on the block",
+          "button": "On error",
+          "title": "On error occurs",
+          "retry": "Retry action",
+          "fallbackTitle": "Will execute when an error occurs in the block",
+          "times": {
+            "name": "Times",
+            "description": "The number of times to retry the action",
+          },
+          "interval": {
+            "name": "Interval",
+            "description": "The time interval to wait between each try",
+            "second": "second"
+          },
+          "toDo": {
+            "error": "Throw error",
+            "continue": "Continue flow",
+            "fallback": "Execute fallback",
+            "restart": "Restart flow"
+          }
+        },
         "table": {
           "checkbox": "Insert to table",
           "select": "Select column",

+ 9 - 0
src/locales/en/newtab.json

@@ -24,6 +24,14 @@
     "shortcuts": {
       "duplicate": "Shortcut already use by \"{name}\""
     },
+    "editor": {
+      "curvature": {
+        "title": "Editor Line Curvature",
+        "line": "Line",
+        "reroute": "Reroute",
+        "rerouteFirstLast": "Reroute first & last point"
+      },
+    },
     "language": {
       "label": "Language",
       "helpTranslate": "Can't find your language? Help translate.",
@@ -31,6 +39,7 @@
     },
     "menu": {
       "backup": "Backup Workflows",
+      "editor": "Editor",
       "general": "General",
       "shortcuts": "Shortcuts",
       "about": "About"

+ 1 - 1
src/newtab/pages/Settings.vue

@@ -2,7 +2,7 @@
   <div class="container pt-8 pb-4">
     <h1 class="text-2xl font-semibold mb-10">{{ t('common.settings') }}</h1>
     <div class="flex items-start">
-      <ui-list class="w-64 mr-12 space-y-2">
+      <ui-list class="w-64 mr-12 space-y-2 sticky top-8">
         <router-link
           v-for="menu in menus"
           :key="menu.id"

+ 0 - 0
src/newtab/pages/settings/About.vue → src/newtab/pages/settings/SettingsAbout.vue


+ 0 - 0
src/newtab/pages/settings/Backup.vue → src/newtab/pages/settings/SettingsBackup.vue


+ 43 - 3
src/newtab/pages/settings/index.vue → src/newtab/pages/settings/SettingsIndex.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="mb-8">
+  <div class="mb-12">
     <p class="font-semibold mb-1">{{ t('settings.theme') }}</p>
     <div class="flex items-center space-x-4">
       <div
@@ -54,14 +54,40 @@
       {{ t('settings.language.reloadPage') }}
     </p>
   </div>
+  <div class="mt-12 max-w-2xl">
+    <p class="font-semibold">
+      {{ t('settings.editor.curvature.title') }}
+    </p>
+    <div class="flex space-x-2 items-end">
+      <ui-input
+        v-for="item in curvatureSettings"
+        :key="item.id"
+        :model-value="settings.editor[item.key]"
+        :label="t(`settings.editor.curvature.${item.id}`)"
+        type="number"
+        min="0"
+        max="1"
+        class="w-full"
+        placeholder="0.5"
+        @change="updateSetting(`editor.${item.key}`, curvatureLimit($event))"
+      />
+    </div>
+  </div>
 </template>
 <script setup>
 import { computed, ref } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
+import browser from 'webextension-polyfill';
 import { useTheme } from '@/composable/theme';
 import { supportLocales } from '@/utils/shared';
 
+const curvatureSettings = [
+  { id: 'line', key: 'curvature' },
+  { id: 'reroute', key: 'reroute_curvature' },
+  { id: 'rerouteFirstLast', key: 'reroute_curvature_start_end' },
+];
+
 const { t } = useI18n();
 const store = useStore();
 const theme = useTheme();
@@ -69,9 +95,23 @@ const theme = useTheme();
 const isLangChange = ref(false);
 const settings = computed(() => store.state.settings);
 
-async function updateLanguage(value) {
+function curvatureLimit(value) {
+  if (value > 1) return 1;
+  if (value < 0) return 0;
+
+  return value;
+}
+function updateSetting(path, value) {
+  store.commit('updateStateNested', {
+    value,
+    path: `settings.${path}`,
+  });
+
+  browser.storage.local.set({ settings: settings.value });
+}
+function updateLanguage(value) {
   isLangChange.value = true;
 
-  store.dispatch('updateSettings', { locale: value });
+  updateSetting('locale', value);
 }
 </script>

+ 0 - 0
src/newtab/pages/settings/Shortcuts.vue → src/newtab/pages/settings/SettingsShortcuts.vue


+ 17 - 0
src/newtab/pages/workflows/[id].vue

@@ -6,7 +6,11 @@
     >
       <workflow-edit-block
         v-if="state.isEditBlock && workflowData.active !== 'shared'"
+        v-model:autocomplete="autocomplete.cache"
         :data="state.blockData"
+        :data-changed="autocomplete.dataChanged"
+        :editor="editor"
+        :workflow="workflow"
         @update="updateBlockData"
         @close="(state.isEditBlock = false), (state.blockData = {})"
       />
@@ -260,6 +264,11 @@ const shortcut = useShortcut('editor:toggle-sidebar', toggleSidebar);
 
 const editor = shallowRef(null);
 const activeTab = shallowRef('editor');
+
+const autocomplete = reactive({
+  cache: null,
+  dataChanged: false,
+});
 const workflowPayload = reactive({
   data: {},
   isUpdating: false,
@@ -304,6 +313,11 @@ const workflowModals = {
     component: WorkflowDataTable,
     title: t('workflow.table.title'),
     docs: 'https://docs.automa.site/api-reference/table.html',
+    events: {
+      change() {
+        autocomplete.dataChanged = true;
+      },
+    },
   },
   'workflow-share': {
     icon: 'riShareLine',
@@ -368,6 +382,7 @@ const updateBlockData = debounce((data) => {
 
   state.blockData.data = data;
   state.isDataChanged = true;
+  autocomplete.dataChanged = true;
 
   if (state.blockData.isInGroup) {
     payload = { itemId: state.blockData.itemId, data };
@@ -699,6 +714,7 @@ function deleteBlock(id) {
   }
 
   state.isDataChanged = true;
+  autocomplete.dataChanged = true;
 }
 function updateWorkflow(data) {
   if (workflowData.active === 'shared') return;
@@ -764,6 +780,7 @@ function editBlock(data) {
 }
 function handleEditorDataChanged() {
   state.isDataChanged = true;
+  autocomplete.dataChanged = true;
 }
 function deleteWorkflow() {
   dialog.confirm({

+ 4 - 4
src/newtab/router.js

@@ -9,10 +9,10 @@ import CollectionsDetails from './pages/collections/[id].vue';
 import Logs from './pages/Logs.vue';
 import LogsDetails from './pages/logs/[id].vue';
 import Settings from './pages/Settings.vue';
-import SettingsIndex from './pages/settings/index.vue';
-import SettingsAbout from './pages/settings/About.vue';
-import SettingsShortcuts from './pages/settings/Shortcuts.vue';
-import SettingsBackup from './pages/settings/Backup.vue';
+import SettingsIndex from './pages/settings/SettingsIndex.vue';
+import SettingsAbout from './pages/settings/SettingsAbout.vue';
+import SettingsShortcuts from './pages/settings/SettingsShortcuts.vue';
+import SettingsBackup from './pages/settings/SettingsBackup.vue';
 
 const routes = [
   {

+ 5 - 11
src/store/index.js

@@ -20,6 +20,11 @@ const store = createStore({
     workflowHosts: {},
     settings: {
       locale: 'en',
+      editor: {
+        curvature: 0.5,
+        reroute_curvature: 0.5,
+        reroute_curvature_start_end: 0.5,
+      },
     },
     userDataRetrieved: false,
   }),
@@ -41,17 +46,6 @@ const store = createStore({
       ),
   },
   actions: {
-    updateSettings({ state, commit }, data) {
-      commit('updateState', {
-        key: 'settings',
-        value: {
-          ...state.settings,
-          ...data,
-        },
-      });
-
-      browser.storage.local.set({ settings: state.settings });
-    },
     async retrieve({ dispatch, getters, commit, state }, keys = 'workflows') {
       try {
         const data = await browser.storage.local.get(keys);

+ 2 - 1
src/utils/reference-data/index.js

@@ -1,10 +1,11 @@
 import objectPath from 'object-path';
+import cloneDeep from 'lodash.clonedeep';
 import mustacheReplacer from './mustache-replacer';
 
 export default function ({ block, refKeys, data }) {
   if (!refKeys || refKeys.length === 0) return block;
 
-  const copyBlock = JSON.parse(JSON.stringify(block));
+  const copyBlock = cloneDeep(block);
   const addReplacedValue = (value) => {
     if (!copyBlock.replacedValue) copyBlock.replacedValue = {};
 

+ 10 - 1
src/utils/shared.js

@@ -197,6 +197,7 @@ export const tasks = {
     maxConnection: 1,
     allowedInputs: true,
     refDataKeys: ['fileName'],
+    autocomplete: ['variableName'],
     data: {
       fileName: '',
       ext: 'png',
@@ -281,6 +282,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     refDataKeys: ['selector', 'prefixText', 'suffixText', 'extraRowValue'],
+    autocomplete: ['variableName'],
     data: {
       description: '',
       findBy: 'cssSelector',
@@ -385,6 +387,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     refDataKeys: ['selector', 'attributeName', 'extraRowValue'],
+    autocomplete: ['variableName'],
     data: {
       description: '',
       findBy: 'cssSelector',
@@ -415,6 +418,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     refDataKeys: ['selector', 'value'],
+    autocomplete: ['variableName'],
     data: {
       description: '',
       findBy: 'cssSelector',
@@ -518,6 +522,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     refDataKeys: ['customData', 'range', 'spreadsheetId'],
+    autocomplete: ['refKey'],
     data: {
       range: '',
       refKey: '',
@@ -579,6 +584,7 @@ export const tasks = {
     allowedInputs: true,
     maxConnection: 1,
     refDataKeys: ['body', 'url'],
+    autocomplete: ['variableName'],
     data: {
       description: '',
       url: '',
@@ -627,6 +633,7 @@ export const tasks = {
       'referenceKey',
       'elementSelector',
     ],
+    autocomplete: ['variableName', 'loopId'],
     data: {
       loopId: '',
       maxLoop: 0,
@@ -684,6 +691,7 @@ export const tasks = {
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
+    autocomplete: ['variableName'],
     data: {
       description: '',
       assignVariable: false,
@@ -825,7 +833,8 @@ export const tasks = {
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
-    refDataKeys: ['promptText'],
+    refDataKeys: ['filename'],
+    autocomplete: ['variableName'],
     data: {
       description: '',
       filename: '',

+ 5 - 0
yarn.lock

@@ -4936,6 +4936,11 @@ lodash.castarray@^4.4.0:
   resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
   integrity sha1-wCUTUV4wna3dTCTGDP3c9ZdtkRU=
 
+lodash.clonedeep@^4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
+  integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
+
 lodash.debounce@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"