Browse Source

feat: add loop data block

Ahmad Kholid 3 years ago
parent
commit
d53302eb81

+ 1 - 1
package.json

@@ -29,7 +29,7 @@
     "mousetrap": "^1.6.5",
     "mustache": "^4.2.0",
     "nanoid": "3.1.28",
-    "object-path": "^0.11.8",
+    "object-path-immutable": "^4.1.2",
     "papaparse": "^5.3.1",
     "prismjs": "^1.25.0",
     "tiny-emitter": "^2.1.0",

+ 1 - 1
src/background/index.js

@@ -79,9 +79,9 @@ chrome.runtime.onInstalled.addListener((details) => {
     browser.storage.local
       .set({
         logs: [],
+        shortcuts: {},
         workflows: [],
         collections: [],
-        shortcuts: {},
         workflowState: [],
         isFirstTime: true,
         visitWebTriggers: [],

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

@@ -2,7 +2,7 @@
 import browser from 'webextension-polyfill';
 import { objectHasKey, fileSaver, isObject } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
-import dataExporter from '@/utils/data-exporter';
+import dataExporter, { generateJSON } from '@/utils/data-exporter';
 import compareBlockValue from '@/utils/compare-block-value';
 import errorMessage from './error-message';
 import { executeWebhook } from '@/utils/webhookUtil';
@@ -83,6 +83,60 @@ export async function trigger(block) {
   }
 }
 
+export function loopBreakpoint(block, prevBlockData) {
+  return new Promise((resolve) => {
+    const currentLoop = this.loopList[block.data.loopId];
+
+    if (
+      currentLoop &&
+      currentLoop.index < currentLoop.maxLoop - 1 &&
+      currentLoop.index <= currentLoop.data.length - 1
+    ) {
+      resolve({
+        data: '',
+        nextBlockId: currentLoop.blockId,
+      });
+    } else {
+      resolve({
+        data: prevBlockData,
+        nextBlockId: getBlockConnection(block),
+      });
+    }
+  });
+}
+
+export function loopData(block) {
+  return new Promise((resolve) => {
+    const { data } = block;
+
+    if (this.loopList[data.loopId]) {
+      this.loopList[data.loopId].index += 1;
+      this.loopData[data.loopId] =
+        this.loopList[data.loopId].data[this.loopList[data.loopId].index];
+    } else {
+      const currLoopData =
+        data.loopThrough === 'data-columns'
+          ? generateJSON(Object.keys(this.data), this.data)
+          : JSON.parse(data.loopData);
+
+      this.loopList[data.loopId] = {
+        index: 0,
+        data: currLoopData,
+        id: data.loopId,
+        blockId: block.id,
+        maxLoop: data.maxLoop || currLoopData.length,
+      };
+      /* eslint-disable-next-line */
+      this.loopData[data.loopId] = currLoopData[0];
+    }
+
+    resolve({
+      data: this.loopData[data.loopId],
+      nextBlockId: getBlockConnection(block),
+    });
+  });
+}
+
 export function goBack(block) {
   return new Promise((resolve, reject) => {
     const nextBlockId = getBlockConnection(block);

+ 8 - 2
src/background/workflow-engine/index.js

@@ -84,6 +84,8 @@ class WorkflowEngine {
     this.blocks = {};
     this.eventListeners = {};
     this.repeatedTasks = {};
+    this.loopList = {};
+    this.loopData = {};
     this.logs = [];
     this.isPaused = false;
     this.isDestroyed = false;
@@ -273,10 +275,14 @@ class WorkflowEngine {
     const handler = blocksHandler[handlerName];
 
     if (handler) {
-      referenceData(block, { data: this.data, prevBlockData });
+      const replacedBlock = referenceData(block, {
+        prevBlockData,
+        data: this.data,
+        loopData: this.loopData,
+      });
 
       handler
-        .call(this, block, prevBlockData)
+        .call(this, replacedBlock, prevBlockData)
         .then((result) => {
           clearTimeout(this.workflowTimeout);
           this.workflowTimeout = null;

+ 49 - 0
src/components/block/BlockLoopBreakpoint.vue

@@ -0,0 +1,49 @@
+<template>
+  <div :id="componentId" class="p-4">
+    <div class="flex items-center mb-2">
+      <div
+        :class="block.category.color"
+        class="inline-block text-sm mr-4 p-2 rounded-lg"
+      >
+        <v-remixicon name="riStopLine" size="20" class="inline-block mr-1" />
+        <span>Loop breakpoint</span>
+      </div>
+      <div class="flex-grow"></div>
+      <v-remixicon
+        name="riDeleteBin7Line"
+        class="cursor-pointer"
+        @click="editor.removeNodeId(`node-${block.id}`)"
+      />
+    </div>
+    <input
+      :value="block.data.loopId"
+      class="px-4 py-2 rounded-lg w-48 bg-input"
+      placeholder="Loop ID"
+      type="text"
+      required
+      @input="handleInput"
+    />
+  </div>
+</template>
+<script setup>
+import emitter from 'tiny-emitter/instance';
+import { useComponentId } from '@/composable/componentId';
+import { useEditorBlock } from '@/composable/editorBlock';
+
+const props = defineProps({
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const componentId = useComponentId('block-delay');
+const block = useEditorBlock(`#${componentId}`, props.editor);
+
+function handleInput({ target }) {
+  const loopId = target.value.replace(/\s/g, '');
+
+  props.editor.updateNodeDataFromId(block.id, { loopId });
+  emitter.emit('editor:data-changed', block.id);
+}
+</script>

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

@@ -12,10 +12,12 @@
       :is="data.editComponent"
       v-if="blockData"
       v-model:data="blockData"
+      :block-id="data.blockId"
     />
   </div>
 </template>
-<script>
+<script></script>
+<script setup>
 import { computed } from 'vue';
 
 const editComponents = require.context(
@@ -23,6 +25,7 @@ const editComponents = require.context(
   false,
   /^(?:.*\/)?Edit[^/]*\.vue$/
 );
+/* eslint-disable-next-line */
 const components = editComponents.keys().reduce((acc, key) => {
   const name = key.replace(/(.\/)|\.vue$/g, '');
   const componentObj = editComponents(key)?.default ?? {};
@@ -35,8 +38,7 @@ const components = editComponents.keys().reduce((acc, key) => {
 export default {
   components,
 };
-</script>
-<script setup>
+
 const props = defineProps({
   data: {
     type: Object,

+ 179 - 0
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -0,0 +1,179 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      placeholder="Description"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
+    <ui-input
+      :model-value="data.loopId"
+      class="w-full mb-3"
+      label="Loop ID"
+      placeholder="Loop ID"
+      @change="updateLoopID"
+    />
+    <ui-select
+      :model-value="data.loopThrough"
+      placeholder="Loop through"
+      class="w-full mb-2"
+      @change="
+        updateData({
+          loopThrough: $event,
+          loopData: $event === 'custom-data' ? data.loopData : '[]',
+        })
+      "
+    >
+      <option v-for="type in loopTypes" :key="type.id" :value="type.id">
+        {{ type.name }}
+      </option>
+    </ui-select>
+    <ui-input
+      :model-value="data.maxLoop"
+      class="w-full mb-4"
+      min="0"
+      type="number"
+      label="Max data to loop (0 to disable)"
+      title="Max numbers of data to loop"
+      @change="updateData({ maxLoop: +$event || 0 })"
+    />
+    <ui-button
+      v-if="data.loopThrough === 'custom-data'"
+      class="w-full"
+      variant="accent"
+      @click="state.showDataModal = true"
+    >
+      Insert data
+    </ui-button>
+    <ui-modal
+      v-model="state.showDataModal"
+      title="Data"
+      content-class="max-w-3xl"
+    >
+      <div class="flex mb-4 items-center">
+        <ui-button variant="accent" @click="importFile">
+          Import file
+        </ui-button>
+        <ui-button
+          v-tooltip="'Options'"
+          :class="{ 'text-primary': state.showOptions }"
+          icon
+          class="ml-2"
+          @click="state.showOptions = !state.showOptions"
+        >
+          <v-remixicon name="riSettings3Line" />
+        </ui-button>
+        <p class="ml-4 flex-1 text-overflow">{{ file.name }}</p>
+        <p>Max file size: 1MB</p>
+      </div>
+      <div style="height: calc(100vh - 11rem)">
+        <!-- <prism-editor
+          v-model="data"
+          v-show="!state.showOptions"
+          class="py-4"
+          :highlight="highlighter('json')"
+          line-numbers
+        /> -->
+        <div v-show="state.showOptions">
+          <p class="font-semibold mb-2">CSV</p>
+          <ui-checkbox v-model="options.header">
+            Use the first row as keys
+          </ui-checkbox>
+        </div>
+      </div>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { onMounted, shallowReactive } from 'vue';
+import { nanoid } from 'nanoid';
+import Papa from 'papaparse';
+import { openFilePicker } from '@/utils/helper';
+
+const props = defineProps({
+  blockId: {
+    type: String,
+    default: '',
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const maxFileSize = 1024 * 1024;
+const loopTypes = [
+  { id: 'data-columns', name: 'Data columns' },
+  { id: 'custom-data', name: 'Custom data' },
+];
+
+const state = shallowReactive({
+  showOptions: false,
+  showDataModal: false,
+  workflowLoopData: {},
+});
+const options = shallowReactive({
+  header: true,
+});
+const file = shallowReactive({
+  name: '',
+  type: '',
+});
+
+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 });
+}
+function importFile() {
+  openFilePicker(['application/json', 'text/csv', 'application/vnd.ms-excel'])
+    .then(async (fileObj) => {
+      if (fileObj.size > maxFileSize) {
+        alert('The file size is the exceeded maximum allowed');
+        return;
+      }
+
+      file.name = fileObj.name;
+      file.type = fileObj.type;
+
+      const csvTypes = ['text/csv', 'application/vnd.ms-excel'];
+
+      const reader = new FileReader();
+
+      reader.onload = ({ target }) => {
+        let loopData;
+
+        if (fileObj.type === 'application/json') {
+          const result = JSON.parse(target.result);
+          loopData = Array.isArray(result) ? result : [result];
+        } else if (csvTypes.includes(fileObj.type)) {
+          loopData = Papa.parse(target.result, options).data;
+        }
+
+        if (Array.isArray(loopData)) {
+          updateData({ loopData: JSON.stringify(loopData) });
+        }
+      };
+
+      reader.readAsText(fileObj);
+    })
+    .catch((error) => {
+      console.error(error);
+      if (error.message.startsWith('invalid')) alert(error.message);
+    });
+}
+
+onMounted(() => {
+  if (!props.data.loopId) {
+    updateData({ loopId: nanoid(6) });
+  }
+});
+</script>

+ 5 - 0
src/content/blocks-handler.js

@@ -158,6 +158,11 @@ export function forms(block) {
     const { data } = block;
     const elements = handleElement(block, true);
 
+    if (block.data.value.trim().length === 0) {
+      resolve('');
+      return;
+    }
+
     if (data.multiple) {
       const promises = Array.from(elements).map((element) => {
         return new Promise((eventResolve) => {

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

@@ -2,6 +2,7 @@ import vRemixicon from 'v-remixicon';
 import {
   riHome5Line,
   riFolderLine,
+  riRefreshLine,
   riGithubFill,
   riCodeSSlashLine,
   riRecordCircleLine,
@@ -68,6 +69,7 @@ import {
 export const icons = {
   riHome5Line,
   riFolderLine,
+  riRefreshLine,
   riGithubFill,
   riCodeSSlashLine,
   riRecordCircleLine,

+ 25 - 0
src/utils/helper.js

@@ -1,3 +1,28 @@
+export function openFilePicker(acceptedFileTypes = [], attrs = {}) {
+  return new Promise((resolve, reject) => {
+    const input = document.createElement('input');
+    input.type = 'file';
+    input.accept = acceptedFileTypes.join(',');
+
+    Object.entries(attrs).forEach(([key, value]) => {
+      input[key] = value;
+    });
+
+    input.onchange = (event) => {
+      const file = event.target.files[0];
+
+      if (!file || !acceptedFileTypes.includes(file.type)) {
+        reject(new Error(`Invalid ${file.type} file type`));
+        return;
+      }
+
+      resolve(file);
+    };
+
+    input.click();
+  });
+}
+
 export function fileSaver(fileName, data) {
   const anchor = document.createElement('a');
   anchor.download = fileName;

+ 15 - 5
src/utils/reference-data.js

@@ -1,10 +1,13 @@
-import objectPath from 'object-path';
+import { get, set } from 'object-path-immutable';
 import { isObject, objectHasKey } from '@/utils/helper';
 
+const objectPath = { get, set };
+
 function parseKey(key) {
   const [dataKey, path] = key.split('@');
 
-  if (dataKey === 'prevBlockData') return { dataKey, path: path || '0' };
+  if (['prevBlockData', 'loopData'].includes(dataKey))
+    return { dataKey, path: path || '0' };
 
   const pathArr = path.split('.');
   let dataPath = '';
@@ -29,11 +32,12 @@ function parseKey(key) {
 
 export default function (block, data) {
   const replaceKeys = ['url', 'fileName', 'name', 'value'];
+  let replacedBlock = block;
 
   replaceKeys.forEach((blockDataKey) => {
     if (!objectHasKey(block.data, blockDataKey)) return;
 
-    const newDataValue = block.data[blockDataKey].replace(
+    const newDataValue = replacedBlock.data[blockDataKey].replace(
       /\[(.+?)]/g,
       (match) => {
         const key = match.replace(/\[|]/g, '');
@@ -46,10 +50,16 @@ export default function (block, data) {
           return data.prevBlockData;
         }
 
-        return objectPath.get(data, `${dataKey}.${path}`) || match;
+        return objectPath.get(data[dataKey], path) ?? match;
       }
     );
 
-    block.data[blockDataKey] = newDataValue;
+    replacedBlock = objectPath.set(
+      replacedBlock,
+      `data.${blockDataKey}`,
+      newDataValue
+    );
   });
+
+  return replacedBlock;
 }

+ 34 - 0
src/utils/shared.js

@@ -382,6 +382,40 @@ export const tasks = {
       content: '{\n "key": "{%data[0].key%}" \n}',
     },
   },
+  'loop-data': {
+    name: 'Loop data',
+    icon: 'riRefreshLine',
+    component: 'BlockBasic',
+    editComponent: 'EditLoopData',
+    category: 'general',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      name: '',
+      loopId: '',
+      maxLoop: 0,
+      loopData: '[]',
+      description: '',
+      loopThrough: 'data-columns',
+    },
+  },
+  'loop-breakpoint': {
+    name: 'Loop breakpoint',
+    description: 'To tell where loop data must stop',
+    icon: 'riStopLine',
+    component: 'BlockLoopBreakpoint',
+    category: 'general',
+    disableEdit: true,
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      loopId: '',
+    },
+  },
 };
 
 export const categories = {

+ 13 - 25
src/utils/workflow-data.js

@@ -1,35 +1,23 @@
-import { fileSaver } from './helper';
+import { fileSaver, openFilePicker } from './helper';
 import Workflow from '@/models/workflow';
 
 export function importWorkflow() {
-  const input = document.createElement('input');
-  input.type = 'file';
-  input.accept = 'application/json';
+  openFilePicker(['application/json'])
+    .then((file) => {
+      const reader = new FileReader();
 
-  input.onchange = (event) => {
-    const file = event.target.files[0];
-
-    if (!file || file.type !== 'application/json') {
-      alert('Invalid file');
-      return;
-    }
-
-    const reader = new FileReader();
-
-    reader.onload = ({ target }) => {
-      try {
+      reader.onload = ({ target }) => {
         const workflow = JSON.parse(target.result);
 
         Workflow.insert({ data: { ...workflow, createdAt: Date.now() } });
-      } catch (error) {
-        console.error(error);
-      }
-    };
-
-    reader.readAsText(file);
-  };
-
-  input.click();
+      };
+
+      reader.readAsText(file);
+    })
+    .catch((error) => {
+      alert(error.message);
+      console.error(error);
+    });
 }
 
 export function exportWorkflow(workflow) {

+ 13 - 0
yarn.lock

@@ -4020,6 +4020,11 @@ is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   dependencies:
     isobject "^3.0.1"
 
+is-plain-object@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
+  integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
+
 is-regex@^1.0.4, is-regex@^1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
@@ -4811,6 +4816,14 @@ object-keys@^1.0.12, object-keys@^1.1.1:
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
 
+object-path-immutable@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/object-path-immutable/-/object-path-immutable-4.1.2.tgz#d78e3587f03c9a41f83dd6465cfef5a9eb390bb4"
+  integrity sha512-Bfrox46OegMkQXL872EzEjofMyBxk/2hgiy99NkCkYFegn6Dm9FvV2jY2Tnp9qLj2QL0TLii12CuPpzonkjJrA==
+  dependencies:
+    is-plain-object "^5.0.0"
+    object-path "^0.11.8"
+
 object-path@^0.11.8:
   version "0.11.8"
   resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.8.tgz#ed002c02bbdd0070b78a27455e8ae01fc14d4742"