Browse Source

feat: add "Data mapping" block

Ahmad Kholid 3 years ago
parent
commit
50920e3c42

+ 61 - 0
src/background/workflowEngine/blocksHandler/handlerDataMapping.js

@@ -0,0 +1,61 @@
+import objectPath from 'object-path';
+import { objectHasKey, isObject } from '@/utils/helper';
+
+function mapData(data, sources) {
+  const mappedData = {};
+
+  sources.forEach((source) => {
+    const dataExist = objectPath.has(data, source.name);
+    if (!dataExist) return;
+
+    const value = objectPath.get(data, source.name);
+
+    source.destinations.forEach(({ name }) => {
+      objectPath.set(mappedData, name, value);
+    });
+  });
+
+  return mappedData;
+}
+
+export async function dataMapping({ id, data }) {
+  let dataToMap = null;
+
+  if (data.dataSource === 'table') {
+    dataToMap = this.engine.referenceData.table;
+  } else if (data.dataSource === 'variable') {
+    const { variables } = this.engine.referenceData;
+
+    if (!objectHasKey(variables, data.varSourceName)) {
+      throw new Error(`Cant find "${data.varSourceName}" variable`);
+    }
+
+    dataToMap = variables[data.varSourceName];
+  }
+
+  if (!isObject(dataToMap) && !Array.isArray(dataToMap)) {
+    const dataType = dataToMap === null ? 'null' : typeof dataToMap;
+
+    throw new Error(`Can't map data with "${dataType}" data type`);
+  }
+
+  if (isObject(dataToMap)) {
+    dataToMap = mapData(dataToMap, data.sources);
+  } else {
+    dataToMap = dataToMap.map((item) => mapData(item, data.sources));
+  }
+
+  if (data.assignVariable) {
+    this.setVariable(data.variableName, dataToMap);
+  }
+  if (data.saveData) {
+    this.addDataToColumn(data.dataColumn, dataToMap);
+  }
+
+  return {
+    data: dataToMap,
+    nextBlockId: this.getBlockConnections(id),
+  };
+}
+
+export default dataMapping;

+ 1 - 1
src/background/workflowEngine/blocksHandler/handlerWebhook.js

@@ -21,7 +21,7 @@ export async function webhook({ data, id }, { refData }) {
     const response = await executeWebhook({ ...data, headers: newHeaders });
     const response = await executeWebhook({ ...data, headers: newHeaders });
 
 
     if (!response.ok) {
     if (!response.ok) {
-      if (fallbackOutput.connections.length > 0) {
+      if (fallbackOutput && fallbackOutput.length > 0) {
         return {
         return {
           data: '',
           data: '',
           nextBlockId: fallbackOutput,
           nextBlockId: fallbackOutput,

+ 242 - 0
src/components/newtab/workflow/edit/EditDataMapping.vue

@@ -0,0 +1,242 @@
+<template>
+  <div>
+    <ui-textarea
+      :model-value="data.description"
+      :placeholder="t('common.description')"
+      class="w-full"
+      @change="updateData({ description: $event })"
+    />
+    <ui-select
+      :label="t('workflow.blocks.data-mapping.dataSource')"
+      :model-value="data.dataSource"
+      class="w-full mt-4"
+      @change="updateData({ dataSource: $event })"
+    >
+      <option v-for="source in dataSources" :key="source.id" :value="source.id">
+        {{ source.name }}
+      </option>
+    </ui-select>
+    <ui-input
+      v-if="data.dataSource === 'variable'"
+      :model-value="data.varSourceName"
+      :placeholder="t('workflow.variables.name')"
+      :title="t('workflow.variables.name')"
+      class="mt-2 w-full"
+      @change="updateData({ varSourceName: $event })"
+    />
+    <ui-button
+      variant="accent"
+      class="mt-4 w-full"
+      @click="state.showModal = true"
+    >
+      {{ t('workflow.blocks.data-mapping.edit') }}
+    </ui-button>
+    <insert-workflow-data :data="data" variables @update="updateData" />
+    <ui-modal
+      v-model="state.showModal"
+      :title="t('workflow.blocks.data-mapping.edit')"
+      content-class="max-w-2xl data-map"
+    >
+      <div
+        class="px-4 my-4 overflow-auto scroll"
+        style="min-height: 400px; max-height: calc(100vh - 12rem)"
+      >
+        <table class="w-full">
+          <thead>
+            <tr class="bg-box-transparent">
+              <th class="w-6/12 rounded-l-lg">
+                {{ t('workflow.blocks.data-mapping.source') }}
+              </th>
+              <th class="w-6/12 rounded-r-lg">
+                {{ t('workflow.blocks.data-mapping.destination') }}
+              </th>
+            </tr>
+          </thead>
+          <tbody class="divide-y">
+            <tr v-for="(source, index) in state.sources" :key="source.id">
+              <td class="align-baseline group relative pr-4">
+                <div class="flex items-center space-x-2">
+                  <ui-autocomplete
+                    :items="state.autocompleteItems"
+                    :disabled="data.dataSource !== 'table'"
+                  >
+                    <ui-input
+                      :model-value="source.name"
+                      class="flex-1"
+                      placeholder="Source property"
+                      @blur="updateSource({ index, source, event: $event })"
+                    />
+                  </ui-autocomplete>
+                  <v-remixicon
+                    name="riDeleteBin7Line"
+                    class="invisible group-hover:visible cursor-pointer"
+                    @click="state.sources.splice(index, 1)"
+                  />
+                  <v-remixicon
+                    name="riArrowLeftLine"
+                    rotate="180"
+                    class="absolute -right-2 top-4 text-gray-600 dark:text-gray-300"
+                  />
+                </div>
+              </td>
+              <td class="align-baseline pl-4">
+                <ul class="space-y-1">
+                  <li
+                    v-for="(destination, destIndex) in source.destinations"
+                    :key="destination.id"
+                    class="flex items-center space-x-2 group"
+                  >
+                    <ui-input
+                      :model-value="destination.name"
+                      class="flex-1"
+                      placeholder="Destination property"
+                      @blur="
+                        updateDestination({
+                          index,
+                          destIndex,
+                          destination,
+                          event: $event,
+                        })
+                      "
+                    />
+                    <v-remixicon
+                      name="riDeleteBin7Line"
+                      class="invisible group-hover:visible cursor-pointer"
+                      @click="
+                        state.sources[index].destinations.splice(destIndex, 1)
+                      "
+                    />
+                  </li>
+                </ul>
+                <ui-button
+                  icon
+                  class="mt-2 text-sm"
+                  @click="addDestination(index)"
+                >
+                  {{ t('workflow.blocks.data-mapping.addDestination') }}
+                </ui-button>
+              </td>
+            </tr>
+            <tr>
+              <td>
+                <ui-button class="text-sm" @click="addSource">
+                  {{ t('workflow.blocks.data-mapping.addSource') }}
+                </ui-button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { reactive, onMounted, inject, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
+import { debounce } from '@/utils/helper';
+import InsertWorkflowData from './InsertWorkflowData.vue';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+const dataSources = [
+  { id: 'table', name: t('workflow.table.title') },
+  { id: 'variable', name: t('workflow.variables.title') },
+];
+
+const workflow = inject('workflow');
+
+const state = reactive({
+  query: '',
+  showModal: true,
+  autocompleteItems: [],
+  sources: [...props.data.sources],
+});
+
+function isNameDuplicate({ items, currItem, newName, event }) {
+  const isDuplicate = items.some(
+    (item) => currItem.id !== item.id && item.name === newName
+  );
+
+  if (isDuplicate || !newName) {
+    event.target.value = currItem.name;
+    return true;
+  }
+
+  return false;
+}
+function updateSource({ index, source, event }) {
+  const newName = event.target.value.trim();
+  const isDuplicate = isNameDuplicate({
+    event,
+    newName,
+    currItem: source,
+    items: state.sources,
+  });
+
+  if (isDuplicate) return;
+
+  state.sources[index].name = newName;
+}
+function updateDestination({ index, destIndex, destination, event }) {
+  const newName = event.target.value.trim();
+  const sourceDests = state.sources[index].destinations;
+  const isDuplicate = isNameDuplicate({
+    event,
+    newName,
+    items: sourceDests,
+    currItem: destination,
+  });
+
+  if (isDuplicate) return;
+
+  sourceDests[destIndex].name = newName;
+}
+function addSource() {
+  const id = nanoid(4);
+
+  state.sources.push({
+    id,
+    destinations: [],
+    name: `source_${id}`,
+  });
+}
+function addDestination(sourceIndex) {
+  const id = nanoid(4);
+
+  state.sources[sourceIndex].destinations.push({
+    id,
+    name: `dest_${id}`,
+  });
+}
+function updateData(value) {
+  emit('update:data', { ...props.data, ...value });
+}
+
+watch(
+  () => state.sources,
+  debounce(() => {
+    updateData({ sources: state.sources });
+  }, 200),
+  { deep: true }
+);
+
+onMounted(() => {
+  state.autocompleteItems = workflow.columns.value.map(({ name }) => name);
+});
+</script>
+<style>
+.data-map {
+  padding: 0 !important;
+  .modal-ui__content-header {
+    @apply px-4 pt-4;
+  }
+}
+</style>

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

@@ -18,7 +18,7 @@
             :padding="padding"
             :padding="padding"
             :class="[contentClass]"
             :class="[contentClass]"
           >
           >
-            <div class="mb-4">
+            <div class="mb-4 modal-ui__content-header">
               <div class="flex items-center justify-between">
               <div class="flex items-center justify-between">
                 <span class="content-header">
                 <span class="content-header">
                   <slot name="header">{{ title }}</slot>
                   <slot name="header">{{ title }}</slot>

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

@@ -106,6 +106,16 @@
         "name": "RegEx variable",
         "name": "RegEx variable",
         "description": "Matching a variable value against a regular expression"
         "description": "Matching a variable value against a regular expression"
       },
       },
+      "data-mapping": {
+        "source": "Source",
+        "destination": "Destination",
+        "name": "Data mapping",
+        "edit": "Edit data map",
+        "dataSource": "Data source",
+        "description": "Map data of a variable or table",
+        "addSource": "Add source",
+        "addDestination": "Add destination"
+      },
       "increase-variable": {
       "increase-variable": {
         "name": "Increase variable",
         "name": "Increase variable",
         "description": "Increase the value of a variable by specific amount",
         "description": "Increase the value of a variable by specific amount",

+ 24 - 0
src/utils/shared.js

@@ -1108,6 +1108,30 @@ export const tasks = {
       flag: [],
       flag: [],
     },
     },
   },
   },
+  'data-mapping': {
+    name: 'Data mapping',
+    description: 'Map data of a variable or table',
+    icon: 'riMindMap',
+    editComponent: 'EditDataMapping',
+    component: 'BlockBasic',
+    category: 'data',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      disableBlock: false,
+      description: '',
+      expression: '',
+      dataSource: 'table',
+      sources: [],
+      varSourceName: '',
+      dataColumn: '',
+      saveData: false,
+      assignVariable: false,
+      variableName: '',
+    },
+  },
 };
 };
 
 
 export const categories = {
 export const categories = {