浏览代码

feat: add loop elements block

Ahmad Kholid 2 年之前
父节点
当前提交
0f2f412637

+ 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] = {

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

@@ -0,0 +1,78 @@
+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(),
+          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;

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

@@ -0,0 +1,103 @@
+<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-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>

+ 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;
 

+ 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);

+ 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,

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

@@ -0,0 +1,119 @@
+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;
+      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 };
+  }
+}

+ 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'

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

@@ -664,6 +664,17 @@
         "editCondition": "Edit condition",
         "fallback": "Execute when the condition is false"
       },
+      "loop-elements": {
+        "name": "Loop elements",
+        "description": "Iterate through elements",
+        "loadMore": "Load more elements",
+        "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",

+ 32 - 0
src/utils/shared.js

@@ -722,6 +722,38 @@ 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',
+      waitForSelector: false,
+      waitSelectorTimeout: 5000,
+    },
+  },
   'loop-breakpoint': {
     name: 'Loop breakpoint',
     description: 'To tell where loop data must stop',