Browse Source

feat: add shared workflow page

Ahmad Kholid 3 years ago
parent
commit
19aec366db

+ 5 - 5
src/components/newtab/workflow/WorkflowEditor.vue

@@ -193,12 +193,12 @@ function applyFlowData() {
     editor.snapGrid.value = Object.values(settings.snapGrid);
   }
 
-  editor.setNodes(props.data.nodes || []);
-  editor.setEdges(props.data.edges || []);
+  editor.setNodes(props.data?.nodes || []);
+  editor.setEdges(props.data?.edges || []);
   editor.setTransform({
-    x: props.data.x || 0,
-    y: props.data.y || 0,
-    zoom: props.data.zoom || 1,
+    x: props.data?.x || 0,
+    y: props.data?.y || 0,
+    zoom: props.data?.zoom || 1,
   });
 }
 

+ 1 - 1
src/components/newtab/workflow/editor/EditorLocalActions.vue

@@ -320,7 +320,7 @@ async function setAsHostWorkflow(isHost) {
 }
 function shareWorkflow() {
   if (shared.value) {
-    router.push(`/${props.workflow.id}/shared`);
+    router.push(`/workflows/${props.workflow.id}/shared`);
     return;
   }
 

+ 2 - 0
src/lib/vRemixicon.js

@@ -72,6 +72,7 @@ import {
   riKeyboardLine,
   riFileEditLine,
   riCompass3Line,
+  riComputerLine,
   riFileCopyLine,
   riCalendarLine,
   riFileTextLine,
@@ -191,6 +192,7 @@ export const icons = {
   riKeyboardLine,
   riFileEditLine,
   riCompass3Line,
+  riComputerLine,
   riFileCopyLine,
   riCalendarLine,
   riFileTextLine,

+ 375 - 0
src/newtab/pages/workflows/Shared.vue

@@ -0,0 +1,375 @@
+<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-card padding="p-1 ml-4">
+        <ui-input
+          v-tooltip="t('workflow.share.url')"
+          prepend-icon="riLinkM"
+          :model-value="`https://automa.site/workflow/${workflow.id}`"
+          readonly
+          @click="$event.target.select()"
+        />
+      </ui-card>
+      <div class="flex-grow pointer-events-none" />
+      <ui-card padding="p-1 ml-4">
+        <router-link
+          v-if="state.hasLocalCopy"
+          v-tooltip.group="'Go to local version'"
+          :to="`/workflows/${workflowId}`"
+          class="hoverable p-2 rounded-lg block"
+        >
+          <v-remixicon name="riComputerLine" />
+        </router-link>
+      </ui-card>
+      <ui-card padding="p-1 ml-4">
+        <button
+          v-if="state.hasLocalCopy"
+          v-tooltip.group="t('workflow.share.fetchLocal')"
+          class="hoverable p-2 rounded-lg"
+          @click="fetchLocalWorkflow"
+        >
+          <v-remixicon name="riRefreshLine" />
+        </button>
+        <button
+          v-else
+          v-tooltip.group="t('workflow.share.download')"
+          class="hoverable p-2 rounded-lg"
+          @click="insertToLocal"
+        >
+          <v-remixicon name="riDownloadLine" />
+        </button>
+        <button
+          v-tooltip.group="t('workflow.share.edit')"
+          class="hoverable p-2 rounded-lg"
+          @click="initEditWorkflow"
+        >
+          <v-remixicon name="riFileEditLine" />
+        </button>
+      </ui-card>
+      <ui-card padding="p-1 flex ml-4">
+        <button
+          v-tooltip.group="t('workflow.share.unpublish')"
+          class="hoverable p-2 mr-2 rounded-lg relative"
+          @click="unpublishSharedWorkflow"
+        >
+          <ui-spinner
+            v-if="state.isUnpublishing"
+            color="text-accent"
+            class="absolute top-2 left-2"
+          />
+          <v-remixicon
+            name="riLock2Line"
+            :class="{ 'opacity-75': state.isUnpublishing }"
+          />
+        </button>
+        <ui-button
+          :loading="state.isUpdating"
+          :disabled="state.isUnpublishing"
+          variant="accent"
+          @click="saveUpdatedSharedWorkflow"
+        >
+          <span
+            v-if="state.isChanged"
+            class="flex h-3 w-3 absolute top-0 left-0 -ml-1 -mt-1"
+          >
+            <span
+              class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
+            ></span>
+            <span
+              class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
+            ></span>
+          </span>
+          {{ t('workflow.share.update') }}
+        </ui-button>
+      </ui-card>
+    </div>
+    <workflow-editor
+      v-if="state.retrieved"
+      :id="route.params.id"
+      :key="state.editorKey"
+      :data="workflow.drawflow"
+      :options="editorOptions"
+      :disabled="true"
+      class="h-full w-full"
+      @init="onEditorInit"
+    />
+  </div>
+  <ui-modal
+    v-model="editState.showModal"
+    custom-content
+    @close="updateSharedWorkflow(editState.data)"
+  >
+    <workflow-share
+      :workflow="workflow"
+      is-update
+      @change="onEditWorkflowChange"
+    >
+      <template #prepend>
+        <div class="flex justify-between mb-6">
+          <p>{{ t('workflow.share.edit') }}</p>
+          <v-remixicon
+            name="riCloseLine"
+            class="cursor-pointer"
+            @click="
+              editState.showModal = false;
+              updateSharedWorkflow(editState.data);
+            "
+          />
+        </div>
+      </template>
+    </workflow-share>
+  </ui-modal>
+</template>
+<script setup>
+import { reactive, onMounted, watch, shallowRef } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRoute, useRouter } from 'vue-router';
+import { useToast } from 'vue-toastification';
+import browser from 'webextension-polyfill';
+import { fetchApi } from '@/utils/api';
+import { useDialog } from '@/composable/dialog';
+import { useGroupTooltip } from '@/composable/groupTooltip';
+import { useWorkflowStore } from '@/stores/workflow';
+import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
+import convertWorkflowData from '@/utils/convertWorkflowData';
+import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
+import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
+
+useGroupTooltip();
+
+const { t } = useI18n();
+const toast = useToast();
+const route = useRoute();
+const router = useRouter();
+const dialog = useDialog();
+const workflowStore = useWorkflowStore();
+const sharedWorkflowStore = useSharedWorkflowStore();
+
+const workflowId = route.params.id;
+const editorOptions = {
+  disabled: true,
+  fitViewOnInit: true,
+  nodesDraggable: false,
+  edgesUpdateable: false,
+  nodesConnectable: false,
+  elementsSelectable: false,
+};
+
+const editState = reactive({
+  showModal: false,
+  data: {
+    name: '',
+    content: '',
+    category: '',
+    description: '',
+  },
+});
+const state = reactive({
+  editorKey: 0,
+  retrieved: false,
+  isChanged: false,
+  isUpdating: false,
+  isUnpublishing: false,
+  trigger: 'Trigger: Manually',
+});
+const editor = shallowRef(null);
+const workflow = shallowRef(null);
+
+const changingKeys = new Set();
+
+function updateSharedWorkflow(data = {}) {
+  Object.keys(data).forEach((key) => {
+    changingKeys.add(key);
+  });
+
+  Object.assign(workflow.value, data);
+
+  if (data.drawflow) {
+    editor.value.setNodes(data.drawflow.nodes);
+    editor.value.setEdges(data.drawflow.edges);
+    editor.value.fitView();
+  }
+
+  state.isChanged = true;
+}
+function initEditWorkflow() {
+  ['name', 'content', 'category', 'description'].forEach((key) => {
+    editState.data[key] = workflow.value[key];
+  });
+  editState.showModal = true;
+}
+function onEditWorkflowChange({ name, content, category, description }) {
+  editState.data.name = name;
+  editState.data.content = content;
+  editState.data.category = category;
+  editState.data.description = description;
+}
+function unpublishSharedWorkflow() {
+  dialog.confirm({
+    title: t('workflow.unpublish.title'),
+    body: t('workflow.unpublish.body', { name: workflow.value.name }),
+    okVariant: 'danger',
+    okText: t('workflow.unpublish.button'),
+    async onConfirm() {
+      try {
+        state.isUnpublishing = true;
+
+        const response = await fetchApi(`/me/workflows/shared/${workflowId}`, {
+          method: 'DELETE',
+        });
+
+        if (response.status !== 200) {
+          throw new Error(response.statusText);
+        }
+
+        sharedWorkflowStore.delete(workflowId);
+        sessionStorage.setItem(
+          'shared-workflows',
+          JSON.stringify(workflowStore.workflows)
+        );
+
+        router.push('/');
+
+        state.isUnpublishing = false;
+      } catch (error) {
+        console.error(error);
+        state.isUnpublishing = false;
+        toast.error(t('message.somethingWrong'));
+      }
+    },
+  });
+}
+async function saveUpdatedSharedWorkflow() {
+  try {
+    state.isUpdating = true;
+
+    const payload = {};
+    changingKeys.forEach((key) => {
+      if (key === 'drawflow') {
+        payload.drawflow = JSON.parse(workflow.value.drawflow);
+      } else {
+        payload[key] = workflow.value[key];
+      }
+    });
+
+    const url = `/me/workflows/shared/${workflowId}`;
+    const response = await fetchApi(url, {
+      method: 'PUT',
+      body: JSON.stringify(payload),
+    });
+
+    if (response.status !== 200) {
+      toast.error(t('message.somethingWrong'));
+      throw new Error(response.statusText);
+    }
+
+    state.isChanged = false;
+    changingKeys.clear();
+    sessionStorage.setItem(
+      'shared-workflows',
+      JSON.stringify(sharedWorkflowStore.workflows)
+    );
+
+    state.isUpdating = false;
+  } catch (error) {
+    console.error(error);
+    state.isUpdating = false;
+  }
+}
+function fetchLocalWorkflow() {
+  const workflowData = {};
+  const keys = [
+    'drawflow',
+    'name',
+    'description',
+    'icon',
+    'globalData',
+    'dataColumns',
+    'table',
+    'settings',
+  ];
+  const localWorkflow = workflowStore.getById(workflowId);
+
+  keys.forEach((key) => {
+    workflowData[key] = localWorkflow[key];
+  });
+
+  const convertedData = convertWorkflowData(workflowData);
+  convertedData.version = browser.runtime.getManifest().version;
+
+  updateSharedWorkflow(convertedData);
+}
+function insertToLocal() {
+  const copy = {
+    ...workflow.value,
+    createdAt: Date.now(),
+    version: browser.runtime.getManifest().version,
+  };
+
+  workflowStore.insert(copy).then(() => {
+    state.hasLocalCopy = true;
+  });
+}
+function onEditorInit(instance) {
+  instance.setInteractive(false);
+  editor.value = instance;
+}
+
+watch(workflow, () => {
+  state.editorKey += 1;
+});
+
+onMounted(() => {
+  const currentWorkflow = sharedWorkflowStore.getById(workflowId);
+  if (!currentWorkflow) {
+    router.push('/workflows');
+    return;
+  }
+
+  const convertedData = convertWorkflowData(currentWorkflow);
+  workflow.value = convertedData;
+
+  state.hasLocalCopy = workflowStore.getWorkflows.some(
+    ({ id }) => id === workflowId
+  );
+
+  state.retrieved = true;
+});
+</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>

+ 6 - 0
src/newtab/router.js

@@ -3,6 +3,7 @@ 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 WorkflowShared from './pages/workflows/Shared.vue';
 import ScheduledWorkflow from './pages/ScheduledWorkflow.vue';
 import Logs from './pages/Logs.vue';
 import LogsDetails from './pages/logs/[id].vue';
@@ -46,6 +47,11 @@ const routes = [
     path: '/workflows/:id/host',
     component: WorkflowHost,
   },
+  {
+    name: 'workflow-host',
+    path: '/workflows/:id/shared',
+    component: WorkflowShared,
+  },
   {
     name: 'logs',
     path: '/logs',

+ 1 - 0
src/stores/hostedWorkflow.js

@@ -111,6 +111,7 @@ export const useHostedWorkflowStore = defineStore('hosted-workflows', {
       result.forEach(({ hostId, status, data }) => {
         if (status === 'deleted') {
           delete this.workflows[hostId];
+          cleanWorkflowTriggers(hostId);
           return;
         }
         if (status === 'updated') {

+ 10 - 0
src/stores/sharedWorkflow.js

@@ -19,6 +19,16 @@ export const useSharedWorkflowStore = defineStore('shared-workflows', {
         this.workflows[data.id] = data;
       }
     },
+    update({ id, data }) {
+      if (!this.workflows[id]) return null;
+
+      Object.assign(this.workflows[id], data);
+
+      return this.workflows[id];
+    },
+    delete(id) {
+      delete this.workflows[id];
+    },
     async fetchWorkflows(useCache = true) {
       const workflows = await cacheApi(
         'shared-workflows',