Prechádzať zdrojové kódy

[Feature][UI Next] Add dag menu (#8481)

* add dag menu

* add dag menu click event

* workflow online edit not allowed
Devosend 3 rokov pred
rodič
commit
66241fd5c2

+ 5 - 1
dolphinscheduler-ui-next/src/locales/modules/en_US.ts

@@ -684,7 +684,11 @@ const project = {
     sql_input_placeholder: 'Please enter non-query sql.',
     sql_empty_tips: 'The sql can not be empty.',
     procedure_method: 'SQL Statement',
-    procedure_method_tips: 'Please enter the procedure script'
+    procedure_method_tips: 'Please enter the procedure script',
+    start: 'Start',
+    edit: 'Edit',
+    copy: 'Copy',
+    delete: 'Delete'
   }
 }
 

+ 5 - 1
dolphinscheduler-ui-next/src/locales/modules/zh_CN.ts

@@ -676,7 +676,11 @@ const project = {
     sql_input_placeholder: '请输入非查询SQL语句',
     sql_empty_tips: '语句不能为空',
     procedure_method: 'SQL语句',
-    procedure_method_tips: '请输入存储脚本'
+    procedure_method_tips: '请输入存储脚本',
+    start: '运行',
+    edit: '编辑',
+    copy: '复制节点',
+    delete: '删除'
   }
 }
 

+ 17 - 0
dolphinscheduler-ui-next/src/utils/common.ts

@@ -314,3 +314,20 @@ export const tasksState = (t: any): ITaskState => ({
     isSpin: false
   }
 })
+
+/**
+ * A simple uuid generator, support prefix and template pattern.
+ *
+ * @example
+ *
+ *  uuid('v-') // -> v-xxx
+ *  uuid('v-ani-%{s}-translate')  // -> v-ani-xxx
+ */
+export function uuid(prefix: string) {
+  const id = Math.floor(Math.random() * 10000).toString(36)
+  return prefix
+    ? ~prefix.indexOf('%{s}')
+      ? prefix.replace(/%\{s\}/g, id)
+      : prefix + id
+    : id
+}

+ 6 - 1
dolphinscheduler-ui-next/src/views/projects/workflow/components/dag/dag-config.ts

@@ -200,7 +200,12 @@ export const NODE = {
         group: X6_PORT_OUT_NAME
       }
     ]
-  }
+  },
+  tools: [
+    {
+      name: 'contextmenu'
+    }
+  ]
 }
 
 export const NODE_HOVER = {

+ 163 - 0
dolphinscheduler-ui-next/src/views/projects/workflow/components/dag/dag-context-menu.tsx

@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { genTaskCodeList } from '@/service/modules/task-definition'
+import type { Cell } from '@antv/x6'
+import {
+  defineComponent,
+  onMounted,
+  PropType,
+  inject,
+  ref,
+  computed
+} from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useRoute } from 'vue-router'
+import styles from './menu.module.scss'
+import { uuid } from '@/utils/common'
+
+const props = {
+  cell: {
+    type: Object as PropType<Cell>,
+    require: true
+  },
+  visible: {
+    type: Boolean as PropType<boolean>,
+    default: true
+  },
+  left: {
+    type: Number as PropType<number>,
+    default: 0
+  },
+  top: {
+    type: Number as PropType<number>,
+    default: 0
+  },
+  releaseState: {
+    type: String as PropType<string>,
+    default: 'OFFLINE'
+  }
+}
+
+export default defineComponent({
+  name: 'dag-context-menu',
+  props,
+  emits: ['hide', 'start', 'edit', 'copyTask', 'removeTasks'],
+  setup(props, ctx) {
+    const graph = inject('graph', ref())
+    const route = useRoute()
+    const projectCode = Number(route.params.projectCode)
+
+    const startAvailable = computed(
+      () =>
+        route.name === 'workflow-definition-detail' &&
+        props.releaseState !== 'NOT_RELEASE'
+    )
+
+    const hide = () => {
+      ctx.emit('hide', false)
+    }
+
+    const startRunning = () => {
+      ctx.emit('start')
+    }
+
+    const handleEdit = () => {
+      ctx.emit('edit', Number(props.cell?.id))
+    }
+
+    const handleCopy = () => {
+      const genNums = 1
+      const type = props.cell?.data.taskType
+      const taskName = uuid(props.cell?.data.taskName + '_')
+      const targetCode = Number(props.cell?.id)
+
+      genTaskCodeList(genNums, projectCode).then((res) => {
+        const [code] = res
+        ctx.emit('copyTask', taskName, code, targetCode, type, {
+          x: props.left + 100,
+          y: props.top + 100
+        })
+      })
+    }
+
+    const handleDelete = () => {
+      graph.value?.removeCell(props.cell)
+      ctx.emit('removeTasks', [Number(props.cell?.id)])
+    }
+
+    onMounted(() => {
+      document.addEventListener('click', () => {
+        hide()
+      })
+    })
+
+    return {
+      startAvailable,
+      startRunning,
+      handleEdit,
+      handleCopy,
+      handleDelete
+    }
+  },
+  render() {
+    const { t } = useI18n()
+
+    return (
+      this.visible && (
+        <div
+          class={styles['dag-context-menu']}
+          style={{ left: `${this.left}px`, top: `${this.top}px` }}
+        >
+          <div
+            class={`${styles['menu-item']} ${
+              !this.startAvailable ? styles['disabled'] : ''
+            } `}
+            onClick={this.startRunning}
+          >
+            {t('project.node.start')}
+          </div>
+          <div
+            class={`${styles['menu-item']} ${
+              this.releaseState === 'ONLINE' ? styles['disabled'] : ''
+            } `}
+            onClick={this.handleEdit}
+          >
+            {t('project.node.edit')}
+          </div>
+          <div
+            class={`${styles['menu-item']} ${
+              this.releaseState === 'ONLINE' ? styles['disabled'] : ''
+            } `}
+            onClick={this.handleCopy}
+          >
+            {t('project.node.copy')}
+          </div>
+          <div
+            class={`${styles['menu-item']} ${
+              this.releaseState === 'ONLINE' ? styles['disabled'] : ''
+            } `}
+            onClick={this.handleDelete}
+          >
+            {t('project.node.delete')}
+          </div>
+          {/* TODO: view log */}
+        </div>
+      )
+    )
+  }
+})

+ 3 - 1
dolphinscheduler-ui-next/src/views/projects/workflow/components/dag/dag-hooks.ts

@@ -26,6 +26,7 @@ import { useCustomCellBuilder } from './use-custom-cell-builder'
 import { useGraphBackfill } from './use-graph-backfill'
 import { useDagDragAndDrop } from './use-dag-drag-drop'
 import { useTaskEdit } from './use-task-edit'
+import { useNodeMenu } from './use-node-menu'
 
 export {
   useCanvasInit,
@@ -38,5 +39,6 @@ export {
   useGraphBackfill,
   useCellUpdate,
   useDagDragAndDrop,
-  useTaskEdit
+  useTaskEdit,
+  useNodeMenu
 }

+ 37 - 1
dolphinscheduler-ui-next/src/views/projects/workflow/components/dag/index.tsx

@@ -27,13 +27,16 @@ import {
   useGraphBackfill,
   useDagDragAndDrop,
   useTaskEdit,
-  useBusinessMapper
+  useBusinessMapper,
+  useNodeMenu
 } from './dag-hooks'
 import { useThemeStore } from '@/store/theme/theme'
 import VersionModal from '../../definition/components/version-modal'
 import { WorkflowDefinition } from './types'
 import DagSaveModal from './dag-save-modal'
 import TaskModal from '@/views/projects/task/components/node/detail-modal'
+import StartModal from '@/views/projects/workflow/definition/components/start-modal'
+import ContextMenuItem from './dag-context-menu'
 import './x6-style.scss'
 
 const props = {
@@ -82,10 +85,25 @@ export default defineComponent({
       currTask,
       taskCancel,
       appendTask,
+      editTask,
+      copyTask,
       taskDefinitions,
       removeTasks
     } = useTaskEdit({ graph, definition: toRef(props, 'definition') })
 
+    // Right click cell
+    const {
+      menuCell,
+      pageX,
+      pageY,
+      menuVisible,
+      startModalShow,
+      menuHide,
+      menuStart
+    } = useNodeMenu({
+      graph
+    })
+
     const { onDragStart, onDrop } = useDagDragAndDrop({
       graph,
       readonly: toRef(props, 'readonly'),
@@ -177,6 +195,24 @@ export default defineComponent({
           onSubmit={taskConfirm}
           onCancel={taskCancel}
         />
+        <ContextMenuItem
+          cell={menuCell.value}
+          visible={menuVisible.value}
+          left={pageX.value}
+          top={pageY.value}
+          releaseState={props.definition?.processDefinition.releaseState}
+          onHide={menuHide}
+          onStart={menuStart}
+          onEdit={editTask}
+          onCopyTask={copyTask}
+          onRemoveTasks={removeTasks}
+        />
+        {!!props.definition && (
+          <StartModal
+            v-model:row={props.definition.processDefinition}
+            v-model:show={startModalShow.value}
+          />
+        )}
       </div>
     )
   }

+ 43 - 0
dolphinscheduler-ui-next/src/views/projects/workflow/components/dag/menu.module.scss

@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+ .dag-context-menu{
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: 100px;
+  background-color: #ffffff;
+  box-shadow: 0 2px 10px rgba(0,0,0,0.12);
+
+  .menu-item{
+    padding: 5px 10px;
+    border-bottom: solid 1px #f2f3f7;
+    cursor: pointer;
+    color: rgb(89, 89, 89);
+    font-size: 12px;
+
+    &:hover:not(.disabled){
+      color: #262626;
+      background-color: #f5f5f5; 
+    }
+
+    &.disabled{
+      cursor: not-allowed;
+      color: rgba(89, 89, 89, .4);
+    }
+  }
+}

+ 3 - 0
dolphinscheduler-ui-next/src/views/projects/workflow/components/dag/use-canvas-init.ts

@@ -21,6 +21,7 @@ import { Graph } from '@antv/x6'
 import { NODE, EDGE, X6_NODE_NAME, X6_EDGE_NAME } from './dag-config'
 import { debounce } from 'lodash'
 import { useResizeObserver } from '@vueuse/core'
+import ContextMenuTool from './dag-context-menu'
 
 interface Options {
   readonly: Ref<boolean>
@@ -45,6 +46,8 @@ export function useCanvasInit(options: Options) {
    * Graph Init, bind graph to the dom
    */
   function graphInit() {
+    Graph.registerNodeTool('contextmenu', ContextMenuTool, true)
+
     return new Graph({
       container: paper.value,
       selecting: {

+ 75 - 0
dolphinscheduler-ui-next/src/views/projects/workflow/components/dag/use-node-menu.ts

@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Ref } from 'vue'
+import { onMounted, ref } from 'vue'
+import type { Graph, Cell } from '@antv/x6'
+
+interface Options {
+  graph: Ref<Graph | undefined>
+}
+
+/**
+ * Get position of the right-clicked Cell.
+ */
+export function useNodeMenu(options: Options) {
+  const { graph } = options
+  const startModalShow = ref(false)
+  const menuVisible = ref(false)
+  const pageX = ref()
+  const pageY = ref()
+  const menuCell = ref<Cell>()
+
+  const menuHide = () => {
+    menuVisible.value = false
+
+    // unlock scroller
+    graph.value?.unlockScroller()
+  }
+
+  const menuStart = () => {
+    startModalShow.value = true
+  }
+
+  onMounted(() => {
+    if (graph.value) {
+      // contextmenu
+      graph.value.on('node:contextmenu', ({ cell, x, y }) => {
+        menuCell.value = cell
+        const data = graph.value?.localToPage(x, y)
+        pageX.value = data?.x
+        pageY.value = data?.y
+
+        // show menu
+        menuVisible.value = true
+
+        // lock scroller
+        graph.value?.lockScroller()
+      })
+    }
+  })
+
+  return {
+    pageX,
+    pageY,
+    startModalShow,
+    menuVisible,
+    menuCell,
+    menuHide,
+    menuStart
+  }
+}

+ 38 - 5
dolphinscheduler-ui-next/src/views/projects/workflow/components/dag/use-task-edit.ts

@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import _ from 'lodash'
 import { ref, onMounted, watch } from 'vue'
 import type { Ref } from 'vue'
 import type { Graph } from '@antv/x6'
@@ -60,6 +61,28 @@ export function useTaskEdit(options: Options) {
     openTaskModal({ code, taskType: type, name: '' })
   }
 
+  /**
+   * Copy a task
+   */
+  function copyTask(
+    name: string,
+    code: number,
+    targetCode: number,
+    type: TaskType,
+    coordinate: Coordinate
+  ) {
+    addNode(code + '', type, name, coordinate)
+    const definition = taskDefinitions.value.find((t) => t.code === targetCode)
+
+    const newDefinition = {
+      ...definition,
+      code,
+      name
+    } as NodeData
+
+    taskDefinitions.value.push(newDefinition)
+  }
+
   /**
    * Remove task
    * @param {number} code
@@ -75,6 +98,18 @@ export function useTaskEdit(options: Options) {
     taskModalVisible.value = true
   }
 
+  /**
+   * Edit task
+   * @param {number} code
+   */
+  function editTask(code: number) {
+    const definition = taskDefinitions.value.find((t) => t.code === code)
+    if (definition) {
+      currTask.value = definition
+    }
+    taskModalVisible.value = true
+  }
+
   /**
    * The confirm event in task config modal
    * @param formRef
@@ -108,11 +143,7 @@ export function useTaskEdit(options: Options) {
     if (graph.value) {
       graph.value.on('cell:dblclick', ({ cell }) => {
         const code = Number(cell.id)
-        const definition = taskDefinitions.value.find((t) => t.code === code)
-        if (definition) {
-          currTask.value = definition
-        }
-        taskModalVisible.value = true
+        editTask(code)
       })
     }
   })
@@ -127,6 +158,8 @@ export function useTaskEdit(options: Options) {
     taskConfirm,
     taskCancel,
     appendTask,
+    editTask,
+    copyTask,
     taskDefinitions,
     removeTasks
   }