Browse Source

feat: add workflow folder

Ahmad Kholid 3 years ago
parent
commit
ff6934851e

+ 167 - 0
src/components/newtab/workflows/WorkflowsFolder.vue

@@ -0,0 +1,167 @@
+<template>
+  <div class="mt-6 pt-4 border-t">
+    <div class="flex items-center text-gray-600 dark:text-gray-300">
+      <span class="flex-1"> Folders </span>
+      <button
+        class="dark:hover:text-gray-100 hover:text-black rounded-md transition"
+        @click="newFolder"
+      >
+        <v-remixicon
+          size="20"
+          name="riAddLine"
+          class="inline-block align-sub"
+        />
+        <span>{{ t('common.new') }}</span>
+      </button>
+    </div>
+    <ui-list class="mt-2 space-y-1">
+      <ui-list-item
+        small
+        class="cursor-pointer"
+        :active="modelValue === ''"
+        @dragover="onDragover($event, true)"
+        @dragleave="onDragover($event, false)"
+        @drop="onWorkflowsDrop($event, '')"
+        @click="$emit('update:modelValue', '')"
+      >
+        <v-remixicon name="riFolderLine" class="mr-2" />
+        <p class="flex-1 text-overflow">All</p>
+      </ui-list-item>
+      <ui-list-item
+        v-for="folder in folders"
+        :key="folder.id"
+        :active="folder.id === modelValue"
+        small
+        class="group overflow-hidden cursor-pointer"
+        @dragover="onDragover($event, true)"
+        @dragleave="onDragover($event, false)"
+        @drop="onWorkflowsDrop($event, folder.id)"
+        @click="$emit('update:modelValue', folder.id)"
+      >
+        <v-remixicon name="riFolderLine" class="mr-2" />
+        <p class="flex-1 text-overflow">
+          {{ folder.name }}
+        </p>
+        <ui-popover class="leading-none">
+          <template #trigger>
+            <v-remixicon
+              name="riMoreLine"
+              class="group-hover:visible cursor-pointer invisible"
+            />
+          </template>
+          <ui-list class="w-36 space-y-1">
+            <ui-list-item
+              v-close-popover
+              class="cursor-pointer"
+              @click="renameFolder(folder)"
+            >
+              <v-remixicon name="riPencilLine" class="mr-2 -ml-1" />
+              <span>
+                {{ t('common.rename') }}
+              </span>
+            </ui-list-item>
+            <ui-list-item
+              v-close-popover
+              class="cursor-pointer"
+              @click="deleteFolder(folder)"
+            >
+              <v-remixicon name="riDeleteBin7Line" class="mr-2 -ml-1" />
+              <span>
+                {{ t('common.delete') }}
+              </span>
+            </ui-list-item>
+          </ui-list>
+        </ui-popover>
+      </ui-list-item>
+    </ui-list>
+  </div>
+</template>
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useDialog } from '@/composable/dialog';
+import { parseJSON } from '@/utils/helper';
+import Folder from '@/models/folder';
+import Workflow from '@/models/workflow';
+
+defineProps({
+  modelValue: {
+    type: String,
+    default: '',
+  },
+});
+const emit = defineEmits(['update:modelValue']);
+
+const { t } = useI18n();
+const dialog = useDialog();
+
+const folders = computed(() => Folder.query().orderBy('name', 'asc').get());
+
+function onDragover(event, toggle) {
+  const parent = event.target.closest('.ui-list-item');
+  if (!parent) return;
+
+  event.preventDefault();
+  parent.classList.toggle('ring-2', toggle);
+}
+function newFolder() {
+  dialog.prompt({
+    title: t('workflows.folder.new'),
+    placeholder: t('workflows.folder.name'),
+    okText: t('common.add'),
+    onConfirm(value) {
+      if (!value || !value.trim()) return false;
+
+      Folder.insert({
+        data: {
+          name: value,
+        },
+      });
+
+      return true;
+    },
+  });
+}
+function deleteFolder({ name, id }) {
+  dialog.confirm({
+    title: t('workflows.folder.delete'),
+    body: t('message.delete', { name }),
+    okText: t('common.delete'),
+    okVariant: 'danger',
+    onConfirm() {
+      Folder.delete(id);
+
+      emit('update:modelValue', '');
+    },
+  });
+}
+function renameFolder({ id, name }) {
+  dialog.prompt({
+    inputValue: name,
+    okText: t('common.rename'),
+    title: t('workflows.folder.rename'),
+    placeholder: t('workflows.folder.name'),
+    onConfirm(newName) {
+      if (!newName || !newName.trim()) return false;
+
+      Folder.update({
+        where: id,
+        data: { name: newName },
+      });
+
+      return true;
+    },
+  });
+}
+function onWorkflowsDrop({ dataTransfer }, folderId) {
+  const ids = parseJSON(dataTransfer.getData('workflows'), null);
+  if (!ids || !Array.isArray(ids)) return;
+
+  ids.forEach((id) => {
+    Workflow.update({
+      where: id,
+      data: { folderId },
+    });
+  });
+}
+</script>

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

@@ -5,6 +5,8 @@
     "collection": "Collection | Collections",
     "collection": "Collection | Collections",
     "log": "Log | Logs",
     "log": "Log | Logs",
     "block": "Block | Blocks",
     "block": "Block | Blocks",
+    "folder": "Folder | Folders",
+    "new": "New",
     "docs": "Documentation",
     "docs": "Documentation",
     "search": "Search",
     "search": "Search",
     "example": "Example | Examples",
     "example": "Example | Examples",

+ 8 - 0
src/locales/en/newtab.json

@@ -12,6 +12,14 @@
     "text1": "Automa has been updated to v{version},",
     "text1": "Automa has been updated to v{version},",
     "text2": "see what's new."
     "text2": "see what's new."
   },
   },
+  "workflows": {
+    "folder": {
+      "new": "New folder",
+      "name": "Folder name",
+      "delete": "Delete folder",
+      "rename": "Rename folder"
+    }
+  },
   "auth": {
   "auth": {
     "title": "Auth",
     "title": "Auth",
     "signIn": "Sign in",
     "signIn": "Sign in",

+ 30 - 0
src/models/folder.js

@@ -0,0 +1,30 @@
+import { Model } from '@vuex-orm/core';
+import { nanoid } from 'nanoid';
+import Workflow from './workflow';
+
+class Folder extends Model {
+  static entity = 'folders';
+
+  static primaryKey = 'id';
+
+  static autoSave = true;
+
+  static fields() {
+    return {
+      id: this.uid(() => nanoid()),
+      name: this.string(''),
+      createdAt: this.number(),
+      workflows: this.hasMany(Workflow, 'folderId'),
+    };
+  }
+
+  static async insert(payload) {
+    const res = await super.insert(payload);
+
+    await this.store().dispatch('saveToStorage', 'folders');
+
+    return res;
+  }
+}
+
+export default Folder;

+ 1 - 0
src/models/index.js

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

+ 1 - 0
src/models/workflow.js

@@ -20,6 +20,7 @@ class Workflow extends Model {
       name: this.string(''),
       name: this.string(''),
       icon: this.string('riGlobalLine'),
       icon: this.string('riGlobalLine'),
       data: this.attr(null),
       data: this.attr(null),
+      folderId: this.attr(null),
       drawflow: this.attr(''),
       drawflow: this.attr(''),
       table: this.attr([]),
       table: this.attr([]),
       dataColumns: this.attr([]),
       dataColumns: this.attr([]),

+ 6 - 1
src/newtab/App.vue

@@ -316,7 +316,12 @@ window.addEventListener('storage', ({ key, newValue }) => {
     }
     }
 
 
     await Promise.allSettled([
     await Promise.allSettled([
-      store.dispatch('retrieve', ['workflows', 'logs', 'collections']),
+      store.dispatch('retrieve', [
+        'workflows',
+        'logs',
+        'collections',
+        'folders',
+      ]),
       store.dispatch('retrieveWorkflowState'),
       store.dispatch('retrieveWorkflowState'),
     ]);
     ]);
 
 

+ 72 - 4
src/newtab/pages/Workflows.vue

@@ -99,8 +99,16 @@
             </ui-list>
             </ui-list>
           </ui-expand>
           </ui-expand>
         </ui-list>
         </ui-list>
+        <workflows-folder
+          v-if="state.activeTab === 'local'"
+          v-model="state.activeFolder"
+        />
       </div>
       </div>
-      <div class="flex-1 ml-8">
+      <div
+        class="flex-1 workflows-list ml-8"
+        style="min-height: calc(100vh - 8rem)"
+        @dblclick="clearSelectedWorkflows"
+      >
         <div class="flex items-center">
         <div class="flex items-center">
           <ui-input
           <ui-input
             id="search-input"
             id="search-input"
@@ -182,6 +190,10 @@
                   v-for="workflow in localWorkflows"
                   v-for="workflow in localWorkflows"
                   :key="workflow.id"
                   :key="workflow.id"
                   :data="workflow"
                   :data="workflow"
+                  :data-workflow="workflow.id"
+                  draggable="true"
+                  class="cursor-default select-none ring-accent local-workflow"
+                  @dragstart="onDragStart"
                   @click="$router.push(`/workflows/${$event.id}`)"
                   @click="$router.push(`/workflows/${$event.id}`)"
                 >
                 >
                   <template #header>
                   <template #header>
@@ -194,7 +206,10 @@
                           style="height: 40px; width: 40px"
                           style="height: 40px; width: 40px"
                           alt="Can not display"
                           alt="Can not display"
                         />
                         />
-                        <span v-else class="p-2 rounded-lg bg-box-transparent">
+                        <span
+                          v-else
+                          class="p-2 rounded-lg bg-box-transparent inline-block"
+                        >
                           <v-remixicon :name="workflow.icon" />
                           <v-remixicon :name="workflow.icon" />
                         </span>
                         </span>
                       </template>
                       </template>
@@ -364,6 +379,8 @@ import {
 import { findTriggerBlock, isWhitespace } from '@/utils/helper';
 import { findTriggerBlock, isWhitespace } from '@/utils/helper';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import SharedCard from '@/components/newtab/shared/SharedCard.vue';
 import Workflow from '@/models/workflow';
 import Workflow from '@/models/workflow';
+import WorkflowsFolder from '@/components/newtab/workflows/WorkflowsFolder.vue';
+import SelectionArea from '@viselect/vanilla';
 
 
 useGroupTooltip();
 useGroupTooltip();
 const { t } = useI18n();
 const { t } = useI18n();
@@ -385,7 +402,9 @@ const workflowHostMenu = [
 const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
 const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
 const state = shallowReactive({
 const state = shallowReactive({
   query: '',
   query: '',
+  activeFolder: '',
   activeTab: 'local',
   activeTab: 'local',
+  selectedWorkflows: [],
   sortBy: savedSorts.sortBy || 'createdAt',
   sortBy: savedSorts.sortBy || 'createdAt',
   sortOrder: savedSorts.sortOrder || 'desc',
   sortOrder: savedSorts.sortOrder || 'desc',
 });
 });
@@ -399,13 +418,46 @@ const pagination = shallowReactive({
   perPage: savedSorts.perPage || 18,
   perPage: savedSorts.perPage || 18,
 });
 });
 
 
+const selection = new SelectionArea({
+  container: '.workflows-list',
+  startareas: ['.workflows-list'],
+  boundaries: ['.workflows-list'],
+  selectables: ['.local-workflow'],
+});
+selection
+  .on('beforestart', ({ event }) => {
+    return (
+      event.target.tagName !== 'INPUT' &&
+      !event.target.closest('.local-workflow')
+    );
+  })
+  .on('start', () => {
+    /* eslint-disable-next-line */
+  clearSelectedWorkflows();
+  })
+  .on('move', (event) => {
+    event.store.changed.added.forEach((el) => {
+      el.classList.add('ring-2');
+    });
+    event.store.changed.removed.forEach((el) => {
+      el.classList.remove('ring-2');
+    });
+  })
+  .on('stop', (event) => {
+    state.selectedWorkflows = event.store.selected.map(
+      (el) => el.dataset.workflow
+    );
+  });
+
 const hostWorkflows = computed(() => store.state.hostWorkflows || {});
 const hostWorkflows = computed(() => store.state.hostWorkflows || {});
 const workflowHosts = computed(() => Object.values(store.state.workflowHosts));
 const workflowHosts = computed(() => Object.values(store.state.workflowHosts));
 const sharedWorkflows = computed(() => store.state.sharedWorkflows || {});
 const sharedWorkflows = computed(() => store.state.sharedWorkflows || {});
 const workflows = computed(() =>
 const workflows = computed(() =>
   Workflow.query()
   Workflow.query()
-    .where(({ name }) =>
-      name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase())
+    .where(
+      ({ name, folderId }) =>
+        name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase()) &&
+        (!state.activeFolder || state.activeFolder === folderId)
     )
     )
     .orderBy(state.sortBy, state.sortOrder)
     .orderBy(state.sortBy, state.sortOrder)
     .get()
     .get()
@@ -417,6 +469,22 @@ const localWorkflows = computed(() =>
   )
   )
 );
 );
 
 
+function clearSelectedWorkflows() {
+  state.selectedWorkflows = [];
+
+  selection.getSelection().forEach((el) => {
+    el.classList.remove('ring-2');
+  });
+  selection.clearSelection();
+}
+function onDragStart({ dataTransfer, target }) {
+  const payload = [...state.selectedWorkflows];
+
+  const targetId = target.dataset.workflow;
+  if (targetId && !payload.includes(targetId)) payload.push(targetId);
+
+  dataTransfer.setData('workflows', JSON.stringify(payload));
+}
 async function deleteWorkflowHost(workflow) {
 async function deleteWorkflowHost(workflow) {
   dialog.confirm({
   dialog.confirm({
     title: t('workflow.delete'),
     title: t('workflow.delete'),