浏览代码

feat: add workflows tabs

Ahmad Kholid 2 年之前
父节点
当前提交
ddd26530f1

+ 6 - 0
src/assets/css/tailwind.css

@@ -104,8 +104,14 @@ pre {
   &::-webkit-scrollbar-track {
     background: transparent;
   }
+
+  &.scroll-xs::-webkit-scrollbar {
+    width: 5px;
+    height: 5px;
+  }
 }
 
+
 .tippy-box[data-theme~='tooltip-theme'] {
   @apply px-2 py-1 bg-gray-900 dark:bg-gray-200 dark:text-black text-sm text-gray-200 rounded-md;
 }

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

@@ -280,7 +280,7 @@ function applyFlowData() {
   }
 
   editor.setNodes(
-    props.data?.nodes.map((node) => ({ ...node, events: {} })) || []
+    props.data?.nodes?.map((node) => ({ ...node, events: {} })) || []
   );
   editor.setEdges(props.data?.edges || []);
   editor.setTransform({

+ 138 - 558
src/newtab/pages/Workflows.vue

@@ -1,599 +1,179 @@
 <template>
-  <div class="container pt-8 pb-4">
-    <h1 class="text-2xl font-semibold capitalize">
-      {{ t('common.workflow', 2) }}
-    </h1>
-    <div class="flex items-start mt-8">
-      <div class="w-60 sticky top-8 hidden lg:block">
-        <div class="flex w-full">
-          <ui-button
-            :title="shortcut['action:new'].readable"
-            variant="accent"
-            class="border-r rounded-r-none flex-1 font-semibold"
-            @click="addWorkflowModal.show = true"
-          >
-            {{ 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 class="space-y-1">
-              <ui-list-item
-                v-close-popover
-                class="cursor-pointer"
-                @click="openImportDialog"
-              >
-                {{ t('workflow.import') }}
-              </ui-list-item>
-              <ui-list-item
-                v-close-popover
-                class="cursor-pointer"
-                @click="initRecordWorkflow"
-              >
-                {{ t('home.record.title') }}
-              </ui-list-item>
-              <ui-list-item
-                v-close-popover
-                class="cursor-pointer"
-                @click="addHostedWorkflow"
-              >
-                {{ t('workflow.host.add') }}
-              </ui-list-item>
-            </ui-list>
-          </ui-popover>
-        </div>
-        <ui-list class="mt-6 space-y-2">
-          <ui-list-item
-            tag="a"
-            href="https://www.automa.site/workflows"
-            target="_blank"
-          >
-            <v-remixicon name="riCompass3Line" />
-            <span class="ml-4 capitalize">
-              {{ t('workflow.browse') }}
-            </span>
-          </ui-list-item>
-          <ui-expand
-            v-if="state.teams.length > 0"
-            append-icon
-            header-class="px-4 py-2 rounded-lg mb-1 hoverable w-full flex items-center"
-          >
-            <template #header>
-              <v-remixicon name="riTeamLine" />
-              <span class="ml-4 capitalize flex-1 text-left">
-                Team Workflows
-              </span>
-            </template>
-            <ui-list class="space-y-1">
-              <ui-list-item
-                v-for="team in state.teams"
-                :key="team.id"
-                :active="state.teamId === team.id || +state.teamId === team.id"
-                :title="team.name"
-                color="bg-box-transparent font-semibold"
-                class="pl-14 cursor-pointer"
-                @click="updateActiveTab({ activeTab: 'team', teamId: team.id })"
-              >
-                <span class="text-overflow">
-                  {{ team.name }}
-                </span>
-              </ui-list-item>
-            </ui-list>
-          </ui-expand>
-          <ui-expand
-            :model-value="true"
-            append-icon
-            header-class="px-4 py-2 rounded-lg hoverable w-full flex items-center"
-          >
-            <template #header>
-              <v-remixicon name="riFlowChart" />
-              <span class="ml-4 capitalize flex-1 text-left">
-                {{ t('workflow.my') }}
-              </span>
-            </template>
-            <ui-list class="space-y-1 mt-1">
-              <ui-list-item
-                tag="button"
-                :active="state.activeTab === 'local'"
-                color="bg-box-transparent font-semibold"
-                class="pl-14"
-                @click="updateActiveTab({ activeTab: 'local' })"
-              >
-                <span class="capitalize">
-                  {{ t('workflow.type.local') }}
-                </span>
-              </ui-list-item>
-              <ui-list-item
-                v-if="userStore.user"
-                :active="state.activeTab === 'shared'"
-                tag="button"
-                color="bg-box-transparent font-semibold"
-                class="pl-14"
-                @click="updateActiveTab({ activeTab: 'shared' })"
-              >
-                <span class="capitalize">
-                  {{ t('workflow.type.shared') }}
-                </span>
-              </ui-list-item>
-              <ui-list-item
-                v-if="hostedWorkflows?.length > 0"
-                :active="state.activeTab === 'host'"
-                color="bg-box-transparent font-semibold"
-                tag="button"
-                class="pl-14"
-                @click="updateActiveTab({ activeTab: 'host' })"
-              >
-                <span class="capitalize">
-                  {{ t('workflow.type.host') }}
-                </span>
-              </ui-list-item>
-            </ui-list>
-          </ui-expand>
-        </ui-list>
-        <workflows-folder
-          v-if="state.activeTab === 'local'"
-          v-model="state.activeFolder"
-        />
-      </div>
-      <div
-        class="flex-1 workflows-list lg:ml-8"
-        style="min-height: calc(100vh - 8rem)"
-        @dblclick="clearSelectedWorkflows"
+  <div class="flex flex-col">
+    <div class="flex items-center border-b h-12">
+      <draggable
+        v-model="state.tabs"
+        item-key="id"
+        class="scroll overflow-auto text-gray-600 h-full dark:text-gray-300 scroll-xs flex items-center"
       >
-        <div class="flex items-center flex-wrap">
-          <div class="flex items-center w-full md:w-auto">
-            <ui-input
-              id="search-input"
-              v-model="state.query"
-              class="flex-1 md:w-auto"
-              :placeholder="`${t(`common.search`)}... (${
-                shortcut['action:search'].readable
-              })`"
-              prepend-icon="riSearch2Line"
-            />
-            <ui-popover>
-              <template #trigger>
-                <ui-button variant="accent" class="md:hidden ml-4">
-                  <v-remixicon name="riAddLine" class="mr-2 -ml-1" />
-                  <span>{{ t('common.workflow') }}</span>
-                </ui-button>
-              </template>
-              <ui-list class="space-y-1">
-                <ui-list-item
-                  v-close-popover
-                  class="cursor-pointer"
-                  @click="addWorkflowModal.show = true"
-                >
-                  {{ t('workflow.new') }}
-                </ui-list-item>
-                <ui-list-item
-                  v-close-popover
-                  class="cursor-pointer"
-                  @click="openImportDialog"
-                >
-                  {{ t('workflow.import') }}
-                </ui-list-item>
-                <ui-list-item
-                  v-close-popover
-                  class="cursor-pointer"
-                  @click="initRecordWorkflow"
-                >
-                  {{ t('home.record.title') }}
-                </ui-list-item>
-                <ui-list-item
-                  v-close-popover
-                  class="cursor-pointer"
-                  @click="addHostedWorkflow"
-                >
-                  {{ t('workflow.host.add') }}
-                </ui-list-item>
-              </ui-list>
-            </ui-popover>
-          </div>
-          <div class="flex-grow"></div>
-          <div class="w-full md:w-auto flex items-center mt-4 md:mt-0">
+        <template #item="{ element: tab, index }">
+          <button
+            :value="tab.id"
+            :class="[
+              state.activeTab === tab.id
+                ? 'border-accent'
+                : 'border-transparent',
+              {
+                'bg-box-transparent text-black dark:text-gray-100':
+                  state.activeTab === tab.id,
+              },
+            ]"
+            class="flex items-center h-full px-4 cursor-default focus:ring-0 hoverable border-b-2"
+            @click="state.activeTab = tab.id"
+          >
+            <p
+              :title="tab.name"
+              class="flex-1 mr-2 text-overflow max-w-[170px]"
+            >
+              {{ tab.name }}
+            </p>
             <span
-              v-tooltip:bottom.group="t('workflow.backupCloud')"
-              class="mr-4"
+              class="p-0.5 rounded-full hoverable text-gray-600 dark:text-gray-300"
+              title="Close tab"
+              @click.stop="closeTab(index, tab)"
             >
-              <ui-button
-                tag="router-link"
-                to="/backup"
-                class="inline-block"
-                icon
-              >
-                <v-remixicon name="riUploadCloud2Line" />
-              </ui-button>
+              <v-remixicon name="riCloseLine" size="20" />
             </span>
-            <div class="flex items-center workflow-sort flex-1">
-              <ui-button
-                icon
-                class="rounded-r-none border-gray-300 dark:border-gray-700 border-r"
-                @click="
-                  state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc'
-                "
-              >
-                <v-remixicon
-                  :name="state.sortOrder === 'asc' ? 'riSortAsc' : 'riSortDesc'"
-                />
-              </ui-button>
-              <ui-select
-                v-model="state.sortBy"
-                :placeholder="t('sort.sortBy')"
-                class="flex-1"
-              >
-                <option v-for="sort in sorts" :key="sort" :value="sort">
-                  {{ t(`sort.${sort}`) }}
-                </option>
-              </ui-select>
-            </div>
-          </div>
-        </div>
-        <ui-tab-panels v-model="state.activeTab" class="flex-1 mt-6">
-          <ui-tab-panel value="team" cache>
-            <workflows-user-team
-              :active="state.activeTab === 'team'"
-              :team-id="state.teamId"
-              :search="state.query"
-              :sort="{ by: state.sortBy, order: state.sortOrder }"
-            />
-          </ui-tab-panel>
-          <ui-tab-panel value="shared" class="workflows-container">
-            <workflows-shared
-              :search="state.query"
-              :sort="{ by: state.sortBy, order: state.sortOrder }"
-            />
-          </ui-tab-panel>
-          <ui-tab-panel value="host" class="workflows-container">
-            <workflows-hosted
-              :search="state.query"
-              :sort="{ by: state.sortBy, order: state.sortOrder }"
-            />
-          </ui-tab-panel>
-          <ui-tab-panel value="local">
-            <workflows-local
-              :search="state.query"
-              :per-page="state.perPage"
-              :folder-id="state.activeFolder"
-              :sort="{ by: state.sortBy, order: state.sortOrder }"
-            />
-          </ui-tab-panel>
-        </ui-tab-panels>
-        <ui-card
-          v-if="workflowStore.isFirstTime"
-          class="mt-8 first-card relative dark:text-gray-200"
-        >
-          <v-remixicon
-            name="riCloseLine"
-            class="absolute top-4 right-4 cursor-pointer"
-            @click="workflowStore.isFirstTime = false"
-          />
-          <p>Create your first workflow by recording your actions:</p>
-          <ol class="list-decimal list-inside">
-            <li>Open your browser and go to your destination URL</li>
-            <li>
-              Click the "Record workflow" button, and do your simple repetitive
-              task
-            </li>
-            <li>
-              Need more help? Join
-              <a
-                href="https://discord.gg/C6khwwTE84"
-                target="_blank"
-                rel="noreferer"
-                >the community</a
-              >, or email us at
-              <a href="mailto:support@automa.site" target="_blank"
-                >support@automa.site</a
-              >
-            </li>
-          </ol>
-          <p class="mt-4">
-            Learn more about recording in
-            <a
-              href="https://docs.automa.site/guide/quick-start.html#recording-actions"
-              target="_blank"
-              >the documentation</a
-            >
-          </p>
-        </ui-card>
-      </div>
+          </button>
+        </template>
+      </draggable>
+      <button class="px-2 h-full" @click="addTab()">
+        <v-remixicon name="riAddLine" />
+      </button>
+    </div>
+    <div class="flex-1">
+      <router-view v-slot="{ Component }">
+        <keep-alive>
+          <component :is="Component" :key="$route.fullPath"></component>
+        </keep-alive>
+      </router-view>
     </div>
-    <ui-modal v-model="addWorkflowModal.show" title="Workflow">
-      <ui-input
-        v-model="addWorkflowModal.name"
-        :placeholder="t('common.name')"
-        autofocus
-        class="w-full mb-4"
-        @keyup.enter="
-          addWorkflowModal.type === 'manual'
-            ? addWorkflow()
-            : startRecordWorkflow()
-        "
-      />
-      <ui-textarea
-        v-model="addWorkflowModal.description"
-        :placeholder="t('common.description')"
-        height="165px"
-        class="w-full dark:text-gray-200"
-        max="300"
-      />
-      <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
-        {{ addWorkflowModal.description.length }}/300
-      </p>
-      <div class="space-x-2 flex">
-        <ui-button class="w-full" @click="clearAddWorkflowModal">
-          {{ t('common.cancel') }}
-        </ui-button>
-        <ui-button
-          variant="accent"
-          class="w-full"
-          @click="
-            addWorkflowModal.type === 'manual'
-              ? addWorkflow()
-              : startRecordWorkflow()
-          "
-        >
-          {{
-            addWorkflowModal.type === 'manual'
-              ? t('common.add')
-              : t('home.record.button')
-          }}
-        </ui-button>
-      </div>
-    </ui-modal>
-    <shared-permissions-modal
-      v-model="permissionState.showModal"
-      :permissions="permissionState.items"
-    />
   </div>
 </template>
 <script setup>
-import { computed, shallowReactive, watch, onMounted } from 'vue';
-import { useI18n } from 'vue-i18n';
-import { useRouter } from 'vue-router';
-import { useToast } from 'vue-toastification';
-import { useDialog } from '@/composable/dialog';
-import { useShortcut } from '@/composable/shortcut';
-import { useGroupTooltip } from '@/composable/groupTooltip';
-import { fetchApi } from '@/utils/api';
-import { useUserStore } from '@/stores/user';
-import { useWorkflowStore } from '@/stores/workflow';
-import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
-import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
-import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
-import { isWhitespace, findTriggerBlock } from '@/utils/helper';
-import { importWorkflow, getWorkflowPermissions } from '@/utils/workflowData';
-import recordWorkflow from '@/newtab/utils/startRecordWorkflow';
-import WorkflowsLocal from '@/components/newtab/workflows/WorkflowsLocal.vue';
-import WorkflowsShared from '@/components/newtab/workflows/WorkflowsShared.vue';
-import WorkflowsHosted from '@/components/newtab/workflows/WorkflowsHosted.vue';
-import WorkflowsFolder from '@/components/newtab/workflows/WorkflowsFolder.vue';
-import WorkflowsUserTeam from '@/components/newtab/workflows/WorkflowsUserTeam.vue';
-import SharedPermissionsModal from '@/components/newtab/shared/SharedPermissionsModal.vue';
+import { reactive, onMounted, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { nanoid } from 'nanoid/non-secure';
+import Draggable from 'vuedraggable';
+import { parseJSON } from '@/utils/helper';
 
-useGroupTooltip();
-const { t } = useI18n();
-const toast = useToast();
-const dialog = useDialog();
-const router = useRouter();
-const userStore = useUserStore();
-const workflowStore = useWorkflowStore();
-const teamWorkflowStore = useTeamWorkflowStore();
-const hostedWorkflowStore = useHostedWorkflowStore();
+let tabTitleTimeout = null;
 
-const sorts = ['name', 'createdAt'];
-const { teamId, active } = router.currentRoute.value.query;
-const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
-const validTeamId = userStore.user?.teams?.some(
-  ({ id }) => id === teamId || id === +teamId
-);
+const route = useRoute();
+const router = useRouter();
 
-const state = shallowReactive({
-  teams: [],
-  query: '',
-  activeFolder: '',
-  activeTab: active || 'local',
-  teamId: validTeamId ? teamId : '',
-  perPage: savedSorts.perPage || 18,
-  sortBy: savedSorts.sortBy || 'createdAt',
-  sortOrder: savedSorts.sortOrder || 'desc',
-});
-const addWorkflowModal = shallowReactive({
-  name: '',
-  show: false,
-  type: 'manual',
-  description: '',
-});
-const permissionState = shallowReactive({
-  items: [],
-  showModal: false,
+const state = reactive({
+  tabs: [],
+  activeTab: '',
+  tabChanging: false,
 });
 
-const hostedWorkflows = computed(() => hostedWorkflowStore.toArray);
+function addTab(detail = {}) {
+  const workflowsTab = state.tabs.find(
+    (tab) => tab.path === '/' || tab.path === '/workflows'
+  );
 
-function clearAddWorkflowModal() {
-  Object.assign(addWorkflowModal, {
-    name: '',
-    show: false,
-    type: 'manual',
-    description: '',
-  });
-}
-function initRecordWorkflow() {
-  addWorkflowModal.show = true;
-  addWorkflowModal.type = 'recording';
-}
-function startRecordWorkflow() {
-  recordWorkflow({
-    name: addWorkflowModal.name,
-    description: addWorkflowModal.description,
-  }).then(() => {
-    router.push('/recording');
-  });
-}
-function updateActiveTab(data = {}) {
-  if (data.activeTab !== 'team') data.teamId = '';
+  if (workflowsTab) {
+    state.activeTab = workflowsTab.id;
+    return;
+  }
 
-  Object.assign(state, data);
-}
-function addWorkflow() {
-  workflowStore.insert({
-    name: addWorkflowModal.name,
-    folderId: state.activeFolder,
-    description: addWorkflowModal.description,
+  const tabId = nanoid();
+
+  state.tabs.push({
+    id: tabId,
+    path: '/',
+    name: 'Workflows',
+    ...detail,
   });
-  clearAddWorkflowModal();
+  state.activeTab = tabId;
 }
-async function checkWorkflowPermissions(workflows) {
-  let requiredPermissions = [];
-
-  for (const workflow of workflows) {
-    if (workflow.drawflow) {
-      const permissions = await getWorkflowPermissions(workflow.drawflow);
-      requiredPermissions.push(...permissions);
-    }
+function closeTab(index, tab) {
+  if (state.tabs.length === 1) {
+    state.tabs[0] = {
+      path: '/',
+      id: nanoid(),
+      name: 'Workflows',
+    };
+  } else {
+    state.tabs.splice(index, 1);
   }
 
-  requiredPermissions = Array.from(new Set(requiredPermissions));
-  if (requiredPermissions.length === 0) return;
-
-  permissionState.items = requiredPermissions;
-  permissionState.showModal = true;
+  if (tab.id === state.activeTab) {
+    state.activeTab = state.tabs[0].id;
+  }
 }
-function addHostedWorkflow() {
-  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) => {
-      if (isWhitespace(value)) return false;
-      const hostId = value.replace(/\s/g, '');
-
-      try {
-        if (!userStore.user && hostedWorkflowStore.toArray.length >= 3)
-          throw new Error('rate-exceeded');
-
-        const isTheUserHost = userStore.getHostedWorkflows.some(
-          (host) => hostId === host.hostId
-        );
-        if (isTheUserHost) throw new Error('exist');
-
-        const response = await fetchApi('/workflows/hosted', {
-          method: 'POST',
-          body: JSON.stringify({ hostId }),
-        });
-        const result = await response.json();
+function getTabTitle() {
+  if (route.name === 'workflows') return 'Workflows';
 
-        if (!response.ok) {
-          const error = new Error(result.message);
-          error.data = result.data;
-
-          throw error;
-        }
-
-        if (result === null) throw new Error('not-found');
-
-        result.hostId = `${hostId}`;
-        result.createdAt = Date.now();
-
-        await checkWorkflowPermissions([result]);
-        await hostedWorkflowStore.insert(result, hostId);
+  return `${document.title}`.replace(' - Automa', '');
+}
 
-        const triggerBlock = findTriggerBlock(result.drawflow);
-        await registerWorkflowTrigger(hostId, triggerBlock);
+watch(
+  () => state.activeTab,
+  (id) => {
+    const tab = state.tabs.find((item) => item.id === id);
+    if (!tab) return;
 
-        return true;
-      } catch (error) {
-        console.error(error);
-        const messages = {
-          exists: t('workflow.host.messages.hostExist'),
-          'rate-exceeded': t('message.rateExceeded'),
-          'not-found': t('workflow.host.messages.notFound', { id: hostId }),
-        };
-        const errorMessage = messages[error.message] || error.message;
+    state.tabChanging = true;
 
-        toast.error(errorMessage);
+    localStorage.setItem('activeTab', state.activeTab);
+    router.replace(tab.path);
 
-        return false;
-      }
-    },
-  });
-}
-async function openImportDialog() {
-  try {
-    const workflows = await importWorkflow({ multiple: true });
-    await checkWorkflowPermissions(Object.values(workflows));
-  } catch (error) {
-    console.error(error);
+    setTimeout(() => {
+      state.tabChanging = false;
+    }, 1000);
   }
-}
+);
+watch(
+  () => route.path,
+  () => {
+    if (state.tabChanging) return;
 
-const shortcut = useShortcut(['action:search', 'action:new'], ({ id }) => {
-  if (id === 'action:search') {
-    const searchInput = document.querySelector('#search-input input');
-    searchInput?.focus();
-  } else {
-    addWorkflowModal.show = true;
-  }
-});
+    const index = state.tabs.findIndex((tab) => tab.id === state.activeTab);
+    if (index === -1) return;
 
-watch(
-  () => [state.sortOrder, state.sortBy, state.perPage],
-  ([sortOrder, sortBy, perPage]) => {
-    localStorage.setItem(
-      'workflow-sorts',
-      JSON.stringify({ sortOrder, sortBy, perPage })
+    const duplicateTab = state.tabs.find(
+      (tab) => tab.path === route.path && tab.id !== state.activeTab
     );
+    if (duplicateTab) {
+      state.activeTab = duplicateTab.id;
+      state.tabs.splice(index, 1);
+      return;
+    }
+
+    clearTimeout(tabTitleTimeout);
+
+    tabTitleTimeout = setTimeout(() => {
+      Object.assign(state.tabs[index], {
+        path: route.path,
+        name: getTabTitle(),
+      });
+    }, 1000);
   }
 );
 watch(
-  () => [state.activeTab, state.teamId],
-  ([activeTab, teamIdQuery]) => {
-    const query = { active: activeTab };
+  () => state.tabs,
+  () => {
+    localStorage.setItem('tabs', JSON.stringify(state.tabs));
+  },
+  { deep: true }
+);
 
-    if (teamIdQuery) query.teamId = teamIdQuery;
+onMounted(() => {
+  const tabs = parseJSON(localStorage.getItem('tabs'), null);
+  if (tabs) {
+    state.tabs = tabs;
 
-    router.replace({ ...router.currentRoute.value, query });
+    const activeTab = localStorage.getItem('activeTab');
+    state.activeTab = activeTab || tabs[0].id;
   }
-);
 
-onMounted(() => {
-  const teams = [];
-  let unknownInputted = false;
-  Object.keys(teamWorkflowStore.workflows).forEach((id) => {
-    const userTeam = userStore.user?.teams?.find(
-      (team) => team.id === id || team.id === +id
-    );
+  if (state.tabs.length !== 0) return;
 
-    if (userTeam) {
-      teams.push({ name: userTeam.name, id: userTeam.id });
-    } else if (!unknownInputted && teamWorkflowStore.getByTeam(id).length > 0) {
-      unknownInputted = true;
-      teams.unshift({ name: '(unknown)', id: '(unknown)' });
-    }
+  addTab({
+    path: route.path,
+    name: getTabTitle(),
   });
-
-  state.teams = teams;
 });
 </script>
-<style>
-.workflow-sort select {
-  @apply rounded-l-none !important;
-}
-.workflows-container {
-  @apply grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4;
-}
-
-.first-card {
-  a {
-    @apply text-blue-400 underline;
-  }
-}
-</style>

+ 23 - 7
src/newtab/pages/workflows/Shared.vue

@@ -149,8 +149,9 @@
   </ui-modal>
 </template>
 <script setup>
-import { reactive, onMounted, watch, shallowRef } from 'vue';
+import { reactive, onMounted, watch, shallowRef, computed } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useHead } from '@vueuse/head';
 import { useRoute, useRouter } from 'vue-router';
 import { useToast } from 'vue-toastification';
 import browser from 'webextension-polyfill';
@@ -201,7 +202,15 @@ const state = reactive({
   trigger: 'Trigger: Manually',
 });
 const editor = shallowRef(null);
-const workflow = shallowRef(null);
+
+const workflow = computed(() => sharedWorkflowStore.getById(route.params.id));
+
+useHead({
+  title: () =>
+    workflow.value?.name
+      ? `${workflow.value.name} workflow`
+      : 'Shared workflow',
+});
 
 const changingKeys = new Set();
 
@@ -210,7 +219,10 @@ function updateSharedWorkflow(data = {}) {
     changingKeys.add(key);
   });
 
-  Object.assign(workflow.value, data);
+  sharedWorkflowStore.update({
+    data,
+    id: workflowId,
+  });
 
   if (data.drawflow) {
     editor.value.setNodes(data.drawflow.nodes);
@@ -349,14 +361,18 @@ watch(workflow, () => {
 });
 
 onMounted(() => {
-  const currentWorkflow = sharedWorkflowStore.getById(workflowId);
-  if (!currentWorkflow) {
+  if (!workflow.value) {
     router.push('/workflows');
     return;
   }
 
-  const convertedData = convertWorkflowData(currentWorkflow);
-  workflow.value = convertedData;
+  const convertedData = convertWorkflowData(workflow.value);
+  sharedWorkflowStore.update({
+    id: workflowId,
+    data: {
+      drawflow: convertedData.drawflow ?? workflow.value.drawflow,
+    },
+  });
 
   state.hasLocalCopy = workflowStore.getWorkflows.some(
     ({ id }) => id === workflowId

+ 0 - 9
src/newtab/pages/workflows/[id].vue

@@ -1604,15 +1604,6 @@ watch(
     window.isDataChanged = isDataChanged && haveEditAccess.value;
   }
 );
-watch(
-  () => route.params.id,
-  (value, oldValue) => {
-    if (route.name !== 'workflows-details') return;
-    if (value && oldValue && value !== oldValue) {
-      window.location.reload();
-    }
-  }
-);
 
 /* eslint-disable consistent-return */
 onBeforeRouteLeave(() => {

+ 600 - 0
src/newtab/pages/workflows/index.vue

@@ -0,0 +1,600 @@
+<template>
+  <div class="container pt-8 pb-4">
+    <h1 class="text-2xl font-semibold capitalize">
+      {{ t('common.workflow', 2) }}
+    </h1>
+    <div class="flex items-start mt-8">
+      <div class="w-60 sticky top-8 hidden lg:block">
+        <div class="flex w-full">
+          <ui-button
+            :title="shortcut['action:new'].readable"
+            variant="accent"
+            class="border-r rounded-r-none flex-1 font-semibold"
+            @click="addWorkflowModal.show = true"
+          >
+            {{ 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 class="space-y-1">
+              <ui-list-item
+                v-close-popover
+                class="cursor-pointer"
+                @click="openImportDialog"
+              >
+                {{ t('workflow.import') }}
+              </ui-list-item>
+              <ui-list-item
+                v-close-popover
+                class="cursor-pointer"
+                @click="initRecordWorkflow"
+              >
+                {{ t('home.record.title') }}
+              </ui-list-item>
+              <ui-list-item
+                v-close-popover
+                class="cursor-pointer"
+                @click="addHostedWorkflow"
+              >
+                {{ t('workflow.host.add') }}
+              </ui-list-item>
+            </ui-list>
+          </ui-popover>
+        </div>
+        <ui-list class="mt-6 space-y-2">
+          <ui-list-item
+            tag="a"
+            href="https://www.automa.site/workflows"
+            target="_blank"
+          >
+            <v-remixicon name="riCompass3Line" />
+            <span class="ml-4 capitalize">
+              {{ t('workflow.browse') }}
+            </span>
+          </ui-list-item>
+          <ui-expand
+            v-if="state.teams.length > 0"
+            append-icon
+            header-class="px-4 py-2 rounded-lg mb-1 hoverable w-full flex items-center"
+          >
+            <template #header>
+              <v-remixicon name="riTeamLine" />
+              <span class="ml-4 capitalize flex-1 text-left">
+                Team Workflows
+              </span>
+            </template>
+            <ui-list class="space-y-1">
+              <ui-list-item
+                v-for="team in state.teams"
+                :key="team.id"
+                :active="state.teamId === team.id || +state.teamId === team.id"
+                :title="team.name"
+                color="bg-box-transparent font-semibold"
+                class="pl-14 cursor-pointer"
+                @click="updateActiveTab({ activeTab: 'team', teamId: team.id })"
+              >
+                <span class="text-overflow">
+                  {{ team.name }}
+                </span>
+              </ui-list-item>
+            </ui-list>
+          </ui-expand>
+          <ui-expand
+            :model-value="true"
+            append-icon
+            header-class="px-4 py-2 rounded-lg hoverable w-full flex items-center"
+          >
+            <template #header>
+              <v-remixicon name="riFlowChart" />
+              <span class="ml-4 capitalize flex-1 text-left">
+                {{ t('workflow.my') }}
+              </span>
+            </template>
+            <ui-list class="space-y-1 mt-1">
+              <ui-list-item
+                tag="button"
+                :active="state.activeTab === 'local'"
+                color="bg-box-transparent font-semibold"
+                class="pl-14"
+                @click="updateActiveTab({ activeTab: 'local' })"
+              >
+                <span class="capitalize">
+                  {{ t('workflow.type.local') }}
+                </span>
+              </ui-list-item>
+              <ui-list-item
+                v-if="userStore.user"
+                :active="state.activeTab === 'shared'"
+                tag="button"
+                color="bg-box-transparent font-semibold"
+                class="pl-14"
+                @click="updateActiveTab({ activeTab: 'shared' })"
+              >
+                <span class="capitalize">
+                  {{ t('workflow.type.shared') }}
+                </span>
+              </ui-list-item>
+              <ui-list-item
+                v-if="hostedWorkflows?.length > 0"
+                :active="state.activeTab === 'host'"
+                color="bg-box-transparent font-semibold"
+                tag="button"
+                class="pl-14"
+                @click="updateActiveTab({ activeTab: 'host' })"
+              >
+                <span class="capitalize">
+                  {{ t('workflow.type.host') }}
+                </span>
+              </ui-list-item>
+            </ui-list>
+          </ui-expand>
+        </ui-list>
+        <workflows-folder
+          v-if="state.activeTab === 'local'"
+          v-model="state.activeFolder"
+        />
+      </div>
+      <div
+        class="flex-1 workflows-list lg:ml-8"
+        style="min-height: calc(100vh - 8rem)"
+        @dblclick="clearSelectedWorkflows"
+      >
+        <div class="flex items-center flex-wrap">
+          <div class="flex items-center w-full md:w-auto">
+            <ui-input
+              id="search-input"
+              v-model="state.query"
+              class="flex-1 md:w-auto"
+              :placeholder="`${t(`common.search`)}... (${
+                shortcut['action:search'].readable
+              })`"
+              prepend-icon="riSearch2Line"
+            />
+            <ui-popover>
+              <template #trigger>
+                <ui-button variant="accent" class="md:hidden ml-4">
+                  <v-remixicon name="riAddLine" class="mr-2 -ml-1" />
+                  <span>{{ t('common.workflow') }}</span>
+                </ui-button>
+              </template>
+              <ui-list class="space-y-1">
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="addWorkflowModal.show = true"
+                >
+                  {{ t('workflow.new') }}
+                </ui-list-item>
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="openImportDialog"
+                >
+                  {{ t('workflow.import') }}
+                </ui-list-item>
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="initRecordWorkflow"
+                >
+                  {{ t('home.record.title') }}
+                </ui-list-item>
+                <ui-list-item
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="addHostedWorkflow"
+                >
+                  {{ t('workflow.host.add') }}
+                </ui-list-item>
+              </ui-list>
+            </ui-popover>
+          </div>
+          <div class="flex-grow"></div>
+          <div class="w-full md:w-auto flex items-center mt-4 md:mt-0">
+            <span
+              v-tooltip:bottom.group="t('workflow.backupCloud')"
+              class="mr-4"
+            >
+              <ui-button
+                tag="router-link"
+                to="/backup"
+                class="inline-block"
+                icon
+              >
+                <v-remixicon name="riUploadCloud2Line" />
+              </ui-button>
+            </span>
+            <div class="flex items-center workflow-sort flex-1">
+              <ui-button
+                icon
+                class="rounded-r-none border-gray-300 dark:border-gray-700 border-r"
+                @click="
+                  state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc'
+                "
+              >
+                <v-remixicon
+                  :name="state.sortOrder === 'asc' ? 'riSortAsc' : 'riSortDesc'"
+                />
+              </ui-button>
+              <ui-select
+                v-model="state.sortBy"
+                :placeholder="t('sort.sortBy')"
+                class="flex-1"
+              >
+                <option v-for="sort in sorts" :key="sort" :value="sort">
+                  {{ t(`sort.${sort}`) }}
+                </option>
+              </ui-select>
+            </div>
+          </div>
+        </div>
+        <ui-tab-panels v-model="state.activeTab" class="flex-1 mt-6">
+          <ui-tab-panel value="team" cache>
+            <workflows-user-team
+              :active="state.activeTab === 'team'"
+              :team-id="state.teamId"
+              :search="state.query"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
+          </ui-tab-panel>
+          <ui-tab-panel value="shared" class="workflows-container">
+            <workflows-shared
+              :search="state.query"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
+          </ui-tab-panel>
+          <ui-tab-panel value="host" class="workflows-container">
+            <workflows-hosted
+              :search="state.query"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
+          </ui-tab-panel>
+          <ui-tab-panel value="local">
+            <workflows-local
+              :search="state.query"
+              :per-page="state.perPage"
+              :folder-id="state.activeFolder"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
+          </ui-tab-panel>
+        </ui-tab-panels>
+        <ui-card
+          v-if="workflowStore.isFirstTime"
+          class="mt-8 first-card relative dark:text-gray-200"
+        >
+          <v-remixicon
+            name="riCloseLine"
+            class="absolute top-4 right-4 cursor-pointer"
+            @click="workflowStore.isFirstTime = false"
+          />
+          <p>Create your first workflow by recording your actions:</p>
+          <ol class="list-decimal list-inside">
+            <li>Open your browser and go to your destination URL</li>
+            <li>
+              Click the "Record workflow" button, and do your simple repetitive
+              task
+            </li>
+            <li>
+              Need more help? Join
+              <a
+                href="https://discord.gg/C6khwwTE84"
+                target="_blank"
+                rel="noreferer"
+                >the community</a
+              >, or email us at
+              <a href="mailto:support@automa.site" target="_blank"
+                >support@automa.site</a
+              >
+            </li>
+          </ol>
+          <p class="mt-4">
+            Learn more about recording in
+            <a
+              href="https://docs.automa.site/guide/quick-start.html#recording-actions"
+              target="_blank"
+              >the documentation</a
+            >
+          </p>
+        </ui-card>
+      </div>
+    </div>
+    <ui-modal v-model="addWorkflowModal.show" title="Workflow">
+      <ui-input
+        v-model="addWorkflowModal.name"
+        :placeholder="t('common.name')"
+        autofocus
+        class="w-full mb-4"
+        @keyup.enter="
+          addWorkflowModal.type === 'manual'
+            ? addWorkflow()
+            : startRecordWorkflow()
+        "
+      />
+      <ui-textarea
+        v-model="addWorkflowModal.description"
+        :placeholder="t('common.description')"
+        height="165px"
+        class="w-full dark:text-gray-200"
+        max="300"
+      />
+      <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
+        {{ addWorkflowModal.description.length }}/300
+      </p>
+      <div class="space-x-2 flex">
+        <ui-button class="w-full" @click="clearAddWorkflowModal">
+          {{ t('common.cancel') }}
+        </ui-button>
+        <ui-button
+          variant="accent"
+          class="w-full"
+          @click="
+            addWorkflowModal.type === 'manual'
+              ? addWorkflow()
+              : startRecordWorkflow()
+          "
+        >
+          {{
+            addWorkflowModal.type === 'manual'
+              ? t('common.add')
+              : t('home.record.button')
+          }}
+        </ui-button>
+      </div>
+    </ui-modal>
+    <shared-permissions-modal
+      v-model="permissionState.showModal"
+      :permissions="permissionState.items"
+    />
+  </div>
+</template>
+<script setup>
+import { computed, shallowReactive, watch, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
+import { useToast } from 'vue-toastification';
+import { useDialog } from '@/composable/dialog';
+import { useShortcut } from '@/composable/shortcut';
+import { useGroupTooltip } from '@/composable/groupTooltip';
+import { fetchApi } from '@/utils/api';
+import { useUserStore } from '@/stores/user';
+import { useWorkflowStore } from '@/stores/workflow';
+import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
+import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
+import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
+import { isWhitespace, findTriggerBlock } from '@/utils/helper';
+import { importWorkflow, getWorkflowPermissions } from '@/utils/workflowData';
+import recordWorkflow from '@/newtab/utils/startRecordWorkflow';
+import WorkflowsLocal from '@/components/newtab/workflows/WorkflowsLocal.vue';
+import WorkflowsShared from '@/components/newtab/workflows/WorkflowsShared.vue';
+import WorkflowsHosted from '@/components/newtab/workflows/WorkflowsHosted.vue';
+import WorkflowsFolder from '@/components/newtab/workflows/WorkflowsFolder.vue';
+import WorkflowsUserTeam from '@/components/newtab/workflows/WorkflowsUserTeam.vue';
+import SharedPermissionsModal from '@/components/newtab/shared/SharedPermissionsModal.vue';
+
+useGroupTooltip();
+
+const { t } = useI18n();
+const toast = useToast();
+const dialog = useDialog();
+const router = useRouter();
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
+const teamWorkflowStore = useTeamWorkflowStore();
+const hostedWorkflowStore = useHostedWorkflowStore();
+
+const sorts = ['name', 'createdAt'];
+const { teamId, active } = router.currentRoute.value.query;
+const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
+const validTeamId = userStore.user?.teams?.some(
+  ({ id }) => id === teamId || id === +teamId
+);
+
+const state = shallowReactive({
+  teams: [],
+  query: '',
+  activeFolder: '',
+  activeTab: active || 'local',
+  teamId: validTeamId ? teamId : '',
+  perPage: savedSorts.perPage || 18,
+  sortBy: savedSorts.sortBy || 'createdAt',
+  sortOrder: savedSorts.sortOrder || 'desc',
+});
+const addWorkflowModal = shallowReactive({
+  name: '',
+  show: false,
+  type: 'manual',
+  description: '',
+});
+const permissionState = shallowReactive({
+  items: [],
+  showModal: false,
+});
+
+const hostedWorkflows = computed(() => hostedWorkflowStore.toArray);
+
+function clearAddWorkflowModal() {
+  Object.assign(addWorkflowModal, {
+    name: '',
+    show: false,
+    type: 'manual',
+    description: '',
+  });
+}
+function initRecordWorkflow() {
+  addWorkflowModal.show = true;
+  addWorkflowModal.type = 'recording';
+}
+function startRecordWorkflow() {
+  recordWorkflow({
+    name: addWorkflowModal.name,
+    description: addWorkflowModal.description,
+  }).then(() => {
+    router.push('/recording');
+  });
+}
+function updateActiveTab(data = {}) {
+  if (data.activeTab !== 'team') data.teamId = '';
+
+  Object.assign(state, data);
+}
+function addWorkflow() {
+  workflowStore.insert({
+    name: addWorkflowModal.name,
+    folderId: state.activeFolder,
+    description: addWorkflowModal.description,
+  });
+  clearAddWorkflowModal();
+}
+async function checkWorkflowPermissions(workflows) {
+  let requiredPermissions = [];
+
+  for (const workflow of workflows) {
+    if (workflow.drawflow) {
+      const permissions = await getWorkflowPermissions(workflow.drawflow);
+      requiredPermissions.push(...permissions);
+    }
+  }
+
+  requiredPermissions = Array.from(new Set(requiredPermissions));
+  if (requiredPermissions.length === 0) return;
+
+  permissionState.items = requiredPermissions;
+  permissionState.showModal = true;
+}
+function addHostedWorkflow() {
+  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) => {
+      if (isWhitespace(value)) return false;
+      const hostId = value.replace(/\s/g, '');
+
+      try {
+        if (!userStore.user && hostedWorkflowStore.toArray.length >= 3)
+          throw new Error('rate-exceeded');
+
+        const isTheUserHost = userStore.getHostedWorkflows.some(
+          (host) => hostId === host.hostId
+        );
+        if (isTheUserHost) throw new Error('exist');
+
+        const response = await fetchApi('/workflows/hosted', {
+          method: 'POST',
+          body: JSON.stringify({ hostId }),
+        });
+        const result = await response.json();
+
+        if (!response.ok) {
+          const error = new Error(result.message);
+          error.data = result.data;
+
+          throw error;
+        }
+
+        if (result === null) throw new Error('not-found');
+
+        result.hostId = `${hostId}`;
+        result.createdAt = Date.now();
+
+        await checkWorkflowPermissions([result]);
+        await hostedWorkflowStore.insert(result, hostId);
+
+        const triggerBlock = findTriggerBlock(result.drawflow);
+        await registerWorkflowTrigger(hostId, triggerBlock);
+
+        return true;
+      } catch (error) {
+        console.error(error);
+        const messages = {
+          exists: t('workflow.host.messages.hostExist'),
+          'rate-exceeded': t('message.rateExceeded'),
+          'not-found': t('workflow.host.messages.notFound', { id: hostId }),
+        };
+        const errorMessage = messages[error.message] || error.message;
+
+        toast.error(errorMessage);
+
+        return false;
+      }
+    },
+  });
+}
+async function openImportDialog() {
+  try {
+    const workflows = await importWorkflow({ multiple: true });
+    await checkWorkflowPermissions(Object.values(workflows));
+  } catch (error) {
+    console.error(error);
+  }
+}
+
+const shortcut = useShortcut(['action:search', 'action:new'], ({ id }) => {
+  if (id === 'action:search') {
+    const searchInput = document.querySelector('#search-input input');
+    searchInput?.focus();
+  } else {
+    addWorkflowModal.show = true;
+  }
+});
+
+watch(
+  () => [state.sortOrder, state.sortBy, state.perPage],
+  ([sortOrder, sortBy, perPage]) => {
+    localStorage.setItem(
+      'workflow-sorts',
+      JSON.stringify({ sortOrder, sortBy, perPage })
+    );
+  }
+);
+watch(
+  () => [state.activeTab, state.teamId],
+  ([activeTab, teamIdQuery]) => {
+    const query = { active: activeTab };
+
+    if (teamIdQuery) query.teamId = teamIdQuery;
+
+    router.replace({ ...router.currentRoute.value, query });
+  }
+);
+
+onMounted(() => {
+  const teams = [];
+  let unknownInputted = false;
+  Object.keys(teamWorkflowStore.workflows).forEach((id) => {
+    const userTeam = userStore.user?.teams?.find(
+      (team) => team.id === id || team.id === +id
+    );
+
+    if (userTeam) {
+      teams.push({ name: userTeam.name, id: userTeam.id });
+    } else if (!unknownInputted && teamWorkflowStore.getByTeam(id).length > 0) {
+      unknownInputted = true;
+      teams.unshift({ name: '(unknown)', id: '(unknown)' });
+    }
+  });
+
+  state.teams = teams;
+});
+</script>
+<style>
+.workflow-sort select {
+  @apply rounded-l-none !important;
+}
+.workflows-container {
+  @apply grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4;
+}
+
+.first-card {
+  a {
+    @apply text-blue-400 underline;
+  }
+}
+</style>

+ 30 - 23
src/newtab/router.js

@@ -1,7 +1,8 @@
 import { createRouter, createWebHashHistory } from 'vue-router';
 import Welcome from './pages/Welcome.vue';
 import Packages from './pages/Packages.vue';
-import Workflows from './pages/Workflows.vue';
+import Workflows from './pages/workflows/index.vue';
+import WorkflowContainer 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';
@@ -45,35 +46,41 @@ const routes = [
     component: WorkflowDetails,
   },
   {
-    name: 'workflows',
     path: '/workflows',
-    component: Workflows,
+    component: WorkflowContainer,
+    children: [
+      {
+        path: '',
+        name: 'workflows',
+        component: Workflows,
+      },
+      {
+        path: ':id',
+        name: 'workflows-details',
+        component: WorkflowDetails,
+      },
+      {
+        name: 'team-workflows',
+        path: 'teams/:teamId/workflows/:id',
+        component: WorkflowDetails,
+      },
+      {
+        name: 'workflow-host',
+        path: 'workflows/:id/host',
+        component: WorkflowHost,
+      },
+      {
+        name: 'workflow-shared',
+        path: '/workflows/:id/shared',
+        component: WorkflowShared,
+      },
+    ],
   },
   {
     name: 'schedule',
     path: '/schedule',
     component: ScheduledWorkflow,
   },
-  {
-    name: 'team-workflows',
-    path: '/teams/:teamId/workflows/:id',
-    component: WorkflowDetails,
-  },
-  {
-    name: 'workflows-details',
-    path: '/workflows/:id',
-    component: WorkflowDetails,
-  },
-  {
-    name: 'workflow-host',
-    path: '/workflows/:id/host',
-    component: WorkflowHost,
-  },
-  {
-    name: 'workflow-shared',
-    path: '/workflows/:id/shared',
-    component: WorkflowShared,
-  },
   {
     name: 'storage',
     path: '/storage',

+ 2 - 0
src/stores/main.js

@@ -5,9 +5,11 @@ import deepmerge from 'lodash.merge';
 
 export const useStore = defineStore('main', {
   storageMap: {
+    tabs: 'tabs',
     settings: 'settings',
   },
   state: () => ({
+    tabs: [],
     copiedEls: {
       edges: [],
       nodes: [],