浏览代码

feat: add workflow collection

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

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "0.3.1",
+  "version": "0.3.2",
   "description": "An extension for automating your browser by connecting blocks",
   "license": "MIT",
   "repository": {
@@ -37,6 +37,7 @@
     "vue": "3.2.19",
     "vue-prism-editor": "^2.0.0-alpha.2",
     "vue-router": "^4.0.11",
+    "vuedraggable": "^4.1.0",
     "vuex": "^4.0.2",
     "webextension-polyfill": "^0.8.0"
   },

+ 51 - 0
src/background/collection-engine/flow-handler.js

@@ -0,0 +1,51 @@
+import WorkflowEngine from '../workflow-engine';
+import dataExporter from '@/utils/data-exporter';
+
+export function workflow(flow) {
+  return new Promise((resolve, reject) => {
+    const currentWorkflow = this.workflows.find(({ id }) => id === flow.itemId);
+
+    if (!currentWorkflow) {
+      const error = new Error(`Can't find workflow with ${flow.itemId} ID`);
+      error.name = 'Workflow';
+
+      reject(error);
+      return;
+    }
+
+    this.currentWorkflow = currentWorkflow;
+
+    const engine = new WorkflowEngine(currentWorkflow, {
+      isInCollection: true,
+      collectionLogId: this.id,
+      collectionId: this.collection.id,
+    });
+
+    this.workflowEngine = engine;
+
+    engine.init();
+    engine.on('update', (state) => {
+      this.workflowState = state;
+      this.updateState();
+    });
+    engine.on('destroyed', ({ id, status, message }) => {
+      this.data.push({
+        id,
+        status,
+        errorMessage: message,
+        workflowId: currentWorkflow.id,
+        workflowName: currentWorkflow.name,
+      });
+
+      resolve({ id, name: currentWorkflow.name, type: status, message });
+    });
+  });
+}
+
+export function exportResult() {
+  return new Promise((resolve) => {
+    dataExporter(this.data, { name: this.collection.name, type: 'json' }, true);
+
+    resolve({ name: 'Export result' });
+  });
+}

+ 176 - 0
src/background/collection-engine/index.js

@@ -0,0 +1,176 @@
+import { nanoid } from 'nanoid';
+import browser from 'webextension-polyfill';
+import { toCamelCase } from '@/utils/helper';
+import * as flowHandler from './flow-handler';
+import workflowState from '../workflow-state';
+
+class CollectionEngine {
+  constructor(collection) {
+    this.id = nanoid();
+    this.collection = collection;
+    this.workflows = [];
+    this.data = [];
+    this.logs = [];
+    this.isDestroyed = false;
+    this.currentFlow = null;
+    this.workflowState = null;
+    this.workflowEngine = null;
+    this.currentWorkflow = null;
+    this.currentIndex = 0;
+    this.eventListeners = {};
+  }
+
+  async init() {
+    try {
+      if (this.collection.flow.length === 0) return;
+
+      await workflowState.add(this.id, {
+        state: this.state,
+        isCollection: true,
+        collectionId: this.collection.id,
+      });
+
+      const { workflows } = await browser.storage.local.get('workflows');
+
+      this.workflows = workflows;
+      this.startedTimestamp = Date.now();
+      this._flowHandler(this.collection.flow[0]);
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  on(name, listener) {
+    (this.eventListeners[name] = this.eventListeners[name] || []).push(
+      listener
+    );
+  }
+
+  dispatchEvent(name, params) {
+    const listeners = this.eventListeners[name];
+
+    if (!listeners) return;
+
+    listeners.forEach((callback) => {
+      callback(params);
+    });
+  }
+
+  async destroy(status) {
+    this.isDestroyed = true;
+    this.dispatchEvent('destroyed', { id: this.id });
+
+    const { logs } = await browser.storage.local.get('logs');
+    const { name, icon } = this.collection;
+
+    logs.push({
+      name,
+      icon,
+      status,
+      id: this.id,
+      data: this.data,
+      history: this.logs,
+      endedAt: Date.now(),
+      collectionId: this.collection.id,
+      startedAt: this.startedTimestamp,
+    });
+
+    await browser.storage.local.set({ logs });
+    await workflowState.delete(this.id);
+
+    this.listeners = {};
+  }
+
+  nextFlow() {
+    this.currentIndex += 1;
+
+    if (this.currentIndex >= this.collection.flow.length) {
+      this.destroy('success');
+
+      return;
+    }
+
+    this._flowHandler(this.collection.flow[this.currentIndex]);
+  }
+
+  get state() {
+    const data = {
+      id: this.id,
+      currentBlock: [],
+      name: this.collection.name,
+      startedTimestamp: this.startedTimestamp,
+    };
+
+    if (this.currentWorkflow) {
+      const { name, icon } = this.currentWorkflow;
+
+      data.currentBlock.push({ name, icon });
+    }
+
+    if (this.workflowState) {
+      const { name } = this.workflowState.currentBlock;
+
+      data.currentBlock.push({ name });
+    }
+
+    return data;
+  }
+
+  updateState() {
+    workflowState.update(this.id, this.state);
+  }
+
+  stop() {
+    this.workflowEngine.stop();
+
+    this.destroy('stopped');
+  }
+
+  _flowHandler(flow) {
+    if (this.isDestroyed) return;
+
+    const handlerName =
+      flow.type === 'workflow' ? 'workflow' : toCamelCase(flow.itemId);
+    const handler = flowHandler[handlerName];
+    const started = Date.now();
+
+    this.currentFlow = flow;
+    this.updateState();
+
+    if (handler) {
+      if (flow.type !== 'workflow') {
+        this.workflowState = null;
+        this.currentWorkflow = null;
+        this.workflowEngine = null;
+      }
+
+      handler
+        .call(this, flow)
+        .then((data) => {
+          this.logs.push({
+            type: data.type || 'success',
+            name: data.name,
+            logId: data.id,
+            duration: Math.round(Date.now() - started),
+          });
+
+          this.nextFlow();
+        })
+        .catch((error) => {
+          this.logs.push({
+            type: 'error',
+            name: error.name,
+            logId: error.id,
+            message: error.message,
+            duration: Math.round(Date.now() - started),
+          });
+
+          this.nextFlow();
+        });
+    } else {
+      console.error(`"${flow.type}" flow doesn't have a handler`);
+    }
+  }
+}
+
+export default CollectionEngine;

+ 28 - 2
src/background/index.js

@@ -2,6 +2,7 @@ import browser from 'webextension-polyfill';
 import { MessageListener } from '@/utils/message';
 import workflowState from './workflow-state';
 import WorkflowEngine from './workflow-engine';
+import CollectionEngine from './collection-engine';
 
 function getWorkflow(workflowId) {
   return new Promise((resolve) => {
@@ -14,15 +15,16 @@ function getWorkflow(workflowId) {
 }
 
 const runningWorkflows = {};
+const runningCollections = {};
 
 async function executeWorkflow(workflow, tabId) {
   try {
-    const engine = new WorkflowEngine(workflow, tabId);
+    const engine = new WorkflowEngine(workflow, { tabId });
 
     runningWorkflows[engine.id] = engine;
 
     engine.init();
-    engine.on('destroyed', (id) => {
+    engine.on('destroyed', ({ id }) => {
       delete runningWorkflows[id];
     });
 
@@ -32,6 +34,18 @@ async function executeWorkflow(workflow, tabId) {
     return error;
   }
 }
+function executeCollection(collection) {
+  const engine = new CollectionEngine(collection);
+
+  runningCollections[engine.id] = engine;
+
+  engine.init();
+  engine.on('destroyed', (id) => {
+    delete runningWorkflows[id];
+  });
+
+  return true;
+}
 
 browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
   if (changeInfo.status === 'complete') {
@@ -66,6 +80,7 @@ chrome.runtime.onInstalled.addListener((details) => {
       .set({
         logs: [],
         workflows: [],
+        collections: [],
         shortcuts: {},
         workflowState: [],
         isFirstTime: true,
@@ -86,6 +101,17 @@ chrome.runtime.onInstalled.addListener((details) => {
 
 const message = new MessageListener('background');
 
+message.on('collection:execute', executeCollection);
+message.on('collection:stop', (id) => {
+  const collection = runningCollections[id];
+  if (!collection) {
+    workflowState.delete(id);
+    return;
+  }
+
+  collection.stop();
+});
+
 message.on('workflow:execute', (workflow) => executeWorkflow(workflow));
 message.on('workflow:stop', (id) => {
   const workflow = runningWorkflows[id];

+ 4 - 2
src/background/blocks-handler.js → src/background/workflow-engine/blocks-handler.js

@@ -258,8 +258,10 @@ export async function takeScreenshot(block) {
 
       const uri = await browser.tabs.captureVisibleTab(options);
 
-      await browser.windows.update(tab.windowId, { focused: true });
-      await browser.tabs.update(tab.id, { active: true });
+      if (tab) {
+        await browser.windows.update(tab.windowId, { focused: true });
+        await browser.tabs.update(tab.id, { active: true });
+      }
 
       saveImage(uri);
     } else {

+ 0 - 0
src/background/error-message.js → src/background/workflow-engine/error-message.js


+ 15 - 8
src/background/workflow-engine.js → src/background/workflow-engine/index.js

@@ -5,7 +5,7 @@ import { toCamelCase } from '@/utils/helper';
 import { tasks } from '@/utils/shared';
 import referenceData from '@/utils/reference-data';
 import errorMessage from './error-message';
-import workflowState from './workflow-state';
+import workflowState from '../workflow-state';
 import * as blocksHandler from './blocks-handler';
 
 let reloadTimeout;
@@ -31,7 +31,7 @@ function tabRemovedHandler(tabId) {
   delete this.tabId;
 
   if (tasks[this.currentBlock.name].category === 'interaction') {
-    this.destroy('error');
+    this.destroy('error', 'Current active tab is removed');
   }
 
   workflowState.update(this.id, this.state);
@@ -74,10 +74,12 @@ function tabUpdatedHandler(tabId, changeInfo) {
 }
 
 class WorkflowEngine {
-  constructor(workflow, tabId = null) {
+  constructor(workflow, { tabId = null, isInCollection, collectionLogId }) {
     this.id = nanoid();
     this.tabId = tabId;
     this.workflow = workflow;
+    this.isInCollection = isInCollection;
+    this.collectionLogId = collectionLogId;
     this.data = {};
     this.blocks = {};
     this.eventListeners = {};
@@ -137,8 +139,9 @@ class WorkflowEngine {
 
     workflowState
       .add(this.id, {
-        workflowId: this.workflow.id,
         state: this.state,
+        workflowId: this.workflow.id,
+        isInCollection: this.isInCollection,
       })
       .then(() => {
         this._blockHandler(triggerBlock);
@@ -166,8 +169,10 @@ class WorkflowEngine {
     this.destroy('stopped');
   }
 
-  async destroy(status) {
+  async destroy(status, message) {
     try {
+      this.dispatchEvent('destroyed', { id: this.id, status, message });
+
       this.eventListeners = {};
       this.tabMessageListeners = {};
       this.tabUpdatedListeners = {};
@@ -195,12 +200,12 @@ class WorkflowEngine {
           history: this.logs,
           endedAt: this.endedTimestamp,
           startedAt: this.startedTimestamp,
+          isInCollection: this.isInCollection,
+          collectionLogId: this.collectionLogId,
         });
 
         await browser.storage.local.set({ logs });
       }
-
-      this.dispatchEvent('destroyed', this.id);
     } catch (error) {
       console.error(error);
     }
@@ -222,6 +227,7 @@ class WorkflowEngine {
       'isPaused',
       'isDestroyed',
       'currentBlock',
+      'isInCollection',
       'startedTimestamp',
     ];
     const state = keys.reduce((acc, key) => {
@@ -252,6 +258,7 @@ class WorkflowEngine {
     this.currentBlock = block;
 
     workflowState.update(this.id, this.state);
+    this.dispatchEvent('update', this.state);
 
     const started = Date.now();
     const isInteraction = tasks[block.name].category === 'interaction';
@@ -304,7 +311,7 @@ class WorkflowEngine {
               error.data || ''
             );
           } else {
-            this.destroy('error');
+            this.destroy('error', error.message);
           }
 
           clearTimeout(this.workflowTimeout);

+ 2 - 2
src/background/workflow-state.js

@@ -22,11 +22,11 @@ class WorkflowState {
     try {
       let { workflowState } = await browser.storage.local.get('workflowState');
 
-      if (filter) {
+      if (workflowState && filter) {
         workflowState = workflowState.filter(filter);
       }
 
-      return workflowState;
+      return workflowState || [];
     } catch (error) {
       console.error(error);
 

+ 5 - 0
src/components/newtab/app/AppSidebar.vue

@@ -91,6 +91,11 @@ const tabs = [
     icon: 'riFlowChart',
     path: '/workflows',
   },
+  {
+    name: 'Collections',
+    icon: 'riFolderLine',
+    path: '/collections',
+  },
   {
     name: 'Logs',
     icon: 'riHistoryLine',

+ 3 - 1
src/components/newtab/logs/LogsDataViewer.vue

@@ -48,7 +48,9 @@ const props = defineProps({
   },
 });
 
-const data = generateJSON(Object.keys(props.log.data), props.log.data);
+const data = Array.isArray(props.log.data)
+  ? props.log.data
+  : generateJSON(Object.keys(props.log.data), props.log.data);
 const dataStr = JSON.stringify(data, null, 2);
 
 const fileName = ref(props.log.name);

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

@@ -0,0 +1,73 @@
+<template>
+  <ui-card class="hover:ring-2 group hover:ring-accent">
+    <div class="flex items-center mb-4">
+      <span class="p-2 rounded-lg bg-box-transparent">
+        <v-remixicon :name="data.icon || icon" />
+      </span>
+      <div class="flex-grow"></div>
+      <button
+        class="invisible group-hover:visible"
+        @click="$emit('execute', data)"
+      >
+        <v-remixicon name="riPlayLine" />
+      </button>
+      <ui-popover v-if="showDetails" class="h-6 ml-2">
+        <template #trigger>
+          <button>
+            <v-remixicon name="riMoreLine" />
+          </button>
+        </template>
+        <ui-list class="w-36 space-y-1">
+          <ui-list-item
+            v-for="item in menu"
+            :key="item.name"
+            v-close-popover
+            class="cursor-pointer"
+            @click="$emit('menuSelected', { name: item.name, data })"
+          >
+            <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+            <span class="capitalize">{{ item.name }}</span>
+          </ui-list-item>
+        </ui-list>
+      </ui-popover>
+    </div>
+    <div class="cursor-pointer" @click="$emit('click', data)">
+      <p class="line-clamp font-semibold leading-tight">
+        {{ data.name }}
+      </p>
+      <p class="text-gray-600 dark:text-gray-200">{{ formatDate() }}</p>
+    </div>
+  </ui-card>
+</template>
+<script setup>
+import dayjs from '@/lib/dayjs';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  icon: {
+    type: String,
+    default: 'riGlobalLine',
+  },
+  showDetails: {
+    type: Boolean,
+    default: true,
+  },
+  menu: {
+    type: Array,
+    default: () => [],
+  },
+});
+defineEmits(['execute', 'click', 'menuSelected']);
+
+let formattedDate = null;
+const formatDate = () => {
+  if (formattedDate) return formattedDate;
+
+  formattedDate = dayjs(props.data.createdAt).fromNow();
+
+  return formattedDate;
+};
+</script>

+ 31 - 20
src/components/newtab/shared/SharedWorkflowState.vue

@@ -2,19 +2,19 @@
   <ui-card>
     <div class="flex items-center mb-4">
       <div class="flex-1 text-overflow mr-4">
-        <p class="w-full mr-2 text-overflow">{{ state.name }}</p>
+        <p class="w-full mr-2 text-overflow">{{ data.state.name }}</p>
         <p
           class="w-full mr-2 text-gray-600 leading-tight text-overflow"
           :title="`Started at: ${formatDate(
-            state.startedTimestamp,
+            data.state.startedTimestamp,
             'DD MMM, hh:mm A'
           )}`"
         >
-          {{ formatDate(state.startedTimestamp, 'relative') }}
+          {{ formatDate(data.state.startedTimestamp, 'relative') }}
         </p>
       </div>
       <ui-button
-        v-if="state.tabId"
+        v-if="data.state.tabId"
         icon
         class="mr-2"
         title="Open tab"
@@ -22,18 +22,21 @@
       >
         <v-remixicon name="riExternalLinkLine" />
       </ui-button>
-      <ui-button variant="accent" @click="stopWorkflow(item)">
+      <ui-button variant="accent" @click="stopWorkflow">
         <v-remixicon name="riStopLine" class="mr-2 -ml-1" />
         <span>Stop</span>
       </ui-button>
     </div>
-    <div class="flex items-center bg-box-transparent px-4 py-2 rounded-lg">
-      <template v-if="state.currentBlock">
-        <v-remixicon :name="getBlock().icon" />
-        <p class="flex-1 ml-2 mr-4">{{ getBlock().name }}</p>
+    <div class="divide-y bg-box-transparent divide-y px-4 rounded-lg">
+      <div
+        v-for="block in getBlock()"
+        :key="block.name"
+        class="flex items-center py-2"
+      >
+        <v-remixicon :name="block.icon" />
+        <p class="flex-1 ml-2 mr-4">{{ block.name }}</p>
         <ui-spinner color="text-accnet" size="20" />
-      </template>
-      <p v-else>No block</p>
+      </div>
     </div>
   </ui-card>
 </template>
@@ -44,20 +47,24 @@ import { tasks } from '@/utils/shared';
 import dayjs from '@/lib/dayjs';
 
 const props = defineProps({
-  id: {
-    type: String,
-    default: '',
-  },
-  state: {
+  data: {
     type: Object,
     default: () => ({}),
   },
 });
 
 function getBlock() {
-  if (!props.state.currentBlock) return {};
+  if (!props.data.state.currentBlock) return [];
+
+  if (Array.isArray(props.data.state.currentBlock)) {
+    return props.data.state.currentBlock.map((item) => {
+      if (tasks[item.name]) return tasks[item.name];
+
+      return item;
+    });
+  }
 
-  return tasks[props.state.currentBlock.name];
+  return [tasks[props.data.state.currentBlock.name]];
 }
 function formatDate(date, format) {
   if (format === 'relative') return dayjs(date).fromNow();
@@ -65,9 +72,13 @@ function formatDate(date, format) {
   return dayjs(date).format(format);
 }
 function openTab() {
-  browser.tabs.update(props.state.tabId, { active: true });
+  browser.tabs.update(props.data.state.tabId, { active: true });
 }
 function stopWorkflow() {
-  sendMessage('workflow:stop', props.id, 'background');
+  sendMessage(
+    props.data.isCollection ? 'collection:stop' : 'workflow:stop',
+    props.data.id,
+    'background'
+  );
 }
 </script>

+ 0 - 62
src/components/newtab/workflow/WorkflowCard.vue

@@ -1,62 +0,0 @@
-<template>
-  <ui-card class="hover:ring-accent hover:ring-2">
-    <div class="mb-4 flex items-center">
-      <span class="p-2 rounded-lg bg-box-transparent inline-block">
-        <v-remixicon :name="workflow.icon" />
-      </span>
-      <div class="flex-grow"></div>
-      <button @click="$emit('execute', workflow)">
-        <v-remixicon name="riPlayLine" />
-      </button>
-      <ui-popover v-if="showDetails" class="ml-2 h-6">
-        <template #trigger>
-          <button>
-            <v-remixicon name="riMoreLine" />
-          </button>
-        </template>
-        <ui-list class="w-36 space-y-1">
-          <ui-list-item
-            v-for="item in menu"
-            :key="item.name"
-            v-close-popover
-            class="cursor-pointer"
-            @click="$emit(item.name, workflow)"
-          >
-            <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
-            <span class="capitalize">{{ item.name }}</span>
-          </ui-list-item>
-        </ui-list>
-      </ui-popover>
-    </div>
-    <router-link :to="`/workflows/${workflow.id}`">
-      <p class="line-clamp leading-tight font-semibold" :title="workflow.name">
-        {{ workflow.name }}
-      </p>
-      <p class="text-gray-600 dark:text-gray-200 leading-tight text-overflow">
-        {{ formatDate() }}
-      </p>
-    </router-link>
-  </ui-card>
-</template>
-<script setup>
-import dayjs from '@/lib/dayjs';
-
-const props = defineProps({
-  workflow: {
-    type: Object,
-    default: () => ({}),
-  },
-  showDetails: {
-    type: Boolean,
-    default: true,
-  },
-});
-defineEmits(['delete', 'export', 'rename', 'execute']);
-
-const formatDate = () => dayjs(props.workflow.createdAt).fromNow();
-const menu = [
-  { name: 'export', icon: 'riDownloadLine' },
-  { name: 'rename', icon: 'riPencilLine' },
-  { name: 'delete', icon: 'riDeleteBin7Line' },
-];
-</script>

+ 7 - 1
src/components/ui/UiPagination.vue

@@ -2,6 +2,7 @@
   <div class="flex items-center">
     <ui-button
       v-tooltip="'Previous page'"
+      :disabled="modelValue <= 1"
       icon
       @click="updatePage(modelValue - 1)"
     >
@@ -30,7 +31,12 @@
       of
       {{ maxPage }}
     </div>
-    <ui-button v-tooltip="'Next page'" icon @click="updatePage(modelValue + 1)">
+    <ui-button
+      v-tooltip="'Next page'"
+      :disabled="modelValue >= maxPage"
+      icon
+      @click="updatePage(modelValue + 1)"
+    >
       <v-remixicon rotate="180" name="riArrowLeftSLine" />
     </ui-button>
   </div>

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

@@ -1,6 +1,7 @@
 import vRemixicon from 'v-remixicon';
 import {
   riHome5Line,
+  riFolderLine,
   riGithubFill,
   riCodeSSlashLine,
   riRecordCircleLine,
@@ -66,6 +67,7 @@ import {
 
 export const icons = {
   riHome5Line,
+  riFolderLine,
   riGithubFill,
   riCodeSSlashLine,
   riRecordCircleLine,

+ 29 - 0
src/models/collection.js

@@ -0,0 +1,29 @@
+import { Model } from '@vuex-orm/core';
+import { nanoid } from 'nanoid';
+
+class Collection extends Model {
+  static entity = 'collections';
+
+  static primaryKey = 'id';
+
+  static autoSave = true;
+
+  static fields() {
+    return {
+      id: this.uid(() => nanoid()),
+      name: this.string(''),
+      flow: this.attr([]),
+      createdAt: this.number(),
+    };
+  }
+
+  static async insert(payload) {
+    const res = await super.insert(payload);
+
+    await this.store().dispatch('saveToStorage', 'collections');
+
+    return res;
+  }
+}
+
+export default Collection;

+ 1 - 0
src/models/index.js

@@ -1,2 +1,3 @@
 export { default as Workflow } from './workflow';
+export { default as Collection } from './collection';
 export { default as Log } from './log';

+ 13 - 0
src/models/log.js

@@ -13,10 +13,23 @@ class Log extends Model {
       endedAt: this.number(0),
       startedAt: this.number(0),
       workflowId: this.attr(null),
+      collectionId: this.attr(null),
       status: this.string('success'),
+      collectionLogId: this.attr(null),
       icon: this.string('riGlobalLine'),
+      isInCollection: this.boolean(false),
     };
   }
+
+  static afterDelete(item) {
+    const logs = this.query().where('collectionLogId', item.id).get();
+
+    if (logs.length !== 0) {
+      Promise.allSettled(logs.map(({ id }) => this.delete(id))).then(() => {
+        this.store().dispatch('saveToStorage', 'workflows');
+      });
+    }
+  }
 }
 
 export default Log;

+ 1 - 1
src/newtab/App.vue

@@ -17,7 +17,7 @@ const retrieved = ref(false);
 
 store.dispatch('retrieveWorkflowState');
 store
-  .dispatch('retrieve', ['workflows', 'logs'])
+  .dispatch('retrieve', ['workflows', 'logs', 'collections'])
   .then(() => {
     retrieved.value = true;
   })

+ 103 - 0
src/newtab/pages/Collections.vue

@@ -0,0 +1,103 @@
+<template>
+  <div class="container pt-8 pb-4">
+    <h1 class="text-2xl font-semibold">Collections</h1>
+    <p class="text-gray-600 dark:text-gray-200">
+      Execute your workflows continuously
+    </p>
+    <div class="flex items-center my-6 space-x-4">
+      <ui-input
+        v-model="query"
+        prepend-icon="riSearch2Line"
+        placeholder="Search..."
+        class="flex-1"
+      />
+      <ui-button variant="accent" @click="newCollection">
+        New collection
+      </ui-button>
+    </div>
+    <div class="grid gap-4 grid-cols-5 2xl:grid-cols-6">
+      <shared-card
+        v-for="collection in collections"
+        :key="collection.id"
+        :data="collection"
+        :menu="collectionCardMenu"
+        icon="riFolderLine"
+        @click="$router.push(`/collections/${$event.id}`)"
+        @execute="executeCollection"
+        @menuSelected="menuHandlers[$event.name]($event.data)"
+      />
+    </div>
+  </div>
+</template>
+<script setup>
+import { ref, computed } from 'vue';
+import { sendMessage } from '@/utils/message';
+import { useDialog } from '@/composable/dialog';
+import Collection from '@/models/collection';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+
+const dialog = useDialog();
+
+const collectionCardMenu = [
+  { name: 'rename', icon: 'riPencilLine' },
+  { name: 'delete', icon: 'riDeleteBin7Line' },
+];
+
+const query = ref('');
+
+const collections = computed(() =>
+  Collection.query()
+    .where(({ name }) =>
+      name.toLocaleLowerCase().includes(query.value.toLocaleLowerCase())
+    )
+    .orderBy('createdAt', 'desc')
+    .get()
+);
+
+function executeCollection(collection) {
+  sendMessage('collection:execute', collection, 'background');
+}
+function newCollection() {
+  dialog.prompt({
+    title: 'New collection',
+    placeholder: 'Collection name',
+    okText: 'Add collection',
+    onConfirm: (name) => {
+      Collection.insert({
+        data: {
+          name: name || 'Unnamed',
+          createdAt: Date.now(),
+        },
+      });
+    },
+  });
+}
+function renameCollection({ id, name }) {
+  dialog.prompt({
+    title: 'Rename collection',
+    placeholder: 'Collection name',
+    okText: 'Rename',
+    inputValue: name,
+    onConfirm: (newName) => {
+      Collection.update({
+        where: id,
+        data: {
+          name: newName,
+        },
+      });
+    },
+  });
+}
+function deleteCollection({ name, id }) {
+  dialog.confirm({
+    title: 'Delete collection',
+    okVariant: 'danger',
+    body: `Are you sure you want to delete "${name}" collection?`,
+    onConfirm: () => {
+      Collection.delete(id);
+    },
+  });
+}
+
+const menuHandlers = { rename: renameCollection, delete: deleteCollection };
+</script>

+ 13 - 7
src/newtab/pages/Home.vue

@@ -7,13 +7,14 @@
           <p v-if="workflows.length === 0" class="text-center text-gray-600">
             No data
           </p>
-          <workflow-card
+          <shared-card
             v-for="workflow in workflows"
             :key="workflow.id"
-            :workflow="workflow"
+            :data="workflow"
             :show-details="false"
             style="max-width: 250px"
             @execute="executeWorkflow"
+            @click="$router.push(`/workflows/${$event.id}`)"
           />
         </div>
         <div>
@@ -38,9 +39,8 @@
         </p>
         <shared-workflow-state
           v-for="item in workflowState"
-          :id="item.id"
+          v-bind="{ data: item }"
           :key="item.id"
-          :state="item.state"
           class="w-full"
         />
       </div>
@@ -53,7 +53,7 @@ import { useStore } from 'vuex';
 import { sendMessage } from '@/utils/message';
 import Log from '@/models/log';
 import Workflow from '@/models/workflow';
-import WorkflowCard from '@/components/newtab/workflow/WorkflowCard.vue';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
 import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
 
@@ -63,9 +63,15 @@ const workflows = computed(() =>
   Workflow.query().orderBy('createdAt', 'desc').limit(3).get()
 );
 const logs = computed(() =>
-  Log.query().orderBy('startedAt', 'desc').limit(10).get()
+  Log.query()
+    .where('isInCollection', false)
+    .orderBy('startedAt', 'desc')
+    .limit(10)
+    .get()
+);
+const workflowState = computed(() =>
+  store.state.workflowState.filter(({ isInCollection }) => !isInCollection)
 );
-const workflowState = computed(() => store.state.workflowState);
 
 function executeWorkflow(workflow) {
   sendMessage('workflow:execute', workflow, 'background');

+ 19 - 8
src/newtab/pages/Workflows.vue

@@ -43,15 +43,14 @@
         >
       </div>
     </div>
-    <div v-else class="grid gap-4 grid-cols-5">
-      <workflow-card
+    <div v-else class="grid gap-4 grid-cols-5 2xl:grid-cols-6">
+      <shared-card
         v-for="workflow in workflows"
+        v-bind="{ data: workflow, menu }"
         :key="workflow.id"
-        v-bind="{ workflow }"
-        @export="exportWorkflow"
-        @delete="deleteWorkflow"
-        @rename="renameWorkflow"
+        @click="$router.push(`/workflows/${$event.id}`)"
         @execute="executeWorkflow"
+        @menuSelected="menuHandlers[$event.name]($event.data)"
       />
     </div>
   </div>
@@ -61,7 +60,7 @@ import { computed, shallowReactive } from 'vue';
 import { useDialog } from '@/composable/dialog';
 import { sendMessage } from '@/utils/message';
 import { exportWorkflow, importWorkflow } from '@/utils/workflow-data';
-import WorkflowCard from '@/components/newtab/workflow/WorkflowCard.vue';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import Workflow from '@/models/workflow';
 
 const dialog = useDialog();
@@ -70,6 +69,12 @@ const sorts = [
   { name: 'Name', id: 'name' },
   { name: 'Created date', id: 'createdAt' },
 ];
+const menu = [
+  { name: 'export', icon: 'riDownloadLine' },
+  { name: 'rename', icon: 'riPencilLine' },
+  { name: 'delete', icon: 'riDeleteBin7Line' },
+];
+
 const state = shallowReactive({
   query: '',
   sortBy: 'createdAt',
@@ -96,7 +101,7 @@ function newWorkflow() {
     onConfirm: (name) => {
       Workflow.insert({
         data: {
-          name,
+          name: name || 'Unnamed',
           createdAt: Date.now(),
         },
       });
@@ -129,6 +134,12 @@ function renameWorkflow({ id, name }) {
     },
   });
 }
+
+const menuHandlers = {
+  export: exportWorkflow,
+  rename: renameWorkflow,
+  delete: deleteWorkflow,
+};
 </script>
 <style>
 .workflow-sort select {

+ 295 - 0
src/newtab/pages/collections/[id].vue

@@ -0,0 +1,295 @@
+<template>
+  <div class="container pt-8 pb-4">
+    <div class="flex items-center mb-8">
+      <input
+        :value="collection.name"
+        placeholder="Collection name"
+        class="
+          text-2xl
+          hover:ring-2 hover:ring-accent
+          font-semibold
+          bg-transparent
+        "
+        @blur="updateCollection({ name: $event.target.value || 'Unnamed' })"
+      />
+      <div class="flex-grow"></div>
+      <ui-button variant="accent" class="mr-4" @click="executeCollection">
+        Execute
+      </ui-button>
+      <ui-button class="text-red-500" @click="deleteCollection">
+        Delete
+      </ui-button>
+    </div>
+    <div class="flex items-start">
+      <div
+        class="w-80 border-r pr-6 mr-6 p-1 scroll overflow-auto"
+        style="max-height: calc(100vh - 8rem)"
+      >
+        <ui-input
+          v-model="state.query"
+          placeholder="Search workflows"
+          class="w-full space-x-1 mb-3"
+          prepend-icon="riSearch2Line"
+        />
+        <ui-tabs v-model="state.sidebarTab" fill class="w-full mb-4">
+          <ui-tab value="workflows">Workflows</ui-tab>
+          <ui-tab value="blocks">Blocks</ui-tab>
+        </ui-tabs>
+        <draggable
+          :list="state.sidebarTab === 'workflows' ? workflows : blocksArr"
+          :group="{ name: 'collection', pull: 'clone', put: false }"
+          :sort="false"
+          item-key="id"
+        >
+          <template #item="{ element }">
+            <ui-card
+              v-bind="{
+                title: element.description ? element.description : element.name,
+              }"
+              class="mb-2 cursor-move flex items-center"
+            >
+              <v-remixicon :name="element.icon" class="mr-2" />
+              <p class="flex-1 text-overflow">{{ element.name }}</p>
+            </ui-card>
+          </template>
+        </draggable>
+      </div>
+      <div class="flex-1 relative">
+        <div class="px-1 mb-4 inline-block rounded-lg bg-white">
+          <ui-tabs
+            v-model="state.activeTab"
+            class="border-none h-full space-x-1"
+          >
+            <ui-tab value="flow">Flow</ui-tab>
+            <ui-tab value="logs">Logs</ui-tab>
+            <ui-tab value="running">
+              Running
+              <span
+                v-if="runningCollection.length > 0"
+                class="
+                  ml-2
+                  p-1
+                  text-center
+                  inline-block
+                  text-xs
+                  rounded-full
+                  bg-black
+                  text-white
+                "
+                style="min-width: 25px"
+              >
+                {{ runningCollection.length }}
+              </span>
+            </ui-tab>
+          </ui-tabs>
+        </div>
+        <ui-tab-panels v-model="state.activeTab">
+          <ui-tab-panel value="flow">
+            <draggable
+              :model-value="collectionFlow"
+              item-key="id"
+              group="collection"
+              style="min-height: 200px"
+              @update:modelValue="updateCollectionFlow"
+            >
+              <template #item="{ element, index }">
+                <ui-card class="group flex cursor-move mb-2 items-center">
+                  <v-remixicon :name="element.icon" class="mr-4" />
+                  <p class="flex-1 text-overflow">{{ element.name }}</p>
+                  <router-link
+                    v-if="element.type !== 'block'"
+                    :to="'/workflows/' + element.id"
+                    title="Open workflow"
+                    class="mr-4 group group-hover:visible invisible"
+                  >
+                    <v-remixicon name="riExternalLinkLine" />
+                  </router-link>
+                  <v-remixicon
+                    name="riDeleteBin7Line"
+                    class="cursor-pointer"
+                    @click="deleteCollectionFlow(index)"
+                  />
+                </ui-card>
+              </template>
+            </draggable>
+          </ui-tab-panel>
+          <ui-tab-panel value="logs">
+            <shared-logs-table :logs="logs" class="w-full">
+              <template #item-append="{ log }">
+                <td class="text-right">
+                  <v-remixicon
+                    name="riDeleteBin7Line"
+                    class="inline-block text-red-500 cursor-pointer"
+                    @click="deleteLog(log.id)"
+                  />
+                </td>
+              </template>
+            </shared-logs-table>
+          </ui-tab-panel>
+          <ui-tab-panel value="running">
+            <div class="grid grid-cols-2 gap-4">
+              <shared-workflow-state
+                v-for="item in runningCollection"
+                :key="item.id"
+                :data="item"
+              />
+            </div>
+          </ui-tab-panel>
+        </ui-tab-panels>
+        <div
+          v-if="collection.flow.length === 0"
+          class="
+            border
+            text-gray-600
+            absolute
+            top-16
+            w-full
+            z-0
+            dark:text-gray-200
+            rounded-lg
+            border-dashed
+            text-center
+            p-4
+          "
+        >
+          Drop a workflow or block in here
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup>
+import { computed, shallowReactive, onMounted } from 'vue';
+import { nanoid } from 'nanoid';
+import { useStore } from 'vuex';
+import { useRoute, useRouter } from 'vue-router';
+import Draggable from 'vuedraggable';
+import { useDialog } from '@/composable/dialog';
+import { sendMessage } from '@/utils/message';
+import Log from '@/models/log';
+import Workflow from '@/models/workflow';
+import Collection from '@/models/collection';
+import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
+import SharedWorkflowState from '@/components/newtab/shared/SharedWorkflowState.vue';
+
+const blocks = {
+  'export-result': {
+    type: 'block',
+    id: 'export-result',
+    icon: 'riDownloadLine',
+    name: 'Export result',
+    description: 'Export the collection result as JSON',
+    data: {
+      type: 'json',
+    },
+  },
+};
+const blocksArr = Object.entries(blocks).map(([id, value]) => ({
+  ...value,
+  id,
+}));
+
+const store = useStore();
+const route = useRoute();
+const router = useRouter();
+const dialog = useDialog();
+
+const state = shallowReactive({
+  query: '',
+  activeTab: 'flow',
+  sidebarTab: 'workflows',
+});
+
+const runningCollection = computed(() =>
+  store.state.workflowState.filter(
+    ({ collectionId }) => collectionId === route.params.id
+  )
+);
+const logs = computed(() =>
+  Log.query()
+    .where(
+      ({ collectionId, isInCollection }) =>
+        collectionId === route.params.id && !isInCollection
+    )
+    .orderBy('startedAt', 'desc')
+    .limit(10)
+    .get()
+);
+const workflows = computed(() =>
+  Workflow.query()
+    .where(({ name }) =>
+      name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase())
+    )
+    .orderBy('createdAt', 'desc')
+    .get()
+);
+const collection = computed(() => Collection.find(route.params.id));
+const collectionFlow = computed(() => {
+  if (!collection.value) return [];
+
+  return collection.value.flow.map(({ itemId, type }) => {
+    if (type === 'workflow') return Workflow.find(itemId) || { type };
+
+    return blocks[itemId];
+  });
+});
+
+function deleteLog(logId) {
+  Log.delete(logId).then(() => {
+    store.dispatch('saveToStorage', 'logs');
+  });
+}
+function executeCollection() {
+  sendMessage('collection:execute', collection.value, 'background');
+}
+function updateCollection(data) {
+  Collection.update({
+    where: route.params.id,
+    data,
+  });
+}
+function updateCollectionFlow(event) {
+  const flow = event.map(({ type, id, flowId, data }) => {
+    const itemFlowId = flowId || nanoid();
+
+    return type === 'block'
+      ? { type, itemId: id, id: itemFlowId, data }
+      : { type: 'workflow', itemId: id, id: itemFlowId };
+  });
+
+  updateCollection({ flow });
+}
+function deleteCollectionFlow(index) {
+  const flow = [...collection.value.flow];
+
+  flow.splice(index, 1);
+
+  updateCollection({ flow });
+}
+function deleteCollection() {
+  dialog.confirm({
+    title: 'Delete collection',
+    okVariant: 'danger',
+    body: 'Are you sure you want to delete this collection?',
+    onConfirm: () => {
+      Collection.delete(route.params.id).then(() => {
+        router.replace('/collections');
+      });
+    },
+  });
+}
+
+onMounted(() => {
+  collectionFlow.value.forEach((item, index) => {
+    if (!item.itemId && item.type === 'workflow') {
+      deleteCollectionFlow(index);
+    }
+  });
+});
+</script>
+<style>
+.ghost {
+  position: relative;
+  z-index: 100;
+}
+</style>

+ 2 - 2
src/newtab/pages/logs.vue

@@ -109,7 +109,7 @@ const exportDataModal = shallowReactive({
 
 const filteredLogs = computed(() =>
   Log.query()
-    .where(({ name, status, startedAt }) => {
+    .where(({ name, status, startedAt, isInCollection }) => {
       let statusFilter = true;
       let dateFilter = true;
       const searchFilter = name
@@ -126,7 +126,7 @@ const filteredLogs = computed(() =>
         dateFilter = date <= startedAt;
       }
 
-      return searchFilter && statusFilter && dateFilter;
+      return !isInCollection && searchFilter && statusFilter && dateFilter;
     })
     .orderBy(sortsBuilder.by, sortsBuilder.order)
     .get()

+ 26 - 5
src/newtab/pages/logs/[id].vue

@@ -24,14 +24,23 @@
     </div>
     <div class="flex items-start">
       <ui-list class="w-7/12 mr-6">
+        <router-link
+          v-if="collectionLog"
+          :to="activeLog.collectionLogId"
+          class="mb-4 flex block"
+        >
+          <v-remixicon name="riArrowLeftLine" class="mr-2" />
+          Go back
+          <span class="font-semibold mx-1">{{ collectionLog.name }}</span> log
+        </router-link>
         <ui-list-item v-for="(item, index) in activeLog.history" :key="index">
           <span
-            :class="logsType[item.type].color"
+            :class="logsType[item.type]?.color"
             class="p-1 rounded-lg align-middle inline-block mr-2"
           >
-            <v-remixicon :name="logsType[item.type].icon" size="20" />
+            <v-remixicon :name="logsType[item.type]?.icon" size="20" />
           </span>
-          <div class="flex-1">
+          <div class="flex-1 text-overflow pr-2">
             <p class="w-full text-overflow leading-tight">
               {{ item.name }}
             </p>
@@ -41,14 +50,21 @@
               class="
                 text-sm
                 leading-tight
-                line-clamp
-                text-gray-600
+                text-overflow text-gray-600
                 dark:text-gray-200
               "
             >
               {{ item.message }}
             </p>
           </div>
+          <router-link
+            v-if="item.logId"
+            :to="'/logs/' + item.logId"
+            class="mr-4"
+            title="Open log detail"
+          >
+            <v-remixicon name="riExternalLinkLine" />
+          </router-link>
           <p class="text-gray-600">
             {{ countDuration(0, item.duration || 0) }}
           </p>
@@ -77,6 +93,10 @@ const logsType = {
     color: 'bg-yellow-200',
     icon: 'riStopLine',
   },
+  stopped: {
+    color: 'bg-yellow-200',
+    icon: 'riStopLine',
+  },
   error: {
     color: 'bg-red-200',
     icon: 'riErrorWarningLine',
@@ -91,6 +111,7 @@ const route = useRoute();
 const router = useRouter();
 
 const activeLog = computed(() => Log.find(route.params.id));
+const collectionLog = computed(() => Log.find(activeLog.value.collectionLogId));
 
 function deleteLog() {
   Log.delete(route.params.id).then(() => {

+ 5 - 3
src/newtab/pages/workflows/[id].vue

@@ -89,9 +89,8 @@
             <div class="grid grid-cols-2 gap-4">
               <shared-workflow-state
                 v-for="item in workflowState"
-                :id="item.id"
                 :key="item.id"
-                :state="item.state"
+                :data="item"
               />
             </div>
           </template>
@@ -162,7 +161,10 @@ const workflowState = computed(() =>
 );
 const workflow = computed(() => Workflow.find(workflowId) || {});
 const logs = computed(() =>
-  Log.query().where('workflowId', workflowId).orderBy('startedAt', 'desc').get()
+  Log.query()
+    .where((item) => item.workflowId === workflowId && !item.isInCollection)
+    .orderBy('startedAt', 'desc')
+    .get()
 );
 
 const updateBlockData = debounce((data) => {

+ 12 - 0
src/newtab/router.js

@@ -2,6 +2,8 @@ import { createRouter, createWebHashHistory } from 'vue-router';
 import Home from './pages/Home.vue';
 import Workflows from './pages/Workflows.vue';
 import WorkflowDetails from './pages/workflows/[id].vue';
+import Collections from './pages/Collections.vue';
+import CollectionsDetails from './pages/collections/[id].vue';
 import Logs from './pages/Logs.vue';
 import LogsDetails from './pages/logs/[id].vue';
 
@@ -21,6 +23,16 @@ const routes = [
     path: '/workflows/:id',
     component: WorkflowDetails,
   },
+  {
+    name: 'collections',
+    path: '/collections',
+    component: Collections,
+  },
+  {
+    name: 'collections-details',
+    path: '/collections/:id',
+    component: CollectionsDetails,
+  },
   {
     name: 'logs',
     path: '/logs',

+ 9 - 5
src/store/index.js

@@ -16,7 +16,9 @@ const store = createStore({
   },
   getters: {
     getWorkflowState: (state) => (id) =>
-      (state.workflowState || []).filter(({ workflowId }) => workflowId === id),
+      (state.workflowState || []).filter(
+        ({ workflowId, isInCollection }) => workflowId === id && !isInCollection
+      ),
   },
   actions: {
     async retrieve({ dispatch, getters }, keys = 'workflows') {
@@ -41,8 +43,8 @@ const store = createStore({
           });
           await browser.storage.local.set({
             isFirstTime: false,
-            workflows: firstWorkflows,
           });
+          await dispatch('saveToStorage', 'workflows');
         }
 
         return await Promise.allSettled(promises);
@@ -73,9 +75,11 @@ const store = createStore({
         }
         const data = getters[`entities/${key}/all`]();
 
-        browser.storage.local.set({ [key]: data }).then(() => {
-          resolve();
-        });
+        browser.storage.local
+          .set({ [key]: JSON.parse(JSON.stringify(data)) })
+          .then(() => {
+            resolve();
+          });
       });
     },
   },

+ 2 - 0
src/utils/shared.js

@@ -410,11 +410,13 @@ export const dataExportTypes = [
 
 export const firstWorkflows = [
   {
+    id: 'google-search',
     name: 'Google search',
     createdAt: Date.now(),
     drawflow: `{"drawflow":{"Home":{"data":{"d634ff22-5dfe-44dc-83d2-842412bd9fbf":{"id":"d634ff22-5dfe-44dc-83d2-842412bd9fbf","name":"trigger","data":{"type":"manual","interval":10},"class":"trigger","html":"BlockBasic","typenode":"vue","inputs":{},"outputs":{"output_1":{"connections":[{"node":"b9e7e0d4-e86a-4635-a352-31c63723fef4","output":"input_1"}]}},"pos_x":50,"pos_y":300},"b9e7e0d4-e86a-4635-a352-31c63723fef4":{"id":"b9e7e0d4-e86a-4635-a352-31c63723fef4","name":"new-tab","data":{"url":"https://google.com","active":true},"class":"new-tab","html":"BlockNewTab","typenode":"vue","inputs":{"input_1":{"connections":[{"node":"d634ff22-5dfe-44dc-83d2-842412bd9fbf","input":"output_1"}]}},"outputs":{"output_1":{"connections":[{"node":"09f3a14c-0514-4287-93b0-aa92b0064fba","output":"input_1"}]}},"pos_x":278,"pos_y":268},"09f3a14c-0514-4287-93b0-aa92b0064fba":{"id":"09f3a14c-0514-4287-93b0-aa92b0064fba","name":"forms","data":{"description":"Type query","selector":"[name='q']","markEl":false,"multiple":false,"selected":true,"type":"text-field","value":"Stackoverflow","delay":"120","events":[]},"class":"forms","html":"BlockBasic","typenode":"vue","inputs":{"input_1":{"connections":[{"node":"b9e7e0d4-e86a-4635-a352-31c63723fef4","input":"output_1"}]}},"outputs":{"output_1":{"connections":[{"node":"5f76370d-aa3d-4258-8319-230fcfc49a3a","output":"input_1"}]}},"pos_x":551,"pos_y":290},"5f76370d-aa3d-4258-8319-230fcfc49a3a":{"id":"5f76370d-aa3d-4258-8319-230fcfc49a3a","name":"event-click","data":{"description":"Click search","selector":"center:nth-child(1) > .gNO89b","markEl":false,"multiple":false},"class":"event-click","html":"BlockBasic","typenode":"vue","inputs":{"input_1":{"connections":[{"node":"09f3a14c-0514-4287-93b0-aa92b0064fba","input":"output_1"}]}},"outputs":{"output_1":{"connections":[]}},"pos_x":794,"pos_y":308}}}}}`,
   },
   {
+    id: 'lorem-ipsum',
     name: 'Generate lorem ipsum',
     createdAt: Date.now(),
     drawflow:

+ 12 - 0
yarn.lock

@@ -6018,6 +6018,11 @@ sockjs@^0.3.21:
     uuid "^3.4.0"
     websocket-driver "^0.7.4"
 
+sortablejs@1.14.0:
+  version "1.14.0"
+  resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.14.0.tgz#6d2e17ccbdb25f464734df621d4f35d4ab35b3d8"
+  integrity sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==
+
 source-map-js@^0.6.2:
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e"
@@ -6707,6 +6712,13 @@ vue@3.2.19:
     "@vue/server-renderer" "3.2.19"
     "@vue/shared" "3.2.19"
 
+vuedraggable@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-4.1.0.tgz#edece68adb8a4d9e06accff9dfc9040e66852270"
+  integrity sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==
+  dependencies:
+    sortablejs "1.14.0"
+
 vuex@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/vuex/-/vuex-4.0.2.tgz#f896dbd5bf2a0e963f00c67e9b610de749ccacc9"