Browse Source

feat: add host workflow

Ahmad Kholid 3 years ago
parent
commit
d50d05a972

+ 17 - 3
src/background/index.js

@@ -33,8 +33,19 @@ const workflow = {
   states: new WorkflowState({ storage }),
   logger: new WorkflowLogger({ storage }),
   async get(workflowId) {
-    const { workflows } = await browser.storage.local.get('workflows');
-    const findWorkflow = workflows.find(({ id }) => id === workflowId);
+    const { workflows, workflowHosts } = await browser.storage.local.get([
+      'workflows',
+      'workflowHosts',
+    ]);
+    let findWorkflow = workflows.find(({ id }) => id === workflowId);
+
+    if (!findWorkflow) {
+      findWorkflow = Object.values(workflowHosts || {}).find(
+        ({ hostId }) => hostId === workflowId
+      );
+
+      if (findWorkflow) findWorkflow.id = findWorkflow.hostId;
+    }
 
     return findWorkflow;
   },
@@ -93,7 +104,10 @@ async function openDashboard(url) {
 
     if (tab) {
       await browser.tabs.update(tab.id, tabOptions);
-      await browser.tabs.reload(tab.id);
+
+      if (tab.url.includes('workflows/')) {
+        await browser.tabs.reload(tab.id);
+      }
     } else {
       browser.tabs.create(tabOptions);
     }

+ 18 - 4
src/components/newtab/settings/SettingsBackup.vue

@@ -111,12 +111,26 @@ async function restoreWorkflows() {
     const file = await openFilePicker('application/json');
     const reader = new FileReader();
     const insertWorkflows = (workflows) => {
-      const newWorkflows = workflows.map((workflow) => {
-        if (!state.updateIfExists) delete workflow.id;
+      workflows.forEach((workflow) => {
+        const isWorkflowExists = Workflow.query()
+          .where('id', workflow.id)
+          .exists();
 
-        workflow.createdAt = Date.now();
+        if (!state.updateIfExists || !isWorkflowExists) {
+          workflow.createdAt = Date.now();
+          delete workflow.id;
 
-        return workflow;
+          Workflow.insert({
+            data: workflow,
+          });
+
+          return;
+        }
+
+        Workflow.update({
+          where: workflow.id,
+          data: workflow,
+        });
       });
       const showMessage = (event) => {
         toast(

+ 5 - 21
src/components/newtab/shared/SharedCard.vue

@@ -55,9 +55,11 @@
     </div>
     <div class="flex items-center text-gray-600 dark:text-gray-200">
       <p class="flex-1">{{ state.date }}</p>
+      <slot name="footer-content" />
       <v-remixicon
         v-if="state.triggerText"
         v-tooltip="state.triggerText"
+        :class="{ 'ml-2': $slots['footer-content'] }"
         name="riFlashlightLine"
         size="20"
       />
@@ -67,8 +69,8 @@
 <script setup>
 import { onMounted, shallowReactive } from 'vue';
 import { useI18n } from 'vue-i18n';
-import browser from 'webextension-polyfill';
 import dayjs from '@/lib/dayjs';
+import triggerText from '@/utils/trigger-text';
 
 const props = defineProps({
   data: {
@@ -93,7 +95,6 @@ defineEmits(['execute', 'click', 'menuSelected']);
 
 const { t } = useI18n();
 
-const excludeTrigger = ['manual'];
 const state = shallowReactive({
   triggerText: null,
   date: dayjs(props.data.createdAt).fromNow(),
@@ -101,26 +102,9 @@ const state = shallowReactive({
 
 onMounted(async () => {
   const { trigger, id } = props.data;
-  const hasTrigger = trigger && !excludeTrigger.includes(trigger.type);
 
-  if (state.triggerText || !hasTrigger) return;
+  if (!trigger) return;
 
-  const triggerName = t(`workflow.blocks.trigger.items.${trigger.type}`);
-  let text = '';
-
-  if (trigger.type === 'keyboard-shortcut') {
-    text = trigger.shortcut;
-  } else if (trigger.type === 'visit-web') {
-    text = trigger.url;
-  } else if (['specific-day', 'date'].includes(trigger.type)) {
-    const triggerTime = (await browser.alarms.get(id))?.scheduledTime;
-
-    text = dayjs(triggerTime || Date.now()).format('DD-MMM-YYYY, hh:mm A');
-  }
-
-  text = text && `: \n ${text}`;
-  state.triggerText = `${t(
-    'workflow.blocks.trigger.name'
-  )} (${triggerName})${text}`;
+  state.triggerText = await triggerText(trigger, t, id);
 });
 </script>

+ 60 - 10
src/components/newtab/workflow/WorkflowActions.vue

@@ -1,5 +1,59 @@
 <template>
-  <ui-card padding="p-1">
+  <ui-card v-if="!workflow.isProtected" padding="p-1 flex items-center">
+    <ui-popover>
+      <template #trigger>
+        <button
+          v-tooltip.group="t('workflow.host.title')"
+          class="hoverable p-2 rounded-lg"
+        >
+          <v-remixicon
+            :class="{ 'text-primary': data.isHost }"
+            name="riBaseStationLine"
+          />
+        </button>
+      </template>
+      <div :class="{ 'text-center': data.loadingHost }" class="w-64">
+        <div class="flex items-center text-gray-600 dark:text-gray-200">
+          <p>
+            {{ t('workflow.host.set') }}
+          </p>
+          <a :title="t('common.docs')" class="ml-1">
+            <v-remixicon name="riInformationLine" size="20" />
+          </a>
+          <div class="flex-grow"></div>
+          <template v-if="$store.state.user">
+            <ui-spinner v-if="data.loadingHost" color="text-accent" />
+            <ui-switch
+              v-else
+              :model-value="data.isHost"
+              @change="$emit('host', $event)"
+            />
+          </template>
+          <ui-switch v-else v-close-popover @click="$emit('host', 'auth')" />
+        </div>
+        <transition-expand>
+          <ui-input
+            v-if="data.isHost"
+            v-tooltip:bottom="t('workflow.host.id')"
+            :model-value="host.hostId"
+            prepend-icon="riLinkM"
+            readonly
+            class="mt-4 block w-full"
+            @click="$event.target.select()"
+          />
+        </transition-expand>
+      </div>
+    </ui-popover>
+    <button
+      v-tooltip.group="t('workflow.share.title')"
+      :class="{ 'text-primary': data.hasShared }"
+      class="hoverable p-2 rounded-lg"
+      @click="$emit('share')"
+    >
+      <v-remixicon name="riShareLine" />
+    </button>
+  </ui-card>
+  <ui-card padding="p-1 ml-4">
     <button
       v-for="item in modalActions"
       :key="item.id"
@@ -11,15 +65,6 @@
     </button>
   </ui-card>
   <ui-card padding="p-1 ml-4 flex items-center">
-    <button
-      v-if="!workflow.isProtected"
-      v-tooltip.group="t('workflow.share.title')"
-      :class="{ 'text-primary': data.hasShared }"
-      class="hoverable p-2 rounded-lg"
-      @click="$emit('share')"
-    >
-      <v-remixicon name="riShareLine" />
-    </button>
     <button
       v-tooltip.group="
         t(`workflow.protect.${workflow.isProtected ? 'remove' : 'title'}`)
@@ -114,6 +159,10 @@ defineProps({
     type: Object,
     default: () => ({}),
   },
+  host: {
+    type: Object,
+    default: () => ({}),
+  },
   data: {
     type: Object,
     default: () => ({}),
@@ -129,6 +178,7 @@ const emit = defineEmits([
   'export',
   'update',
   'share',
+  'host',
 ]);
 
 useGroupTooltip();

+ 52 - 15
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -5,7 +5,37 @@
     @drop="dropHandler"
     @dragover.prevent="handleDragOver"
   >
-    <slot v-bind="{ editor }"></slot>
+    <div
+      class="flex items-end absolute w-full p-4 left-0 bottom-0 justify-between z-10"
+    >
+      <div id="zoom">
+        <button
+          v-tooltip.group="t('workflow.editor.resetZoom')"
+          class="p-2 rounded-lg bg-white dark:bg-gray-800 mr-2"
+          @click="editor.zoom_reset()"
+        >
+          <v-remixicon name="riFullscreenLine" />
+        </button>
+        <div class="rounded-lg bg-white dark:bg-gray-800 inline-block">
+          <button
+            v-tooltip.group="t('workflow.editor.zoomOut')"
+            class="p-2 rounded-lg relative z-10"
+            @click="editor.zoom_out()"
+          >
+            <v-remixicon name="riSubtractLine" />
+          </button>
+          <hr class="h-6 border-r inline-block" />
+          <button
+            v-tooltip.group="t('workflow.editor.zoomIn')"
+            class="p-2 rounded-lg"
+            @click="editor.zoom_in()"
+          >
+            <v-remixicon name="riAddLine" />
+          </button>
+        </div>
+      </div>
+      <slot v-bind="{ editor }"></slot>
+    </div>
     <ui-popover
       v-model="contextMenu.show"
       :options="contextMenu.position"
@@ -65,9 +95,13 @@ export default {
       default: false,
     },
     version: {
-      type: String,
+      type: [String, Boolean],
       default: '',
     },
+    mode: {
+      type: String,
+      default: 'edit',
+    },
   },
   emits: ['load', 'deleteBlock', 'update', 'save'],
   setup(props, { emit }) {
@@ -305,6 +339,17 @@ export default {
         editor.value.updateConnectionNodes(node.id);
       });
     }
+    function saveEditorState() {
+      const editorStates =
+        parseJSON(localStorage.getItem('editor-states'), {}) || {};
+      editorStates[workflowId] = {
+        zoom: editor.value.zoom,
+        canvas_x: editor.value.canvas_x,
+        canvas_y: editor.value.canvas_y,
+      };
+
+      localStorage.setItem('editor-states', JSON.stringify(editorStates));
+    }
 
     useShortcut('editor:duplicate-block', () => {
       const selectedElement = document.querySelector('.drawflow-node.selected');
@@ -321,6 +366,7 @@ export default {
       const element = document.querySelector('#drawflow');
 
       editor.value = drawflow(element, { context, options: { reroute: true } });
+
       const editorStates =
         parseJSON(localStorage.getItem('editor-states'), {}) || {};
       const editorState = editorStates[workflowId];
@@ -341,7 +387,7 @@ export default {
             ? parseJSON(props.data, null)
             : props.data;
 
-        if (!data) return;
+        if (!data || !data?.drawflow?.Home) return;
 
         const currentExtVersion = chrome.runtime.getManifest().version;
         const isOldWorkflow = compare(
@@ -350,7 +396,7 @@ export default {
           '>'
         );
 
-        if (isOldWorkflow) {
+        if (isOldWorkflow && typeof props.version !== 'boolean') {
           const newDrawflowData = Object.entries(
             data.drawflow.Home.data
           ).reduce((obj, [key, value]) => {
@@ -419,6 +465,7 @@ export default {
       editor.value.on('connectionRemoved', () => {
         emitter.emit('editor:data-changed');
       });
+      editor.value.on('export', saveEditorState);
       editor.value.on('contextmenu', ({ clientY, clientX, target }) => {
         const isBlock = target.closest('.drawflow .drawflow-node');
 
@@ -448,17 +495,7 @@ export default {
         refreshConnection();
       }, 500);
     });
-    onBeforeUnmount(() => {
-      const editorStates =
-        parseJSON(localStorage.getItem('editor-states'), {}) || {};
-      editorStates[workflowId] = {
-        zoom: editor.value.zoom,
-        canvas_x: editor.value.canvas_x,
-        canvas_y: editor.value.canvas_y,
-      };
-
-      localStorage.setItem('editor-states', JSON.stringify(editorStates));
-    });
+    onBeforeUnmount(saveEditorState);
 
     return {
       t,

+ 3 - 2
src/components/newtab/workflow/WorkflowSettings.vue

@@ -52,6 +52,7 @@
 <script setup>
 import { onMounted, reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { debounce } from '@/utils/helper';
 
 const props = defineProps({
   workflow: {
@@ -78,11 +79,11 @@ const settings = reactive({});
 
 watch(
   settings,
-  (newSettings) => {
+  debounce((newSettings) => {
     emit('update', {
       settings: newSettings,
     });
-  },
+  }, 500),
   { deep: true }
 );
 

+ 1 - 1
src/components/newtab/workflow/WorkflowShare.vue

@@ -209,7 +209,7 @@ async function publishWorkflow() {
       nodes[nodeId].data.loopData = '';
     });
 
-    const response = await fetchApi('/workflow/publish', {
+    const response = await fetchApi('/me/workflows/shared', {
       method: 'POST',
       body: JSON.stringify({ workflow }),
     });

+ 2 - 1
src/components/newtab/workflow/edit/EditAttributeValue.vue

@@ -3,7 +3,8 @@
     <hr />
     <ui-input
       :model-value="data.attributeName"
-      :placeholder="t('workflow.blocks.attribute-value.forms.name')"
+      :label="t('workflow.blocks.attribute-value.forms.name')"
+      placeholder="name"
       class="w-full"
       @change="updateData({ attributeName: $event })"
     />

+ 49 - 21
src/components/ui/UiDialog.vue

@@ -20,6 +20,7 @@
         v-if="state.type === 'prompt'"
         v-model="state.input"
         autofocus
+        :disabled="state.loading"
         :placeholder="state.options.placeholder"
         :label="state.options.label"
         :type="
@@ -43,6 +44,7 @@
         </ui-button>
         <ui-button
           class="w-6/12"
+          :loading="state.loading"
           :variant="state.options.okVariant"
           @click="fireCallback('onConfirm')"
         >
@@ -53,9 +55,10 @@
   </ui-modal>
 </template>
 <script>
-import { reactive, watch } from 'vue';
+import { reactive, watch, onUnmounted } from 'vue';
 import { useI18n } from 'vue-i18n';
 import defu from 'defu';
+import { throttle } from '@/utils/helper';
 import emitter from '@/lib/mitt';
 
 export default {
@@ -63,52 +66,71 @@ export default {
     const { t } = useI18n();
 
     const defaultOptions = {
-      html: false,
       body: '',
       title: '',
-      placeholder: '',
       label: '',
+      html: false,
+      onCancel: null,
+      onConfirm: null,
+      placeholder: '',
       inputType: 'text',
-      okText: t('common.confirm'),
+      showLoading: false,
       okVariant: 'accent',
+      okText: t('common.confirm'),
       cancelText: t('common.cancel'),
-      onConfirm: null,
-      onCancel: null,
     };
     const state = reactive({
-      show: false,
       type: '',
       input: '',
+      show: false,
+      loading: false,
       showPassword: false,
       options: defaultOptions,
     });
 
-    emitter.on('show-dialog', ({ type, options }) => {
+    function handleShowDialog({ type, options }) {
       state.type = type;
       state.input = options?.inputValue ?? '';
       state.options = defu(options, defaultOptions);
 
       state.show = true;
-    });
-
-    function fireCallback(type) {
+    }
+    function destroy() {
+      state.input = '';
+      state.show = false;
+      state.showPassword = false;
+      state.options = defaultOptions;
+    }
+    const fireCallback = throttle((type) => {
       const callback = state.options[type];
       const param = state.type === 'prompt' ? state.input : true;
-      let hide = true;
 
       if (callback) {
-        const cbReturn = callback(param);
+        const isAsync = state.options.async;
+        if (isAsync) state.loading = true;
 
-        if (typeof cbReturn === 'boolean') hide = cbReturn;
-      }
+        const cbReturn = callback(param) ?? true;
+
+        if (typeof cbReturn === 'boolean') {
+          if (cbReturn) destroy();
+          state.loading = false;
+
+          return;
+        }
+        if (isAsync && cbReturn?.then) {
+          cbReturn.then((value) => {
+            if (value) destroy();
+            state.loading = false;
+          });
 
-      if (hide) {
-        state.options = defaultOptions;
-        state.showPassword = false;
-        state.show = false;
-        state.input = '';
+          return;
+        }
+
+        destroy();
+      } else {
+        destroy();
       }
-    }
+    }, 200);
     function keyupHandler({ code }) {
       if (code === 'Enter') {
         fireCallback('onConfirm');
@@ -128,6 +150,12 @@ export default {
       }
     );
 
+    emitter.on('show-dialog', handleShowDialog);
+
+    onUnmounted(() => {
+      emitter.off('show-dialog', handleShowDialog);
+    });
+
     return {
       state,
       fireCallback,

+ 9 - 2
src/components/ui/UiTabs.vue

@@ -1,6 +1,9 @@
 <template>
   <div
-    :class="tabTypes[type] || tabTypes['default']"
+    :class="[
+      tabTypes[type] || tabTypes['default'],
+      { [color]: type === 'fill' },
+    ]"
     aria-role="tablist"
     class="ui-tabs text-gray-600 dark:text-gray-200 flex space-x-1 items-center relative"
     @mouseleave="showHoverIndicator = false"
@@ -27,6 +30,10 @@ const props = defineProps({
     default: 'default',
     validator: (value) => ['default', 'fill'].includes(value),
   },
+  color: {
+    type: String,
+    default: 'bg-box-transparent',
+  },
   small: Boolean,
   fill: Boolean,
 });
@@ -34,7 +41,7 @@ const emit = defineEmits(['update:modelValue']);
 
 const tabTypes = {
   default: 'border-b',
-  fill: 'p-2 rounded-lg bg-box-transparent',
+  fill: 'p-2 rounded-lg',
 };
 
 const hoverIndicator = ref(null);

+ 15 - 5
src/content/services/shortcut-listener.js

@@ -8,16 +8,26 @@ Mousetrap.prototype.stopCallback = function () {
 
 (async () => {
   try {
-    const { shortcuts, workflows } = await browser.storage.local.get([
-      'shortcuts',
-      'workflows',
-    ]);
+    const { shortcuts, workflows, workflowHosts } =
+      await browser.storage.local.get([
+        'shortcuts',
+        'workflows',
+        'workflowHosts',
+      ]);
     const shortcutsArr = Object.entries(shortcuts || {});
 
     if (shortcutsArr.length === 0) return;
 
     const keyboardShortcuts = shortcutsArr.reduce((acc, [id, value]) => {
-      const workflow = workflows.find((item) => item.id === id);
+      let workflow = workflows.find((item) => item.id === id);
+
+      if (!workflow) {
+        workflow = Object.values(workflowHosts || {}).find(
+          ({ hostId }) => hostId === id
+        );
+
+        if (workflow) workflow.id = workflow.hostId;
+      }
 
       (acc[value] = acc[value] || []).push({
         id,

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

@@ -4,6 +4,7 @@ import {
   riH2,
   riLinkM,
   riLock2Line,
+  riBaseStationLine,
   riKeyboardLine,
   riLinkUnlinkM,
   riFileEditLine,
@@ -101,6 +102,7 @@ export const icons = {
   riH2,
   riLinkM,
   riLock2Line,
+  riBaseStationLine,
   riKeyboardLine,
   riLinkUnlinkM,
   riFileEditLine,

+ 1 - 0
src/locales/en/common.json

@@ -47,6 +47,7 @@
     "maxSizeExceeded": "The file size is the exceeded maximum allowed",
     "notSaved": "Do you really want to leave? you have unsaved changes!",
     "somethingWrong": "Something went wrong",
+    "limitExceeded": "You have exceeded the limit"
   },
   "sort": {
     "sortBy": "Sort by",

+ 17 - 1
src/locales/en/newtab.json

@@ -57,9 +57,24 @@
     "clickToEnable": "Click to enable",
     "toggleSidebar": "Toggle sidebar",
     "cantEdit": "Can't edit shared workflow",
+    "host": {
+      "title": "Host workflow",
+      "set": "Set as host workflow",
+      "id": "Host Id",
+      "add": "Add hosted workflow",
+      "sync": {
+        "title": "Sync",
+        "description": "Sync with host workflow"
+      },
+      "messages": {
+        "hostExist": "You already add this host",
+        "notFound": "Can't find hosted workflow with \"{id}\" id"
+      },
+    },
     "type": {
       "local": "Local",
-      "shared": "Shared"
+      "shared": "Shared",
+      "host": "Host",
     },
     "unpublish": {
       "title": "Unpublish workflow",
@@ -69,6 +84,7 @@
     "share": {
       "url": "Share URL",
       "publish": "Publish",
+      "sharedAs": "Shared as \"{name}\"",
       "title": "Share workflow",
       "download": "Add workflow to local",
       "edit": "Edit description",

+ 4 - 21
src/models/workflow.js

@@ -1,7 +1,7 @@
 import { Model } from '@vuex-orm/core';
 import { nanoid } from 'nanoid';
-import browser from 'webextension-polyfill';
 import Log from './log';
+import { cleanWorkflowTriggers } from '@/utils/workflow-trigger';
 
 class Workflow extends Model {
   static entity = 'workflows';
@@ -22,11 +22,10 @@ class Workflow extends Model {
       description: this.string(''),
       pass: this.string(''),
       trigger: this.attr(null),
-      isProtected: this.boolean(false),
       version: this.string(''),
-      globalData: this.string('[{ "key": "value" }]'),
       createdAt: this.number(Date.now()),
       isDisabled: this.boolean(false),
+      isProtected: this.boolean(false),
       settings: this.attr({
         blockDelay: 0,
         saveLog: true,
@@ -35,6 +34,7 @@ class Workflow extends Model {
         executedBlockOnWeb: false,
       }),
       logs: this.hasMany(Log, 'workflowId'),
+      globalData: this.string('[{ "key": "value" }]'),
     };
   }
 
@@ -57,24 +57,7 @@ class Workflow extends Model {
 
   static async afterDelete({ id }) {
     try {
-      const { visitWebTriggers, shortcuts } = await browser.storage.local.get([
-        'visitWebTriggers',
-        'shortcuts',
-      ]);
-      const index = visitWebTriggers.findIndex((item) => item.id === id);
-
-      if (index !== -1) {
-        visitWebTriggers.splice(index, 1);
-      }
-
-      const keyboardShortcuts = shortcuts || {};
-      delete keyboardShortcuts[id];
-
-      await browser.storage.local.set({
-        visitWebTriggers,
-        shortcuts: keyboardShortcuts,
-      });
-      await browser.alarms.clear(id);
+      await cleanWorkflowTriggers(id);
     } catch (error) {
       console.error(error);
     }

+ 39 - 7
src/newtab/App.vue

@@ -57,8 +57,8 @@ import { useI18n } from 'vue-i18n';
 import { compare } from 'compare-versions';
 import browser from 'webextension-polyfill';
 import { useTheme } from '@/composable/theme';
-import { fetchApi, getSharedWorkflows } from '@/utils/api';
 import { loadLocaleMessages, setI18nLanguage } from '@/lib/vue-i18n';
+import { fetchApi, getSharedWorkflows, getHostWorkflows } from '@/utils/api';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 
 const { t } = useI18n();
@@ -73,13 +73,36 @@ const currentVersion = browser.runtime.getManifest().version;
 const prevVersion = localStorage.getItem('ext-version') || '0.0.0';
 const isUpdated = ref(false);
 
+async function syncHostWorkflow(hosts) {
+  const hostIds = [];
+  const workflowHosts = hosts || store.state.workflowHosts;
+  const localWorkflowHost = Object.values(store.state.hostWorkflows);
+
+  Object.keys(workflowHosts).forEach((hostId) => {
+    const isItsOwn = localWorkflowHost.find((item) => item.hostId === hostId);
+
+    if (isItsOwn) return;
+
+    hostIds.push({ hostId, updatedAt: workflowHosts[hostId].updatedAt });
+  });
+
+  try {
+    await store.dispatch('fetchWorkflowHosts', hostIds);
+  } catch (error) {
+    console.error(error);
+  }
+}
 async function fetchUserData() {
   try {
     const response = await fetchApi('/me');
     const user = await response.json();
 
-    if (response.status !== 200 || !user) {
-      if (!user) sessionStorage.removeItem('shared-workflows');
+    if (response.status !== 200) {
+      throw new Error(response.statusText);
+    }
+    if (!user) {
+      sessionStorage.removeItem('shared-workflows');
+      sessionStorage.removeItem('host-workflows');
 
       return;
     }
@@ -89,11 +112,18 @@ async function fetchUserData() {
       value: user,
     });
 
-    const sharedWorkflows = await getSharedWorkflows();
+    const mapPromises = { 0: 'sharedWorkflows', 1: 'hostWorkflows' };
+    const promises = await Promise.allSettled([
+      getSharedWorkflows(),
+      getHostWorkflows(),
+    ]);
+    promises.forEach(({ status, value }, index) => {
+      if (status !== 'fulfilled') return;
 
-    store.commit('updateState', {
-      key: 'sharedWorkflows',
-      value: sharedWorkflows,
+      store.commit('updateState', {
+        value,
+        key: mapPromises[index],
+      });
     });
   } catch (error) {
     console.error(error);
@@ -140,6 +170,8 @@ onMounted(async () => {
     await setI18nLanguage(store.state.settings.locale);
 
     retrieved.value = true;
+
+    await syncHostWorkflow();
   } catch (error) {
     retrieved.value = true;
     console.error(error);

+ 188 - 15
src/newtab/pages/Workflows.vue

@@ -54,16 +54,34 @@
         <v-remixicon name="riUploadLine" class="mr-2 -ml-1" />
         {{ t('workflow.import') }}
       </ui-button>
-      <ui-button
-        :title="shortcut['action:new'].readable"
-        variant="accent"
-        @click="newWorkflow"
-      >
-        {{ t('workflow.new') }}
-      </ui-button>
+      <div class="flex">
+        <ui-button
+          :title="shortcut['action:new'].readable"
+          variant="accent"
+          class="border-r rounded-r-none"
+          @click="newWorkflow"
+        >
+          {{ t('workflow.new') }}
+        </ui-button>
+        <ui-popover>
+          <template #trigger>
+            <ui-button icon class="rounded-l-none" variant="accent">
+              <v-remixicon name="riArrowLeftSLine" rotate="-90" />
+            </ui-button>
+          </template>
+          <ui-list>
+            <ui-list-item
+              v-close-popover
+              class="cursor-pointer"
+              @click="addHostWorkflow"
+            >
+              {{ t('workflow.host.add') }}
+            </ui-list-item>
+          </ui-list>
+        </ui-popover>
+      </div>
     </div>
     <ui-tabs
-      v-if="store.state.user"
       v-model="state.activeTab"
       class="mt-4 space-x-2"
       type="fill"
@@ -72,24 +90,37 @@
       <ui-tab value="local">
         {{ t('workflow.type.local') }}
       </ui-tab>
-      <ui-tab value="shared">
+      <ui-tab v-if="store.state.user" value="shared">
         {{ t('workflow.type.shared') }}
       </ui-tab>
+      <ui-tab v-if="workflowHosts.length > 0" value="host">
+        {{ t('workflow.type.host') }}
+      </ui-tab>
     </ui-tabs>
     <ui-tab-panels v-model="state.activeTab" class="mt-6">
       <ui-tab-panel value="shared">
-        <div v-if="state.loadingShared" class="text-center">
-          <ui-spinner color="text-accent" />
-        </div>
-        <div v-else class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
+        <div class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
           <shared-card
-            v-for="workflow in store.state.sharedWorkflows"
+            v-for="workflow in sharedWorkflows"
             :key="workflow.id"
             :data="workflow"
+            :show-details="false"
             @click="$router.push(`/workflows/${$event.id}?shared=true`)"
           />
         </div>
       </ui-tab-panel>
+      <ui-tab-panel value="host">
+        <div class="grid gap-4 grid-cols-4 2xl:grid-cols-5">
+          <shared-card
+            v-for="workflow in workflowHosts"
+            :key="workflow.hostId"
+            :data="workflow"
+            :menu="workflowHostMenu"
+            @click="$router.push(`/workflows/${$event.hostId}/host`)"
+            @menuSelected="deleteWorkflowHost(workflow)"
+          />
+        </div>
+      </ui-tab-panel>
       <ui-tab-panel value="local">
         <div v-if="Workflow.all().length === 0" class="py-12 flex items-center">
           <img src="@/assets/svg/alien.svg" class="w-96" />
@@ -177,6 +208,26 @@
                 </ui-popover>
               </div>
             </template>
+            <template #footer-content>
+              <v-remixicon
+                v-if="sharedWorkflows[workflow.id]"
+                v-tooltip="
+                  t('workflow.share.sharedAs', {
+                    name: sharedWorkflows[workflow.id]?.name.slice(0, 64),
+                  })
+                "
+                name="riShareLine"
+                size="20"
+                class="ml-2"
+              />
+              <v-remixicon
+                v-if="hostWorkflows[workflow.id]"
+                v-tooltip="t('workflow.host.title')"
+                name="riBaseStationLine"
+                size="20"
+                class="ml-2"
+              />
+            </template>
           </shared-card>
         </div>
       </ui-tab-panel>
@@ -218,14 +269,23 @@
 import { computed, shallowReactive, watch } from 'vue';
 import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import browser from 'webextension-polyfill';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
 import { sendMessage } from '@/utils/message';
+import { fetchApi } from '@/utils/api';
 import { exportWorkflow, importWorkflow } from '@/utils/workflow-data';
+import {
+  registerWorkflowTrigger,
+  cleanWorkflowTriggers,
+} from '@/utils/workflow-trigger';
+import { findTriggerBlock, isWhitespace } from '@/utils/helper';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import Workflow from '@/models/workflow';
 
 const { t } = useI18n();
+const toast = useToast();
 const store = useStore();
 const dialog = useDialog();
 
@@ -236,12 +296,14 @@ const menu = [
   { id: 'rename', name: t('common.rename'), icon: 'riPencilLine' },
   { id: 'delete', name: t('common.delete'), icon: 'riDeleteBin7Line' },
 ];
+const workflowHostMenu = [
+  { id: 'delete', name: t('common.delete'), icon: 'riDeleteBin7Line' },
+];
 
 const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
 const state = shallowReactive({
   query: '',
   activeTab: 'local',
-  loadingShared: false,
   sortBy: savedSorts.sortBy || 'createdAt',
   sortOrder: savedSorts.sortOrder || 'desc',
   highlightBrowse: !localStorage.getItem('first-time-browse'),
@@ -252,6 +314,9 @@ const workflowModal = shallowReactive({
   description: '',
 });
 
+const hostWorkflows = computed(() => store.state.hostWorkflows || {});
+const workflowHosts = computed(() => Object.values(store.state.workflowHosts));
+const sharedWorkflows = computed(() => store.state.sharedWorkflows || {});
 const workflows = computed(() =>
   Workflow.query()
     .where(({ name }) =>
@@ -260,6 +325,114 @@ const workflows = computed(() =>
     .orderBy(state.sortBy, state.sortOrder)
     .get()
 );
+
+async function deleteWorkflowHost(workflow) {
+  dialog.confirm({
+    title: t('workflow.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name: workflow.name }),
+    onConfirm: async () => {
+      try {
+        store.commit('deleteStateNested', `workflowHosts.${workflow.hostId}`);
+
+        await browser.storage.local.set({
+          workflowHosts: store.state.sharedWorkflows,
+        });
+        await cleanWorkflowTriggers(workflow.hostId);
+      } catch (error) {
+        console.error(error);
+      }
+    },
+  });
+}
+function addHostWorkflow() {
+  dialog.prompt({
+    async: true,
+    inputType: 'url',
+    okText: t('common.add'),
+    title: t('workflow.host.add'),
+    label: t('workflow.host.id'),
+    placeholder: 'abcd123',
+    onConfirm: async (value) => {
+      try {
+        if (isWhitespace(value)) return false;
+
+        let length = 0;
+        let isItsOwn = false;
+        let isHostExist = false;
+        const hostId = value.replace(/\s/g, '');
+
+        workflowHosts.value.forEach((host) => {
+          if (hostId === host.hostId) isHostExist = true;
+
+          length += 1;
+        });
+
+        if (!store.state.user && length >= 3) {
+          toast.error(r('message.rateExceeded'));
+          return false;
+        }
+
+        Object.values(store.state.hostWorkflows).forEach((host) => {
+          if (hostId === host.hostId) isItsOwn = true;
+        });
+
+        if (isHostExist || isItsOwn) {
+          toast.error(t('workflow.host.messages.hostExist'));
+          return false;
+        }
+
+        const response = await fetchApi('/host', {
+          method: 'POST',
+          body: JSON.stringify({ length, hostId }),
+        });
+        const result = await response.json();
+
+        if (response.status !== 200) {
+          const error = new Error(response.statusText);
+          error.data = result.data;
+
+          throw error;
+        }
+
+        if (result === null) {
+          toast.error(t('workflow.host.messages.notFound', { id: hostId }));
+          return false;
+        }
+
+        result.hostId = hostId;
+        result.createdAt = Date.now();
+
+        store.commit('updateStateNested', {
+          value: result,
+          path: `workflowHosts.${hostId}`,
+        });
+
+        const triggerBlock = findTriggerBlock(result.drawflow);
+        await registerWorkflowTrigger(hostId, triggerBlock);
+
+        result.drawflow = JSON.stringify(result.drawflow);
+
+        let { workflowHosts: storageHosts } = await browser.storage.local.get(
+          'workflowHosts'
+        );
+        (storageHosts = storageHosts || {})[hostId] = result;
+
+        await browser.storage.local.set({ workflowHosts: storageHosts });
+
+        return true;
+      } catch (error) {
+        console.error(error);
+
+        toast.error(
+          error.data?.show ? error.message : t('message.somethingWrong')
+        );
+
+        return false;
+      }
+    },
+  });
+}
 function browseWorkflow() {
   state.highlightBrowse = false;
   localStorage.setItem('first-time-browse', false);

+ 271 - 0
src/newtab/pages/workflows/Host.vue

@@ -0,0 +1,271 @@
+<template>
+  <div v-if="workflow" class="h-screen relative">
+    <div class="absolute top-0 left-0 w-full flex items-center p-4 z-10">
+      <ui-card
+        padding="px-2"
+        class="flex items-center overflow-hidden"
+        style="min-width: 150px; height: 48px"
+      >
+        <span class="inline-block">
+          <ui-img
+            v-if="workflow.icon.startsWith('http')"
+            :src="workflow.icon"
+            class="w-8 h-8"
+          />
+          <v-remixicon v-else :name="workflow.icon" size="26" />
+        </span>
+        <div class="ml-2 max-w-sm">
+          <p
+            :class="{ 'text-lg': !workflow.description }"
+            class="font-semibold leading-tight text-overflow"
+          >
+            {{ workflow.name }}
+          </p>
+          <p
+            :class="{ 'text-sm': workflow.description }"
+            class="text-gray-600 leading-tight dark:text-gray-200 text-overflow"
+          >
+            {{ workflow.description }}
+          </p>
+        </div>
+      </ui-card>
+      <ui-tabs
+        v-model="state.activeTab"
+        class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800 ml-4"
+        style="height: 48px"
+      >
+        <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
+        <ui-tab value="logs">{{ t('common.log', 2) }}</ui-tab>
+        <ui-tab value="running" class="flex items-center">
+          {{ t('common.running') }}
+          <span
+            v-if="workflowState.length > 0"
+            class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
+            style="min-width: 25px"
+          >
+            {{ workflowState.length }}
+          </span>
+        </ui-tab>
+      </ui-tabs>
+      <div class="flex-grow"></div>
+      <ui-card padding="p-1">
+        <button
+          v-tooltip.group="state.triggerText"
+          class="p-2 hoverable rounded-lg"
+        >
+          <v-remixicon name="riFlashlightLine" />
+        </button>
+        <button
+          v-tooltip.group="
+            `${t('common.execute')} (${
+              shortcut['editor:execute-workflow'].readable
+            })`
+          "
+          class="p-2 hoverable rounded-lg"
+          @click="executeWorkflow"
+        >
+          <v-remixicon name="riPlayLine" />
+        </button>
+      </ui-card>
+      <ui-card padding="p-1 ml-4 flex items-center">
+        <button
+          v-tooltip.group="t('common.delete')"
+          class="p-2 hoverable rounded-lg mr-2"
+          @click="deleteWorkflowHost"
+        >
+          <v-remixicon name="riDeleteBin7Line" />
+        </button>
+        <ui-button
+          v-tooltip.group="t('workflow.host.sync.description')"
+          :loading="state.loadingSync"
+          variant="accent"
+          @click="syncWorkflow"
+        >
+          {{ t('workflow.host.sync.title') }}
+        </ui-button>
+      </ui-card>
+    </div>
+    <ui-tab-panels
+      v-model="state.activeTab"
+      :class="{ 'container pb-4 pt-24': state.activeTab !== 'editor' }"
+      class="h-full"
+    >
+      <ui-tab-panel class="h-full" value="editor">
+        <workflow-builder
+          v-if="workflow?.drawflow"
+          :key="state.editorKey"
+          :version="false"
+          :is-shared="true"
+          :data="workflow.drawflow"
+          class="h-full w-full"
+        />
+      </ui-tab-panel>
+      <ui-tab-panel value="logs">
+        <shared-logs-table :logs="logs" class="w-full">
+          <template #item-append="{ log: itemLog }">
+            <td class="text-right">
+              <v-remixicon
+                name="riDeleteBin7Line"
+                class="inline-block text-red-500 cursor-pointer dark:text-red-400"
+                @click="deleteLog(itemLog.id)"
+              />
+            </td>
+          </template>
+        </shared-logs-table>
+      </ui-tab-panel>
+      <ui-tab-panel value="running">
+        <div class="grid grid-cols-3 gap-4">
+          <shared-workflow-state
+            v-for="item in workflowState"
+            :key="item.id"
+            :data="item"
+          />
+        </div>
+      </ui-tab-panel>
+    </ui-tab-panels>
+  </div>
+</template>
+<script setup>
+import { computed, reactive, onMounted, watch } from 'vue';
+import { useStore } from 'vuex';
+import { useI18n } from 'vue-i18n';
+import { useRoute, useRouter } from 'vue-router';
+import { useDialog } from '@/composable/dialog';
+import { useShortcut } from '@/composable/shortcut';
+import { useGroupTooltip } from '@/composable/groupTooltip';
+import { parseJSON, findTriggerBlock } from '@/utils/helper';
+import { sendMessage } from '@/utils/message';
+import Log from '@/models/log';
+import getTriggerText from '@/utils/trigger-text';
+import WorkflowBuilder from '@/components/newtab/workflow/WorkflowBuilder.vue';
+import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
+import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
+
+useGroupTooltip();
+
+const { t } = useI18n();
+const store = useStore();
+const route = useRoute();
+const router = useRouter();
+const dialog = useDialog();
+/* eslint-disable-next-line */
+const shortcut = useShortcut('editor:execute-workflow', executeWorkflow);
+
+const workflowId = route.params.id;
+
+const state = reactive({
+  editorKey: 0,
+  loadingSync: false,
+  activeTab: 'editor',
+  trigger: 'Trigger: Manually',
+});
+
+const workflow = computed(() => store.state.workflowHosts[workflowId]);
+const workflowState = computed(() =>
+  store.getters.getWorkflowState(workflowId)
+);
+const logs = computed(() =>
+  Log.query()
+    .where(
+      (item) =>
+        item.workflowId === workflowId &&
+        (!item.isInCollection || !item.isChildLog || !item.parentLog)
+    )
+    .orderBy('startedAt', 'desc')
+    .get()
+);
+
+function syncWorkflow() {
+  state.loadingSync = true;
+  const hostId = {
+    hostId: workflow.value.hostId,
+    updatedAt: workflow.value.updatedAt,
+  };
+
+  store
+    .dispatch('fetchWorkflowHosts', [hostId])
+    .then(() => {
+      if (!workflow.value) {
+        router.replace('/workflows');
+      }
+      state.loadingSync = false;
+    })
+    .catch((error) => {
+      console.error(error);
+      state.loadingSync = false;
+    });
+}
+async function deleteWorkflowHost() {
+  dialog.confirm({
+    title: t('workflow.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name: workflow.value.name }),
+    onConfirm: async () => {
+      try {
+        store.commit('deleteStateNested', `workflowHosts.${workflowId}`);
+
+        await browser.storage.local.set({
+          workflowHosts: store.state.sharedWorkflows,
+        });
+        await cleanWorkflowTriggers(workflowId);
+
+        router.replace('/workflows');
+      } catch (error) {
+        console.error(error);
+      }
+    },
+  });
+}
+function executeWorkflow() {
+  const payload = {
+    ...workflow.value,
+    id: workflowId,
+  };
+
+  sendMessage('workflow:execute', payload, 'background');
+}
+function deleteLog(logId) {
+  Log.delete(logId).then(() => {
+    store.dispatch('saveToStorage', 'logs');
+  });
+}
+async function retrieveTriggerText() {
+  const flow = parseJSON(workflow.value.drawflow, null);
+  const triggerBlock = findTriggerBlock(flow);
+
+  if (!triggerBlock) return;
+
+  state.triggerText = await getTriggerText(
+    triggerBlock.data,
+    t,
+    workflowId,
+    true
+  );
+}
+
+watch(
+  () => workflow.value.drawflow,
+  () => {
+    state.editorKey += 1;
+    retrieveTriggerText();
+  }
+);
+
+onMounted(() => {
+  if (!workflow.value) {
+    router.push('/workflows');
+    return;
+  }
+
+  retrieveTriggerText();
+});
+</script>
+<style>
+.parent-drawflow.is-shared .drawflow-node * {
+  pointer-events: none;
+}
+.parent-drawflow.is-shared .drawflow-node .move-to-group,
+.parent-drawflow.is-shared .drawflow-node .menu {
+  display: none;
+}
+</style>

+ 129 - 49
src/newtab/pages/workflows/[id].vue

@@ -101,6 +101,7 @@
         <workflow-actions
           v-else
           :data="workflowData"
+          :host="hostWorkflow"
           :workflow="workflow"
           :is-data-changed="state.isDataChanged"
           @save="saveWorkflow"
@@ -108,6 +109,7 @@
           @rename="renameWorkflow"
           @update="updateWorkflow"
           @delete="deleteWorkflow"
+          @host="setAsHostWorkflow"
           @execute="executeWorkflow"
           @export="workflowExporter"
           @protect="toggleProtection"
@@ -117,7 +119,6 @@
       <keep-alive>
         <workflow-builder
           v-if="activeTab === 'editor' && state.drawflow !== null"
-          v-slot="{ editor: currEditor }"
           class="h-full w-full"
           :is-shared="workflowData.active === 'shared'"
           :data="state.drawflow"
@@ -127,53 +128,24 @@
           @load="editor = $event"
           @deleteBlock="deleteBlock"
         >
-          <div
-            class="flex items-end absolute w-full p-4 left-0 bottom-0 justify-between"
+          <ui-tabs
+            v-if="
+              workflowData.hasLocal &&
+              workflowData.hasShared &&
+              !state.isDataChanged
+            "
+            v-model="workflowData.active"
+            class="z-10 text-sm"
+            color="bg-white dark:bg-gray-800"
+            type="fill"
           >
-            <div>
-              <button
-                v-tooltip.group="t('workflow.editor.resetZoom')"
-                class="p-2 rounded-lg bg-white dark:bg-gray-800 mr-2"
-                @click="currEditor.zoom_reset()"
-              >
-                <v-remixicon name="riFullscreenLine" />
-              </button>
-              <div class="rounded-lg bg-white dark:bg-gray-800 inline-block">
-                <button
-                  v-tooltip.group="t('workflow.editor.zoomOut')"
-                  class="p-2 rounded-lg relative z-10"
-                  @click="currEditor.zoom_out()"
-                >
-                  <v-remixicon name="riSubtractLine" />
-                </button>
-                <hr class="h-6 border-r inline-block" />
-                <button
-                  v-tooltip.group="t('workflow.editor.zoomIn')"
-                  class="p-2 rounded-lg"
-                  @click="currEditor.zoom_in()"
-                >
-                  <v-remixicon name="riAddLine" />
-                </button>
-              </div>
-            </div>
-            <ui-tabs
-              v-if="
-                workflowData.hasLocal &&
-                workflowData.hasShared &&
-                !state.isDataChanged
-              "
-              v-model="workflowData.active"
-              class="bg-white dark:bg-gray-800 text-sm"
-              type="fill"
-            >
-              <ui-tab value="local">
-                {{ t('workflow.type.local') }}
-              </ui-tab>
-              <ui-tab value="shared">
-                {{ t('workflow.type.shared') }}
-              </ui-tab>
-            </ui-tabs>
-          </div>
+            <ui-tab value="local">
+              {{ t('workflow.type.local') }}
+            </ui-tab>
+            <ui-tab value="shared">
+              {{ t('workflow.type.shared') }}
+            </ui-tab>
+          </ui-tabs>
         </workflow-builder>
         <div v-else class="container pb-4 mt-24 px-4">
           <template v-if="activeTab === 'logs'">
@@ -281,7 +253,7 @@ import emitter from '@/lib/mitt';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
 import { sendMessage } from '@/utils/message';
-import { exportWorkflow } from '@/utils/workflow-data';
+import { exportWorkflow, convertWorkflow } from '@/utils/workflow-data';
 import { tasks } from '@/utils/shared';
 import { fetchApi } from '@/utils/api';
 import { debounce, isObject, objectHasKey, parseJSON } from '@/utils/helper';
@@ -323,10 +295,12 @@ const state = reactive({
   isDataChanged: false,
 });
 const workflowData = reactive({
+  isHost: false,
   hasLocal: true,
   hasShared: false,
   isChanged: false,
   isUpdating: false,
+  loadingHost: false,
   isUnpublishing: false,
   changingKeys: new Set(),
   active: route.query.shared ? 'shared' : 'local',
@@ -390,6 +364,9 @@ const workflowModals = {
   },
 };
 
+let hostWorkflowPayload = {};
+
+const hostWorkflow = computed(() => store.state.hostWorkflows[workflowId]);
 const sharedWorkflow = computed(() => store.state.sharedWorkflows[workflowId]);
 const localWorkflow = computed(() => Workflow.find(workflowId));
 const workflow = computed(() =>
@@ -432,6 +409,29 @@ const updateBlockData = debounce((data) => {
     );
 }, 250);
 
+async function updateHostedWorkflow() {
+  if (!workflowData.isHost || Object.keys(hostWorkflowPayload).length === 0)
+    return;
+
+  try {
+    if (hostWorkflowPayload.drawflow) {
+      hostWorkflowPayload.drawflow = parseJSON(
+        hostWorkflowPayload.drawflow,
+        null
+      );
+    }
+
+    await fetchApi(`/me/workflows/host?id=${hostWorkflow.value.hostId}`, {
+      method: 'PUT',
+      keepalive: true,
+      body: JSON.stringify({
+        workflow: hostWorkflowPayload,
+      }),
+    });
+  } catch (error) {
+    console.error(error);
+  }
+}
 function unpublishSharedWorkflow() {
   dialog.confirm({
     title: t('workflow.unpublish.title'),
@@ -564,6 +564,70 @@ function insertToLocal() {
     workflowData.hasLocal = true;
   });
 }
+async function setAsHostWorkflow(isHost) {
+  if (!store.state.user || isHost === 'auth') {
+    dialog.custom('auth', {
+      title: t('auth.title'),
+    });
+    return;
+  }
+
+  workflowData.loadingHost = true;
+
+  try {
+    let url = '/me/workflows/host';
+    let payload = {};
+
+    if (isHost) {
+      const workflowPaylod = convertWorkflow(workflow.value, ['id']);
+      workflowPaylod.drawflow = parseJSON(workflow.value.drawflow, null);
+      delete workflowPaylod.extVersion;
+
+      payload = {
+        method: 'POST',
+        body: JSON.stringify({
+          workflow: workflowPaylod,
+        }),
+      };
+    } else {
+      url += `?id=${hostWorkflow.value?.hostId}`;
+      payload.method = 'DELETE';
+    }
+
+    const response = await fetchApi(url, payload);
+    const result = await response.json();
+
+    if (response.status !== 200) {
+      const error = new Error(response.statusText);
+      error.data = result.data;
+
+      throw error;
+    }
+
+    if (isHost) {
+      store.commit('updateStateNested', {
+        path: `hostWorkflows.${workflowId}`,
+        value: result,
+      });
+    } else {
+      store.commit('deleteStateNested', `hostWorkflows.${workflowId}`);
+    }
+
+    sessionStorage.setItem(
+      'host-workflows',
+      JSON.stringify(store.state.hostWorkflows)
+    );
+
+    workflowData.isHost = isHost;
+    workflowData.loadingHost = false;
+  } catch (error) {
+    console.error(error);
+    workflowData.loadingHost = false;
+    toast.error(
+      error?.data?.show ? error.message : t('message.somethingWrong')
+    );
+  }
+}
 function shareWorkflow() {
   if (workflowData.hasShared) {
     workflowData.active = 'shared';
@@ -653,6 +717,18 @@ function updateWorkflow(data) {
   return Workflow.update({
     where: workflowId,
     data,
+  }).then((event) => {
+    delete data.id;
+    delete data.pass;
+    delete data.logs;
+    delete data.trigger;
+    delete data.createdAt;
+    delete data.isDisabled;
+    delete data.isProtected;
+
+    hostWorkflowPayload = { ...hostWorkflowPayload, ...data };
+
+    return event;
   });
 }
 function updateNameAndDesc() {
@@ -765,9 +841,10 @@ watch(
 );
 
 onBeforeRouteLeave(() => {
+  updateHostedWorkflow();
+
   if (!state.isDataChanged) return;
 
-  /* eslint-disable-next-line */
   const answer = window.confirm(t('message.notSaved'));
 
   if (!answer) return false;
@@ -780,6 +857,7 @@ onMounted(() => {
     store.state.sharedWorkflows,
     workflowId
   );
+  workflowData.isHost = objectHasKey(store.state.hostWorkflows, workflowId);
 
   const dontHaveLocal = !isWorkflowExists && workflowData.active === 'local';
   const dontHaveShared =
@@ -800,6 +878,8 @@ onMounted(() => {
     JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
 
   window.onbeforeunload = () => {
+    updateHostedWorkflow();
+
     if (state.isDataChanged) {
       return t('message.notSaved');
     }

+ 6 - 0
src/newtab/router.js

@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
 import Home from './pages/Home.vue';
 import Welcome from './pages/Welcome.vue';
 import Workflows from './pages/Workflows.vue';
+import WorkflowHost from './pages/workflows/Host.vue';
 import WorkflowDetails from './pages/workflows/[id].vue';
 import Collections from './pages/Collections.vue';
 import CollectionsDetails from './pages/collections/[id].vue';
@@ -33,6 +34,11 @@ const routes = [
     path: '/workflows/:id',
     component: WorkflowDetails,
   },
+  {
+    name: 'workflow-host',
+    path: '/workflows/:id/host',
+    component: WorkflowHost,
+  },
   {
     name: 'collections',
     path: '/collections',

+ 55 - 16
src/store/index.js

@@ -4,6 +4,9 @@ import browser from 'webextension-polyfill';
 import vuexORM from '@/lib/vuex-orm';
 import * as models from '@/models';
 import { firstWorkflows } from '@/utils/shared';
+import { fetchApi } from '@/utils/api';
+import { findTriggerBlock } from '@/utils/helper';
+import { registerWorkflowTrigger } from '@/utils/workflow-trigger';
 
 const store = createStore({
   plugins: [vuexORM(models)],
@@ -11,18 +14,9 @@ const store = createStore({
     user: null,
     workflowState: [],
     contributors: null,
-    sharedWorkflows: {
-      'Tely-7beu0zHiJrBhzrC4': {
-        id: 'Tely-7beu0zHiJrBhzrC4',
-        name: 'data columns',
-        description: 'Halo perkenalkan nama saya adalah anu 123',
-        icon: 'riGlobalLine',
-        createdAt: '2021-12-20T01:30:08.508289+00:00',
-      },
-    },
-    retrievedData: {
-      sharedWorkflows: false,
-    },
+    hostWorkflows: {},
+    sharedWorkflows: {},
+    workflowHosts: {},
     settings: {
       locale: 'en',
     },
@@ -70,15 +64,21 @@ const store = createStore({
           });
         });
 
-        const { isFirstTime, settings } = await browser.storage.local.get([
-          'isFirstTime',
-          'settings',
-        ]);
+        const { isFirstTime, settings, workflowHosts } =
+          await browser.storage.local.get([
+            'isFirstTime',
+            'settings',
+            'workflowHosts',
+          ]);
 
         commit('updateState', {
           key: 'settings',
           value: { ...state.settings, ...(settings || {}) },
         });
+        commit('updateState', {
+          key: 'workflowHosts',
+          value: workflowHosts || {},
+        });
 
         if (isFirstTime) {
           await dispatch('entities/insert', {
@@ -129,6 +129,45 @@ const store = createStore({
           });
       });
     },
+    async fetchWorkflowHosts({ commit, state }, hosts) {
+      if (!hosts || hosts.length === 0) return null;
+
+      const response = await fetchApi('/host', {
+        method: 'POST',
+        body: JSON.stringify({ hosts }),
+      });
+
+      if (response.status !== 200) throw new Error(response.statusText);
+
+      const result = await response.json();
+      const newValue = JSON.parse(JSON.stringify(state.workflowHosts));
+
+      result.forEach(({ hostId, status, data }) => {
+        if (status === 'deleted') {
+          delete newValue[hostId];
+          return;
+        }
+        if (status === 'updated') {
+          const triggerBlock = findTriggerBlock(data.drawflow);
+          registerWorkflowTrigger(hostId, triggerBlock);
+
+          data.drawflow = JSON.stringify(data.drawflow);
+        }
+
+        data.hostId = hostId;
+        newValue[hostId] = data;
+      });
+
+      commit('updateState', {
+        key: 'workflowHosts',
+        value: newValue,
+      });
+      await browser.storage.local.set({
+        workflowHosts: newValue,
+      });
+
+      return newValue;
+    },
   },
 });
 

+ 53 - 26
src/utils/api.js

@@ -28,38 +28,65 @@ export const googleSheets = {
   },
 };
 
-export async function getSharedWorkflows(useCache = true) {
-  try {
-    const sharedWorkflowsStorage = parseJSON(
-      sessionStorage.getItem('shared-workflows'),
-      null
-    );
-
-    if (sharedWorkflowsStorage && useCache) {
-      return sharedWorkflowsStorage;
-    }
+async function cacheApi(key, callback) {
+  const cacheResult = parseJSON(sessionStorage.getItem(key), null);
 
-    const response = await fetchApi('/me/workflows/shared?data=all');
+  if (cacheResult) {
+    return cacheResult;
+  }
 
-    if (response.status !== 200) throw new Error(response.statusText);
+  const result = await callback();
+  sessionStorage.setItem(key, JSON.stringify(result));
 
-    const result = await response.json();
-    const sharedWorkflows = result.reduce((acc, item) => {
-      item.drawflow = JSON.stringify(item.drawflow);
-      item.table = item.table || item.dataColumns || [];
-      item.createdAt = new Date(item.createdAt || Date.now()).getTime();
+  return result;
+}
 
-      acc[item.id] = item;
+export async function getSharedWorkflows() {
+  return cacheApi('shared-workflows', async () => {
+    try {
+      const response = await fetchApi('/me/workflows/shared?data=all');
 
-      return acc;
-    }, {});
+      if (response.status !== 200) throw new Error(response.statusText);
 
-    sessionStorage.setItem('shared-workflows', JSON.stringify(sharedWorkflows));
+      const result = await response.json();
+      const sharedWorkflows = result.reduce((acc, item) => {
+        item.drawflow = JSON.stringify(item.drawflow);
+        item.table = item.table || item.dataColumns || [];
+        item.createdAt = new Date(item.createdAt || Date.now()).getTime();
 
-    return sharedWorkflows;
-  } catch (error) {
-    console.error(error);
+        acc[item.id] = item;
 
-    return {};
-  }
+        return acc;
+      }, {});
+
+      return sharedWorkflows;
+    } catch (error) {
+      console.error(error);
+
+      return {};
+    }
+  });
+}
+
+export async function getHostWorkflows() {
+  return cacheApi('host-workflows', async () => {
+    try {
+      const response = await fetchApi('/me/workflows/host');
+
+      if (response.status !== 200) throw new Error(response.statusText);
+
+      const result = await response.json();
+      const hostWorkflows = result.reduce((acc, item) => {
+        acc[item.id] = item;
+
+        return acc;
+      }, {});
+
+      return hostWorkflows || {};
+    } catch (error) {
+      console.error(error);
+
+      return {};
+    }
+  });
 }

+ 24 - 0
src/utils/helper.js

@@ -1,3 +1,27 @@
+export function findTriggerBlock(drawflow = {}) {
+  if (!drawflow) return null;
+
+  const blocks = Object.values(drawflow.drawflow?.Home?.data);
+
+  if (!blocks) return null;
+
+  return blocks.find(({ name }) => name === 'trigger');
+}
+
+export function throttle(callback, limit) {
+  let waiting = false;
+
+  return (...args) => {
+    if (!waiting) {
+      callback.apply(this, args);
+      waiting = true;
+      setTimeout(() => {
+        waiting = false;
+      }, limit);
+    }
+  };
+}
+
 export function convertArrObjTo2DArr(arr) {
   const keyIndex = new Map();
   const values = [[]];

+ 0 - 5
src/utils/shared.js

@@ -10,7 +10,6 @@ export const tasks = {
     editComponent: 'EditTrigger',
     category: 'general',
     inputs: 0,
-    docs: true,
     outputs: 1,
     allowedInputs: true,
     maxConnection: 1,
@@ -456,7 +455,6 @@ export const tasks = {
     category: 'interaction',
     inputs: 1,
     outputs: 1,
-    docs: true,
     allowedInputs: true,
     maxConnection: 1,
     data: {
@@ -498,7 +496,6 @@ export const tasks = {
     category: 'onlineServices',
     inputs: 1,
     outputs: 1,
-    docs: true,
     allowedInputs: true,
     maxConnection: 1,
     refDataKeys: ['customData', 'range', 'spreadsheetId'],
@@ -578,7 +575,6 @@ export const tasks = {
     component: 'BlockBasic',
     editComponent: 'EditLoopData',
     category: 'general',
-    docs: true,
     inputs: 1,
     outputs: 1,
     allowedInputs: true,
@@ -604,7 +600,6 @@ export const tasks = {
     component: 'BlockLoopBreakpoint',
     category: 'general',
     disableEdit: true,
-    docs: true,
     inputs: 1,
     outputs: 1,
     allowedInputs: true,

+ 30 - 0
src/utils/trigger-text.js

@@ -0,0 +1,30 @@
+import browser from 'webextension-polyfill';
+import dayjs from '@/lib/dayjs';
+import { getReadableShortcut } from '@/composable/shortcut';
+
+export default async function (trigger, t, workflowId, includeManual = false) {
+  if (!trigger || (trigger.type === 'manual' && !includeManual)) return null;
+
+  const triggerLocale = t('workflow.blocks.trigger.name');
+
+  if (trigger.type === 'manual') {
+    return `${triggerLocale}: ${t('workflow.blocks.trigger.items.manual')}`;
+  }
+
+  const triggerName = t(`workflow.blocks.trigger.items.${trigger.type}`);
+  let text = '';
+
+  if (trigger.type === 'keyboard-shortcut') {
+    text = getReadableShortcut(trigger.shortcut);
+  } else if (trigger.type === 'visit-web') {
+    text = trigger.url;
+  } else if (['specific-day', 'date'].includes(trigger.type)) {
+    const triggerTime = (await browser.alarms.get(workflowId))?.scheduledTime;
+
+    text = dayjs(triggerTime || Date.now()).format('DD-MMM-YYYY, hh:mm A');
+  }
+
+  text = text && `: \n ${text}`;
+
+  return `${triggerLocale} (${triggerName})${text}`;
+}