Jelajahi Sumber

feat(newtab): add workflow task

Ahmad Kholid 3 tahun lalu
induk
melakukan
3a4a304d86

+ 1 - 1
src/assets/css/tailwind.css

@@ -5,7 +5,7 @@
 body {
 	font-family: 'Inter', sans-serif;
   font-size: 16px;
-  @apply bg-gray-100 dark:bg-gray-900;
+  @apply bg-gray-50 dark:bg-gray-900;
 }
 
 input:focus,

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

@@ -34,7 +34,7 @@
         </ui-list>
       </ui-popover>
     </div>
-    <router-link to="/workflows/anId">
+    <router-link :to="`/workflows/${workflow.id}`">
       <p class="line-clamp leading-tight font-semibold" :title="workflow.name">
         {{ workflow.name }}
       </p>

+ 39 - 72
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -1,5 +1,5 @@
 <template>
-  <ui-card class="w-80 h-full" padding="p-0">
+  <ui-card class="w-80 h-full sticky top-[10px]" padding="p-0">
     <div class="mb-4 px-4 pt-4">
       <span
         class="p-2 inline-block align-middle rounded-lg bg-box-transparent mr-2"
@@ -28,26 +28,37 @@
       class="scroll bg-scroll overflow-auto pb-4 px-4"
       style="max-height: calc(100vh - 240px); overflow: overlay"
     >
-      <ui-tab-panel value="tasks" class="grid grid-cols-2 gap-2">
-        <div
-          v-for="task in taskList"
-          :key="task.id"
-          :title="task.name"
-          class="
-            cursor-move
-            select-none
-            group
-            p-4
-            rounded-lg
-            bg-input
-            transition
-          "
+      <ui-tab-panel value="tasks">
+        <draggable
+          :list="taskList"
+          :sort="false"
+          :group="{ name: 'tasks', pull: 'clone', put: false }"
+          item-key="id"
+          ghost-class="ghost"
+          class="grid grid-cols-2 gap-2"
+          @start="$emit('dragstart')"
+          @end="$emit('dragend')"
         >
-          <v-remixicon :name="task.icon" size="24" class="mb-3" />
-          <p class="leading-tight text-overflow">
-            {{ task.name }}
-          </p>
-        </div>
+          <template #item="{ element }">
+            <div
+              :title="element.name"
+              class="
+                cursor-move
+                select-none
+                group
+                p-4
+                rounded-lg
+                bg-input
+                transition
+              "
+            >
+              <v-remixicon :name="element.icon" size="24" class="mb-3" />
+              <p class="leading-tight text-overflow">
+                {{ element.name }}
+              </p>
+            </div>
+          </template>
+        </draggable>
       </ui-tab-panel>
       <ui-tab-panel value="data-schema">
         <p>sss</p>
@@ -57,59 +68,15 @@
 </template>
 <script setup>
 import { shallowReactive } from 'vue';
+import Draggable from 'vuedraggable';
+import { tasks } from '@/utils/shared';
 
-const taskList = [
-  {
-    id: 'event-click',
-    name: 'Click element',
-    icon: 'riCursorLine',
-  },
-  {
-    id: 'get-text',
-    name: 'Get text',
-    icon: 'riParagraph',
-  },
-  {
-    id: 'save-assets',
-    name: 'Save assets',
-    icon: 'riImageLine',
-  },
-  {
-    id: 'export-data',
-    name: 'Export data',
-    icon: 'riDownloadLine',
-  },
-  {
-    id: 'element-scroll',
-    name: 'Scroll element',
-    icon: 'riMouseLine',
-  },
-  {
-    id: 'open-website',
-    name: 'Open website',
-    icon: 'riGlobalLine',
-  },
-  {
-    id: 'text-input',
-    name: 'Text input',
-    icon: 'riInputCursorMove',
-  },
-  {
-    id: 'repeat-task',
-    name: 'Repeat tasks',
-    icon: 'riRepeat2Line',
-  },
-  {
-    id: 'get-attribute',
-    name: 'Get attribute',
-    icon: 'riBracketsLine',
-  },
-  {
-    id: 'trigger-events',
-    name: 'Trigger events',
-    icon: 'riEqualizerLine',
-  },
-].sort((a, b) => (a.name > b.name ? 1 : -1));
+/* eslint-disable-next-line */
+defineEmits(['dragstart', 'dragend']);
+
+const taskList = Object.keys(tasks)
+  .map((id) => ({ id, isNewTask: true, ...tasks[id] }))
+  .sort((a, b) => (a.name > b.name ? 1 : -1));
 
 const state = shallowReactive({
   activeTab: 'tasks',

+ 66 - 0
src/components/newtab/workflow/WorkflowTask.vue

@@ -0,0 +1,66 @@
+<template>
+  <div
+    class="workflow-task rounded-lg group hoverable"
+    :class="{ 'bg-box-transparent': show }"
+  >
+    <div
+      class="
+        flex
+        items-center
+        w-full
+        text-left
+        py-2
+        px-3
+        cursor-pointer
+        rounded-lg
+      "
+      @click="show = !show"
+    >
+      <v-remixicon
+        :rotate="show ? 270 : 180"
+        name="riArrowLeftSLine"
+        class="-ml-1 mr-4 text-gray-600 dark:text-gray-200 transition-transform"
+      />
+      <v-remixicon :name="currentTask.icon" size="22" class="mr-3" />
+      <p class="flex-1 mr-2 text-overflow">
+        {{ task.name || currentTask.name }}
+      </p>
+      <v-remixicon
+        name="riDeleteBin7Line"
+        class="group-hover:visible mr-2 invisible cursor-pointer"
+        size="22"
+        @click.stop="$emit('delete', task)"
+      />
+      <v-remixicon
+        id="drag-handler"
+        name="mdiDrag"
+        style="cursor: grab"
+        @mousedown="show = false"
+      />
+    </div>
+    <transition-expand>
+      <template v-if="show">
+        <div class="pb-2 px-4">
+          {{ task.type }}
+        </div>
+      </template>
+    </transition-expand>
+  </div>
+</template>
+<script setup>
+/* eslint-disable no-undef */
+
+import { ref, computed } from 'vue';
+import { tasks } from '@/utils/shared';
+
+const props = defineProps({
+  task: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+defineEmits(['delete']);
+
+const show = ref(false);
+const currentTask = computed(() => tasks[props.task.type]);
+</script>

+ 67 - 0
src/components/transitions/TransitionExpand.vue

@@ -0,0 +1,67 @@
+<script>
+import { h, Transition, TransitionGroup } from 'vue';
+
+/* eslint-disable */
+export default {
+  props: {
+    group: Boolean,
+  },
+  setup(props, { slots, attrs }) {
+    function enter (element) {
+      const { width } = getComputedStyle(element);
+
+      element.style.width = width;
+      element.style.position = 'absolute';
+      element.style.visibility = 'hidden';
+      element.style.height = 'auto';
+
+      const { height } = getComputedStyle(element);
+
+      element.style.width = null;
+      element.style.position = null;
+      element.style.visibility = null;
+      element.style.height = 0;
+
+      getComputedStyle(element).height;
+
+      requestAnimationFrame(() => {
+        element.style.height = height;
+      });
+    }
+    function afterEnter (element) {
+      element.style.height = 'auto';
+    }
+    function leave (element) {
+      const { height } = getComputedStyle(element);
+
+      element.style.height = height;
+
+      getComputedStyle(element).height;
+
+      requestAnimationFrame(() => {
+        element.style.height = 0;
+      });
+    }
+
+    return () => h(props.group ? TransitionGroup : Transition, {
+      ...attrs,
+      name: 'expand',
+      onEnter: enter,
+      onAfterEnter: afterEnter,
+      onLeave: leave,
+    }, slots.default)
+  }
+};
+</script>
+<style>
+.expand-enter-active,
+.expand-leave-active {
+  transition: height 0.2s ease-in-out;
+  overflow: hidden;
+}
+
+.expand-enter,
+.expand-leave-to {
+  height: 0;
+}
+</style>

+ 6 - 0
src/lib/comps-ui.js

@@ -2,6 +2,11 @@ import VAutofocus from '../directives/VAutofocus';
 import VClosePopover from '../directives/VClosePopover';
 
 const uiComponents = require.context('../components/ui', false, /\.vue$/);
+const transitionComponents = require.context(
+  '../components/transitions',
+  false,
+  /\.vue$/
+);
 
 function componentsExtractor(app, components) {
   components.keys().forEach((key) => {
@@ -17,4 +22,5 @@ export default function (app) {
   app.directive('close-popover', VClosePopover);
 
   componentsExtractor(app, uiComponents);
+  componentsExtractor(app, transitionComponents);
 }

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

@@ -31,6 +31,7 @@ import {
   riParagraph,
   riImageLine,
   riCloseLine,
+  riDragDropLine,
 } from 'v-remixicon/icons';
 
 export const icons = {
@@ -65,6 +66,7 @@ export const icons = {
   riParagraph,
   riImageLine,
   riCloseLine,
+  riDragDropLine,
   mdiDrag:
     'M7,19V17H9V19H7M11,19V17H13V19H11M15,19V17H17V19H15M7,15V13H9V15H7M11,15V13H13V15H11M15,15V13H17V15H15M7,11V9H9V11H7M11,11V9H13V11H11M15,11V9H17V11H15M7,7V5H9V7H7M11,7V5H13V7H11M15,7V5H17V7H15Z',
 };

+ 3 - 1
src/models/task.js

@@ -10,6 +10,8 @@ class Task extends Model {
       name: this.string(''),
       type: this.string(''),
       createdAt: this.number(),
+      data: this.attr(null),
+      order: this.number(0),
       workflowId: this.attr(null),
     };
   }
@@ -17,7 +19,7 @@ class Task extends Model {
   static async insert(payload) {
     const res = await super.insert(payload);
 
-    console.log(payload);
+    await this.store().dispatch('saveToStorage', 'tasks');
 
     return res;
   }

+ 13 - 8
src/newtab/App.vue

@@ -1,30 +1,35 @@
 <template>
   <app-sidebar />
-  <main class="pl-16 container mx-auto pr-2 py-6">
-    <router-view />
+  <main class="pl-16 container mx-auto pr-2 pt-6 pb-4">
+    <router-view v-if="retrieved" />
   </main>
   <ui-dialog />
 </template>
 <script setup>
-import { onMounted } from 'vue';
+import { onMounted, ref } from 'vue';
 import { useStore } from 'vuex';
 import browser from 'webextension-polyfill';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 
 const store = useStore();
 
+const retrieved = ref(false);
+
 onMounted(async () => {
-  console.log(browser, 'browser');
   try {
     const data = await browser.storage.local.get(['workflows', 'tasks']);
-
-    Object.keys(data).forEach((entity) => {
+    const promises = Object.keys(data).map((entity) =>
       store.dispatch('entities/create', {
         entity,
         data: data[entity],
-      });
-    });
+      })
+    );
+
+    console.log(await Promise.allSettled(promises));
+
+    retrieved.value = true;
   } catch (error) {
+    retrieved.value = true;
     console.error(error);
   }
 });

+ 1 - 1
src/newtab/pages/Workflows.vue

@@ -34,7 +34,7 @@
       <ui-button variant="accent" @click="newWorkflow">New workflow</ui-button>
     </div>
   </div>
-  <div v-else class="grid gap-4 grid-cols-4">
+  <div v-else class="grid gap-4 grid-cols-5">
     <workflow-card
       v-for="workflow in workflows"
       :key="workflow.id"

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

@@ -1,11 +1,135 @@
 <template>
   <div class="flex items-start">
-    <workflow-details-card class="mr-6" />
-    <div class="flex-1">
-      <h1 class="text-xl font-semibold">Tasks</h1>
+    <workflow-details-card
+      class="mr-6"
+      @dragstart="showEmptyState = false"
+      @dragend="showEmptyState = true"
+    />
+    <div class="flex-1 relative">
+      <div class="flex items-center justify-between mb-6">
+        <h1 class="text-xl font-semibold">Tasks</h1>
+        <ui-input
+          v-model="query"
+          placeholder="Search..."
+          prepend-icon="riSearch2Line"
+        />
+      </div>
+      <div
+        v-if="workflowTasks.length === 0 && showEmptyState"
+        class="text-center absolute w-full mt-20"
+      >
+        <div class="inline-block p-6 rounded-lg mb-4 bg-box-transparent">
+          <v-remixicon name="riDragDropLine" size="36" />
+        </div>
+        <p class="text-lg">Drag and drop tasks to here</p>
+      </div>
+      <div class="space-y-1 z-20 relative" style="min-height: 400px">
+        <draggable
+          v-model="tasks"
+          :component-data="{ name: 'list' }"
+          handle="#drag-handler"
+          item-key="id"
+          group="tasks"
+          ghost-class="ghost-task"
+          tag="transition-group"
+        >
+          <template #item="{ element }">
+            <workflow-task
+              :task="element"
+              class="list-item-transition"
+              @delete="deleteTask"
+            />
+          </template>
+        </draggable>
+      </div>
     </div>
   </div>
 </template>
 <script setup>
+import { computed, onMounted, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import Draggable from 'vuedraggable';
+import Workflow from '@/models/workflow';
+import Task from '@/models/task';
 import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
+import WorkflowTask from '@/components/newtab/workflow/WorkflowTask.vue';
+
+const route = useRoute();
+const router = useRouter();
+
+const query = ref('');
+const showEmptyState = ref(true);
+
+const workflowTasks = computed(() =>
+  Task.query().where('workflowId', route.params.id).orderBy('order').get()
+);
+const tasks = computed({
+  set(value) {
+    const newTasks = value.map((item, index) => {
+      let task = item;
+
+      if (item.isNewTask) {
+        task = {
+          name: item.name,
+          type: item.id,
+          createdAt: Date.now(),
+          workflowId: route.params.id,
+        };
+      }
+
+      task.order = index;
+
+      return task;
+    });
+
+    Task.insertOrUpdate({ data: newTasks });
+  },
+  get() {
+    return workflowTasks.value.filter(({ name }) =>
+      name.toLocaleLowerCase().includes(query.value.toLocaleLowerCase())
+    );
+  },
+});
+
+function deleteTask({ id }) {
+  Task.delete(id);
+}
+
+onMounted(() => {
+  const isWorkflowExists = Workflow.query()
+    .where('id', route.params.id)
+    .exists();
+
+  if (!isWorkflowExists) {
+    router.push('/workflows');
+  }
+});
 </script>
+<style>
+.ghost-task {
+  height: 40px;
+  @apply bg-box-transparent;
+}
+.ghost-task:not(.workflow-task) * {
+  display: none;
+}
+
+.list-item-transition {
+  transition: all 0.4s ease;
+}
+
+.list-leave-active {
+  position: absolute;
+  width: 100%;
+}
+
+.list-enter-from,
+.list-leave-to {
+  opacity: 0;
+}
+
+.list-enter-from,
+.list-enter-from {
+  transform: translateY(30px);
+}
+</style>

+ 42 - 0
src/utils/shared.js

@@ -0,0 +1,42 @@
+export const tasks = {
+  'event-click': {
+    name: 'Click element',
+    icon: 'riCursorLine',
+  },
+  'get-text': {
+    name: 'Get text',
+    icon: 'riParagraph',
+  },
+  'save-assets': {
+    name: 'Save assets',
+    icon: 'riImageLine',
+  },
+  'export-data': {
+    name: 'Export data',
+    icon: 'riDownloadLine',
+  },
+  'element-scroll': {
+    name: 'Scroll element',
+    icon: 'riMouseLine',
+  },
+  'open-website': {
+    name: 'Open website',
+    icon: 'riGlobalLine',
+  },
+  'text-input': {
+    name: 'Text input',
+    icon: 'riInputCursorMove',
+  },
+  'repeat-task': {
+    name: 'Repeat tasks',
+    icon: 'riRepeat2Line',
+  },
+  'get-attribute': {
+    name: 'Get attribute',
+    icon: 'riBracketsLine',
+  },
+  'trigger-events': {
+    name: 'Trigger events',
+    icon: 'riEqualizerLine',
+  },
+};

+ 1 - 1
webpack.config.js

@@ -152,7 +152,7 @@ const options = {
       cache: false,
     }),
     new webpack.DefinePlugin({
-      __VUE_OPTIONS_API__: false,
+      __VUE_OPTIONS_API__: true,
       __VUE_PROD_DEVTOOLS__: false,
     }),
   ],