Ahmad Kholid vor 2 Jahren
Ursprung
Commit
82a14512ed
56 geänderte Dateien mit 1530 neuen und 349 gelöschten Zeilen
  1. 1 1
      package.json
  2. 12 0
      src/background/workflowEngine/blocksHandler/handlerGoogleSheets.js
  3. 66 32
      src/background/workflowEngine/blocksHandler/handlerLoopBreakpoint.js
  4. 1 5
      src/background/workflowEngine/blocksHandler/handlerLoopData.js
  5. 79 0
      src/background/workflowEngine/blocksHandler/handlerLoopElements.js
  6. 80 0
      src/background/workflowEngine/blocksHandler/handlerParameterPrompt.js
  7. 8 2
      src/background/workflowEngine/blocksHandler/handlerSwitchTab.js
  8. 7 2
      src/background/workflowEngine/blocksHandler/handlerWorkflowState.js
  9. 2 2
      src/background/workflowEngine/engine.js
  10. 6 1
      src/background/workflowEngine/worker.js
  11. 1 0
      src/components/block/BlockConditions.vue
  12. 1 1
      src/components/block/BlockLoopBreakpoint.vue
  13. 22 0
      src/components/block/BlockPackage.vue
  14. 1 1
      src/components/newtab/app/AppSidebar.vue
  15. 40 20
      src/components/newtab/logs/LogsHistory.vue
  16. 3 0
      src/components/newtab/settings/SettingsBackupItems.vue
  17. 7 2
      src/components/newtab/settings/SettingsCloudBackup.vue
  18. 2 0
      src/components/newtab/shared/SharedConditionBuilder/index.vue
  19. 1 0
      src/components/newtab/workflow/WorkflowEditBlock.vue
  20. 11 0
      src/components/newtab/workflow/edit/EditConditions.vue
  21. 1 2
      src/components/newtab/workflow/edit/EditForms.vue
  22. 1 1
      src/components/newtab/workflow/edit/EditGoogleSheets.vue
  23. 23 15
      src/components/newtab/workflow/edit/EditInsertData.vue
  24. 111 0
      src/components/newtab/workflow/edit/EditLoopElements.vue
  25. 51 0
      src/components/newtab/workflow/edit/EditParameterPrompt.vue
  26. 25 1
      src/components/newtab/workflow/edit/EditPressKey.vue
  27. 11 2
      src/components/newtab/workflow/edit/EditSwitchTab.vue
  28. 5 3
      src/components/newtab/workflow/edit/EditWorkflowParameters.vue
  29. 88 1
      src/components/newtab/workflow/edit/EditWorkflowState.vue
  30. 1 1
      src/components/newtab/workflow/edit/InsertWorkflowData.vue
  31. 1 32
      src/components/newtab/workflow/editor/EditorLocalActions.vue
  32. 82 7
      src/components/newtab/workflow/editor/EditorLocalSavedBlocks.vue
  33. 76 89
      src/components/newtab/workflows/WorkflowsLocal.vue
  34. 116 0
      src/components/newtab/workflows/WorkflowsLocalCard.vue
  35. 21 12
      src/components/popup/home/HomeWorkflowCard.vue
  36. 9 0
      src/components/ui/UiInput.vue
  37. 2 17
      src/content/blocksHandler/handlerEventClick.js
  38. 6 1
      src/content/blocksHandler/handlerForms.js
  39. 2 17
      src/content/blocksHandler/handlerLoopData.js
  40. 123 0
      src/content/blocksHandler/handlerLoopElements.js
  41. 30 6
      src/content/blocksHandler/handlerPressKey.js
  42. 32 0
      src/content/utils.js
  43. 20 2
      src/locales/en/blocks.json
  44. 1 0
      src/locales/en/common.json
  45. 9 0
      src/locales/en/newtab.json
  46. 1 1
      src/newtab/pages/Packages.vue
  47. 47 52
      src/newtab/pages/workflows/[id].vue
  48. 47 5
      src/params/App.vue
  49. 63 1
      src/popup/pages/Home.vue
  50. 6 4
      src/stores/package.js
  51. 10 0
      src/stores/workflow.js
  52. 5 0
      src/utils/api.js
  53. 71 0
      src/utils/editor/editorAutocomplete.js
  54. 74 8
      src/utils/shared.js
  55. 5 0
      src/utils/testConditions.js
  56. 3 0
      src/utils/workflowData.js

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.19.0",
+  "version": "1.20.0",
   "description": "An extension for automating your browser by connecting blocks",
   "repository": {
     "type": "git",

+ 12 - 0
src/background/workflowEngine/blocksHandler/handlerGoogleSheets.js

@@ -35,6 +35,16 @@ async function getSpreadsheetRange({ spreadsheetId, range }) {
 
   return data;
 }
+async function clearSpreadsheetValues({ spreadsheetId, range }) {
+  const response = await googleSheets.clearValues({ spreadsheetId, range });
+  const result = await response.json();
+
+  if (!response.ok) {
+    throw new Error(result.message);
+  }
+
+  return result;
+}
 async function updateSpreadsheetValues(
   {
     range,
@@ -123,6 +133,8 @@ export default async function ({ data, id }, { refData }) {
       },
       refData.table
     );
+  } else if (data.type === 'clear') {
+    result = await clearSpreadsheetValues(data);
   }
 
   return {

+ 66 - 32
src/background/workflowEngine/blocksHandler/handlerLoopBreakpoint.js

@@ -1,44 +1,78 @@
-function loopBreakpoint(block, { prevBlockData }) {
-  const currentLoop = this.loopList[block.data.loopId];
+import { waitTabLoaded } from '../helper';
 
-  return new Promise((resolve) => {
-    let validLoopData = false;
+async function loopBreakpoint(block, { prevBlockData }) {
+  const currentLoop = this.loopList[block.data.loopId];
 
-    if (currentLoop) {
-      validLoopData =
-        currentLoop.type === 'numbers'
-          ? true
-          : currentLoop.index <= currentLoop.data.length - 1;
-    }
+  let validLoopData = false;
 
-    const continueLoop =
-      currentLoop &&
-      currentLoop.index < currentLoop.maxLoop - 1 &&
-      validLoopData;
+  if (currentLoop) {
+    validLoopData =
+      currentLoop.type === 'numbers'
+        ? true
+        : currentLoop.index < currentLoop.data.length - 1;
+  }
 
-    if (!block.data.clearLoop && continueLoop) {
-      resolve({
-        data: '',
-        nextBlockId: [{ id: currentLoop.blockId }],
+  const notReachMaxLoop =
+    currentLoop && currentLoop.maxLoop > 0
+      ? currentLoop.index < currentLoop.maxLoop - 1
+      : true;
+  if (!block.data.clearLoop && validLoopData && notReachMaxLoop) {
+    return {
+      data: '',
+      nextBlockId: [{ id: currentLoop.blockId }],
+    };
+  }
+  if (currentLoop.type === 'elements') {
+    if (currentLoop.loadMoreAction && notReachMaxLoop) {
+      const isClickLink = currentLoop.loadMoreAction.type === 'click-link';
+      let result = await this._sendMessageToTab({
+        id: currentLoop.blockId,
+        label: 'loop-elements',
+        data: {
+          ...currentLoop.loadMoreAction,
+          index: currentLoop.index,
+          onlyClickLink: isClickLink,
+        },
       });
-    } else {
-      if (currentLoop.type === 'elements') {
-        const loopElsIndex = this.loopEls.findIndex(
-          ({ blockId }) => blockId === currentLoop.blockId
-        );
 
-        if (loopElsIndex !== -1) this.loopEls.splice(loopElsIndex, 1);
+      if (!result.continue && isClickLink) {
+        await waitTabLoaded({
+          tabId: this.activeTab.id,
+          ms: currentLoop.loadMoreAction.actionPageMaxWaitTime * 1000,
+        });
+        result = await this._sendMessageToTab({
+          id: currentLoop.blockId,
+          label: 'loop-elements',
+          data: {
+            ...currentLoop.loadMoreAction,
+            index: currentLoop.index,
+          },
+        });
       }
 
-      delete this.loopList[block.data.loopId];
-      delete this.engine.referenceData.loopData[block.data.loopId];
-
-      resolve({
-        data: prevBlockData,
-        nextBlockId: this.getBlockConnections(block.id),
-      });
+      if (!result.continue && result.length > 0) {
+        this.loopList[block.data.loopId].data.push(...result);
+        return {
+          data: '',
+          nextBlockId: [{ id: currentLoop.blockId }],
+        };
+      }
     }
-  });
+
+    const loopElsIndex = this.loopEls.findIndex(
+      ({ blockId }) => blockId === currentLoop.blockId
+    );
+
+    if (loopElsIndex !== -1) this.loopEls.splice(loopElsIndex, 1);
+  }
+
+  delete this.loopList[block.data.loopId];
+  delete this.engine.referenceData.loopData[block.data.loopId];
+
+  return {
+    data: prevBlockData,
+    nextBlockId: this.getBlockConnections(block.id),
+  };
 }
 
 export default loopBreakpoint;

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

@@ -86,10 +86,6 @@ async function loopData({ data, id }, { refData }) {
         }
       }
 
-      const maxToLoop =
-        maxLoop >= currLoopData.length
-          ? currLoopData.length
-          : maxLoop || currLoopData.length;
       this.loopList[data.loopId] = {
         index,
         blockId: id,
@@ -99,7 +95,7 @@ async function loopData({ data, id }, { refData }) {
         maxLoop:
           data.loopThrough === 'numbers'
             ? data.toNumber + 1 - data.fromNumber
-            : maxToLoop,
+            : maxLoop,
       };
       /* eslint-disable-next-line */
       refData.loopData[data.loopId] = {

+ 79 - 0
src/background/workflowEngine/blocksHandler/handlerLoopElements.js

@@ -0,0 +1,79 @@
+async function loopElements({ data, id }, { refData }) {
+  try {
+    if (!this.activeTab.id) throw new Error('no-tab');
+
+    if (this.loopList[data.loopId]) {
+      const index = this.loopList[data.loopId].index + 1;
+
+      this.loopList[data.loopId].index = index;
+
+      refData.loopData[data.loopId] = {
+        $index: index,
+        data: this.loopList[data.loopId].data[index],
+      };
+    } else {
+      const maxLoop = +data.maxLoop || 0;
+      const { elements, url, loopId } = await this._sendMessageToTab({
+        id,
+        label: 'loop-data',
+        data: {
+          max: maxLoop,
+          multiple: true,
+          ...data,
+        },
+      });
+      this.loopEls.push({
+        url,
+        loopId,
+        max: maxLoop,
+        blockId: id,
+        findBy: data.findBy,
+        selector: data.selector,
+      });
+
+      const loopPayload = {
+        maxLoop,
+        index: 0,
+        blockId: id,
+        data: elements,
+        id: data.loopId,
+        type: 'elements',
+      };
+
+      if (data.loadMoreAction !== 'none') {
+        loopPayload.loadMoreAction = {
+          maxLoop,
+          loopAttrId: loopId,
+          loopId: data.loopId,
+          findBy: data.findBy,
+          type: data.loadMoreAction,
+          selector: data.selector.trim(),
+          scrollToBottom: data.scrollToBottom,
+          actionElMaxWaitTime: data.actionElMaxWaitTime,
+          actionElSelector: data.actionElSelector.trim(),
+          actionPageMaxWaitTime: data.actionPageMaxWaitTime,
+        };
+      }
+
+      this.loopList[data.loopId] = loopPayload;
+      /* eslint-disable-next-line */
+      refData.loopData[data.loopId] = {
+        $index: 0,
+        data: elements[0],
+      };
+    }
+
+    return {
+      data: refData.loopData[data.loopId],
+      nextBlockId: this.getBlockConnections(id),
+    };
+  } catch (error) {
+    if (error?.message === 'element-not-found') {
+      error.data = { selector: data.selector };
+    }
+
+    throw error;
+  }
+}
+
+export default loopElements;

+ 80 - 0
src/background/workflowEngine/blocksHandler/handlerParameterPrompt.js

@@ -0,0 +1,80 @@
+import browser from 'webextension-polyfill';
+import { sleep } from '@/utils/helper';
+
+function getInputtedParams({ execId, blockId }, ms) {
+  return new Promise((resolve) => {
+    let timeout = null;
+    const key = `params-prompt:${execId}__${blockId}`;
+
+    const storageListener = (event) => {
+      if (!event[key]) return;
+
+      clearTimeout(timeout);
+      browser.storage.onChanged.removeListener(storageListener);
+
+      const { newValue } = event[key];
+      resolve(newValue);
+    };
+
+    timeout = setTimeout(() => {
+      browser.storage.onChanged.removeListener(storageListener);
+      resolve({});
+    }, ms || 10000);
+
+    browser.storage.onChanged.addListener(storageListener);
+  });
+}
+
+export default async function ({ data, id }) {
+  const paramURL = browser.runtime.getURL('/params.html');
+  let tab = (await browser.tabs.query({})).find((item) =>
+    item.url.includes(paramURL)
+  );
+
+  if (!tab) {
+    const { tabs } = await browser.windows.create({
+      type: 'popup',
+      width: 480,
+      height: 600,
+      url: browser.runtime.getURL('/params.html'),
+    });
+    [tab] = tabs;
+    await sleep(1000);
+  } else {
+    await browser.tabs.update(tab.id, {
+      active: true,
+    });
+    await browser.windows.update(tab.windowId, { focused: true });
+  }
+
+  const timeout = data.timeout || 20000;
+
+  await browser.tabs.sendMessage(tab.id, {
+    name: 'workflow:params-block',
+    data: {
+      blockId: id,
+      execId: this.engine.id,
+      params: data.parameters,
+      timeout: Date.now() + timeout,
+      name: this.engine.workflow.name,
+      icon: this.engine.workflow.icon,
+      description: this.engine.workflow.description,
+    },
+  });
+
+  const result = await getInputtedParams(
+    {
+      blockId: id,
+      execId: this.engine.id,
+    },
+    timeout
+  );
+  Object.entries(result).forEach(([varName, varValue]) => {
+    this.setVariable(varName, varValue);
+  });
+
+  return {
+    data: '',
+    nextBlockId: this.getBlockConnections(id),
+  };
+}

+ 8 - 2
src/background/workflowEngine/blocksHandler/handlerSwitchTab.js

@@ -22,11 +22,17 @@ export default async function ({ data, id }) {
     throw new Error('no-tab');
   }
 
+  const isTabsQuery = ['match-patterns', 'tab-title'];
   const tabs =
     findTabBy !== 'match-patterns' ? await browser.tabs.query({}) : [];
 
-  if (findTabBy === 'match-patterns') {
-    [tab] = await browser.tabs.query({ url: data.matchPattern });
+  if (isTabsQuery.includes(findTabBy)) {
+    const query = {};
+
+    if (data.findTabBy === 'match-patterns') query.url = data.matchPattern;
+    else if (data.findTabBy === 'tab-title') query.title = data.tabTitle;
+
+    [tab] = await browser.tabs.query(query);
 
     if (!tab) {
       if (data.createIfNoMatch) {

+ 7 - 2
src/background/workflowEngine/blocksHandler/handlerWorkflowState.js

@@ -4,15 +4,20 @@ export default async function ({ data, id }) {
   if (data.type === 'stop-current') {
     return {};
   }
-  if (data.type === 'stop-all') {
+  if (['stop-specific', 'stop-all'].includes(data.type)) {
     const ids = [];
+    const isSpecific = data.type === 'stop-specific';
     this.engine.states.getAll.forEach((state) => {
+      const workflowNotIncluded =
+        isSpecific && !data.workflowsToStop.includes(state.workflowId);
+      if (workflowNotIncluded) return;
+
       ids.push(state.id);
     });
 
     for (const stateId of ids) {
       if (stateId === this.engine.id) {
-        stopCurrent = !data.exceptCurrent;
+        stopCurrent = isSpecific ? true : !data.exceptCurrent;
       } else {
         await this.engine.states.stop(stateId);
       }

+ 2 - 2
src/background/workflowEngine/engine.js

@@ -378,8 +378,8 @@ class WorkflowEngine {
           data: {
             logId: this.id,
             data: {
-              table: this.referenceData.table,
-              variables: this.referenceData.variables,
+              table: [...this.referenceData.table],
+              variables: { ...this.referenceData.variables },
             },
           },
         });

+ 6 - 1
src/background/workflowEngine/worker.js

@@ -177,14 +177,19 @@ class Worker {
     });
     const blockDelay = this.settings?.blockDelay || 0;
     const addBlockLog = (status, obj = {}) => {
+      let { description } = block.data;
+
+      if (block.label === 'loop-breakpoint') description = block.data.loopId;
+      else if (block.label === 'block-package') description = block.data.name;
+
       this.engine.addLogHistory({
+        description,
         prevBlockData,
         type: status,
         name: block.label,
         blockId: block.id,
         workerId: this.id,
         timestamp: startExecuteTime,
-        description: block.data.description,
         replacedValue: replacedBlock.replacedValue,
         duration: Math.round(Date.now() - startExecuteTime),
         ...obj,

+ 1 - 0
src/components/block/BlockConditions.vue

@@ -29,6 +29,7 @@
         v-for="item in data.conditions"
         :key="item.id"
         class="flex items-center flex-1 p-2 bg-box-transparent rounded-lg w-full relative"
+        @dblclick.stop="$emit('edit', { editCondition: item.id })"
       >
         <p
           v-if="item.name"

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

@@ -25,7 +25,7 @@
       @input="handleInput"
     />
     <ui-checkbox
-      :value="data.clearLoop"
+      :model-value="data.clearLoop"
       class="mt-2"
       @change="$emit('update', { clearLoop: $event })"
     >

+ 22 - 0
src/components/block/BlockPackage.vue

@@ -22,11 +22,19 @@
       </div>
       <div class="flex-grow" />
       <v-remixicon
+        v-if="state.isInstalled"
         title="Update package"
         name="riRefreshLine"
         class="cursor-pointer"
         @click="updatePackage"
       />
+      <v-remixicon
+        v-else
+        title="Install package"
+        name="riDownloadLine"
+        class="cursor-pointer"
+        @click="installPackage"
+      />
     </div>
     <div class="grid grid-cols-2 mt-4 gap-x-2">
       <ul class="pkg-handle-container">
@@ -77,6 +85,7 @@
   </ui-card>
 </template>
 <script setup>
+import { onMounted, shallowReactive } from 'vue';
 import cloneDeep from 'lodash.clonedeep';
 import { Handle, Position } from '@braks/vue-flow';
 import { usePackageStore } from '@/stores/package';
@@ -107,6 +116,15 @@ const packageStore = usePackageStore();
 const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-package');
 
+const state = shallowReactive({
+  isInstalled: false,
+});
+
+function installPackage() {
+  packageStore.insert({ ...props.data }, false).then(() => {
+    state.isInstalled = true;
+  });
+}
 function removeConnections(type, old, newEdges) {
   const removedEdges = [];
   old.forEach((edge) => {
@@ -142,6 +160,10 @@ function updatePackage() {
 
   emit('update', cloneDeep(pkg));
 }
+
+onMounted(() => {
+  state.isInstalled = packageStore.getById(props.data.id);
+});
 </script>
 <style>
 .pkg-handle-container li {

+ 1 - 1
src/components/newtab/app/AppSidebar.vue

@@ -126,7 +126,7 @@ const extensionVersion = browser.runtime.getManifest().version;
 const subColors = {
   free: 'bg-box-transparent',
   pro: 'bg-accent text-white',
-  business: 'bg-accent text-white',
+  business: 'bg-accent text-white dark:text-black',
 };
 const tabs = [
   {

+ 40 - 20
src/components/newtab/logs/LogsHistory.vue

@@ -12,21 +12,28 @@
     <div class="flex-1">
       <div class="rounded-lg bg-gray-900 dark:bg-gray-800 text-gray-100 dark">
         <div
-          v-if="currentLog.status === 'error' && errorBlock"
-          class="border-b px-4 pt-4 text-gray-200 pb-4 mb-4"
+          class="border-b px-4 pt-4 flex items-center text-gray-200 pb-4 mb-4"
         >
-          <p class="leading-tight line-clamp">
-            {{ errorBlock.message }}
-          </p>
-          <p class="cursor-pointer" title="Jump to item" @click="jumpToError">
-            On the {{ errorBlock.name }} block
-            <v-remixicon
-              name="riArrowLeftLine"
-              class="inline-block -ml-1"
-              size="18"
-              rotate="135"
-            />
-          </p>
+          <div v-if="currentLog.status === 'error' && errorBlock">
+            <p class="leading-tight line-clamp">
+              {{ errorBlock.message }}
+            </p>
+            <p class="cursor-pointer" title="Jump to item" @click="jumpToError">
+              On the {{ errorBlock.name }} block
+              <v-remixicon
+                name="riArrowLeftLine"
+                class="inline-block -ml-1"
+                size="18"
+                rotate="135"
+              />
+            </p>
+          </div>
+          <div class="flex-grow" />
+          <ui-input
+            v-model="state.search"
+            :placeholder="t('common.search')"
+            prepend-icon="riSearch2Line"
+          />
         </div>
         <div
           id="log-history"
@@ -291,6 +298,7 @@ const { t, te } = useI18n();
 
 const state = shallowReactive({
   itemId: '',
+  search: '',
   activeTab: 'all',
 });
 const pagination = shallowReactive({
@@ -299,13 +307,24 @@ const pagination = shallowReactive({
 });
 const activeLog = shallowRef(null);
 
+const translatedLog = computed(() =>
+  props.currentLog.history.map(translateLog)
+);
+const filteredLog = computed(() =>
+  translatedLog.value.filter((log) => {
+    const query = state.search.toLocaleLowerCase();
+
+    return (
+      log.name.toLocaleLowerCase().includes(query) ||
+      log.description?.toLocaleLowerCase().includes(query)
+    );
+  })
+);
 const history = computed(() =>
-  props.currentLog.history
-    .slice(
-      (pagination.currentPage - 1) * pagination.perPage,
-      pagination.currentPage * pagination.perPage
-    )
-    .map(translateLog)
+  filteredLog.value.slice(
+    (pagination.currentPage - 1) * pagination.perPage,
+    pagination.currentPage * pagination.perPage
+  )
 );
 const errorBlock = computed(() => {
   if (props.currentLog.status !== 'error') return null;
@@ -382,6 +401,7 @@ function jumpToError() {
   if (!element) return;
 
   element.scrollTo(0, element.scrollHeight);
+  document.documentElement.scrollTo(0, document.documentElement.scrollHeight);
 }
 </script>
 <style>

+ 3 - 0
src/components/newtab/settings/SettingsBackupItems.vue

@@ -12,11 +12,13 @@
         class="overflow-hidden group"
       >
         <ui-checkbox
+          v-if="!isLocal || !workflow.isInCloud"
           :disabled="exceedLimit && !isActive(workflow.id)"
           :model-value="isActive(workflow.id)"
           class="mr-4"
           @change="toggleDeleteWorkflow($event, workflow.id)"
         />
+        <div v-else class="w-5 h-5 mr-4" />
         <ui-img
           v-if="workflow.icon?.startsWith('http')"
           :src="workflow.icon"
@@ -78,6 +80,7 @@ const props = defineProps({
     type: String,
     default: '',
   },
+  isLocal: Boolean,
 });
 const emit = defineEmits(['update:modelValue', 'select']);
 

+ 7 - 2
src/components/newtab/settings/SettingsCloudBackup.vue

@@ -115,6 +115,7 @@
           :workflows="workflows"
           :limit="workflowLimit"
           :query="state.query"
+          :is-local="true"
           @select="selectAllLocal"
         >
           <ui-spinner
@@ -254,8 +255,12 @@ function selectAllLocal() {
     return;
   }
 
-  workflows.value.forEach(({ id }) => {
-    if (limit >= workflowLimit.value || state.selectedWorkflows.includes(id))
+  workflows.value.forEach(({ id, isInCloud }) => {
+    if (
+      limit >= workflowLimit.value ||
+      isInCloud ||
+      state.selectedWorkflows.includes(id)
+    )
       return;
 
     state.selectedWorkflows.push(id);

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

@@ -163,6 +163,8 @@ function getConditionText({ category, type, data }) {
     const textDetail = data.attrName || data.selector;
 
     if (textDetail) text += `(${textDetail})`;
+  } else if (type.startsWith('data')) {
+    text = `Data exists (${data.dataPath})`;
   }
 
   return text;

+ 1 - 0
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -39,6 +39,7 @@
       v-model:data="blockData"
       :block-id="data.blockId"
       v-bind="{
+        fullData: data.id === 'conditions' ? data : null,
         editor: data.id === 'conditions' ? editor : null,
         connections: data.id === 'wait-connections' ? data.connections : null,
       }"

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

@@ -140,6 +140,10 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  fullData: {
+    type: Object,
+    default: () => ({}),
+  },
   blockId: {
     type: String,
     default: '',
@@ -205,6 +209,13 @@ watch(
 );
 
 onMounted(() => {
+  if (props.fullData?.editCondition) {
+    const index = props.data.conditions.findIndex(
+      (item) => item.id === props.fullData.editCondition
+    );
+    if (index !== -1) editCondition(index);
+  }
+
   const condition = props.data.conditions[0];
   if (condition && condition.conditions) return;
 

+ 1 - 2
src/components/newtab/workflow/edit/EditForms.vue

@@ -93,8 +93,7 @@
         :placeholder="t('workflow.blocks.forms.text-field.delay.placeholder')"
         class="w-full mt-1"
         min="0"
-        type="number"
-        @change="updateData({ delay: +$event })"
+        @change="updateData({ delay: $event })"
       />
     </template>
   </edit-interaction-base>

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

@@ -225,7 +225,7 @@ const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
 
-const actions = ['get', 'getRange', 'update', 'append'];
+const actions = ['get', 'getRange', 'update', 'append', 'clear'];
 const dataFrom = ['data-columns', 'custom'];
 const valueInputOptions = ['RAW', 'USER_ENTERED'];
 const insertDataOptions = ['OVERWRITE', 'INSERT_ROWS'];

+ 23 - 15
src/components/newtab/workflow/edit/EditInsertData.vue

@@ -69,20 +69,26 @@
             />
           </div>
           <div class="p-2">
-            <div v-if="hasFileAccess && item.isFile" class="flex items-start">
+            <edit-autocomplete
+              v-if="hasFileAccess && item.isFile"
+              class="w-full"
+            >
               <ui-input
                 v-model="item.filePath"
-                placeholder="File absolute path"
-                class="flex-1"
+                class="w-full"
+                :placeholder="
+                  isFirefox ? 'File URL' : 'File absolute path/File URL'
+                "
               />
-            </div>
-            <ui-textarea
-              v-else
-              v-model="item.value"
-              placeholder="value"
-              title="value"
-              class="w-full"
-            />
+            </edit-autocomplete>
+            <edit-autocomplete v-else class="w-full">
+              <ui-textarea
+                v-model="item.value"
+                placeholder="value"
+                title="value"
+                class="w-full"
+              />
+            </edit-autocomplete>
             <div class="flex mt-2 items-center">
               <ui-button
                 v-tooltip="
@@ -146,6 +152,7 @@ import { useToast } from 'vue-toastification';
 import Papa from 'papaparse';
 import browser from 'webextension-polyfill';
 import getFile from '@/utils/getFile';
+import EditAutocomplete from './EditAutocomplete.vue';
 
 const SharedCodemirror = defineAsyncComponent(() =>
   import('@/components/newtab/shared/SharedCodemirror.vue')
@@ -159,10 +166,13 @@ const props = defineProps({
 });
 const emit = defineEmits(['update:data']);
 
+const isFirefox = BROWSER_TYPE === 'firefox';
+
 const { t } = useI18n();
 const toast = useToast();
 
 const workflow = inject('workflow', {});
+
 const showModal = ref(false);
 const hasFileAccess = ref(false);
 const dataList = ref(JSON.parse(JSON.stringify(props.data.dataList)));
@@ -202,9 +212,7 @@ function changeItemType(index, type) {
 }
 function setAsFile(item) {
   if (!hasFileAccess.value) {
-    window.open(
-      'https://docs.automa.site/blocks/upload-file.html#requirements'
-    );
+    window.open('https://docs.automa.site/blocks/insert-data.html#import-file');
     return;
   }
 
@@ -239,7 +247,7 @@ async function previewData(index, item) {
 }
 
 browser.extension.isAllowedFileSchemeAccess().then((value) => {
-  hasFileAccess.value = value;
+  hasFileAccess.value = isFirefox ? true : value;
 });
 
 watch(

+ 111 - 0
src/components/newtab/workflow/edit/EditLoopElements.vue

@@ -0,0 +1,111 @@
+<template>
+  <edit-interaction-base
+    :data="data"
+    hide-multiple
+    hide-mark-el
+    @change="updateData"
+  >
+    <template #prepend:selector>
+      <ui-input
+        :model-value="data.loopId"
+        class="w-full mb-4"
+        :label="t('workflow.blocks.loop-data.loopId')"
+        :placeholder="t('workflow.blocks.loop-data.loopId')"
+        @change="updateLoopId"
+      />
+    </template>
+    <ui-input
+      :model-value="data.maxLoop"
+      :label="t('workflow.blocks.loop-data.maxLoop.label')"
+      :title="t('workflow.blocks.loop-data.maxLoop.title')"
+      class="w-full mt-3"
+      @change="updateData({ maxLoop: $event })"
+    />
+    <div class="mt-4 border-t pt-4 mb-8">
+      <p class="text-sm text-gray-600 dark:text-gray-200">
+        {{ t('workflow.blocks.loop-elements.loadMore') }}
+      </p>
+      <ui-select
+        :model-value="data.loadMoreAction"
+        :label="t('common.action')"
+        class="mt-2"
+        @change="updateData({ loadMoreAction: $event })"
+      >
+        <option v-for="action in actions" :key="action" :value="action">
+          {{ t(`workflow.blocks.loop-elements.actions.${action}`) }}
+        </option>
+      </ui-select>
+      <ui-input
+        v-if="['click-element', 'click-link'].includes(data.loadMoreAction)"
+        :model-value="data.actionElSelector"
+        :label="t('workflow.blocks.base.selector')"
+        placeholder="CSS Selector or XPath"
+        class="mt-2 w-full"
+        @change="updateData({ actionElSelector: $event })"
+      />
+      <ui-input
+        v-if="['click-element', 'scroll'].includes(data.loadMoreAction)"
+        :model-value="data.actionElMaxWaitTime"
+        label="Max seconds wait for more elements"
+        class="w-full mt-2"
+        placeholder="0"
+        type="number"
+        @change="updateData({ actionElMaxWaitTime: +$event })"
+      />
+      <ui-checkbox
+        v-if="data.loadMoreAction === 'scroll'"
+        :model-value="data.scrollToBottom"
+        class="mt-4"
+        @change="updateData({ scrollToBottom: $event })"
+      >
+        {{ t('workflow.blocks.loop-elements.scrollToBottom') }}
+      </ui-checkbox>
+      <ui-input
+        v-if="data.loadMoreAction === 'click-link'"
+        :model-value="data.actionPageMaxWaitTime"
+        label="Max seconds wait for the page to load"
+        class="w-full mt-2"
+        placeholder="0"
+        type="number"
+        @change="updateData({ actionPageMaxWaitTime: +$event })"
+      />
+    </div>
+  </edit-interaction-base>
+</template>
+<script setup>
+import { onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid/non-secure';
+import EditInteractionBase from './EditInteractionBase.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const actions = ['none', 'click-element', 'click-link', 'scroll'];
+
+const { t } = useI18n();
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+function updateLoopId(id) {
+  let loopId = id.replace(/\s/g, '');
+
+  if (!loopId) {
+    loopId = nanoid(6);
+  }
+
+  updateData({ loopId });
+}
+
+onMounted(() => {
+  if (!props.data.loopId) {
+    updateData({ loopId: nanoid(6) });
+  }
+});
+</script>

+ 51 - 0
src/components/newtab/workflow/edit/EditParameterPrompt.vue

@@ -0,0 +1,51 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
+    <ui-input
+      :model-value="data.timeout"
+      type="number"
+      label="Timeout (millisecond)"
+      class="w-full mt-2"
+      @change="updateData({ timeout: +$event })"
+    />
+    <ui-button
+      class="w-full mt-4"
+      variant="accent"
+      @click="showModal = !showModal"
+    >
+      Insert Parameters
+    </ui-button>
+    <ui-modal v-model="showModal" title="Parameters" content-class="max-w-4xl">
+      <edit-workflow-parameters
+        :data="data.parameters"
+        @update="updateData({ parameters: $event })"
+      />
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import EditWorkflowParameters from './EditWorkflowParameters.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+const showModal = ref(false);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+</script>

+ 25 - 1
src/components/newtab/workflow/edit/EditPressKey.vue

@@ -16,7 +16,24 @@
         @change="updateData({ selector: $event })"
       />
     </edit-autocomplete>
-    <div class="flex items-end">
+    <ui-select
+      :model-value="data.action || 'press-key'"
+      :label="t('workflow.blocks.base.action')"
+      class="w-full mt-2"
+      @change="updateData({ action: $event })"
+    >
+      <option
+        v-for="action in ['press-key', 'multiple-keys']"
+        :key="action"
+        :value="action"
+      >
+        {{ t(`workflow.blocks.press-key.actions.${action}`) }}
+      </option>
+    </ui-select>
+    <div
+      v-if="!data.action || data.action === 'press-key'"
+      class="flex items-end"
+    >
       <ui-autocomplete
         :items="keysList"
         :model-value="dataKeys"
@@ -47,6 +64,13 @@
         <v-remixicon :name="isRecordingKey ? 'riCloseLine' : 'riFocus3Line'" />
       </ui-button>
     </div>
+    <ui-textarea
+      v-else
+      :model-value="data.keysToPress"
+      class="w-full mt-2"
+      placeholder="keys"
+      @change="updateData({ keysToPress: $event })"
+    />
   </div>
 </template>
 <script setup>

+ 11 - 2
src/components/newtab/workflow/edit/EditSwitchTab.vue

@@ -16,8 +16,8 @@
         {{ type.name }}
       </option>
     </ui-select>
-    <template v-if="data.findTabBy === 'match-patterns'">
-      <edit-autocomplete>
+    <template v-if="['match-patterns', 'tab-title'].includes(data.findTabBy)">
+      <edit-autocomplete v-if="data.findTabBy === 'match-patterns'">
         <ui-input
           :model-value="data.matchPattern"
           placeholder="https://example.com/*"
@@ -42,6 +42,14 @@
           </template>
         </ui-input>
       </edit-autocomplete>
+      <edit-autocomplete v-else-if="data.findTabBy === 'tab-title'">
+        <ui-input
+          :model-value="data.tabTitle"
+          label="Tab title"
+          class="w-full"
+          @change="updateData({ tabTitle: $event })"
+        />
+      </edit-autocomplete>
       <ui-checkbox
         :model-value="data.createIfNoMatch"
         class="mt-1"
@@ -92,6 +100,7 @@ const emit = defineEmits(['update:data']);
 const { t } = useI18n();
 const types = [
   { id: 'match-patterns', name: 'Match patterns' },
+  { id: 'tab-title', name: 'Tab title' },
   { id: 'next-tab', name: 'Next tab' },
   { id: 'prev-tab', name: 'Previous tab' },
   { id: 'tab-index', name: 'Tab index' },

+ 5 - 3
src/components/newtab/workflow/edit/EditWorkflowParameters.vue

@@ -25,7 +25,7 @@
         <template #item="{ element: param, index }">
           <div class="mb-4">
             <div class="grid grid-cols-12 space-x-2">
-              <div class="col-span-3 flex items-center">
+              <div class="col-span-3 flex">
                 <v-remixicon name="mdiDrag" class="handle mr-2 cursor-move" />
                 <ui-input
                   :model-value="param.name"
@@ -96,7 +96,7 @@
                     v-model="param.description"
                     placeholder="Description"
                     title="Description"
-                    class="mb-2"
+                    class="mb-2 block"
                     style="max-width: 400px"
                   />
                   <component
@@ -154,7 +154,9 @@ const paramTypes = {
   },
   ...customParameters,
 };
-const paramTypesArr = Object.values(paramTypes).filter((item) => item.id);
+const paramTypesArr = Object.values(paramTypes)
+  .filter((item) => item.id)
+  .sort((a, b) => (a.name > b.name ? 1 : -1));
 
 const state = reactive({
   parameters: cloneDeep(props.data || []).map((item) => {

+ 88 - 1
src/components/newtab/workflow/edit/EditWorkflowState.vue

@@ -30,10 +30,54 @@
     >
       Execpt for the current workflow
     </ui-checkbox>
+    <div
+      v-if="data.type === 'stop-specific'"
+      class="rounded-lg bg-input focus-within:bg-box-transparent-2 transition mt-4"
+    >
+      <div
+        v-if="data.workflowsToStop.length > 0"
+        class="px-4 py-2 overflow-auto scroll"
+        style="max-height: 114px"
+      >
+        <div
+          v-for="item in data.workflowsToStop"
+          :key="item"
+          class="inline-flex mb-1 mr-1 items-center p-1 bg-box-transparent rounded-md text-sm"
+        >
+          <span class="flex-1">
+            {{ selectedWorkflows[item] }}
+          </span>
+          <v-remixicon
+            name="riCloseLine"
+            class="cursor-pointer text-gray-600 dark:text-gray-300"
+            size="20"
+            @click="removeSelectedItem(item)"
+          />
+        </div>
+      </div>
+      <ui-autocomplete
+        :model-value="query"
+        :items="workflows"
+        item-key="id"
+        item-label="name"
+        block
+        @selected="onItemSelected"
+      >
+        <input
+          v-model="query"
+          type="text"
+          placeholder="Select a workflow"
+          class="w-full py-2 px-4 bg-transparent rounded-lg"
+        />
+      </ui-autocomplete>
+    </div>
   </div>
 </template>
 <script setup>
+import { computed, inject, ref } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useWorkflowStore } from '@/stores/workflow';
+import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 
 const props = defineProps({
   data: {
@@ -44,6 +88,8 @@ const props = defineProps({
 const emit = defineEmits(['update:data']);
 
 const { t } = useI18n();
+const workflowStore = useWorkflowStore();
+const teamWorkflowStore = useTeamWorkflowStore();
 
 const includeExceptions = ['stop-all'];
 const actions = [
@@ -53,11 +99,52 @@ const actionsItems = {
   stop: [
     { id: 'stop-all', name: 'Stop all workflows' },
     { id: 'stop-current', name: 'Stop current workflow' },
-    // { id: 'stop-specific', name: 'Stop specific workflows' },
+    { id: 'stop-specific', name: 'Stop specific workflows' },
   ],
 };
 
+const query = ref('');
+const selectedWorkflows = ref({});
+const currentWorkflow = inject('workflow', {});
+
+const workflows = computed(() => {
+  let workflowsList = [];
+  const workflow = currentWorkflow.data.value;
+
+  if (workflow.id.startsWith('team')) {
+    workflowsList = teamWorkflowStore.getByTeam(workflow.teamId) || [];
+  } else {
+    workflowsList = workflowStore.getWorkflows;
+  }
+
+  return workflowsList.filter((item) => {
+    const selected = props.data.workflowsToStop.includes(item.id);
+    if (selected) selectedWorkflows.value[item.id] = item.name;
+
+    return !selected;
+  });
+});
+
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
 }
+function onItemSelected({ item }) {
+  const copy = [...props.data.workflowsToStop];
+  copy.push(item.id);
+
+  selectedWorkflows.value[item.id] = item.name;
+
+  updateData({ workflowsToStop: copy });
+
+  query.value = '';
+}
+function removeSelectedItem(itemId) {
+  const copy = [...props.data.workflowsToStop];
+  const index = props.data.workflowsToStop.indexOf(itemId);
+  copy.splice(index, 1);
+
+  updateData({ workflowsToStop: copy });
+
+  delete selectedWorkflows.value[itemId];
+}
 </script>

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

@@ -17,7 +17,7 @@
       @change="updateData({ variableName: $event })"
     />
   </template>
-  <template v-if="table">
+  <template v-if="table && workflow.columns?.value">
     <ui-checkbox
       :model-value="data.saveData"
       block

+ 1 - 32
src/components/newtab/workflow/editor/EditorLocalActions.vue

@@ -6,15 +6,6 @@
   >
     {{ workflow.tag }}
   </span>
-  <ui-card v-if="!isTeam || !canEdit" padding="p-1 pointer-events-auto">
-    <button
-      v-tooltip.group="'Workflow note'"
-      class="hoverable p-2 rounded-lg"
-      @click="state.showNoteModal = true"
-    >
-      <v-remixicon name="riFileEditLine" />
-    </button>
-  </ui-card>
   <ui-card
     v-if="!isTeam"
     padding="p-1"
@@ -287,21 +278,6 @@
       </ui-button>
     </div>
   </ui-modal>
-  <ui-modal
-    v-model="state.showNoteModal"
-    title="Workflow note"
-    content-class="max-w-2xl"
-  >
-    <shared-wysiwyg
-      :model-value="workflow.content || ''"
-      :limit="1000"
-      :readonly="!canEdit"
-      class="bg-box-transparent p-4 rounded-lg overflow-auto scroll"
-      placeholder="Write note here..."
-      style="max-height: calc(100vh - 12rem); min-height: 400px"
-      @change="updateWorkflowNote({ content: $event })"
-    />
-  </ui-modal>
 </template>
 <script setup>
 import { reactive, computed } from 'vue';
@@ -320,12 +296,11 @@ import { useDialog } from '@/composable/dialog';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { tagColors } from '@/utils/shared';
-import { parseJSON, findTriggerBlock, debounce } from '@/utils/helper';
+import { parseJSON, findTriggerBlock } from '@/utils/helper';
 import { exportWorkflow, convertWorkflow } from '@/utils/workflowData';
 import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
 import getTriggerText from '@/utils/triggerText';
 import convertWorkflowData from '@/utils/convertWorkflowData';
-import SharedWysiwyg from '@/components/newtab/shared/SharedWysiwyg.vue';
 import WorkflowShareTeam from '@/components/newtab/workflow/WorkflowShareTeam.vue';
 
 const props = defineProps({
@@ -378,7 +353,6 @@ const state = reactive({
   triggerText: '',
   loadingSync: false,
   isPublishing: false,
-  showNoteModal: false,
   isUploadingHost: false,
   showEditDescription: false,
 });
@@ -398,11 +372,6 @@ const userDontHaveTeamsAccess = computed(() => {
   );
 });
 
-const updateWorkflowNote = debounce((data) => {
-  /* eslint-disable-next-line */
-  updateWorkflow(data, true);
-}, 200);
-
 function updateWorkflow(data = {}, changedIndicator = false) {
   let store = null;
 

+ 82 - 7
src/components/newtab/workflow/editor/EditorLocalSavedBlocks.vue

@@ -78,12 +78,39 @@
             >
               <v-remixicon name="riExternalLinkLine" size="18" />
             </a>
-            <v-remixicon
-              name="riDeleteBin7Line"
-              size="18"
-              class="cursor-pointer"
-              @click="deleteItem(item)"
-            />
+            <ui-popover style="height: 18px">
+              <template #trigger>
+                <v-remixicon
+                  size="18"
+                  class="cursor-pointer"
+                  name="riMore2Line"
+                />
+              </template>
+              <ui-list>
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="updatePackages(item)"
+                >
+                  Update packages
+                  <v-remixicon
+                    v-tooltip="
+                      'Update the current package inside the workflow.'
+                    "
+                    class="ml-2 -mr-1"
+                    name="riInformationLine"
+                    size="20"
+                  />
+                </ui-list-item>
+                <ui-list-item
+                  v-close-popover
+                  class="text-red-400 dark:text-red-500 cursor-pointer"
+                  @click="deleteItem(item)"
+                >
+                  {{ t('common.delete') }}
+                </ui-list-item>
+              </ui-list>
+            </ui-popover>
           </div>
         </div>
       </div>
@@ -91,7 +118,7 @@
   </div>
 </template>
 <script setup>
-import { computed, reactive } from 'vue';
+import { computed, reactive, inject } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useDialog } from '@/composable/dialog';
 import { usePackageStore } from '@/stores/package';
@@ -106,6 +133,8 @@ const state = reactive({
   query: '',
 });
 
+const editor = inject('workflow-editor');
+
 const sortedItems = computed(() =>
   packageStore.packages.slice().sort((a, b) => b.createdAt - a.createdAt)
 );
@@ -128,4 +157,50 @@ function deleteItem({ id, name }) {
     },
   });
 }
+function removeConnections({ id, type, oldEdges, newEdges }) {
+  const removedEdges = [];
+  oldEdges.forEach((edge) => {
+    const isNotDeleted = newEdges.find((item) => item.id === edge.id);
+    if (isNotDeleted) return;
+
+    const handleType = type.slice(0, -1);
+
+    removedEdges.push(`${id}-${handleType}-${edge.id}`);
+  });
+
+  const edgesToRemove = editor.value.getEdges.value.filter(
+    ({ sourceHandle, targetHandle }) => {
+      if (type === 'outputs') {
+        return removedEdges.includes(sourceHandle);
+      }
+
+      return removedEdges.includes(targetHandle);
+    }
+  );
+
+  editor.value.removeEdges(edgesToRemove);
+}
+function updatePackages(item) {
+  const packageNodes = editor.value.getNodes.value.filter(
+    (node) => node.data.id === item.id
+  );
+  if (packageNodes.length === 0) return;
+
+  packageNodes.forEach((node) => {
+    removeConnections({
+      id: node.id,
+      type: 'inputs',
+      newEdges: item.inputs,
+      oldEdges: node.data.inputs,
+    });
+    removeConnections({
+      id: node.id,
+      type: 'outputs',
+      newEdges: item.outputs,
+      oldEdges: node.data.outputs,
+    });
+
+    node.data = { ...item };
+  });
+}
 </script>

+ 76 - 89
src/components/newtab/workflows/WorkflowsLocal.vue

@@ -11,98 +11,41 @@
     </div>
   </div>
   <template v-else>
+    <div v-if="pinnedWorkflows.length > 0" class="mb-8 pb-8 border-b">
+      <div class="flex items-center">
+        <v-remixicon name="riPushpin2Line" class="mr-2" size="20" />
+        <span>{{ t('workflow.pinWorkflow.pinned') }}</span>
+      </div>
+      <div class="workflows-container mt-4">
+        <workflows-local-card
+          v-for="workflow in pinnedWorkflows"
+          :key="workflow.id"
+          :workflow="workflow"
+          :is-hosted="userStore.hostedWorkflows[workflow.id]"
+          :is-shared="sharedWorkflowStore.getById(workflow.id)"
+          :is-pinned="true"
+          :menu="menu"
+          @dragstart="onDragStart"
+          @execute="executeWorkflow"
+          @toggle-pin="togglePinWorkflow(workflow)"
+          @toggle-disable="toggleDisableWorkflow(workflow)"
+        />
+      </div>
+    </div>
     <div class="workflows-container">
-      <shared-card
+      <workflows-local-card
         v-for="workflow in workflows"
         :key="workflow.id"
-        :data="workflow"
-        :data-workflow="workflow.id"
-        draggable="true"
-        class="cursor-default select-none ring-accent local-workflow"
+        :workflow="workflow"
+        :is-hosted="userStore.hostedWorkflows[workflow.id]"
+        :is-shared="sharedWorkflowStore.getById(workflow.id)"
+        :is-pinned="state.pinnedWorkflows.includes(workflow.id)"
+        :menu="menu"
         @dragstart="onDragStart"
-        @click="$router.push(`/workflows/${$event.id}`)"
-      >
-        <template #header>
-          <div class="flex items-center mb-4">
-            <template v-if="workflow && !workflow.isDisabled">
-              <ui-img
-                v-if="workflow.icon.startsWith('http')"
-                :src="workflow.icon"
-                class="rounded-lg overflow-hidden"
-                style="height: 40px; width: 40px"
-                alt="Can not display"
-              />
-              <span
-                v-else
-                class="p-2 rounded-lg bg-box-transparent inline-block"
-              >
-                <v-remixicon :name="workflow.icon" />
-              </span>
-            </template>
-            <p v-else class="py-2">{{ t('common.disabled') }}</p>
-            <div class="flex-grow"></div>
-            <button
-              v-if="!workflow.isDisabled"
-              class="invisible group-hover:visible"
-              @click="executeWorkflow(workflow)"
-            >
-              <v-remixicon name="riPlayLine" />
-            </button>
-            <ui-popover class="h-6 ml-2">
-              <template #trigger>
-                <button>
-                  <v-remixicon name="riMoreLine" />
-                </button>
-              </template>
-              <ui-list class="space-y-1" style="min-width: 150px">
-                <ui-list-item
-                  class="cursor-pointer"
-                  @click="toggleDisableWorkflow(workflow)"
-                >
-                  <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
-                  <span class="capitalize">
-                    {{
-                      t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`)
-                    }}
-                  </span>
-                </ui-list-item>
-                <ui-list-item
-                  v-for="item in menu"
-                  :key="item.id"
-                  v-close-popover
-                  class="cursor-pointer"
-                  @click="item.action(workflow)"
-                >
-                  <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
-                  <span class="capitalize">{{ item.name }}</span>
-                </ui-list-item>
-              </ui-list>
-            </ui-popover>
-          </div>
-        </template>
-        <template #footer-content>
-          <v-remixicon
-            v-if="sharedWorkflowStore.getById(workflow.id)"
-            v-tooltip:bottom.group="
-              t('workflow.share.sharedAs', {
-                name: sharedWorkflowStore
-                  .getById(workflow.id)
-                  ?.name.slice(0, 64),
-              })
-            "
-            name="riShareLine"
-            size="20"
-            class="ml-2"
-          />
-          <v-remixicon
-            v-if="userStore.hostedWorkflows[workflow.id]"
-            v-tooltip:bottom.group="t('workflow.host.title')"
-            name="riBaseStationLine"
-            size="20"
-            class="ml-2"
-          />
-        </template>
-      </shared-card>
+        @execute="executeWorkflow"
+        @toggle-pin="togglePinWorkflow(workflow)"
+        @toggle-disable="toggleDisableWorkflow(workflow)"
+      />
     </div>
     <div
       v-if="filteredWorkflows.length > 18"
@@ -161,6 +104,7 @@
 import { shallowReactive, computed, onMounted, onBeforeUnmount } from 'vue';
 import { useI18n } from 'vue-i18n';
 import SelectionArea from '@viselect/vanilla';
+import browser from 'webextension-polyfill';
 import { arraySorter } from '@/utils/helper';
 import { sendMessage } from '@/utils/message';
 import { useUserStore } from '@/stores/user';
@@ -168,7 +112,7 @@ import { useDialog } from '@/composable/dialog';
 import { useWorkflowStore } from '@/stores/workflow';
 import { exportWorkflow } from '@/utils/workflowData';
 import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
-import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+import WorkflowsLocalCard from './WorkflowsLocalCard.vue';
 
 const props = defineProps({
   search: {
@@ -199,6 +143,7 @@ const workflowStore = useWorkflowStore();
 const sharedWorkflowStore = useSharedWorkflowStore();
 
 const state = shallowReactive({
+  pinnedWorkflows: [],
   selectedWorkflows: [],
 });
 const renameState = shallowReactive({
@@ -262,6 +207,27 @@ const workflows = computed(() =>
     pagination.currentPage * pagination.perPage
   )
 );
+const pinnedWorkflows = computed(() => {
+  const list = [];
+  state.pinnedWorkflows.forEach((workflowId) => {
+    const workflow = workflowStore.getById(workflowId);
+    if (
+      !workflow ||
+      !workflow.name
+        .toLocaleLowerCase()
+        .includes(props.search.toLocaleLowerCase())
+    )
+      return;
+
+    list.push(workflow);
+  });
+
+  return arraySorter({
+    data: list,
+    key: props.sort.by,
+    order: props.sort.order,
+  });
+});
 
 function executeWorkflow(workflow) {
   sendMessage('workflow:execute', workflow, 'background');
@@ -344,6 +310,8 @@ function duplicateWorkflow(workflow) {
     delete copyWorkflow[key];
   });
 
+  copyWorkflow.name += ' - copy';
+
   workflowStore.insert(copyWorkflow);
 }
 function onDragStart({ dataTransfer, target }) {
@@ -362,6 +330,21 @@ function clearSelectedWorkflows() {
   });
   selection.clearSelection();
 }
+function togglePinWorkflow(workflow) {
+  const index = state.pinnedWorkflows.indexOf(workflow.id);
+  const copyData = [...state.pinnedWorkflows];
+
+  if (index === -1) {
+    copyData.push(workflow.id);
+  } else {
+    copyData.splice(index, 1);
+  }
+
+  state.pinnedWorkflows = copyData;
+  browser.storage.local.set({
+    pinnedWorkflows: copyData,
+  });
+}
 
 const menu = [
   {
@@ -392,6 +375,10 @@ const menu = [
 
 onMounted(() => {
   window.addEventListener('keydown', deleteSelectedWorkflows);
+
+  browser.storage.local.get('pinnedWorkflows').then((storage) => {
+    state.pinnedWorkflows = storage.pinnedWorkflows || [];
+  });
 });
 onBeforeUnmount(() => {
   window.removeEventListener('keydown', deleteSelectedWorkflows);

+ 116 - 0
src/components/newtab/workflows/WorkflowsLocalCard.vue

@@ -0,0 +1,116 @@
+<template>
+  <shared-card
+    :data="workflow"
+    :data-workflow="workflow.id"
+    draggable="true"
+    class="cursor-default select-none ring-accent local-workflow"
+    @click="$router.push(`/workflows/${$event.id}`)"
+  >
+    <template #header>
+      <div class="flex items-center mb-4">
+        <template v-if="workflow && !workflow.isDisabled">
+          <ui-img
+            v-if="workflow.icon.startsWith('http')"
+            :src="workflow.icon"
+            class="rounded-lg overflow-hidden"
+            style="height: 40px; width: 40px"
+            alt="Can not display"
+          />
+          <span v-else class="p-2 rounded-lg bg-box-transparent inline-block">
+            <v-remixicon :name="workflow.icon" />
+          </span>
+        </template>
+        <p v-else class="py-2">{{ t('common.disabled') }}</p>
+        <div class="flex-grow"></div>
+        <button
+          v-if="!workflow.isDisabled"
+          class="invisible group-hover:visible"
+          @click="$emit('execute')"
+        >
+          <v-remixicon name="riPlayLine" />
+        </button>
+        <ui-popover class="h-6 ml-2">
+          <template #trigger>
+            <button>
+              <v-remixicon name="riMoreLine" />
+            </button>
+          </template>
+          <ui-list class="space-y-1" style="min-width: 165px">
+            <ui-list-item
+              class="cursor-pointer"
+              @click="$emit('toggleDisable')"
+            >
+              <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
+              <span class="capitalize">
+                {{ t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`) }}
+              </span>
+            </ui-list-item>
+            <ui-list-item class="cursor-pointer" @click="$emit('togglePin')">
+              <v-remixicon name="riPushpin2Line" class="mr-2 -ml-1" />
+              <span>{{
+                t(`workflow.pinWorkflow.${isPinned ? 'unpin' : 'pin'}`)
+              }}</span>
+            </ui-list-item>
+            <ui-list-item
+              v-for="item in menu"
+              :key="item.id"
+              v-close-popover
+              class="cursor-pointer"
+              @click="item.action(workflow)"
+            >
+              <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+              <span class="capitalize">{{ item.name }}</span>
+            </ui-list-item>
+          </ui-list>
+        </ui-popover>
+      </div>
+    </template>
+    <template #footer-content>
+      <v-remixicon
+        v-if="isShared"
+        v-tooltip:bottom.group="
+          t('workflow.share.sharedAs', {
+            name: isShared?.name.slice(0, 64),
+          })
+        "
+        name="riShareLine"
+        size="20"
+        class="ml-2"
+      />
+      <v-remixicon
+        v-if="isHosted"
+        v-tooltip:bottom.group="t('workflow.host.title')"
+        name="riBaseStationLine"
+        size="20"
+        class="ml-2"
+      />
+    </template>
+  </shared-card>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+
+defineProps({
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+  menu: {
+    type: Array,
+    default: () => [],
+  },
+  isShared: {
+    type: Object,
+    default: null,
+  },
+  isHosted: {
+    type: Object,
+    default: null,
+  },
+  isPinned: Boolean,
+});
+defineEmits(['toggleDisable', 'togglePin', 'execute']);
+
+const { t } = useI18n();
+</script>

+ 21 - 12
src/components/popup/home/HomeWorkflowCard.vue

@@ -26,17 +26,25 @@
           <v-remixicon name="riMoreLine" />
         </button>
       </template>
-      <ui-list class="w-40 space-y-1">
-        <ui-list-item
-          v-if="tab === 'local'"
-          class="capitalize cursor-pointer"
-          @click="$emit('update', { isDisabled: !workflow.isDisabled })"
-        >
-          <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
-          <span>{{
-            t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`)
-          }}</span>
-        </ui-list-item>
+      <ui-list class="space-y-1" style="min-width: 160px">
+        <template v-if="tab === 'local'">
+          <ui-list-item
+            class="capitalize cursor-pointer"
+            @click="$emit('update', { isDisabled: !workflow.isDisabled })"
+          >
+            <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
+            <span>{{
+              t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`)
+            }}</span>
+          </ui-list-item>
+          <ui-list-item
+            class="capitalize cursor-pointer"
+            @click="$emit('togglePin')"
+          >
+            <v-remixicon name="riPushpin2Line" class="mr-2 -ml-1" />
+            <span>{{ pinned ? 'Unpin workflow' : 'Pin workflow' }}</span>
+          </ui-list-item>
+        </template>
         <ui-list-item
           v-for="item in filteredMenu"
           :key="item.name"
@@ -64,8 +72,9 @@ const props = defineProps({
     type: String,
     default: 'local',
   },
+  pinned: Boolean,
 });
-defineEmits(['execute', 'rename', 'details', 'delete', 'update']);
+defineEmits(['execute', 'togglePin', 'rename', 'details', 'delete', 'update']);
 
 const { t } = useI18n();
 

+ 9 - 0
src/components/ui/UiInput.vue

@@ -31,6 +31,7 @@
         v-autofocus="autofocus"
         v-imask="mask"
         :class="[
+          statusColors[status],
           inputClass,
           {
             'opacity-75 pointer-events-none': disabled,
@@ -119,6 +120,10 @@ const props = defineProps({
     type: [Array, Object],
     default: null,
   },
+  status: {
+    type: String,
+    default: '',
+  },
   unmaskValue: Boolean,
 });
 const emit = defineEmits([
@@ -132,6 +137,10 @@ const emit = defineEmits([
 
 const componentId = useComponentId('ui-input');
 
+const statusColors = {
+  error: 'ring-red-400 ring-2 focus:ring-red-400 focus:ring-2',
+};
+
 function emitValue(event) {
   let { value } = event.target;
 

+ 2 - 17
src/content/blocksHandler/handlerEventClick.js

@@ -1,17 +1,9 @@
 import { sendMessage } from '@/utils/message';
-import { getElementPosition } from '../utils';
+import { getElementPosition, simulateClickElement } from '../utils';
 import handleSelector from '../handleSelector';
 
 function eventClick(block) {
   return new Promise((resolve, reject) => {
-    const dispatchClickEvents = (element, eventFn) => {
-      const eventOpts = { bubbles: true, view: window };
-
-      element.dispatchEvent(new MouseEvent('mousedown', eventOpts));
-      element.dispatchEvent(new MouseEvent('mouseup', eventOpts));
-      eventFn();
-    };
-
     handleSelector(block, {
       async onSelected(element) {
         if (block.debugMode) {
@@ -41,14 +33,7 @@ function eventClick(block) {
           return;
         }
 
-        if (element.click) {
-          dispatchClickEvents(element, () => element.click());
-        } else {
-          dispatchClickEvents(
-            () => element,
-            element.dispatchEvent(new PointerEvent('click', { bubbles: true }))
-          );
-        }
+        simulateClickElement(element, () => element.click());
       },
       onError(error) {
         reject(error);

+ 6 - 1
src/content/blocksHandler/handlerForms.js

@@ -28,9 +28,14 @@ async function forms(block) {
         text: char,
         type: 'keyDown',
       }));
+      const typeDelay = +block.data.delay;
       await sendMessage(
         'debugger:type',
-        { commands, tabId: block.activeTabId, delay: block.data.delay },
+        {
+          commands,
+          tabId: block.activeTabId,
+          delay: Number.isNaN(typeDelay) ? 0 : typeDelay,
+        },
         'background'
       );
 

+ 2 - 17
src/content/blocksHandler/handlerLoopData.js

@@ -1,21 +1,6 @@
 import { nanoid } from 'nanoid';
 import handleSelector from '../handleSelector';
-
-function generateLoopSelectors(elements, { max, attrId, frameSelector }) {
-  const selectors = [];
-
-  elements.forEach((el, index) => {
-    if (max > 0 && selectors.length - 1 > max) return;
-
-    const attrName = 'automa-loop';
-    const attrValue = `${attrId}--${index}`;
-
-    el.setAttribute(attrName, attrValue);
-    selectors.push(`${frameSelector}[${attrName}="${attrValue}"]`);
-  });
-
-  return selectors;
-}
+import { generateLoopSelectors } from '../utils';
 
 export default async function loopElements(block) {
   const elements = await handleSelector(block);
@@ -36,7 +21,7 @@ export default async function loopElements(block) {
     return {};
   }
 
-  const attrId = nanoid(5);
+  const attrId = `${block.id}-${nanoid(5)}`;
   const selectors = generateLoopSelectors(elements, {
     ...block.data,
     frameSelector,

+ 123 - 0
src/content/blocksHandler/handlerLoopElements.js

@@ -0,0 +1,123 @@
+import { sleep, isXPath } from '@/utils/helper';
+import handleSelector from '../handleSelector';
+import { generateLoopSelectors, simulateClickElement } from '../utils';
+
+function getScrollParent(node) {
+  const isElement = node instanceof HTMLElement;
+  const overflowY = isElement && window.getComputedStyle(node).overflowY;
+  const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden';
+
+  if (!node) {
+    return null;
+  }
+  if (isScrollable && node.scrollHeight >= node.clientHeight) {
+    return node;
+  }
+
+  return (
+    getScrollParent(node.parentNode) ||
+    document.scrollingElement ||
+    document.body
+  );
+}
+function excludeSelector({ type, selector, loopAttr }) {
+  if (type === 'cssSelector') {
+    return `${selector}:not([automa-loop*="${loopAttr}"])`;
+  }
+
+  return `${selector}[not(contains(@automa-loop, 'gku9rbk-qje-F'))]`;
+}
+
+export default async function ({ data, id }) {
+  try {
+    let frameSelector = '';
+    if (data.$frameSelector) {
+      frameSelector = `${data.$frameSelector} |> `;
+    }
+
+    const generateItemsSelector = (elements) => {
+      const selectors = generateLoopSelectors(elements, {
+        frameSelector,
+        attrId: data.loopAttrId,
+        startIndex: data.index + 1,
+      });
+
+      return selectors;
+    };
+    const getNewElementsOptions = {
+      id,
+      data: {
+        multiple: true,
+        findBy: data.findBy,
+        waitForSelector: true,
+        waitSelectorTimeout: data.actionElMaxWaitTime * 1000,
+        selector: excludeSelector({
+          type: data.findBy,
+          selector: data.selector,
+          loopAttr: data.loopAttrId,
+        }),
+      },
+    };
+    let elements = null;
+
+    if (data.type === 'scroll') {
+      const loopItems = document.querySelectorAll(
+        `[automa-loop*="${data.loopAttrId}"]`
+      );
+      if (loopItems.length === 0) return { continue: true };
+
+      const scrollableParent = getScrollParent(loopItems[0]);
+      if (!scrollableParent) return { continue: true };
+
+      let scrollHeight = 0;
+      if (data.scrollToBottom) {
+        scrollHeight = scrollableParent.scrollHeight;
+      } else {
+        loopItems.forEach((item) => {
+          scrollHeight += item.getBoundingClientRect().height;
+        });
+      }
+
+      scrollableParent.scrollTo(0, scrollHeight + 30);
+
+      await sleep(500);
+
+      elements = await handleSelector(getNewElementsOptions);
+    } else if (['click-element', 'click-link'].includes(data.type)) {
+      const elementForLoad = await handleSelector({
+        id,
+        data: {
+          waitForSelector: true,
+          waitSelectorTimeout: 2000,
+          selector: data.actionElSelector,
+          findBy: isXPath(data.actionElSelector),
+        },
+      });
+      if (!elementForLoad) return { continue: true };
+
+      if (data.type === 'click-element') {
+        simulateClickElement(elementForLoad);
+        await sleep(500);
+
+        elements = await handleSelector(getNewElementsOptions);
+      } else {
+        if (data.onlyClickLink) {
+          if (elementForLoad.tagName !== 'A' || !elementForLoad.href)
+            return { continue: true };
+
+          window.location.href = elementForLoad.href;
+
+          return {};
+        }
+        elements = await handleSelector(getNewElementsOptions);
+      }
+    }
+
+    if (!elements) return { continue: true };
+
+    return generateItemsSelector(elements);
+  } catch (error) {
+    console.error(error);
+    return { continue: true };
+  }
+}

+ 30 - 6
src/content/blocksHandler/handlerPressKey.js

@@ -1,4 +1,4 @@
-import { isXPath } from '@/utils/helper';
+import { isXPath, objectHasKey } from '@/utils/helper';
 import { sendMessage } from '@/utils/message';
 import { keyDefinitions } from '@/utils/USKeyboardLayout';
 import { queryElements } from '../handleSelector';
@@ -87,8 +87,12 @@ function pressKeyWithJs(element, keys) {
     });
   });
 }
-async function pressKeyWithCommand(_, keys, activeTabId) {
-  for (const event of ['keyDown', 'keyUp']) {
+async function pressKeyWithCommand(_, keys, activeTabId, actionType) {
+  const commands = [];
+  const events =
+    actionType === 'multiple-keys' ? ['keyDown'] : ['keyDown', 'keyUp'];
+
+  for (const event of events) {
     let modifierKey = 0;
 
     for (const key of keys) {
@@ -116,9 +120,26 @@ async function pressKeyWithCommand(_, keys, activeTabId) {
         else command.params.modifiers = modifierKey;
       }
 
-      await sendMessage('debugger:send-command', command, 'background');
+      if (!actionType || actionType === 'press-key') {
+        await sendMessage('debugger:send-command', command, 'background');
+      } else {
+        const secondEvent = { ...command.params };
+        if (!objectHasKey(command, 'text')) {
+          secondEvent.text = key;
+        }
+
+        commands.push(command.params, secondEvent);
+      }
     }
   }
+
+  if (actionType === 'multiple-keys') {
+    await sendMessage(
+      'debugger:type',
+      { commands, tabId: activeTabId },
+      'background'
+    );
+  }
 }
 
 async function pressKey({ data, debugMode, activeTabId }) {
@@ -133,10 +154,13 @@ async function pressKey({ data, debugMode, activeTabId }) {
     element = customElement || element;
   }
 
-  const keys = data.keys.split('+');
+  const keys =
+    !data.action || data.action === 'press-key'
+      ? data.keys.split('+')
+      : data.keysToPress.split('');
   const pressKeyFunction = debugMode ? pressKeyWithCommand : pressKeyWithJs;
 
-  await pressKeyFunction(element, keys, activeTabId);
+  await pressKeyFunction(element, keys, activeTabId, data.action);
 
   return '';
 }

+ 32 - 0
src/content/utils.js

@@ -1,3 +1,35 @@
+export function simulateClickElement(element) {
+  const eventOpts = { bubbles: true, view: window };
+
+  element.dispatchEvent(new MouseEvent('mousedown', eventOpts));
+  element.dispatchEvent(new MouseEvent('mouseup', eventOpts));
+
+  if (element.click) {
+    element.click();
+  } else {
+    element.dispatchEvent(new PointerEvent('click', { bubbles: true }));
+  }
+}
+
+export function generateLoopSelectors(
+  elements,
+  { max, attrId, frameSelector, startIndex = 0 }
+) {
+  const selectors = [];
+
+  elements.forEach((el, index) => {
+    if (max > 0 && selectors.length - 1 > max) return;
+
+    const attrName = 'automa-loop';
+    const attrValue = `${attrId}--${(startIndex || 0) + index}`;
+
+    el.setAttribute(attrName, attrValue);
+    selectors.push(`${frameSelector}[${attrName}="${attrValue}"]`);
+  });
+
+  return selectors;
+}
+
 export function elementSelectorInstance() {
   const rootElementExist = document.querySelector(
     '#app-container.automa-element-selector'

+ 20 - 2
src/locales/en/blocks.json

@@ -17,6 +17,7 @@
         "timeout": "Timeout (milliseconds)",
         "noPermission": "Automa don't have enough permission to do this action",
         "grantPermission": "Grant permission",
+        "action": "Action",
         "settings": {
           "title": "Block settings",
           "line": {
@@ -187,7 +188,11 @@
         "description": "Press a key or a combination",
         "target": "Target element (optional)",
         "key": "Key",
-        "detect": "Detect key"
+        "detect": "Detect key",
+        "actions": {
+          "press-key": "Press a key",
+          "multiple-keys": "Press multiple keys"
+        }
       },
       "save-assets": {
         "name": "Save assets",
@@ -404,7 +409,8 @@
           "get": "Get spreadsheet cell values",
           "getRange": "Get spreadsheet range",
           "update": "Update spreadsheet cell values",
-          "append": "Append spreadsheet cell values"
+          "append": "Append spreadsheet cell values",
+          "clear": "Clear spreadsheet cell values"
         }
       },
       "active-tab": {
@@ -658,6 +664,18 @@
         "editCondition": "Edit condition",
         "fallback": "Execute when the condition is false"
       },
+      "loop-elements": {
+        "name": "Loop elements",
+        "description": "Iterate through elements",
+        "loadMore": "Load more elements",
+        "scrollToBottom": "Scroll to bottom",
+        "actions": {
+          "none": "None",
+          "click-element": "Click an element to load more",
+          "scroll": "Scroll down to load more",
+          "click-link": "Click a link to load more"
+        }
+      },
       "loop-data": {
         "name": "Loop data",
         "description": "Iterate through table or your custom data",

+ 1 - 0
src/locales/en/common.json

@@ -26,6 +26,7 @@
     "save": "Save",
     "data": "data",
     "stop": "Stop",
+    "action": "Action | Actions",
     "packages": "Packages",
     "storage": "Storage",
     "editor": "Editor",

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

@@ -181,6 +181,15 @@
     }
   },
   "workflow": {
+    "previewMode": {
+      "title": "Preview mode",
+      "description": "You're in preview mode, changes you made won't be saved"
+    },
+    "pinWorkflow": {
+      "pin": "Pin workflow",
+      "unpin": "Unpin workflow",
+      "pinned": "Pinned workflows"
+    },
     "my": "My workflows",
     "import": "Import workflow",
     "new": "New workflow",

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

@@ -101,7 +101,7 @@
               </ui-popover>
             </div>
             <router-link
-              :to="`/packages/${pkg.isExternal ? '' : pkg.id}`"
+              :to="`/packages/${pkg.id}`"
               class="mt-4 flex-1 cursor-pointer"
             >
               <p class="font-semibold text-overflow">

+ 47 - 52
src/newtab/pages/workflows/[id].vue

@@ -98,13 +98,23 @@
         </ui-card>
         <div class="flex-grow pointer-events-none" />
         <editor-used-credentials v-if="editor" :editor="editor" />
-        <editor-pkg-actions
-          v-if="isPackage"
-          :editor="editor"
-          :data="workflow"
-          :is-data-changed="state.dataChanged"
-          @update="onActionUpdated"
-        />
+        <template v-if="isPackage">
+          <ui-button
+            v-if="workflow.isExternal"
+            v-tooltip="t('workflow.previewMode.description')"
+            class="pointer-events-auto cursor-default"
+          >
+            <v-remixicon name="riEyeLine" class="mr-2 -ml-1" />
+            <span>{{ t('workflow.previewMode.title') }}</span>
+          </ui-button>
+          <editor-pkg-actions
+            v-else
+            :editor="editor"
+            :data="workflow"
+            :is-data-changed="state.dataChanged"
+            @update="onActionUpdated"
+          />
+        </template>
         <editor-local-actions
           v-else
           :editor="editor"
@@ -309,6 +319,7 @@ import dbStorage from '@/db/storage';
 import DroppedNode from '@/utils/editor/DroppedNode';
 import EditorCommands from '@/utils/editor/EditorCommands';
 import convertWorkflowData from '@/utils/convertWorkflowData';
+import extractAutocopmleteData from '@/utils/editor/editorAutocomplete';
 import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
 import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
 import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
@@ -349,6 +360,11 @@ const teamWorkflowStore = useTeamWorkflowStore();
 const { teamId, id: workflowId } = route.params;
 const isTeamWorkflow = route.name === 'team-workflows';
 const isPackage = route.name === 'packages-details';
+const funcsAutocomplete = Object.keys(functions).reduce((acc, name) => {
+  acc[`$${name}`] = '';
+
+  return acc;
+}, {});
 
 const editor = shallowRef(null);
 const connectedTable = shallowRef(null);
@@ -466,27 +482,23 @@ const workflowModals = {
     },
   },
 };
-const autocompleteKeys = {
-  loopId: 'loopData',
-  refKey: 'googleSheets',
-  variableName: 'variables',
-};
 
 const autocompleteList = computed(() => {
   const autocompleteData = {
     loopData: {},
     googleSheets: {},
     table: {},
-    ...Object.keys(functions),
+    ...funcsAutocomplete,
     globalData: autocompleteState.common.globalData,
     variables: { ...autocompleteState.common.variables },
   };
 
   Object.values(autocompleteState.blocks).forEach((item) => {
-    Object.keys(item).forEach((key) => {
-      const autocompleteKey = autocompleteKeys[key] || [];
-      autocompleteData[autocompleteKey][item[key]] = '';
-    });
+    if (item.loopData) Object.assign(autocompleteData.loopData, item.loopData);
+    if (item.variables)
+      Object.assign(autocompleteData.variables, item.variables);
+    if (item.googleSheets)
+      Object.assign(autocompleteData.googleSheets, item.googleSheets);
   });
 
   return autocompleteData;
@@ -863,33 +875,6 @@ function ungroupBlocks({ nodes }) {
   editor.value.addSelectedNodes(groupBlocksList);
   editor.value.addEdges(edges);
 }
-function extractAutocopmleteData(label, { data, id }) {
-  const autocompleteData = { [id]: {} };
-  const getData = (blockName, blockData) => {
-    const keys = blocks[blockName]?.autocomplete;
-    const dataList = {};
-    if (!keys) return dataList;
-
-    keys.forEach((key) => {
-      const value = blockData[key];
-      if (!value) return;
-
-      dataList[key] = value;
-    });
-
-    return dataList;
-  };
-
-  if (label === 'blocks-group') {
-    data.blocks.forEach((block) => {
-      autocompleteData[block.itemId] = getData(block.id, block.data);
-    });
-  } else {
-    autocompleteData[id] = getData(label, data);
-  }
-
-  return autocompleteData;
-}
 async function initAutocomplete() {
   const autocompleteCache = sessionStorage.getItem(
     `autocomplete:${workflowId}`
@@ -1086,6 +1071,8 @@ function initEditBlock(data) {
 async function updateWorkflow(data) {
   try {
     if (isPackage) {
+      if (workflow.value.isExternal) return;
+
       delete data.drawflow;
       await packageStore.update({
         id: workflowId,
@@ -1144,13 +1131,19 @@ function onEditorInit(instance) {
     if (targetNode && targetNode.dataset.id !== nodeToConnect.nodeId) {
       const nodeId = targetNode.dataset.id;
       const nodeData = editor.value.getNode.value(nodeId);
+
       if (nodeData && nodeData.handleBounds.target.length >= 1) {
+        const targetHandle = nodeData.handleBounds.target.find(
+          (item) => item.id
+        );
+        if (!targetHandle) return;
+
         editor.value.addEdges([
           {
             target: nodeId,
             source: nodeToConnect.nodeId,
+            targetHandle: targetHandle.id,
             sourceHandle: nodeToConnect.handleId,
-            targetHandle: nodeData.handleBounds.target[0].id,
           },
         ]);
       }
@@ -1472,6 +1465,8 @@ function onKeydown({ ctrlKey, metaKey, shiftKey, key, target }) {
   )
     return;
 
+  if (isPackage && workflow.value.isExternal) return;
+
   const command = (keyName) => (ctrlKey || metaKey) && keyName === key;
   if (command('c')) {
     copySelectedElements();
@@ -1543,7 +1538,9 @@ watch(
 onBeforeRouteLeave(() => {
   updateHostedWorkflow();
 
-  if (!state.dataChanged || !haveEditAccess.value) return;
+  const dataNotChanged = !state.dataChanged || !haveEditAccess.value;
+  const isExternalPkg = isPackage && workflow.value.isExternal;
+  if (dataNotChanged || isExternalPkg) return;
 
   const confirm = window.confirm(t('message.notSaved'));
 
@@ -1555,11 +1552,6 @@ onMounted(() => {
     return null;
   }
 
-  if (isPackage && workflow.value.isExternal) {
-    router.replace('/packages');
-    return;
-  }
-
   state.showSidebar =
     JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
 
@@ -1586,6 +1578,8 @@ onMounted(() => {
   initAutocomplete();
 
   window.onbeforeunload = () => {
+    if (isPackage && workflow.value.isExternal) return;
+
     updateHostedWorkflow();
 
     if (state.dataChanged && haveEditAccess.value) {
@@ -1598,7 +1592,8 @@ onBeforeUnmount(() => {
   const editorContainer = document.querySelector(
     '.vue-flow__viewport.vue-flow__container'
   );
-  editorContainer.removeEventListener('click', onClickEditor);
+  if (editorContainer)
+    editorContainer.removeEventListener('click', onClickEditor);
 
   window.onbeforeunload = null;
   window.removeEventListener('keydown', onKeydown);

+ 47 - 5
src/params/App.vue

@@ -43,6 +43,9 @@
             </p>
           </div>
         </template>
+        <p v-if="workflow.type === 'block'" class="px-4 pb-2">
+          By Parameter Prompt block
+        </p>
         <div class="px-4 pb-4">
           <ul class="space-y-4 divide-y">
             <li v-for="(param, paramIdx) in workflow.params" :key="paramIdx">
@@ -78,7 +81,18 @@
             <ui-button class="mr-4" @click="deleteWorkflow(index)">
               Cancel
             </ui-button>
-            <ui-button variant="accent" @click="runWorkflow(index, workflow)">
+            <ui-button
+              v-if="workflow.type === 'block'"
+              variant="accent"
+              @click="continueWorkflow(index, workflow)"
+            >
+              Continue
+            </ui-button>
+            <ui-button
+              v-else
+              variant="accent"
+              @click="runWorkflow(index, workflow)"
+            >
               <v-remixicon name="riPlayLine" class="mr-2 -ml-1" />
               Run
             </ui-button>
@@ -177,14 +191,14 @@ async function addWorkflow(workflowId) {
     console.error(error);
   }
 }
-function runWorkflow(index, { data, params }) {
+function getParamsValues(params) {
   const getParamVal = {
     string: (str) => str,
     number: (num) => (Number.isNaN(+num) ? 0 : +num),
     default: (value) => value,
   };
 
-  const variables = params.reduce((acc, param) => {
+  return params.reduce((acc, param) => {
     const valueFunc =
       getParamVal[param.type] ||
       paramsList[param.type]?.getValue ||
@@ -194,6 +208,9 @@ function runWorkflow(index, { data, params }) {
 
     return acc;
   }, {});
+}
+function runWorkflow(index, { data, params }) {
+  const variables = getParamsValues(params);
   const payload = {
     name: 'background--workflow:execute',
     data: {
@@ -212,11 +229,36 @@ function runWorkflow(index, { data, params }) {
       deleteWorkflow(index);
     });
 }
+function continueWorkflow(index, { data, params }) {
+  if (Date.now() > data.timeout) {
+    deleteWorkflow(index);
+    return;
+  }
+
+  const key = `params-prompt:${data.execId}__${data.blockId}`;
+  browser.storage.local
+    .set({
+      [key]: getParamsValues(params),
+    })
+    .then(() => {
+      deleteWorkflow(index);
+    });
+}
 
 browser.runtime.onMessage.addListener(({ name, data }) => {
-  if (name !== 'workflow:params') return;
+  if (name === 'workflow:params') {
+    addWorkflow(data);
+  } else if (name === 'workflow:params-block') {
+    const params = [...data.params];
+    delete data.params;
 
-  addWorkflow(data);
+    workflows.value.push({
+      data,
+      params,
+      type: 'block',
+      addedDate: Date.now(),
+    });
+  }
 });
 
 onMounted(async () => {

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

@@ -67,7 +67,10 @@
     v-show="state.activeTab === 'team'"
     :search="state.query"
   />
-  <div v-if="state.activeTab !== 'team'" class="px-5 pb-5 space-y-2">
+  <div
+    v-if="state.activeTab !== 'team'"
+    class="px-5 z-20 relative pb-5 space-y-2"
+  >
     <ui-card v-if="workflowStore.getWorkflows.length === 0" class="text-center">
       <img src="@/assets/svg/alien.svg" />
       <p class="font-semibold">{{ t('message.empty') }}</p>
@@ -79,16 +82,37 @@
         {{ t('home.workflow.new') }}
       </ui-button>
     </ui-card>
+    <div v-if="pinnedWorkflows.length > 0" class="mt-1 mb-4 border-b pb-4">
+      <div class="flex items-center text-gray-300 mb-1">
+        <v-remixicon name="riPushpin2Line" size="20" class="mr-2" />
+        <span>Pinned workflows</span>
+      </div>
+      <home-workflow-card
+        v-for="workflow in pinnedWorkflows"
+        :key="workflow.id"
+        :workflow="workflow"
+        :tab="state.activeTab"
+        :pinned="true"
+        @details="openWorkflowPage"
+        @update="updateWorkflow(workflow.id, $event)"
+        @execute="executeWorkflow"
+        @rename="renameWorkflow"
+        @delete="deleteWorkflow"
+        @toggle-pin="togglePinWorkflow(workflow)"
+      />
+    </div>
     <home-workflow-card
       v-for="workflow in workflows"
       :key="workflow.id"
       :workflow="workflow"
       :tab="state.activeTab"
+      :pinned="state.pinnedWorkflows.includes(workflow.id)"
       @details="openWorkflowPage"
       @update="updateWorkflow(workflow.id, $event)"
       @execute="executeWorkflow"
       @rename="renameWorkflow"
       @delete="deleteWorkflow"
+      @toggle-pin="togglePinWorkflow(workflow)"
     />
   </div>
   <ui-modal v-model="state.newRecordingModal" custom-content>
@@ -153,9 +177,29 @@ const state = shallowReactive({
   retrieved: false,
   haveAccess: true,
   activeTab: 'local',
+  pinnedWorkflows: [],
   newRecordingModal: false,
 });
 
+const pinnedWorkflows = computed(() => {
+  if (state.activeTab !== 'local') return [];
+
+  const list = [];
+  state.pinnedWorkflows.forEach((workflowId) => {
+    const workflow = workflowStore.getById(workflowId);
+    if (
+      !workflow ||
+      !workflow.name
+        .toLocaleLowerCase()
+        .includes(state.query.toLocaleLowerCase())
+    )
+      return;
+
+    list.push(workflow);
+  });
+
+  return list;
+});
 const hostedWorkflows = computed(() => {
   if (state.activeTab !== 'host') return [];
 
@@ -179,6 +223,21 @@ const showTab = computed(
   () => hostedWorkflowStore.toArray.length > 0 || userStore.user?.teams
 );
 
+function togglePinWorkflow(workflow) {
+  const index = state.pinnedWorkflows.indexOf(workflow.id);
+  const copyData = [...state.pinnedWorkflows];
+
+  if (index === -1) {
+    copyData.push(workflow.id);
+  } else {
+    copyData.splice(index, 1);
+  }
+
+  state.pinnedWorkflows = copyData;
+  browser.storage.local.set({
+    pinnedWorkflows: copyData,
+  });
+}
 function executeWorkflow(workflow) {
   sendMessage('workflow:execute', workflow, 'background');
 }
@@ -297,6 +356,9 @@ onMounted(async () => {
   const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
   state.haveAccess = /^(https?)/.test(tab.url);
 
+  const storage = await browser.storage.local.get('pinnedWorkflows');
+  state.pinnedWorkflows = storage.pinnedWorkflows || [];
+
   await userStore.loadUser({ storage: localStorage, ttl: 1000 * 60 * 5 });
   await teamWorkflowStore.loadData();
 

+ 6 - 4
src/stores/package.js

@@ -40,13 +40,15 @@ export const usePackageStore = defineStore('packages', {
     },
   },
   actions: {
-    async insert(data) {
-      this.packages.push({
+    async insert(data, newId = true) {
+      const packageData = {
         ...defaultPackage,
         ...data,
         createdAt: Date.now(),
-        id: nanoid(),
-      });
+      };
+      if (newId) packageData.id = nanoid();
+
+      this.packages.push(packageData);
       await this.saveToStorage('packages');
     },
     async update({ id, data }) {

+ 10 - 0
src/stores/workflow.js

@@ -257,6 +257,16 @@ export const useWorkflowStore = defineStore('workflow', {
       ]);
       await this.saveToStorage('workflows');
 
+      const { pinnedWorkflows } = await browser.storage.local.get(
+        'pinnedWorkflows'
+      );
+      const pinnedWorkflowIndex =
+        pinnedWorkflows && pinnedWorkflows.indexOf(id);
+      if (pinnedWorkflowIndex !== -1) {
+        pinnedWorkflows.splice(pinnedWorkflowIndex, 1);
+        await browser.storage.local.set({ pinnedWorkflows });
+      }
+
       return id;
     },
   },

+ 5 - 0
src/utils/api.js

@@ -49,6 +49,11 @@ export const googleSheets = {
       },
     });
   },
+  clearValues({ spreadsheetId, range }) {
+    return fetchApi(this.getUrl(spreadsheetId, range), {
+      method: 'DELETE',
+    });
+  },
   updateValues({ spreadsheetId, range, options = {}, append }) {
     const url = `${this.getUrl(spreadsheetId, range)}&${queryBuilder(
       options?.queries || {}

+ 71 - 0
src/utils/editor/editorAutocomplete.js

@@ -0,0 +1,71 @@
+import { getBlocks } from '../getSharedData';
+
+const blocks = getBlocks();
+const autocompleteKeys = {
+  loopId: 'loopData',
+  refKey: 'googleSheets',
+  variableName: 'variables',
+};
+
+function getData(blockName, blockData) {
+  const keys = blocks[blockName]?.autocomplete;
+  const dataList = {};
+  if (!keys) return dataList;
+
+  keys.forEach((key) => {
+    const value = blockData[key];
+    if (!value) return;
+
+    const autocompleteKey = autocompleteKeys[key];
+    if (!dataList[autocompleteKey]) dataList[autocompleteKey] = {};
+
+    dataList[autocompleteKey][value] = '';
+  });
+
+  return dataList;
+}
+
+const extractBlocksAutocomplete = {
+  trigger(blockId, data) {
+    if (!this[blockId].variables) this[blockId].variables = {};
+
+    data.parameters?.forEach((param) => {
+      this[blockId].variables[param.name] = '';
+    });
+
+    if (data.type === 'context-menu') {
+      Object.assign(this[blockId].variables, {
+        $ctxElSelector: '',
+        $ctxTextSelection: '',
+        $ctxLink: '',
+        $ctxMediaUrl: '',
+      });
+    }
+  },
+  'blocks-group': function (blockId, data) {
+    data.blocks.forEach((block) => {
+      this[block.itemId] = getData(block.id, block.data);
+    });
+  },
+  'insert-data': function (blockId, data) {
+    if (!this[blockId].variables) this[blockId].variables = {};
+
+    data.dataList.forEach((item) => {
+      if (item.type !== 'variable' || !item.name.trim()) return;
+
+      this[blockId].variables[item.name] = '';
+    });
+  },
+};
+
+export default function (label, { data, id }) {
+  const autocompleteData = { [id]: {} };
+
+  if (extractBlocksAutocomplete[label]) {
+    extractBlocksAutocomplete[label].call(autocompleteData, id, data);
+  } else {
+    autocompleteData[id] = getData(label, data);
+  }
+
+  return autocompleteData;
+}

+ 74 - 8
src/utils/shared.js

@@ -119,12 +119,13 @@ export const tasks = {
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
-    refDataKeys: ['url', 'matchPattern'],
+    refDataKeys: ['url', 'matchPattern', 'tabTitle'],
     data: {
       disableBlock: false,
       description: '',
       url: '',
       tabIndex: 0,
+      tabTitle: '',
       matchPattern: '',
       activeTab: true,
       createIfNoMatch: false,
@@ -474,7 +475,7 @@ export const tasks = {
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
-    refDataKeys: ['selector', 'value', 'optionPosition'],
+    refDataKeys: ['selector', 'value', 'optionPosition', 'delay'],
     autocomplete: ['variableName'],
     data: {
       disableBlock: false,
@@ -504,7 +505,7 @@ export const tasks = {
     name: 'Repeat task',
     icon: 'riRepeat2Line',
     component: 'BlockRepeatTask',
-    category: 'general',
+    category: 'conditions',
     inputs: 1,
     outputs: 2,
     allowedInputs: true,
@@ -673,7 +674,7 @@ export const tasks = {
     icon: 'riRefreshFill',
     component: 'BlockBasicWithFallback',
     editComponent: 'EditWhileLoop',
-    category: 'general',
+    category: 'conditions',
     inputs: 1,
     outputs: 2,
     allowedInputs: true,
@@ -689,7 +690,7 @@ export const tasks = {
     icon: 'riRefreshLine',
     component: 'BlockBasic',
     editComponent: 'EditLoopData',
-    category: 'general',
+    category: 'conditions',
     inputs: 1,
     outputs: 1,
     allowedInputs: true,
@@ -721,12 +722,45 @@ export const tasks = {
       loopThrough: 'data-columns',
     },
   },
+  'loop-elements': {
+    name: 'Loop elements',
+    icon: 'riRestartLine',
+    component: 'BlockBasic',
+    editComponent: 'EditLoopElements',
+    category: 'conditions',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    refDataKeys: [
+      'maxLoop',
+      'variableName',
+      'elementSelector',
+      'actionElSelector',
+    ],
+    autocomplete: ['loopId'],
+    data: {
+      disableBlock: false,
+      loopId: '',
+      maxLoop: '0',
+      description: '',
+      selector: '',
+      findBy: 'cssSelector',
+      actionElSelector: '',
+      actionElMaxWaitTime: 5,
+      actionPageMaxWaitTime: 10,
+      loadMoreAction: 'none',
+      scrollToBottom: true,
+      waitForSelector: false,
+      waitSelectorTimeout: 5000,
+    },
+  },
   'loop-breakpoint': {
     name: 'Loop breakpoint',
     description: 'To tell where loop data must stop',
     icon: 'riStopLine',
     component: 'BlockLoopBreakpoint',
-    category: 'general',
+    category: 'conditions',
     disableEdit: true,
     inputs: 1,
     outputs: 1,
@@ -919,12 +953,14 @@ export const tasks = {
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
-    refDataKeys: ['selector', 'keys'],
+    refDataKeys: ['selector', 'keys', 'keysToPress'],
     data: {
       disableBlock: false,
       keys: '',
       selector: '',
       description: '',
+      keysToPress: '',
+      action: 'press-key',
     },
   },
   'handle-dialog': {
@@ -1315,6 +1351,24 @@ export const tasks = {
       workflowsToStop: [],
     },
   },
+  'parameter-prompt': {
+    name: 'Parameter prompt',
+    description: '',
+    icon: 'riCommandLine',
+    component: 'BlockBasic',
+    category: 'general',
+    editComponent: 'EditParameterPrompt',
+    inputs: 1,
+    outputs: 1,
+    maxConnection: 1,
+    allowedInputs: true,
+    data: {
+      disableBlock: false,
+      description: '',
+      timeout: 60000,
+      parameters: [],
+    },
+  },
 };
 
 export const categories = {
@@ -1346,7 +1400,7 @@ export const categories = {
     color: 'bg-lime-200 dark:bg-lime-300 fill-lime-200 dark:fill-lime-300',
   },
   conditions: {
-    name: 'Conditions',
+    name: 'Control flow',
     border: 'border-blue-200 dark:border-blue-300',
     color: 'bg-blue-200 dark:bg-blue-300 fill-blue-200 dark:fill-blue-300',
   },
@@ -1488,6 +1542,14 @@ export const conditionBuilder = {
       compareable: false,
       data: { code: '\nreturn true;' },
     },
+    {
+      id: 'data#exists',
+      category: 'value',
+      name: 'Data exists',
+      compareable: false,
+      valueKey: 'dataPath',
+      data: { dataPath: '' },
+    },
     {
       id: 'element#text',
       category: 'element',
@@ -1594,5 +1656,9 @@ export const conditionBuilder = {
       label: 'Attribute name',
       placeholder: 'name',
     },
+    dataPath: {
+      label: 'variables.variableName',
+      placeholder: '',
+    },
   },
 };

+ 5 - 0
src/utils/testConditions.js

@@ -1,4 +1,5 @@
 import cloneDeep from 'lodash.clonedeep';
+import objectPath from 'object-path';
 import mustacheReplacer from './referenceData/mustacheReplacer';
 import { conditionBuilder } from './shared';
 
@@ -41,6 +42,10 @@ export default async function (conditionsArr, workflowData) {
   };
 
   async function getConditionItemValue({ type, data }) {
+    if (type.startsWith('data')) {
+      return objectPath.has(workflowData.refData, data.dataPath);
+    }
+
     const copyData = cloneDeep(data);
 
     Object.keys(data).forEach((key) => {

+ 3 - 0
src/utils/workflowData.js

@@ -106,6 +106,9 @@ export function importWorkflow(attrs = {}) {
           workflow.table = workflow.table || workflow.dataColumns;
           delete workflow.dataColumns;
 
+          if (typeof workflow.drawflow === 'string')
+            workflow.drawflow = parseJSON(workflow.drawflow, {});
+
           workflowStore
             .insert({
               ...workflow,