Browse Source

feat: add while loop block

Ahmad Kholid 3 years ago
parent
commit
7b4dffebda

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "automa",
   "name": "automa",
-  "version": "1.5.1",
+  "version": "1.5.4",
   "description": "An extension for automating your browser by connecting blocks",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "license": "MIT",
   "repository": {
   "repository": {

+ 21 - 0
src/background/workflow-engine/blocks-handler/handler-while-loop.js

@@ -0,0 +1,21 @@
+import testConditions from '@/utils/test-conditions';
+import { getBlockConnection } from '../helper';
+
+async function whileLoop({ data, outputs }, { refData }) {
+  const conditionPayload = {
+    refData,
+    activeTab: this.activeTab.id,
+    sendMessage: (payload) =>
+      this._sendMessageToTab({ ...payload, isBlock: false }),
+  };
+  const result = await testConditions(data.conditions, conditionPayload);
+  const nextBlockId = getBlockConnection({ outputs }, result.isMatch ? 1 : 2);
+
+  return {
+    data: '',
+    nextBlockId,
+    replacedValue: result?.replacedValue || {},
+  };
+}
+
+export default whileLoop;

+ 5 - 1
src/background/workflow-engine/engine.js

@@ -210,7 +210,10 @@ class WorkflowEngine {
     const historyId = nanoid();
     const historyId = nanoid();
     detail.id = historyId;
     detail.id = historyId;
 
 
-    if (tasks[detail.name]?.refDataKeys && this.saveLog) {
+    if (
+      detail.replacedValue ||
+      (tasks[detail.name]?.refDataKeys && this.saveLog)
+    ) {
       const { activeTabUrl, loopData, prevBlockData } = JSON.parse(
       const { activeTabUrl, loopData, prevBlockData } = JSON.parse(
         JSON.stringify(this.referenceData)
         JSON.stringify(this.referenceData)
       );
       );
@@ -538,6 +541,7 @@ class WorkflowEngine {
         frameSelector: this.frameSelector,
         frameSelector: this.frameSelector,
         ...payload,
         ...payload,
       };
       };
+
       const data = await browser.tabs.sendMessage(
       const data = await browser.tabs.sendMessage(
         this.activeTab.id,
         this.activeTab.id,
         messagePayload,
         messagePayload,

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

@@ -32,7 +32,7 @@
         />
         />
       </div>
       </div>
     </div>
     </div>
-    <slot></slot>
+    <slot :block="block"></slot>
     <template #prepend>
     <template #prepend>
       <div
       <div
         v-if="block.details.id !== 'trigger'"
         v-if="block.details.id !== 'trigger'"

+ 41 - 0
src/components/block/BlockBasicWithFallback.vue

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

+ 123 - 0
src/components/newtab/shared/SharedConditionBuilder/ConditionBuilderInputs.vue

@@ -0,0 +1,123 @@
+<template>
+  <div
+    v-for="(item, index) in inputsData"
+    :key="item.id"
+    class="condition-input"
+  >
+    <div
+      v-if="item.category === 'value'"
+      class="space-y-1 flex items-end space-x-2 flex-wrap"
+    >
+      <ui-select
+        :model-value="item.type"
+        @change="updateValueType($event, index)"
+      >
+        <optgroup
+          v-for="(types, label) in filterValueTypes(index)"
+          :key="label"
+          :label="label"
+        >
+          <option v-for="type in types" :key="type.id" :value="type.id">
+            {{ type.name }}
+          </option>
+        </optgroup>
+      </ui-select>
+      <ui-input
+        v-for="(_, name) in item.data"
+        :key="item.id + name + index"
+        v-model="inputsData[index].data[name]"
+        :title="conditionBuilder.inputTypes[name].label"
+        :placeholder="conditionBuilder.inputTypes[name].label"
+        class="flex-1"
+      />
+    </div>
+    <ui-select
+      v-else-if="item.category === 'compare'"
+      :model-value="inputsData[index].type"
+      @change="updateCompareType($event, index)"
+    >
+      <option
+        v-for="type in conditionBuilder.compareTypes"
+        :key="type.id"
+        :value="type.id"
+      >
+        {{ type.name }}
+      </option>
+    </ui-select>
+  </div>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+import { nanoid } from 'nanoid';
+import { conditionBuilder } from '@/utils/shared';
+
+const props = defineProps({
+  data: {
+    type: Array,
+    default: () => [],
+  },
+});
+const emit = defineEmits(['update']);
+
+const inputsData = ref(JSON.parse(JSON.stringify(props.data)));
+
+function getDefaultValues(items) {
+  const defaultValues = {
+    value: {
+      id: nanoid(),
+      type: 'value',
+      category: 'value',
+      data: { value: '' },
+    },
+    compare: { id: nanoid(), category: 'compare', type: 'eq' },
+  };
+
+  if (typeof items === 'string') return defaultValues[items];
+
+  return items.map((item) => defaultValues[item]);
+}
+function filterValueTypes(index) {
+  const exclude = ['element#visible', 'element#invisible'];
+
+  return conditionBuilder.valueTypes.reduce((acc, item) => {
+    if (index < 1 || !exclude.includes(item.id)) {
+      (acc[item.category] = acc[item.category] || []).push(item);
+    }
+
+    return acc;
+  }, {});
+}
+function updateValueType(newType, index) {
+  const type = conditionBuilder.valueTypes.find(({ id }) => id === newType);
+
+  if (index === 0 && !type.compareable) {
+    inputsData.value.splice(index + 1);
+  } else if (inputsData.value.length === 1) {
+    inputsData.value.push(...getDefaultValues(['compare', 'value']));
+  }
+
+  inputsData.value[index].type = newType;
+  inputsData.value[index].data = { ...type.data };
+}
+function updateCompareType(newType, index) {
+  const { needValue } = conditionBuilder.compareTypes.find(
+    ({ id }) => id === newType
+  );
+
+  if (!needValue) {
+    inputsData.value.splice(index + 1);
+  } else if (inputsData.value.length === 2) {
+    inputsData.value.push(getDefaultValues('value'));
+  }
+
+  inputsData.value[index].type = newType;
+}
+
+watch(
+  inputsData,
+  (value) => {
+    emit('update', value);
+  },
+  { deep: true }
+);
+</script>

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

@@ -0,0 +1,220 @@
+<template>
+  <div class="space-y-4">
+    <ui-button v-if="conditions.length === 0" @click="addOrCondition">
+      {{ t('workflow.conditionBuilder.add') }}
+    </ui-button>
+    <div v-for="(item, index) in conditions" :key="item.id">
+      <div class="flex relative condition-group">
+        <div
+          v-show="item.conditions.length > 1"
+          class="and-text mr-4 relative mb-12 flex items-center"
+          :class="{ 'add-line': item.conditions.length > 1 }"
+        >
+          <span
+            class="py-1 w-14 text-center text-white dark:text-black rounded-md dark:bg-blue-300 bg-blue-500 inline-block z-10 relative"
+          >
+            {{ t('workflow.conditionBuilder.and') }}
+          </span>
+        </div>
+        <div class="flex-1 space-y-2">
+          <ui-expand
+            v-for="(inputs, inputsIndex) in item.conditions"
+            :key="inputs.id"
+            class="border rounded-lg w-full"
+            header-class="px-4 py-2 w-full flex items-center h-full rounded-lg overflow-hidden group focus:ring-0"
+          >
+            <template #header>
+              <p class="text-overflow flex-1 text-left space-x-2 w-64">
+                <span
+                  v-for="input in inputs.items"
+                  :key="`text-${input.id}`"
+                  :class="[
+                    input.category === 'compare'
+                      ? 'font-semibold'
+                      : 'text-gray-600 dark:text-gray-200',
+                  ]"
+                >
+                  {{ getConditionText(input) }}
+                </span>
+              </p>
+              <v-remixicon
+                name="riDeleteBin7Line"
+                class="ml-4 group-hover:visible invisible"
+                @click.stop="deleteCondition(index, inputsIndex)"
+              />
+            </template>
+            <div class="space-y-2 px-4 py-2">
+              <condition-builder-inputs
+                :data="inputs.items"
+                @update="
+                  conditions[index].conditions[inputsIndex].items = $event
+                "
+              />
+            </div>
+          </ui-expand>
+          <div class="space-x-2 text-sm">
+            <ui-button @click="addAndCondition(index)">
+              <v-remixicon name="riAddLine" class="-ml-2 mr-1" size="20" />
+              {{ t('workflow.conditionBuilder.and') }}
+            </ui-button>
+            <ui-button
+              v-if="index === conditions.length - 1"
+              @click="addOrCondition"
+            >
+              <v-remixicon name="riAddLine" class="-ml-2 mr-1" size="20" />
+              {{ t('workflow.conditionBuilder.or') }}
+            </ui-button>
+          </div>
+        </div>
+      </div>
+      <div
+        v-show="index !== conditions.length - 1"
+        class="text-left or-text relative mt-4"
+      >
+        <span
+          class="line bg-indigo-500 dark:bg-indigo-400 w-full absolute top-1/2 -translate-y-1/2 left-0"
+          style="height: 2px"
+        ></span>
+        <span
+          class="py-1 dark:text-black rounded-md dark:bg-indigo-300 bg-indigo-500 w-14 relative z-10 inline-block text-white text-center"
+        >
+          {{ t('workflow.conditionBuilder.or') }}
+        </span>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
+import { conditionBuilder } from '@/utils/shared';
+import ConditionBuilderInputs from './ConditionBuilderInputs.vue';
+
+const props = defineProps({
+  modelValue: {
+    type: Array,
+    default: () => [],
+  },
+});
+const emit = defineEmits(['update:modelValue', 'change']);
+
+const { t } = useI18n();
+
+const conditions = ref(JSON.parse(JSON.stringify(props.modelValue)));
+
+// const conditions = ref([
+//   {
+//     id: nanoid(),
+//     conditions: [
+//       {
+//         id: nanoid(),
+//         items: [
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//           { id: nanoid(), category: 'compare', type: 'eq' },
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//         ],
+//       },
+//       {
+//         id: nanoid(),
+//         items: [
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//           { id: nanoid(), category: 'compare', type: 'lt' },
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//         ]
+//       }
+//     ],
+//   },
+//   {
+//     id: nanoid(),
+//     conditions: [
+//       {
+//         id: nanoid(),
+//         items: [
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//           { id: nanoid(), category: 'compare', type: 'eq' },
+//           { id: nanoid(), type: 'value', category: 'value', data: { value: '' } },
+//         ],
+//       }
+//     ]
+//   }
+// ]);
+
+function getDefaultValues(items = ['value', 'compare', 'value']) {
+  const defaultValues = {
+    value: {
+      id: nanoid(),
+      type: 'value',
+      category: 'value',
+      data: { value: '' },
+    },
+    compare: { id: nanoid(), category: 'compare', type: 'eq' },
+  };
+
+  if (typeof items === 'string') return defaultValues[items];
+
+  return items.map((item) => defaultValues[item]);
+}
+function getConditionText({ category, type, data }) {
+  if (category === 'compare') {
+    return conditionBuilder.compareTypes.find(({ id }) => id === type).name;
+  }
+
+  let text = '';
+
+  if (type === 'value') {
+    text = data.value || 'Empty';
+  } else if (type.startsWith('element')) {
+    text = type;
+
+    const textDetail = data.attrName || data.selector;
+
+    if (textDetail) text += `(${textDetail})`;
+  }
+
+  return text;
+}
+function addOrCondition() {
+  const newOrCondition = getDefaultValues();
+
+  conditions.value.push({
+    id: nanoid(),
+    conditions: [{ id: nanoid(), items: newOrCondition }],
+  });
+}
+function addAndCondition(index) {
+  const newAndCondition = getDefaultValues();
+
+  conditions.value[index].conditions.push({
+    id: nanoid(),
+    items: newAndCondition,
+  });
+}
+function deleteCondition(index, itemIndex) {
+  const condition = conditions.value[index].conditions;
+
+  condition.splice(itemIndex, 1);
+
+  if (condition.length === 0) conditions.value.splice(index, 1);
+}
+
+watch(
+  conditions,
+  (value) => {
+    emit('change', value);
+    emit('update:modelValue', value);
+  },
+  { deep: true }
+);
+</script>
+<style scoped>
+.and-text.add-line:before {
+  content: '';
+  position: absolute;
+  top: 0;
+  width: 30px;
+  height: 100%;
+  left: 50%;
+  @apply dark:border-blue-400 border-blue-500 border-2 border-r-0 rounded-bl-lg rounded-tl-lg;
+}
+</style>

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

@@ -0,0 +1,108 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full mb-1"
+      @change="updateData({ description: $event })"
+    />
+    <ui-button
+      variant="accent"
+      class="w-full mt-4"
+      @click="showConditionBuilder = true"
+    >
+      {{ t('workflow.blocks.while-loop.editCondition') }}
+    </ui-button>
+    <ui-modal v-model="showConditionBuilder" custom-content>
+      <ui-card padding="p-0" class="w-full max-w-3xl">
+        <div class="px-4 pt-4 flex items-center">
+          <p class="flex-1">
+            {{ t('workflow.conditionBuilder.title') }}
+          </p>
+          <v-remixicon
+            name="riCloseLine"
+            class="cursor-pointer"
+            @click="showConditionBuilder = false"
+          />
+        </div>
+        <shared-condition-builder
+          :model-value="data.conditions"
+          class="overflow-auto p-4 mt-4 scroll"
+          style="height: calc(100vh - 8rem)"
+          @change="updateData({ conditions: $event })"
+        />
+      </ui-card>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { onMounted, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
+import SharedConditionBuilder from '@/components/newtab/shared/SharedConditionBuilder/index.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const defaultConditions = () => [
+  {
+    id: nanoid(),
+    conditions: [
+      {
+        id: nanoid(),
+        items: [
+          {
+            id: nanoid(),
+            type: 'value',
+            category: 'value',
+            data: { value: '' },
+          },
+          { id: nanoid(), category: 'compare', type: 'eq' },
+          {
+            id: nanoid(),
+            type: 'value',
+            category: 'value',
+            data: { value: '' },
+          },
+        ],
+      },
+      {
+        id: nanoid(),
+        items: [
+          {
+            id: nanoid(),
+            type: 'value',
+            category: 'value',
+            data: { value: '' },
+          },
+          { id: nanoid(), category: 'compare', type: 'eq' },
+          {
+            id: nanoid(),
+            type: 'value',
+            category: 'value',
+            data: { value: '' },
+          },
+        ],
+      },
+    ],
+  },
+];
+
+const showConditionBuilder = ref(false);
+
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+
+onMounted(() => {
+  if (props.data.conditions === null) {
+    updateData({ conditions: defaultConditions() });
+  }
+});
+</script>

+ 2 - 2
src/components/ui/UiAutocomplete.vue

@@ -138,14 +138,14 @@ function selectItem(index) {
 
 
 watch(
 watch(
   () => state.activeIndex,
   () => state.activeIndex,
-  debounce((activeIndex, prevIndex) => {
+  debounce((activeIndex) => {
     const container = document.querySelector(`.${componentId}`);
     const container = document.querySelector(`.${componentId}`);
     const element = container.querySelector(`#list-item-${activeIndex}`);
     const element = container.querySelector(`#list-item-${activeIndex}`);
 
 
     if (element && !checkInView(container, element)) {
     if (element && !checkInView(container, element)) {
       element.scrollIntoView({
       element.scrollIntoView({
+        block: 'nearest',
         behavior: 'smooth',
         behavior: 'smooth',
-        block: activeIndex > prevIndex ? 'end' : 'start',
       });
       });
     }
     }
   }, 100)
   }, 100)

+ 8 - 1
src/components/ui/UiInput.vue

@@ -20,6 +20,7 @@
           readonly: disabled || readonly || null,
           readonly: disabled || readonly || null,
           placeholder,
           placeholder,
           type,
           type,
+          autocomplete,
           autofocus,
           autofocus,
           min,
           min,
           max,
           max,
@@ -38,7 +39,9 @@
         :value="modelValue"
         :value="modelValue"
         class="py-2 px-4 rounded-lg w-full bg-input bg-transparent transition"
         class="py-2 px-4 rounded-lg w-full bg-input bg-transparent transition"
         @keydown="$emit('keydown', $event)"
         @keydown="$emit('keydown', $event)"
+        @keyup="$emit('keyup', $event)"
         @blur="$emit('blur', $event)"
         @blur="$emit('blur', $event)"
+        @focus="$emit('focus', $event)"
         @input="emitValue"
         @input="emitValue"
       />
       />
       <slot name="append" />
       <slot name="append" />
@@ -101,8 +104,12 @@ export default {
       type: [String, Number],
       type: [String, Number],
       default: null,
       default: null,
     },
     },
+    autocomplete: {
+      type: String,
+      default: null,
+    },
   },
   },
-  emits: ['update:modelValue', 'change', 'keydown', 'blur'],
+  emits: ['update:modelValue', 'change', 'keydown', 'blur', 'keyup', 'focus'],
   setup(props, { emit }) {
   setup(props, { emit }) {
     const componentId = useComponentId('ui-input');
     const componentId = useComponentId('ui-input');
 
 

+ 6 - 1
src/components/ui/UiModal.vue

@@ -7,7 +7,7 @@
       <transition name="modal" mode="out-in">
       <transition name="modal" mode="out-in">
         <div
         <div
           v-if="show"
           v-if="show"
-          class="bg-black p-5 overflow-y-auto bg-opacity-20 dark:bg-opacity-60 modal-ui__content-container z-50 flex justify-center items-end md:items-center"
+          class="bg-black overflow-y-auto bg-opacity-20 dark:bg-opacity-60 modal-ui__content-container z-50 flex justify-center items-end md:items-center"
           :style="{ 'backdrop-filter': blur && 'blur(2px)' }"
           :style="{ 'backdrop-filter': blur && 'blur(2px)' }"
           @click.self="closeModal"
           @click.self="closeModal"
         >
         >
@@ -15,6 +15,7 @@
           <ui-card
           <ui-card
             v-else
             v-else
             class="modal-ui__content shadow-lg w-full"
             class="modal-ui__content shadow-lg w-full"
+            :padding="padding"
             :class="[contentClass]"
             :class="[contentClass]"
           >
           >
             <div class="mb-4">
             <div class="mb-4">
@@ -59,6 +60,10 @@ export default {
       type: String,
       type: String,
       default: '',
       default: '',
     },
     },
+    padding: {
+      type: String,
+      default: 'p-4',
+    },
     customContent: Boolean,
     customContent: Boolean,
     persist: Boolean,
     persist: Boolean,
     blur: Boolean,
     blur: Boolean,

+ 1 - 1
src/content/element-selector/App.vue

@@ -196,7 +196,7 @@ const getElementSelector = (element) =>
   state.selectorType === 'css'
   state.selectorType === 'css'
     ? getCssSelector(element, {
     ? getCssSelector(element, {
         includeTag: true,
         includeTag: true,
-        blacklist: ['[focused]', /focus/],
+        blacklist: ['[focused]', /focus/, /href/],
       })
       })
     : generateXPath(element);
     : generateXPath(element);
 
 

+ 3 - 17
src/content/handle-selector.js

@@ -1,4 +1,5 @@
 import FindElement from '@/utils/find-element';
 import FindElement from '@/utils/find-element';
+import { scrollIfNeeded } from '@/utils/helper';
 
 
 /* eslint-disable consistent-return */
 /* eslint-disable consistent-return */
 
 
@@ -36,21 +37,6 @@ export function waitForSelector({
   });
   });
 }
 }
 
 
-function scrollIfNeeded(debugMode, element) {
-  if (!debugMode) return;
-
-  const { top, left, bottom, right } = element.getBoundingClientRect();
-  const isInViewport =
-    top >= 0 &&
-    left >= 0 &&
-    bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
-    right <= (window.innerWidth || document.documentElement.clientWidth);
-
-  if (!isInViewport) {
-    element.scrollIntoView();
-  }
-}
-
 export default async function (
 export default async function (
   { data, id, frameSelector, debugMode },
   { data, id, frameSelector, debugMode },
   { onSelected, onError, onSuccess, returnElement }
   { onSelected, onError, onSuccess, returnElement }
@@ -109,13 +95,13 @@ export default async function (
       await Promise.allSettled(
       await Promise.allSettled(
         Array.from(element).map((el) => {
         Array.from(element).map((el) => {
           markElement(el, { id, data });
           markElement(el, { id, data });
-          scrollIfNeeded(debugMode, el);
+          if (debugMode) scrollIfNeeded(el);
           return onSelected(el);
           return onSelected(el);
         })
         })
       );
       );
     } else if (element) {
     } else if (element) {
       markElement(element, { id, data });
       markElement(element, { id, data });
-      scrollIfNeeded(debugMode, element);
+      if (debugMode) scrollIfNeeded(element);
       await onSelected(element);
       await onSelected(element);
     }
     }
 
 

+ 32 - 0
src/content/index.js

@@ -4,6 +4,35 @@ import { toCamelCase } from '@/utils/helper';
 import executedBlock from './executed-block';
 import executedBlock from './executed-block';
 import blocksHandler from './blocks-handler';
 import blocksHandler from './blocks-handler';
 
 
+const elementActions = {
+  text: (element) => element.innerText,
+  visible: (element) => {
+    const { visibility, display } = getComputedStyle(element);
+
+    return visibility !== 'hidden' || display !== 'none';
+  },
+  invisible: (element) => !elementActions.visible(element),
+  attribute: (element, { attrName }) => {
+    if (!element.hasAttribute(attrName)) return null;
+
+    return element.getAttribute(attrName);
+  },
+};
+function handleConditionBuilder({ data, type }) {
+  if (!type.startsWith('element')) return null;
+
+  const element = document.querySelector(data.selector);
+  const { 1: actionType } = type.split('#');
+
+  if (!element) {
+    if (actionType === 'visible' || actionType === 'invisible') return false;
+
+    return null;
+  }
+
+  return elementActions[actionType](element, data);
+}
+
 (() => {
 (() => {
   if (window.isAutomaInjected) return;
   if (window.isAutomaInjected) return;
 
 
@@ -36,6 +65,9 @@ import blocksHandler from './blocks-handler';
       }
       }
 
 
       switch (data.type) {
       switch (data.type) {
+        case 'condition-builder':
+          resolve(handleConditionBuilder(data.data));
+          break;
         case 'content-script-exists':
         case 'content-script-exists':
           resolve(true);
           resolve(true);
           break;
           break;

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

@@ -36,6 +36,7 @@ import {
   riWindow2Line,
   riWindow2Line,
   riArrowUpDownLine,
   riArrowUpDownLine,
   riRefreshLine,
   riRefreshLine,
+  riRefreshFill,
   riBook3Line,
   riBook3Line,
   riGithubFill,
   riGithubFill,
   riCodeSSlashLine,
   riCodeSSlashLine,
@@ -140,6 +141,7 @@ export const icons = {
   riWindow2Line,
   riWindow2Line,
   riArrowUpDownLine,
   riArrowUpDownLine,
   riRefreshLine,
   riRefreshLine,
+  riRefreshFill,
   riBook3Line,
   riBook3Line,
   riGithubFill,
   riGithubFill,
   riCodeSSlashLine,
   riCodeSSlashLine,

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

@@ -426,6 +426,12 @@
           "response": "Response"
           "response": "Response"
         }
         }
       },
       },
+      "while-loop": {
+        "name": "While loop",
+        "description": "Execute blocks while the condition is met",
+        "editCondition": "Edit condition",
+        "fallback": "Execute when the condition is false"
+      },
       "loop-data": {
       "loop-data": {
         "name": "Loop data",
         "name": "Loop data",
         "description": "Iterate through table or your custom data",
         "description": "Iterate through table or your custom data",

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

@@ -79,6 +79,12 @@
     "clickToEnable": "Click to enable",
     "clickToEnable": "Click to enable",
     "toggleSidebar": "Toggle sidebar",
     "toggleSidebar": "Toggle sidebar",
     "cantEdit": "Can't edit shared workflow",
     "cantEdit": "Can't edit shared workflow",
+    "conditionBuilder": {
+      "title": "Condition builder",
+      "add": "Add condition",
+      "and": "AND",
+      "or": "OR"
+    },
     "host": {
     "host": {
       "title": "Host workflow",
       "title": "Host workflow",
       "set": "Set as host workflow",
       "set": "Set as host workflow",

+ 13 - 0
src/utils/helper.js

@@ -1,3 +1,16 @@
+export function scrollIfNeeded(element) {
+  const { top, left, bottom, right } = element.getBoundingClientRect();
+  const isInViewport =
+    top >= 0 &&
+    left >= 0 &&
+    bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
+    right <= (window.innerWidth || document.documentElement.clientWidth);
+
+  if (!isInViewport) {
+    element.scrollIntoView();
+  }
+}
+
 export function sleep(timeout = 500) {
 export function sleep(timeout = 500) {
   return new Promise((resolve) => setTimeout(resolve, timeout));
   return new Promise((resolve) => setTimeout(resolve, timeout));
 }
 }

+ 82 - 1
src/utils/shared.js

@@ -572,7 +572,7 @@ export const tasks = {
     name: 'HTTP Request',
     name: 'HTTP Request',
     description: 'make an HTTP request',
     description: 'make an HTTP request',
     icon: 'riEarthLine',
     icon: 'riEarthLine',
-    component: 'BlockWebhook',
+    component: 'BlockBasicWithFallback',
     editComponent: 'EditWebhook',
     editComponent: 'EditWebhook',
     category: 'general',
     category: 'general',
     inputs: 1,
     inputs: 1,
@@ -596,6 +596,22 @@ export const tasks = {
       responseType: 'json',
       responseType: 'json',
     },
     },
   },
   },
+  'while-loop': {
+    name: 'While loop',
+    description: 'Execute blocks while the condition is met',
+    icon: 'riRefreshFill',
+    component: 'BlockBasicWithFallback',
+    editComponent: 'EditWhileLoop',
+    category: 'general',
+    inputs: 1,
+    outputs: 2,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      description: '',
+      conditions: null,
+    },
+  },
   'loop-data': {
   'loop-data': {
     name: 'Loop data',
     name: 'Loop data',
     icon: 'riRefreshLine',
     icon: 'riRefreshLine',
@@ -880,3 +896,68 @@ export const supportLocales = [
   { id: 'vi', name: 'Tiếng Việt' },
   { id: 'vi', name: 'Tiếng Việt' },
   { id: 'fr', name: 'Français' },
   { id: 'fr', name: 'Français' },
 ];
 ];
+
+export const conditionBuilder = {
+  valueTypes: [
+    {
+      id: 'value',
+      category: 'value',
+      name: 'Value',
+      compareable: true,
+      data: { value: '' },
+    },
+    {
+      id: 'element#text',
+      category: 'element',
+      name: 'Element text',
+      compareable: true,
+      data: { selector: '' },
+    },
+    {
+      id: 'element#visible',
+      category: 'element',
+      name: 'Element visible',
+      compareable: false,
+      data: { selector: '' },
+    },
+    {
+      id: 'element#invisible',
+      category: 'element',
+      name: 'Element invisible',
+      compareable: false,
+      data: { selector: '' },
+    },
+    {
+      id: 'element#attribute',
+      category: 'element',
+      name: 'Element attribute value',
+      compareable: true,
+      data: { selector: '', attrName: '' },
+    },
+  ],
+  compareTypes: [
+    { id: 'eq', name: 'Equals', needValue: true },
+    { id: 'nq', name: 'Not equals', needValue: true },
+    { id: 'gt', name: 'Greater than', needValue: true },
+    { id: 'gte', name: 'Greater than or equal', needValue: true },
+    { id: 'lt', name: 'Less than', needValue: true },
+    { id: 'lte', name: 'Less than or equal', needValue: true },
+    { id: 'cnt', name: 'Contains', needValue: true },
+    { id: 'itr', name: 'Is truthy', needValue: false },
+    { id: 'ifl', name: 'Is falsy', needValue: false },
+  ],
+  inputTypes: {
+    selector: {
+      placeholder: '.class',
+      label: 'CSS selector',
+    },
+    value: {
+      label: 'Value',
+      placeholder: 'abc123',
+    },
+    attrName: {
+      label: 'Attribute name',
+      placeholder: 'name',
+    },
+  },
+};

+ 113 - 0
src/utils/test-conditions.js

@@ -0,0 +1,113 @@
+/* eslint-disable no-restricted-syntax */
+import mustacheReplacer from './reference-data/mustache-replacer';
+import { conditionBuilder } from './shared';
+
+const comparisons = {
+  eq: (a, b) => a === b,
+  nq: (a, b) => a !== b,
+  gt: (a, b) => a > b,
+  gte: (a, b) => a >= b,
+  lt: (a, b) => a < b,
+  lte: (a, b) => a <= b,
+  cnt: (a, b) => a?.includes(b) ?? false,
+  itr: (a) => Boolean(a),
+  ifl: (a) => !a,
+};
+
+export default async function (conditionsArr, workflowData) {
+  const result = {
+    isMatch: false,
+    replacedValue: {},
+  };
+
+  async function getConditionItemValue({ type, data }) {
+    const copyData = JSON.parse(JSON.stringify(data));
+
+    Object.keys(data).forEach((key) => {
+      const { value, list } = mustacheReplacer(
+        copyData[key],
+        workflowData.refData
+      );
+
+      copyData[key] = value;
+      Object.assign(result.replacedValue, list);
+    });
+
+    if (type === 'value') return copyData.value;
+
+    if (type.startsWith('element')) {
+      const conditionValue = await workflowData.sendMessage({
+        type: 'condition-builder',
+        data: {
+          type,
+          data: copyData,
+        },
+      });
+
+      return conditionValue;
+    }
+
+    return '';
+  }
+  async function checkConditions(items) {
+    let conditionResult = true;
+    const condition = {
+      value: '',
+      operator: '',
+    };
+
+    for (const { category, data, type } of items) {
+      if (!conditionResult) return conditionResult;
+
+      if (category === 'compare') {
+        const isNeedValue = conditionBuilder.compareTypes.find(
+          ({ id }) => id === type
+        ).needValue;
+
+        if (!isNeedValue) {
+          conditionResult = comparisons[type](condition.value);
+
+          return conditionResult;
+        }
+
+        condition.operator = type;
+      } else if (category === 'value') {
+        const conditionValue = await getConditionItemValue({ data, type });
+        const isCompareable = conditionBuilder.valueTypes.find(
+          ({ id }) => id === type
+        ).compareable;
+
+        if (!isCompareable) {
+          conditionResult = conditionValue;
+        } else if (condition.operator) {
+          conditionResult = comparisons[condition.operator](
+            condition.value,
+            conditionValue
+          );
+
+          condition.operator = '';
+        }
+
+        condition.value = conditionValue;
+      }
+    }
+
+    return conditionResult;
+  }
+
+  for (const { conditions } of conditionsArr) {
+    if (result.isMatch) return result;
+
+    let isAllMatch = false;
+
+    for (const { items } of conditions) {
+      isAllMatch = await checkConditions(items, workflowData);
+
+      if (!isAllMatch) break;
+    }
+
+    result.isMatch = isAllMatch;
+  }
+
+  return result;
+}