Bläddra i källkod

feat: add workflow events

Ahmad Kholid 1 år sedan
förälder
incheckning
70ae8837c7

+ 6 - 0
.eslintrc.js

@@ -43,6 +43,12 @@ module.exports = {
     'import/no-named-default': 'off',
     'no-restricted-syntax': 'off',
     'vue/multi-word-component-names': 'off',
+    'prettier/prettier': [
+      'error',
+      {
+        endOfLine: 'auto',
+      },
+    ],
     'import/extensions': [
       'error',
       'always',

+ 0 - 1
.vscode/settings.json

@@ -1,5 +1,4 @@
 {
-  "editor.formatOnSave": true,
   "i18n-ally.localesPaths": [
     "src/locales"
   ]

+ 6 - 0
src/components/newtab/workflow/WorkflowSettings.vue

@@ -39,6 +39,7 @@ import cloneDeep from 'lodash.clonedeep';
 import { debounce } from '@/utils/helper';
 import SettingsTable from './settings/SettingsTable.vue';
 import SettingsBlocks from './settings/SettingsBlocks.vue';
+import SettingsEvents from './settings/SettingsEvents.vue';
 import SettingsGeneral from './settings/SettingsGeneral.vue';
 
 const props = defineProps({
@@ -67,6 +68,11 @@ const tabs = [
     component: SettingsBlocks,
     name: t('workflow.blocks.base.title'),
   },
+  {
+    value: 'events',
+    component: SettingsEvents,
+    name: t('workflow.events.title'),
+  },
 ];
 
 const activeTab = ref('general');

+ 240 - 0
src/components/newtab/workflow/settings/SettingsEvents.vue

@@ -0,0 +1,240 @@
+<template>
+  <div>
+    <div class="flex items-center">
+      <p class="flex-1">{{ t('workflow.events.description') }}</p>
+      <ui-button variant="accent" @click="updateModalState({ show: true })">
+        {{ t('workflow.events.add-action') }}
+      </ui-button>
+    </div>
+    <ui-list class="mt-4 space-y-1">
+      <ui-list-item
+        v-for="action in settings.events"
+        :key="action.id"
+        class="gap-2 group"
+      >
+        <div class="flex-1 overflow-hidden">
+          <p class="text-overflow">{{ action.name || 'Untitled action' }}</p>
+          <div
+            v-for="event in action.events"
+            :key="event"
+            :class="[
+              WORKFLOW_EVENTS_CLASSES[event],
+              'border rounded-md px-2 py-1 text-xs inline-flex items-center mr-0.5',
+            ]"
+          >
+            {{ t(`workflow.events.types.${event}.name`) }}
+          </div>
+        </div>
+        <v-remixicon
+          name="riPencilLine"
+          class="group-hover:visible invisible cursor-pointer"
+          @click="
+            Object.assign(actionModal, {
+              show: true,
+              type: 'edit',
+              data: cloneDeep(action),
+            })
+          "
+        />
+        <v-remixicon
+          name="riDeleteBin7Line"
+          class="group-hover:visible invisible cursor-pointer text-red-500 dark:text-red-400"
+          @click="
+            emit('update', {
+              key: 'events',
+              value: settings.events.filter((item) => item.id !== action.id),
+            })
+          "
+        />
+      </ui-list-item>
+    </ui-list>
+    <ui-modal
+      v-model="actionModal.show"
+      persist
+      :title="t('workflow.events.add-action')"
+      content-class="max-w-xl"
+      @close="updateModalState({})"
+    >
+      <ui-input
+        v-model="actionModal.data.name"
+        :label="t('common.name')"
+        placeholder="Untitled"
+        autofocus
+        class="w-full"
+      />
+      <p class="mt-4">{{ t('workflow.events.event', 2) }}</p>
+      <div class="mt-1 flex flex-wrap items-center space-x-2">
+        <div
+          v-for="(event, index) in actionModal.data.events"
+          :key="event"
+          :class="[
+            WORKFLOW_EVENTS_CLASSES[event],
+            'border rounded-lg px-3 text-sm h-8 inline-flex items-center',
+          ]"
+        >
+          <p class="flex-1">{{ t(`workflow.events.types.${event}.name`) }}</p>
+          <v-remixicon
+            name="riCloseLine"
+            height="20"
+            width="20"
+            class="text-gray-200 dark:text-gray-600 ml-1 -mr-1 cursor-pointer"
+            @click="actionModal.data.events.splice(index, 1)"
+          />
+        </div>
+        <ui-popover
+          v-if="WORKFLOW_EVENTS.length !== actionModal.data.events.length"
+        >
+          <template #trigger>
+            <ui-button class="!h-8 !px-3">
+              <v-remixicon
+                name="riAddLine"
+                class="-ml-1 mr-2"
+                height="20"
+                width="20"
+              />
+              <p class="text-sm">{{ t('common.add') }}</p>
+            </ui-button>
+          </template>
+          <ui-list>
+            <ui-list-item
+              v-for="event in WORKFLOW_EVENTS.filter(
+                (item) => !actionModal.data.events.includes(item)
+              )"
+              :key="event"
+              small
+              class="cursor-pointer !items-stretch"
+              @click="actionModal.data.events.push(event)"
+            >
+              <div
+                :class="[
+                  WORKFLOW_EVENTS_CLASSES[event],
+                  'w-2 flex-shrink-0 rounded-full',
+                ]"
+              ></div>
+              <div class="text-sm ml-2">
+                <p>{{ t(`workflow.events.types.${event}.name`) }}</p>
+                <p class="text-gray-600 dark:text-gray-300 leading-tight">
+                  {{ t(`workflow.events.types.${event}.description`) }}
+                </p>
+              </div>
+            </ui-list-item>
+          </ui-list>
+        </ui-popover>
+      </div>
+      <p class="mt-4">{{ t('workflow.events.action') }}</p>
+      <ui-select
+        :model-value="actionModal.data.action.type"
+        class="mt-1 w-full"
+        @change="actionModal.data.action = EVENT_ACTIONS[$event]"
+      >
+        <option
+          v-for="action in Object.keys(EVENT_ACTIONS)"
+          :key="action"
+          :value="action"
+        >
+          {{ t(`workflow.events.actions.${action}.title`) }}
+        </option>
+      </ui-select>
+      <div class="mt-2">
+        <component
+          :is="EVENT_ACTIONS_COMP[actionModal.data.action.type]"
+          :data="actionModal.data.action"
+          @update:data="Object.assign(actionModal.data.action, $event)"
+        />
+      </div>
+      <div class="mt-6 flex justify-end space-x-4">
+        <ui-button @click="actionModal.show = false">
+          {{ t('common.cancel') }}
+        </ui-button>
+        <ui-button
+          :disabled="actionModal.data.events.length === 0"
+          variant="accent"
+          class="min-w-[90px]"
+          @click="upsertAction"
+        >
+          {{
+            actionModal.type === 'edit' ? t('common.update') : t('common.add')
+          }}
+        </ui-button>
+      </div>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { reactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { nanoid } from 'nanoid';
+import cloneDeep from 'lodash.clonedeep';
+import EventCodeHTTP from './event/EventCodeHTTP.vue';
+import EventCodeAction from './event/EventCodeAction.vue';
+
+const props = defineProps({
+  settings: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+
+const EVENT_ACTIONS = {
+  'http-request': {
+    type: 'http-request',
+    url: '',
+    body: '{\n\t"workflowStatus": {{workflow.status}},\n\t"workflowLogs": {{workflow.logs}},\n\t"errorMessage": {{workflow.errorMessage}}\n}',
+    headers: [],
+    method: 'POST',
+  },
+  'js-code': {
+    code: "const workflow = automaRefData('workflow');\nconsole.log(\n\tworkflow.status,\n\tworkflow.logs,\n\tworkflow.errorMessage\n)",
+    type: 'js-code',
+  },
+};
+const EVENT_ACTIONS_COMP = {
+  'js-code': EventCodeAction,
+  'http-request': EventCodeHTTP,
+};
+const WORKFLOW_EVENTS_CLASSES = {
+  'finish:success': 'bg-green-300 dark:text-black dark:bg-green-200',
+  'finish:failed': 'bg-red-300 dark:text-black dark:bg-red-200',
+};
+const WORKFLOW_EVENTS = ['finish:success', 'finish:failed'];
+
+const defaultActionModal = {
+  type: 'add',
+  show: false,
+  data: {
+    name: '',
+    events: [],
+    action: EVENT_ACTIONS['http-request'],
+  },
+};
+
+const actionModal = reactive({ ...cloneDeep(defaultActionModal) });
+
+function updateModalState(detail) {
+  Object.assign(actionModal, { ...cloneDeep(defaultActionModal), ...detail });
+}
+function upsertAction() {
+  let copyEvents = [...(props.settings.events ?? [])];
+
+  if (actionModal.type === 'add') {
+    copyEvents.push({
+      id: nanoid(),
+      ...actionModal.data,
+      name: actionModal.data.name || 'Untitled action',
+    });
+  } else {
+    copyEvents = copyEvents.map((event) => {
+      if (event.id !== actionModal.data.id) return event;
+
+      return actionModal.data;
+    });
+  }
+
+  updateModalState({ show: false });
+
+  emit('update', { key: 'events', value: copyEvents });
+}
+</script>

+ 22 - 0
src/components/newtab/workflow/settings/event/EventCodeAction.vue

@@ -0,0 +1,22 @@
+<template>
+  <shared-codemirror
+    :model-value="data.code"
+    class="h-full w-full"
+    @change="$emit('update:data', { code: $event })"
+  />
+</template>
+<script setup>
+import { defineAsyncComponent } from 'vue';
+
+const SharedCodemirror = defineAsyncComponent(() =>
+  import('@/components/newtab/shared/SharedCodemirror.vue')
+);
+
+defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+defineEmits(['update:data']);
+</script>

+ 100 - 0
src/components/newtab/workflow/settings/event/EventCodeHTTP.vue

@@ -0,0 +1,100 @@
+<template>
+  <div class="flex items-center gap-2">
+    <ui-select
+      :model-value="data.method"
+      @change="emitData({ method: $event })"
+    >
+      <option
+        v-for="method in ['GET', 'PUT', 'POST', 'PATCH', 'DELETE']"
+        :key="method"
+        :value="method"
+      >
+        {{ method }}
+      </option>
+    </ui-select>
+    <ui-input
+      :model-value="data.url"
+      placeholder="URL"
+      type="url"
+      class="flex-1"
+      @change="emitData({ url: $event })"
+    />
+  </div>
+  <ui-tabs v-model="activeTab" class="mt-1">
+    <ui-tab value="headers">
+      {{ t('workflow.blocks.webhook.tabs.headers') }}
+    </ui-tab>
+    <ui-tab v-if="data.method !== 'GET'" value="body">
+      {{ t('workflow.blocks.webhook.tabs.body') }}
+    </ui-tab>
+  </ui-tabs>
+  <ui-tab-panels v-model="activeTab">
+    <ui-tab-panel value="headers">
+      <div class="mt-4 grid grid-cols-7 justify-items-center gap-2">
+        <template v-for="(items, index) in data.headers" :key="index">
+          <ui-input
+            v-model="items.name"
+            :title="items.name"
+            :placeholder="`Header ${index + 1}`"
+            type="text"
+            class="col-span-3"
+          />
+          <ui-input
+            v-model="items.value"
+            :title="items.value"
+            placeholder="Value"
+            type="text"
+            class="col-span-3"
+          />
+          <button
+            @click="
+              emitData({ headers: data.filter((_, idx) => idx !== index) })
+            "
+          >
+            <v-remixicon name="riCloseCircleLine" size="20" />
+          </button>
+        </template>
+      </div>
+      <ui-button
+        class="mt-2"
+        @click="
+          emitData({ headers: [...data.headers, { name: '', value: '' }] })
+        "
+      >
+        <span> {{ t('workflow.blocks.webhook.buttons.header') }} </span>
+      </ui-button>
+    </ui-tab-panel>
+    <ui-tab-panel value="body" class="mt-4">
+      <shared-codemirror
+        :model-value="data.body"
+        lang="json"
+        class="h-full w-full"
+        @change="emitData({ body: $event })"
+      />
+    </ui-tab-panel>
+  </ui-tab-panels>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { defineAsyncComponent, shallowRef } from 'vue';
+
+const SharedCodemirror = defineAsyncComponent(() =>
+  import('@/components/newtab/shared/SharedCodemirror.vue')
+);
+
+defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update:data']);
+
+const { t } = useI18n();
+
+const activeTab = shallowRef('headers');
+
+function emitData(data) {
+  emit('update:data', data);
+}
+</script>

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

@@ -182,6 +182,31 @@
     }
   },
   "workflow": {
+    "events": {
+      "title": "Workflow Events",
+      "add-action": "Add action",
+      "description": "Perform actions when the event occurs.",
+      "event": "Event | Events",
+      "action": "Action",
+      "actions": {
+        "js-code": {
+          "title": "Execute JS Code"
+        },
+        "http-request": {
+          "title": "HTTP Request"
+        }
+      },
+      "types": {
+        "finish:success": {
+          "name": "Finish (success)",
+          "description": "Workflow execution finished with success"
+        },
+        "finish:failed": {
+          "name": "Finish (failed)",
+          "description": "Workflow execution finished with error"
+        }
+      }
+    },
     "previewMode": {
       "title": "Preview mode",
       "description": "You're in preview mode, changes you've made won't be saved"

+ 3 - 0
src/sandbox/utils/handleJavascriptBlock.js

@@ -41,6 +41,9 @@ export default function (data) {
   script.textContent = `
     (() => {
       function automaRefData(keyword, path = '') {
+        if (!keyword) return null;
+        if (!path) return ${propertyName}.refData[keyword];
+
         return window.$getNestedProperties(${propertyName}.refData, keyword + '.' + path);
       }
       function automaSetVariable(name, value) {

+ 33 - 1
src/workflowEngine/index.js

@@ -6,10 +6,12 @@ import { parseJSON } from '@/utils/helper';
 import { fetchApi } from '@/utils/api';
 import { sendMessage } from '@/utils/message';
 import convertWorkflowData from '@/utils/convertWorkflowData';
+import getBlockMessage from '@/utils/getBlockMessage';
 import WorkflowState from './WorkflowState';
 import WorkflowLogger from './WorkflowLogger';
 import WorkflowEngine from './WorkflowEngine';
 import blocksHandler from './blocksHandler';
+import { workflowEventHandler } from './workflowEvent';
 
 const workflowStateStorage = {
   get() {
@@ -64,7 +66,7 @@ export function startWorkflowExec(workflowData, options, isPopup = true) {
   });
 
   engine.init();
-  engine.on('destroyed', ({ id, status }) => {
+  engine.on('destroyed', ({ id, status, history, blockDetail, ...rest }) => {
     if (status !== 'stopped') {
       browser.permissions
         .contains({ permissions: ['notifications'] })
@@ -84,6 +86,36 @@ export function startWorkflowExec(workflowData, options, isPopup = true) {
           });
         });
     }
+
+    if (convertedWorkflow.settings?.events) {
+      const workflowHistory = history.map((item) => {
+        delete item.logId;
+        delete item.prevBlockData;
+        delete item.workerId;
+
+        item.description = item.description || '';
+
+        return item;
+      });
+      const workflowRefData = {
+        status,
+        startedAt: rest.startedTimestamp,
+        endedAt: rest.endedTimestamp
+          ? rest.endedTimestamp - rest.startedTimestamp
+          : null,
+        logs: workflowHistory,
+        errorMessage: status === 'error' ? getBlockMessage(blockDetail) : null,
+      };
+
+      convertedWorkflow.settings.events.forEach((event) => {
+        if (status === 'success' && !event.events.includes('finish:success'))
+          return;
+        if (status === 'error' && !event.events.includes('finish:failed'))
+          return;
+
+        workflowEventHandler(event.action, { workflow: workflowRefData });
+      });
+    }
   });
 
   browser.storage.local.get('checkStatus').then(({ checkStatus }) => {

+ 49 - 0
src/workflowEngine/workflowEvent.js

@@ -0,0 +1,49 @@
+import { nanoid } from 'nanoid';
+import { messageSandbox } from './helper';
+import renderString from './templating/renderString';
+
+async function javascriptCode(event, refData) {
+  const instanceId = `automa${nanoid()}`;
+
+  await messageSandbox('javascriptBlock', {
+    refData,
+    instanceId,
+    preloadScripts: [],
+    blockData: {
+      code: event.code,
+    },
+  });
+}
+
+async function httpRequest({ url, method, headers, body }, refData) {
+  if (!url.trim()) return;
+
+  const reqHeaders = {
+    'Content-Type': 'application/json',
+  };
+  headers.forEach((header) => {
+    reqHeaders[header.name] = header.value;
+  });
+
+  const renderedBody =
+    method !== 'GET' ? (await renderString(body, refData)).value : undefined;
+
+  await fetch(url, {
+    method,
+    body: renderedBody,
+    headers: reqHeaders,
+  });
+}
+
+const eventHandlerMap = {
+  'js-code': javascriptCode,
+  'http-request': httpRequest,
+};
+
+export async function workflowEventHandler(event, refData) {
+  try {
+    await eventHandlerMap[event.type](event, refData);
+  } catch (error) {
+    console.error(error);
+  }
+}