Browse Source

feat: add user team workflows page

Ahmad Kholid 3 years ago
parent
commit
2e6fbdbf38

+ 10 - 3
src/background/index.js

@@ -531,7 +531,14 @@ message.on('workflow:execute', (workflowData, sender) => {
   workflow.execute(workflowData, workflowData?.options || {});
 });
 message.on('workflow:stop', (id) => workflow.states.stop(id));
-message.on('workflow:added', (workflowId) => {
+message.on('workflow:added', ({ workflowId, teamId, source = 'community' }) => {
+  let path = `/workflows/${workflowId}`;
+
+  if (source === 'team') {
+    if (!teamId) return;
+    path = `/teams/${teamId}/workflows/${workflowId}`;
+  }
+
   browser.tabs
     .query({ url: browser.runtime.getURL('/newtab.html') })
     .then((tabs) => {
@@ -540,7 +547,7 @@ message.on('workflow:added', (workflowId) => {
 
         tabs.forEach((tab) => {
           browser.tabs.sendMessage(tab.id, {
-            data: { workflowId },
+            data: { workflowId, teamId, source },
             type: 'workflow:added',
           });
         });
@@ -549,7 +556,7 @@ message.on('workflow:added', (workflowId) => {
           active: true,
         });
       } else {
-        openDashboard(`/workflows/${workflowId}?permission=true`);
+        openDashboard(`${path}?permission=true`);
       }
     });
 });

+ 1 - 0
src/components/newtab/shared/SharedCard.vue

@@ -32,6 +32,7 @@
               v-for="item in menu"
               :key="item.id"
               v-close-popover
+              :class="menu.class"
               class="cursor-pointer"
               @click="$emit('menuSelected', { id: item.id, data })"
             >

+ 239 - 0
src/components/newtab/workflow/WorkflowShareTeam.vue

@@ -0,0 +1,239 @@
+<template>
+  <ui-card class="w-full max-w-4xl share-workflow overflow-auto scroll">
+    <h1 class="text-xl font-semibold">Share workflow with team</h1>
+    <p class="text-gray-600 dark:text-gray-200">
+      This workflow will be shared with your team
+    </p>
+    <div class="flex items-start mt-4">
+      <div class="flex-1 mr-8">
+        <ui-input
+          v-model="state.workflow.name"
+          :label="t('workflow.name')"
+          type="text"
+          name="workflow name"
+          class="w-full"
+        />
+        <div class="relative mb-2 mt-2">
+          <label
+            for="short-description"
+            class="text-sm ml-2 text-gray-600 dark:text-gray-200"
+          >
+            Short description
+          </label>
+          <ui-textarea
+            id="short-description"
+            v-model="state.workflow.description"
+            :max="300"
+            label="Short description"
+            placeholder="Write here..."
+            class="w-full h-28 scroll resize-none"
+          />
+          <p
+            class="text-sm text-gray-600 dark:text-gray-200 absolute bottom-2 right-2"
+          >
+            {{ state.workflow.description.length }}/300
+          </p>
+        </div>
+        <shared-wysiwyg
+          v-model="state.workflow.content"
+          :placeholder="t('common.description')"
+          :limit="5000"
+          class="prose prose-zinc dark:prose-invert max-w-none content-editor p-4 bg-box-transparent rounded-lg relative"
+          @count="state.contentLength = $event"
+        >
+          <template #append>
+            <p
+              class="text-sm text-gray-600 dark:text-gray-200 absolute bottom-2 right-2"
+            >
+              {{ state.contentLength }}/5000
+            </p>
+          </template>
+        </shared-wysiwyg>
+      </div>
+      <div class="w-64 sticky top-4 pb-4">
+        <div class="flex">
+          <ui-button
+            v-tooltip="'Save as draft'"
+            :disabled="state.isPublishing"
+            icon
+            @click="saveDraft"
+          >
+            <v-remixicon name="riSaveLine" />
+          </ui-button>
+          <ui-button
+            :loading="state.isPublishing"
+            :disabled="!state.workflow.name.trim()"
+            variant="accent"
+            class="w-full ml-2"
+            @click="publishWorkflow"
+          >
+            Publish
+          </ui-button>
+        </div>
+        <ui-button
+          :disabled="state.isPublishing"
+          class="mt-2 w-full"
+          @click="$emit('close')"
+        >
+          Cancel
+        </ui-button>
+        <ui-select
+          v-model="state.workflow.category"
+          class="mt-4 w-full"
+          :label="t('common.category')"
+        >
+          <option value="">(none)</option>
+          <option
+            v-for="(category, id) in workflowCategories"
+            :key="id"
+            :value="id"
+          >
+            {{ category }}
+          </option>
+        </ui-select>
+        <span class="text-sm ml-2 text-gray-600 dark:text-gray-200 mt-5 block">
+          Environment
+        </span>
+        <ui-tabs v-model="state.workflow.tag" type="fill" fill>
+          <ui-tab value="stage"> Stage </ui-tab>
+          <ui-tab value="production"> Production </ui-tab>
+        </ui-tabs>
+      </div>
+    </div>
+  </ui-card>
+</template>
+<script setup>
+import { reactive, watch, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useToast } from 'vue-toastification';
+import browser from 'webextension-polyfill';
+import { fetchApi } from '@/utils/api';
+import { useUserStore } from '@/stores/user';
+import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
+import { workflowCategories } from '@/utils/shared';
+import { parseJSON, debounce } from '@/utils/helper';
+import { convertWorkflow } from '@/utils/workflowData';
+import SharedWysiwyg from '@/components/newtab/shared/SharedWysiwyg.vue';
+
+const props = defineProps({
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+  isUpdate: Boolean,
+});
+const emit = defineEmits(['close', 'publish', 'change']);
+
+const { t } = useI18n();
+const toast = useToast();
+const userStore = useUserStore();
+const sharedWorkflowStore = useSharedWorkflowStore();
+
+const state = reactive({
+  contentLength: 0,
+  isPublishing: false,
+  workflow: JSON.parse(JSON.stringify(props.workflow)),
+});
+
+async function publishWorkflow() {
+  try {
+    state.isPublishing = true;
+
+    const workflow = convertWorkflow(state.workflow, ['id', 'category']);
+    workflow.name = workflow.name || 'unnamed';
+    workflow.tag = state.workflow.tag || 'stage';
+    workflow.content = state.workflow.content || null;
+    workflow.description = state.workflow.description.slice(0, 300);
+    workflow.drawflow = parseJSON(workflow.drawflow, workflow.drawflow);
+
+    delete workflow.extVersion;
+
+    const response = await fetchApi(
+      `/teams/${userStore.user.team.id}/workflows`,
+      {
+        method: 'POST',
+        body: JSON.stringify({ workflow }),
+      }
+    );
+    const result = await response.json();
+
+    if (!response.ok) {
+      const error = new Error(response.statusText);
+      error.data = result.data;
+
+      throw error;
+    }
+
+    workflow.drawflow = props.workflow.drawflow;
+
+    sharedWorkflowStore.insert(workflow);
+    sessionStorage.setItem(
+      'shared-workflows',
+      JSON.stringify(sharedWorkflowStore.shared)
+    );
+
+    state.isPublishing = false;
+
+    emit('publish');
+  } catch (error) {
+    let errorMessage = t('message.somethingWrong');
+
+    if (error.data && error.data.show) {
+      errorMessage = error.message;
+    }
+
+    toast.error(errorMessage);
+    console.error(error);
+
+    state.isPublishing = false;
+  }
+}
+function saveDraft() {
+  const key = `draft-team:${props.workflow.id}`;
+  browser.storage.local.set({
+    [key]: {
+      name: state.workflow.name,
+      tag: state.tag,
+      content: state.workflow.content,
+      category: state.workflow.category,
+      description: state.workflow.description,
+    },
+  });
+}
+
+watch(
+  () => state.workflow,
+  debounce(() => {
+    emit('change', state.workflow);
+  }, 200),
+  { deep: true }
+);
+
+onMounted(() => {
+  const key = `draft-team:${props.workflow.id}`;
+  browser.storage.local.get(key).then((data) => {
+    Object.assign(state.workflow, data[key]);
+
+    if (!state.workflow.tag) {
+      state.workflow.tag = 'stage';
+    }
+  });
+});
+</script>
+<style scoped>
+.share-workflow {
+  min-height: 500px;
+  max-height: calc(100vh - 4rem);
+}
+.editor-menu-btn {
+  @apply p-1 rounded-md transition;
+}
+</style>
+<style>
+.content-editor .ProseMirror {
+  min-height: 200px;
+}
+.content-editor .ProseMirror :first-child {
+  margin-top: 0 !important;
+}
+</style>

+ 39 - 9
src/components/newtab/workflow/editor/EditorLocalActions.vue

@@ -55,14 +55,37 @@
         </transition-expand>
       </div>
     </ui-popover>
-    <button
-      v-tooltip.group="t('workflow.share.title')"
-      :class="{ 'text-primary': shared }"
-      class="hoverable p-2 rounded-lg"
-      @click="shareWorkflow"
-    >
-      <v-remixicon name="riShareLine" />
-    </button>
+    <ui-popover :disabled="userDontHaveTeamAccess">
+      <template #trigger>
+        <button
+          v-tooltip.group="t('workflow.share.title')"
+          :class="{ 'text-primary': shared }"
+          class="hoverable p-2 rounded-lg"
+          @click="shareWorkflow(!userDontHaveTeamAccess)"
+        >
+          <v-remixicon name="riShareLine" />
+        </button>
+      </template>
+      <p class="font-semibold">Share the workflow</p>
+      <ui-list class="mt-2 space-y-1 w-56">
+        <ui-list-item
+          v-close-popover
+          class="cursor-pointer"
+          @click="shareWorkflowWithTeam"
+        >
+          <v-remixicon name="riTeamLine" class="-ml-1 mr-2" />
+          With your team
+        </ui-list-item>
+        <ui-list-item
+          v-close-popover
+          class="cursor-pointer"
+          @click="shareWorkflow()"
+        >
+          <v-remixicon name="riGroupLine" class="-ml-1 mr-2" />
+          With the community
+        </ui-list-item>
+      </ui-list>
+    </ui-popover>
   </ui-card>
   <ui-card padding="p-1 ml-4 pointer-events-auto">
     <button
@@ -251,6 +274,9 @@ const renameState = reactive({
 
 const shared = computed(() => sharedWorkflowStore.getById(props.workflow.id));
 const hosted = computed(() => userStore.hostedWorkflows[props.workflow.id]);
+const userDontHaveTeamAccess = computed(
+  () => !userStore.validateTeamAccess(['owner', 'create'])
+);
 
 function updateWorkflow(data = {}, changedIndicator = false) {
   return workflowStore
@@ -339,7 +365,11 @@ async function setAsHostWorkflow(isHost) {
     toast.error(error.message);
   }
 }
-function shareWorkflow() {
+function shareWorkflowWithTeam() {
+  emit('modal', 'workflow-share-team');
+}
+function shareWorkflow(disabled = false) {
+  if (disabled) return;
   if (shared.value) {
     router.push(`/workflows/${props.workflow.id}/shared`);
     return;

+ 121 - 0
src/components/newtab/workflows/WorkflowsUserTeam.vue

@@ -0,0 +1,121 @@
+<template>
+  <p v-if="!userStore.user" class="text-center my-4">
+    <ui-spinner v-if="!userStore.retrieved" color="text-accent" />
+    <template v-else>
+      You must
+      <a href="https://www.automa.site/auth" class="underline" target="_blank"
+        >login</a
+      >
+      to use these workflows
+    </template>
+  </p>
+  <div v-else-if="teamWorkflows.length === 0" class="text-center">
+    <img src="@/assets/svg/files-and-folder.svg" class="mx-auto w-96" />
+    <p class="text-lg font-semibold">Nothing to see here</p>
+    <p class="text-gray-600 dark:text-gray-200">
+      Browse workflows that been shared by your team
+    </p>
+    <ui-button
+      :href="`http://localhost:3002/workflows?teamId=${userStore.user.team.id}&workflowsBy=team`"
+      tag="a"
+      target="_blank"
+      variant="accent"
+      class="mt-8 inline-block"
+    >
+      Browse workflows
+    </ui-button>
+  </div>
+  <div v-else class="workflows-container">
+    <shared-card
+      v-for="workflow in workflows"
+      :key="workflow.id"
+      :data="workflow"
+      :menu="menu"
+      @menuSelected="onMenuSelected"
+      @execute="executeWorkflow(workflow)"
+      @click="$router.push(`/teams/${workflow.teamId}/workflows/${$event.id}`)"
+    >
+      <template #footer-content>
+        <span
+          :class="tagColors[workflow.tag]"
+          class="text-sm rounded-md capitalize p-1"
+        >
+          {{ workflow.tag }}
+        </span>
+      </template>
+    </shared-card>
+  </div>
+</template>
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useUserStore } from '@/stores/user';
+import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
+import { sendMessage } from '@/utils/message';
+import { arraySorter } from '@/utils/helper';
+import { useDialog } from '@/composable/dialog';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+
+const props = defineProps({
+  search: {
+    type: String,
+    default: '',
+  },
+  sort: {
+    type: Object,
+    default: () => ({
+      by: '',
+      order: '',
+    }),
+  },
+});
+
+const tagColors = {
+  stage: 'bg-yellow-200',
+  production: 'bg-green-200',
+};
+const menu = [
+  {
+    id: 'delete',
+    name: 'Delete',
+    icon: 'riDeleteBin7Line',
+    class: 'text-red-400',
+  },
+];
+
+const { t } = useI18n();
+const dialog = useDialog();
+const userStore = useUserStore();
+const teamWorkflowStore = useTeamWorkflowStore();
+
+const teamWorkflows = computed(() =>
+  teamWorkflowStore.getByTeam(userStore.user?.team?.id)
+);
+const workflows = computed(() => {
+  const filtered = teamWorkflows.value.filter(({ name }) =>
+    name.toLocaleLowerCase().includes(props.search.toLocaleLowerCase())
+  );
+
+  return arraySorter({
+    data: filtered,
+    key: props.sort.by,
+    order: props.sort.order,
+  });
+});
+
+function executeWorkflow(workflow) {
+  sendMessage('workflow:execute', workflow, 'background');
+}
+function onMenuSelected({ id, data }) {
+  if (id !== 'delete') return;
+
+  dialog.confirm({
+    title: t('workflow.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name: data.name }),
+    onConfirm: () => {
+      teamWorkflowStore.delete(data.teamId, data.id);
+    },
+  });
+}
+</script>

+ 2 - 2
src/components/ui/UiPopover.vue

@@ -77,10 +77,10 @@ export default {
       () => props.disabled,
       (value) => {
         if (value) {
-          instance.value.enable();
-        } else {
           instance.value.hide();
           instance.value.disable();
+        } else {
+          instance.value.enable();
         }
       }
     );

+ 0 - 8
src/composable/shortcut.js

@@ -32,14 +32,6 @@ const defaultShortcut = {
     id: 'page:settings',
     combo: 'option+s',
   },
-  'action:undo': {
-    id: 'action:undo',
-    combo: 'mod+z',
-  },
-  'action:redo': {
-    id: 'action:redo',
-    combo: 'mod+shift+z',
-  },
   'action:search': {
     id: 'action:search',
     combo: 'mod+f',

+ 39 - 3
src/content/services/webService.js

@@ -1,6 +1,7 @@
 import { openDB } from 'idb';
 import { nanoid } from 'nanoid';
 import browser from 'webextension-polyfill';
+import cloneDeep from 'lodash.clonedeep';
 import { sendMessage } from '@/utils/message';
 import { objectHasKey, parseJSON } from '@/utils/helper';
 
@@ -22,7 +23,7 @@ function initWebListener() {
   return { on };
 }
 
-(async () => {
+window.addEventListener('DOMContentLoaded', async () => {
   try {
     document.body.setAttribute(
       'data-atm-ext-installed',
@@ -76,12 +77,47 @@ function initWebListener() {
         }
 
         await browser.storage.local.set({ workflows: workflowsStorage });
-        sendMessage('workflow:added', workflowId, 'background');
+        sendMessage('workflow:added', { workflowId }, 'background');
       } catch (error) {
         console.error(error);
       }
     });
+    webListener.on('add-team-workflow', async ({ workflow }) => {
+      let { teamWorkflows } = await browser.storage.local.get('teamWorkflows');
+
+      let workflowData = {
+        ...workflow,
+        createdAt: Date.now(),
+        table: workflow.table ?? [],
+      };
+      workflowData.drawflow =
+        typeof workflowData.drawflow === 'string'
+          ? parseJSON(workflowData.drawflow, workflowData.drawflow)
+          : workflowData.drawflow;
+
+      if (!teamWorkflows) teamWorkflows = {};
+      if (!teamWorkflows[workflowData.teamId])
+        teamWorkflows[workflowData.teamId] = {};
+
+      const workflowToMerge =
+        teamWorkflows[workflowData.teamId][workflow.id] || null;
+      if (workflowToMerge) {
+        workflowData = cloneDeep(workflowToMerge, workflowData);
+      }
+
+      teamWorkflows[workflowData.teamId][workflow.id] = workflowData;
+      await browser.storage.local.set({ teamWorkflows });
+      sendMessage(
+        'workflow:added',
+        {
+          workflowId: workflowData.id,
+          teamId: workflowData.teamId,
+          source: 'team',
+        },
+        'background'
+      );
+    });
   } catch (error) {
     console.error(error);
   }
-})();
+});

+ 2 - 0
src/lib/vRemixicon.js

@@ -21,6 +21,7 @@ import {
   riSortDesc,
   riTimeLine,
   riFlagLine,
+  riTeamLine,
   riLinksLine,
   riGroupLine,
   riGuideLine,
@@ -145,6 +146,7 @@ export const icons = {
   riSortDesc,
   riTimeLine,
   riFlagLine,
+  riTeamLine,
   riLinksLine,
   riGroupLine,
   riGuideLine,

+ 16 - 3
src/newtab/App.vue

@@ -63,6 +63,7 @@ import { useStore } from '@/stores/main';
 import { useUserStore } from '@/stores/user';
 import { useFolderStore } from '@/stores/folder';
 import { useWorkflowStore } from '@/stores/workflow';
+import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 import { useTheme } from '@/composable/theme';
 import { parseJSON } from '@/utils/helper';
 import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
@@ -94,6 +95,7 @@ const router = useRouter();
 const userStore = useUserStore();
 const folderStore = useFolderStore();
 const workflowStore = useWorkflowStore();
+const teamWorkflowStore = useTeamWorkflowStore();
 const sharedWorkflowStore = useSharedWorkflowStore();
 const hostedWorkflowStore = useHostedWorkflowStore();
 
@@ -107,6 +109,8 @@ const prevVersion = localStorage.getItem('ext-version') || '0.0.0';
 
 async function fetchUserData() {
   try {
+    if (!useRouter.user) return;
+
     const { backup, hosted } = await getUserWorkflows();
     userStore.hostedWorkflows = hosted || {};
 
@@ -184,9 +188,17 @@ window.addEventListener('storage', ({ key, newValue }) => {
 });
 browser.runtime.onMessage.addListener(({ type, data }) => {
   if (type === 'workflow:added') {
-    workflowStore.loadData().then(() => {
-      router.push(`/workflows/${data.workflowId}?permission=true`);
-    });
+    if (data.source === 'team') {
+      teamWorkflowStore.loadData().then(() => {
+        router.push(
+          `/teams/${data.teamId}/workflows/${data.workflowId}?permission=true`
+        );
+      });
+    } else {
+      workflowStore.loadData().then(() => {
+        router.push(`/workflows/${data.workflowId}?permission=true`);
+      });
+    }
   }
 });
 
@@ -199,6 +211,7 @@ browser.runtime.onMessage.addListener(({ type, data }) => {
       folderStore.load(),
       store.loadSettings(),
       workflowStore.loadData(),
+      teamWorkflowStore.loadData(),
       hostedWorkflowStore.loadData(),
     ]);
 

+ 23 - 3
src/newtab/pages/Workflows.vue

@@ -1,9 +1,9 @@
 <template>
   <div class="container pt-8 pb-4">
-    <h1 class="text-2xl font-semibold mb-8 capitalize">
+    <h1 class="text-2xl font-semibold capitalize">
       {{ t('common.workflow', 2) }}
     </h1>
-    <div class="flex items-start">
+    <div class="flex items-start mt-8">
       <div class="w-60 sticky top-8">
         <div class="flex w-full">
           <ui-button
@@ -49,6 +49,16 @@
               {{ t('workflow.browse') }}
             </span>
           </ui-list-item>
+          <ui-list-item
+            v-if="userTeamWorkflows.length > 0 || userStore.user?.team"
+            :active="state.activeTab === 'team-workflows'"
+            color="bg-box-transparent font-semibold"
+            class="cursor-pointer"
+            @click="state.activeTab = 'team-workflows'"
+          >
+            <v-remixicon name="riTeamLine" />
+            <span class="ml-4"> Team Workflows </span>
+          </ui-list-item>
           <ui-expand
             :model-value="true"
             append-icon
@@ -144,6 +154,12 @@
           </div>
         </div>
         <ui-tab-panels v-model="state.activeTab" class="flex-1 mt-6">
+          <ui-tab-panel value="team-workflows">
+            <workflows-user-team
+              :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"
@@ -210,12 +226,14 @@ import { useGroupTooltip } from '@/composable/groupTooltip';
 import { isWhitespace } from '@/utils/helper';
 import { useUserStore } from '@/stores/user';
 import { useWorkflowStore } from '@/stores/workflow';
+import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 import { useHostedWorkflowStore } from '@/stores/hostedWorkflow';
 import { importWorkflow, getWorkflowPermissions } from '@/utils/workflowData';
 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();
@@ -224,6 +242,7 @@ const toast = useToast();
 const dialog = useDialog();
 const userStore = useUserStore();
 const workflowStore = useWorkflowStore();
+const teamWorkflowStore = useTeamWorkflowStore();
 const hostedWorkflowStore = useHostedWorkflowStore();
 
 const sorts = ['name', 'createdAt'];
@@ -232,7 +251,7 @@ const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
 const state = shallowReactive({
   query: '',
   activeFolder: '',
-  activeTab: 'local',
+  activeTab: 'team-workflows',
   perPage: savedSorts.perPage || 18,
   sortBy: savedSorts.sortBy || 'createdAt',
   sortOrder: savedSorts.sortOrder || 'desc',
@@ -248,6 +267,7 @@ const permissionState = shallowReactive({
 });
 
 const hostedWorkflows = computed(() => hostedWorkflowStore.toArray);
+const userTeamWorkflows = computed(() => teamWorkflowStore.toArray);
 
 function clearAddWorkflowModal() {
   Object.assign(addWorkflowModal, {

+ 3 - 0
src/newtab/pages/teams/[teamId]/workflows/[id].vue

@@ -0,0 +1,3 @@
+<template>
+  <p>hola?</p>
+</template>

+ 38 - 16
src/newtab/pages/workflows/[id].vue

@@ -92,9 +92,7 @@
               <ui-card padding="p-0 ml-2 undo-redo">
                 <button
                   v-tooltip.group="
-                    `${t('workflow.undo')} (${
-                      shortcut['action:undo'].readable
-                    })`
+                    `${t('workflow.undo')} (${getReadableShortcut('mod+z')})`
                   "
                   :disabled="!commandManager.state.value.canUndo"
                   class="p-2 rounded-lg transition-colors"
@@ -104,9 +102,9 @@
                 </button>
                 <button
                   v-tooltip.group="
-                    `${t('workflow.redo')} (${
-                      shortcut['action:redo'].readable
-                    })`
+                    `${t('workflow.redo')} (${getReadableShortcut(
+                      'mod+shift+z'
+                    )})`
                   "
                   :disabled="!commandManager.state.value.canRedo"
                   class="p-2 rounded-lg transition-colors"
@@ -184,7 +182,11 @@ import dagre from 'dagre';
 import { useStore } from '@/stores/main';
 import { useUserStore } from '@/stores/user';
 import { useWorkflowStore } from '@/stores/workflow';
-import { useShortcut, getShortcut } from '@/composable/shortcut';
+import {
+  useShortcut,
+  getShortcut,
+  getReadableShortcut,
+} from '@/composable/shortcut';
 import { getWorkflowPermissions } from '@/utils/workflowData';
 import { tasks } from '@/utils/shared';
 import { fetchApi } from '@/utils/api';
@@ -199,6 +201,7 @@ import convertWorkflowData from '@/utils/convertWorkflowData';
 import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
 import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
 import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
+import WorkflowShareTeam from '@/components/newtab/workflow/WorkflowShareTeam.vue';
 import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
 import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
 import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
@@ -288,6 +291,25 @@ const workflowModals = {
       },
     },
   },
+  'workflow-share-team': {
+    icon: 'riShareLine',
+    component: WorkflowShareTeam,
+    attrs: {
+      blur: true,
+      persist: true,
+      customContent: true,
+    },
+    events: {
+      close() {
+        modalState.show = false;
+        modalState.name = '';
+      },
+      publish() {
+        modalState.show = false;
+        modalState.name = '';
+      },
+    },
+  },
   'global-data': {
     width: 'max-w-2xl',
     icon: 'riDatabase2Line',
@@ -884,7 +906,13 @@ function pasteCopiedElements(position) {
   editor.value.addNodes(nodes);
   editor.value.addEdges(edges);
 }
-function onKeydown({ ctrlKey, metaKey, key, target }) {
+function undoRedoCommand(type, { target }) {
+  const els = ['INPUT', 'SELECT', 'TEXTAREA'];
+  if (els.includes(target.tagName) || target.isContentEditable) return;
+
+  executeCommand(type);
+}
+function onKeydown({ ctrlKey, metaKey, shiftKey, key, target }) {
   const els = ['INPUT', 'SELECT', 'TEXTAREA'];
   if (els.includes(target.tagName) || target.isContentEditable) return;
 
@@ -894,6 +922,8 @@ function onKeydown({ ctrlKey, metaKey, key, target }) {
     copySelectedElements();
   } else if (command('v')) {
     pasteCopiedElements();
+  } else if (command('z')) {
+    undoRedoCommand(shiftKey ? 'redo' : 'undo');
   }
 }
 async function fetchConnectedTable() {
@@ -905,18 +935,10 @@ async function fetchConnectedTable() {
 
   connectedTable.value = table;
 }
-function undoRedoCommand(type, { target }) {
-  const els = ['INPUT', 'SELECT', 'TEXTAREA'];
-  if (els.includes(target.tagName) || target.isContentEditable) return;
-
-  executeCommand(type);
-}
 
 const shortcut = useShortcut([
   getShortcut('editor:toggle-sidebar', toggleSidebar),
   getShortcut('editor:duplicate-block', duplicateElements),
-  getShortcut('action:undo', (_, event) => undoRedoCommand('undo', event)),
-  getShortcut('action:redo', (_, event) => undoRedoCommand('redo', event)),
 ]);
 
 watch(

+ 6 - 0
src/newtab/router.js

@@ -16,6 +16,7 @@ import SettingsAbout from './pages/settings/SettingsAbout.vue';
 import SettingsShortcuts from './pages/settings/SettingsShortcuts.vue';
 import SettingsBackup from './pages/settings/SettingsBackup.vue';
 import SettingsEditor from './pages/settings/SettingsEditor.vue';
+import TeamWorkflows from './pages/teams/[teamId]/workflows/[id].vue';
 
 const routes = [
   {
@@ -39,6 +40,11 @@ const routes = [
     path: '/schedule',
     component: ScheduledWorkflow,
   },
+  {
+    name: 'team-workflows',
+    path: '/teams/:teamId/workflows/:id',
+    component: TeamWorkflows,
+  },
   {
     name: 'workflows-details',
     path: '/workflows/:id',

+ 4 - 0
src/stores/sharedWorkflow.js

@@ -1,5 +1,6 @@
 import { defineStore } from 'pinia';
 import { fetchApi, cacheApi } from '@/utils/api';
+import { useUserStore } from './user';
 
 export const useSharedWorkflowStore = defineStore('shared-workflows', {
   state: () => ({
@@ -34,6 +35,9 @@ export const useSharedWorkflowStore = defineStore('shared-workflows', {
       delete this.workflows[id];
     },
     async fetchWorkflows(useCache = true) {
+      const userStore = useUserStore();
+      if (!userStore.user) return;
+
       const workflows = await cacheApi(
         'shared-workflows',
         async () => {

+ 59 - 0
src/stores/teamWorkflow.js

@@ -0,0 +1,59 @@
+import { defineStore } from 'pinia';
+import browser from 'webextension-polyfill';
+
+export const useTeamWorkflowStore = defineStore('team-workflows', {
+  storageMap: {
+    workflows: 'teamWorkflows',
+  },
+  state: () => ({
+    workflows: {},
+    retrieved: false,
+  }),
+  getters: {
+    toArray: (state) => Object.values(state.workflows),
+    getByTeam: (state) => (teamId) => {
+      if (!state.workflows) return [];
+
+      return Object.values(state.workflows[teamId] || {});
+    },
+    getById: (state) => (teamId, id) => {
+      if (!state.workflows || !state.workflows[teamId]) return null;
+
+      return state.workflows[teamId][id] || null;
+    },
+  },
+  actions: {
+    insert(teamId, data) {
+      if (!this.workflows[teamId]) this.workflows[teamId] = {};
+
+      if (Array.isArray(data)) {
+        data.forEach((item) => {
+          this.workflows[teamId][item.id] = item;
+        });
+      } else {
+        this.workflows[data.id] = data;
+      }
+    },
+    update({ id, data }) {
+      if (!this.workflows[id]) return null;
+
+      Object.assign(this.workflows[id], data);
+
+      return this.workflows[id];
+    },
+    async delete(teamId, id) {
+      if (!this.workflows[teamId]) return;
+
+      delete this.workflows[teamId][id];
+      await this.saveToStorage('workflows');
+    },
+    async loadData() {
+      const { teamWorkflows } = await browser.storage.local.get(
+        'teamWorkflows'
+      );
+
+      this.workflows = teamWorkflows || {};
+      this.retrieved = true;
+    },
+  },
+});

+ 8 - 0
src/stores/user.js

@@ -11,6 +11,13 @@ export const useUserStore = defineStore('user', {
   }),
   getters: {
     getHostedWorkflows: (state) => Object.values(state.hostedWorkflows),
+    validateTeamAccess:
+      (state) =>
+      (access = []) => {
+        if (!state.user?.team) return false;
+
+        return access.some((item) => state.user.team.access.includes(item));
+      },
   },
   actions: {
     async loadUser() {
@@ -41,6 +48,7 @@ export const useUserStore = defineStore('user', {
         this.backupIds = backupIds || [];
 
         this.user = user;
+        this.retrieved = true;
       } catch (error) {
         console.error(error);
       }

+ 5 - 2
src/stores/workflow.js

@@ -248,8 +248,11 @@ export const useWorkflowStore = defineStore('workflow', {
         }
       }
 
-      await browser.storage.local.remove(`state:${id}`);
-
+      await browser.storage.local.remove([
+        `state:${id}`,
+        `draft:${id}`,
+        `draft-team:${id}`,
+      ]);
       await this.saveToStorage('workflows');
 
       return id;