diff --git a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsible.tsx b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsible.tsx index b5e78d6c6..745fa1cd9 100644 --- a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsible.tsx +++ b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupCollapsible.tsx @@ -2,7 +2,7 @@ import { isNode, observer } from '@patternfly/react-topology'; import { FunctionComponent } from 'react'; import { useCollapseStep } from '../hooks/collapse-step.hook'; import { CustomNodeWithSelection } from '../Node/CustomNode'; -import { CustomGroupExpanded } from './CustomGroupExpanded'; +import { CustomGroupExpendedWithDndDrop } from './CustomGroupExpanded'; import { CustomGroupProps } from './Group.models'; export const CustomGroupCollapsible: FunctionComponent = observer( @@ -26,7 +26,7 @@ export const CustomGroupCollapsible: FunctionComponent = obser } return ( - { diff --git a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.scss b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.scss index 08f40ff69..01d001c45 100644 --- a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.scss +++ b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.scss @@ -10,6 +10,10 @@ background-color: var(--custom-node-BackgroundColor); text-align: left; + &__dropTarget { + border: 4px dashed var(--custom-node-BorderColor); + } + &__title { display: flex; position: relative; diff --git a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx index 2e7cee090..8825759c7 100644 --- a/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx +++ b/packages/ui/src/components/Visualization/Custom/Group/CustomGroupExpanded.tsx @@ -2,7 +2,10 @@ import { Icon } from '@patternfly/react-core'; import { BanIcon } from '@patternfly/react-icons'; import { AnchorEnd, + DropTargetSpec, GROUPS_LAYER, + GraphElement, + GraphElementProps, Layer, Node, Rect, @@ -12,6 +15,7 @@ import { useAnchor, useHover, useSelection, + withDndDrop, } from '@patternfly/react-topology'; import { FunctionComponent, useContext, useRef } from 'react'; import { IVisualizationNode, NodeToolbarTrigger } from '../../../../models'; @@ -21,9 +25,10 @@ import { CanvasDefaults } from '../../Canvas/canvas.defaults'; import { TargetAnchor } from '../target-anchor'; import './CustomGroupExpanded.scss'; import { CustomGroupProps } from './Group.models'; +import clsx from 'clsx'; -export const CustomGroupExpanded: FunctionComponent = observer( - ({ element, onContextMenu, onCollapseToggle }) => { +const CustomGroupExpanded: FunctionComponent = observer( + ({ element, onContextMenu, onCollapseToggle, dndDropRef, hover, droppable, canDrop }) => { if (!isNode(element)) { throw new Error('CustomGroupExpanded must be used only on Node elements'); } @@ -39,7 +44,7 @@ export const CustomGroupExpanded: FunctionComponent = observer CanvasDefaults.HOVER_DELAY_IN, CanvasDefaults.HOVER_DELAY_OUT, ); - const boxRef = useRef(element.getBounds()); + const boxRef = useRef(null); const shouldShowToolbar = settingsAdapter.getSettings().nodeToolbarTrigger === NodeToolbarTrigger.onHover ? isGHover || isToolbarHover || isSelected @@ -53,7 +58,9 @@ export const CustomGroupExpanded: FunctionComponent = observer return null; } - boxRef.current = element.getBounds(); + if (!droppable || !boxRef.current) { + boxRef.current = element.getBounds(); + } const toolbarWidth = Math.max(CanvasDefaults.STEP_TOOLBAR_WIDTH, boxRef.current.width); const toolbarX = boxRef.current.x + (boxRef.current.width - toolbarWidth) / 2; const toolbarY = boxRef.current.y - CanvasDefaults.STEP_TOOLBAR_HEIGHT; @@ -72,13 +79,18 @@ export const CustomGroupExpanded: FunctionComponent = observer onContextMenu={onContextMenu} > -
+
{label} @@ -116,3 +128,23 @@ export const CustomGroupExpanded: FunctionComponent = observer ); }, ); + +const groupDropTargetSpec: DropTargetSpec = { + accept: ['#node#'], + canDrop: (item, _monitor, props) => { + if (!!props && (item as Node).getData().vizNode.parentNode.id !== (props.element as Node).getData().vizNode.id) { + return ( + (item as Node).getData().vizNode.parentNode.id === (props.element as Node).getData().vizNode.parentNode?.id + ); + } else { + return false; + } + }, + collect: (monitor) => ({ + droppable: monitor.isDragging(), + hover: monitor.isOver(), + canDrop: monitor.canDrop(), + }), +}; + +export const CustomGroupExpendedWithDndDrop = withDndDrop(groupDropTargetSpec)(CustomGroupExpanded); diff --git a/packages/ui/src/components/Visualization/Custom/Group/Group.models.ts b/packages/ui/src/components/Visualization/Custom/Group/Group.models.ts index 4b4d715c5..37ec053b9 100644 --- a/packages/ui/src/components/Visualization/Custom/Group/Group.models.ts +++ b/packages/ui/src/components/Visualization/Custom/Group/Group.models.ts @@ -1,9 +1,17 @@ -import type { ElementModel, GraphElement, DefaultGroup as TopologyDefaultGroup } from '@patternfly/react-topology'; +import type { + ElementModel, + GraphElement, + DefaultGroup as TopologyDefaultGroup, + WithDndDropProps, +} from '@patternfly/react-topology'; import { CanvasNode } from '../../Canvas'; type DefaultGroupProps = Parameters[0]; -export interface CustomGroupProps extends DefaultGroupProps { +export interface CustomGroupProps extends DefaultGroupProps, WithDndDropProps { element: GraphElement; /** Toggle node collapse / expand */ onCollapseToggle?: () => void; + hover?: boolean; + droppable?: boolean; + canDrop?: boolean; } diff --git a/packages/ui/src/components/Visualization/Custom/Node/CustomNode.scss b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.scss index 6eff7cbd8..3bc4e17e7 100644 --- a/packages/ui/src/components/Visualization/Custom/Node/CustomNode.scss +++ b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.scss @@ -9,6 +9,11 @@ flex-flow: column nowrap; justify-content: space-around; + &__dropTarget { + border: 3px dashed var(--pf-v5-global--BorderColor--100); + border-radius: 5px; + } + &__image { position: relative; display: flex; diff --git a/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx index 35dfca24d..c7f364ded 100644 --- a/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx +++ b/packages/ui/src/components/Visualization/Custom/Node/CustomNode.tsx @@ -1,21 +1,34 @@ import { Icon } from '@patternfly/react-core'; import { BanIcon, ExclamationCircleIcon } from '@patternfly/react-icons'; -import type { DefaultNode, ElementModel, GraphElement, Node } from '@patternfly/react-topology'; import { AnchorEnd, + DefaultNode, DEFAULT_LAYER, + DragObjectWithType, + DragSourceSpec, + DragSpecOperationType, + DropTargetSpec, + EditableDragOperationType, + ElementModel, + GraphElement, + GraphElementProps, + isNode, LabelBadge, Layer, + Node, + observer, Rect, TOP_LAYER, - WithSelectionProps, - isNode, - observer, useAnchor, + useCombineRefs, useHover, + useDragNode, useSelection, withContextMenu, + withDndDrop, withSelection, + WithSelectionProps, + WithDndDropProps, } from '@patternfly/react-topology'; import clsx from 'clsx'; import { FunctionComponent, useContext, useRef } from 'react'; @@ -27,124 +40,215 @@ import { StepToolbar } from '../../Canvas/StepToolbar/StepToolbar'; import { NodeContextMenuFn } from '../ContextMenu/NodeContextMenu'; import { TargetAnchor } from '../target-anchor'; import './CustomNode.scss'; +import { useEntityContext } from '../../../../hooks/useEntityContext/useEntityContext'; type DefaultNodeProps = Parameters[0]; -interface CustomNodeProps extends DefaultNodeProps, WithSelectionProps { + +interface CustomNodeProps extends DefaultNodeProps, WithSelectionProps, WithDndDropProps { element: GraphElement; /** Toggle node collapse / expand */ onCollapseToggle?: () => void; + hover?: boolean; + droppable?: boolean; + canDrop?: boolean; } -const CustomNode: FunctionComponent = observer(({ element, onContextMenu, onCollapseToggle }) => { - if (!isNode(element)) { - throw new Error('CustomNode must be used only on Node elements'); - } - - const vizNode: IVisualizationNode | undefined = element.getData()?.vizNode; - const settingsAdapter = useContext(SettingsContext); - const label = vizNode?.getNodeLabel(settingsAdapter.getSettings().nodeLabel); - const isDisabled = !!vizNode?.getComponentSchema()?.definition?.disabled; - const tooltipContent = vizNode?.getTooltipContent(); - const validationText = vizNode?.getNodeValidationText(); - const doesHaveWarnings = !isDisabled && !!validationText; - const [isSelected, onSelect] = useSelection(); - const [isGHover, gHoverRef] = useHover(CanvasDefaults.HOVER_DELAY_IN, CanvasDefaults.HOVER_DELAY_OUT); - const [isToolbarHover, toolbarHoverRef] = useHover( - CanvasDefaults.HOVER_DELAY_IN, - CanvasDefaults.HOVER_DELAY_OUT, - ); - const childCount = element.getAllNodeChildren().length; - const boxRef = useRef(element.getBounds()); - const shouldShowToolbar = - settingsAdapter.getSettings().nodeToolbarTrigger === NodeToolbarTrigger.onHover - ? isGHover || isToolbarHover || isSelected - : isSelected; - - useAnchor((element: Node) => { - return new TargetAnchor(element); - }, AnchorEnd.both); - - const labelX = (boxRef.current.width - CanvasDefaults.DEFAULT_LABEL_WIDTH) / 2; - const toolbarWidth = CanvasDefaults.STEP_TOOLBAR_WIDTH; - const toolbarX = (boxRef.current.width - toolbarWidth) / 2; - const toolbarY = CanvasDefaults.STEP_TOOLBAR_HEIGHT * -1; - - if (!vizNode) { - return null; - } - - return ( - - - -
-
- - - {isDisabled && ( - - - - )} -
-
-
- - = observer( + ({ element, onContextMenu, onCollapseToggle, dndDropRef, hover, droppable, canDrop }) => { + if (!isNode(element)) { + throw new Error('CustomNode must be used only on Node elements'); + } + + const vizNode: IVisualizationNode | undefined = element.getData()?.vizNode; + const entitiesContext = useEntityContext(); + const settingsAdapter = useContext(SettingsContext); + const label = vizNode?.getNodeLabel(settingsAdapter.getSettings().nodeLabel); + const isDisabled = !!vizNode?.getComponentSchema()?.definition?.disabled; + const tooltipContent = vizNode?.getTooltipContent(); + const validationText = vizNode?.getNodeValidationText(); + const doesHaveWarnings = !isDisabled && !!validationText; + const [isSelected, onSelect] = useSelection(); + const [isGHover, gHoverRef] = useHover(CanvasDefaults.HOVER_DELAY_IN, CanvasDefaults.HOVER_DELAY_OUT); + const [isToolbarHover, toolbarHoverRef] = useHover( + CanvasDefaults.HOVER_DELAY_IN, + CanvasDefaults.HOVER_DELAY_OUT, + ); + const childCount = element.getAllNodeChildren().length; + const boxRef = useRef(null); + const shouldShowToolbar = + settingsAdapter.getSettings().nodeToolbarTrigger === NodeToolbarTrigger.onHover + ? isGHover || isToolbarHover || isSelected + : isSelected; + + useAnchor((element: Node) => { + return new TargetAnchor(element); + }, AnchorEnd.both); + + const nodeDragSourceSpec: DragSourceSpec< + DragObjectWithType, + DragSpecOperationType, + GraphElement, + object, + GraphElementProps + > = { + item: { type: '#node#' }, + begin: () => { + const node = element; + + // Hide connected edges when dragging starts, also for the parent group + // todo: hide all the edges in case something is draging + node.getSourceEdges().forEach((edge) => edge.setVisible(false)); + node.getTargetEdges().forEach((edge) => edge.setVisible(false)); + (node.getParent() as Node).getSourceEdges().forEach((edge) => edge.setVisible(false)); + (node.getParent() as Node).getTargetEdges().forEach((edge) => edge.setVisible(false)); + }, + canDrag: () => { + return element.getData()?.vizNode?.canDragNode(); + }, + end(dropResult, monitor) { + const node = element; + + // Show edges again after dropping + node.getSourceEdges().forEach((edge) => edge.setVisible(true)); + node.getTargetEdges().forEach((edge) => edge.setVisible(true)); + (node.getParent() as Node).getSourceEdges().forEach((edge) => edge.setVisible(true)); + (node.getParent() as Node).getTargetEdges().forEach((edge) => edge.setVisible(true)); + + if (monitor.didDrop() && dropResult) { + const draggedNodePath = element.getData().vizNode.data.path; + dropResult.getData()?.vizNode?.switchSteps(draggedNodePath); + } + + // Added timeout because of the error, check if promise can be used somehow + setTimeout(() => { + /** Update entity */ + entitiesContext.updateEntitiesFromCamelResource(); + }, 1); + }, + }; + + const [_, dragNodeRef] = useDragNode(nodeDragSourceSpec); + const gCombinedRef = useCombineRefs(gHoverRef, dragNodeRef); + + if (!droppable || !boxRef.current) { + boxRef.current = element.getBounds(); + } + const labelX = (boxRef.current.width - CanvasDefaults.DEFAULT_LABEL_WIDTH) / 2; + const toolbarWidth = CanvasDefaults.STEP_TOOLBAR_WIDTH; + const toolbarX = (boxRef.current.width - toolbarWidth) / 2; + const toolbarY = CanvasDefaults.STEP_TOOLBAR_HEIGHT * -1; + + if (!vizNode) { + return null; + } + + return ( + + -
- {doesHaveWarnings && ( - - - - )} - {label} -
-
- - {shouldShowToolbar && ( - - - - - - )} - - {childCount && } -
-
- ); -}); - -export const CustomNodeWithSelection = withSelection()(withContextMenu(NodeContextMenuFn)(CustomNode)); +
+ + + {isDisabled && ( + + + + )} +
+
+ + + +
+ {doesHaveWarnings && ( + + + + )} + {label} +
+
+ + {!droppable && shouldShowToolbar && ( + + + + + + )} + + {childCount && } + + + ); + }, +); + +const nodeDropTargetSpec: DropTargetSpec = { + accept: ['#node#'], + canDrop: (item, _monitor, props) => { + const targetNode = props.element; + const draggedNode = item as Node; + + // Ensure that the node is not dropped onto itself + if (draggedNode !== targetNode) { + // // Ensure that node cant be dropped from 1 group to another. Exception: root node (node who parent is route) + if (draggedNode.getData().vizNode.parentNode.id === targetNode.getData().vizNode.parentNode.id) { + return true; + } + } + + return false; + }, + collect: (monitor) => ({ + droppable: monitor.isDragging(), + hover: monitor.isOver(), + canDrop: monitor.canDrop(), + }), +}; + +export const CustomNodeWithSelection = withDndDrop(nodeDropTargetSpec)( + withSelection()(withContextMenu(NodeContextMenuFn)(CustomNode)), +); diff --git a/packages/ui/src/models/visualization/base-visual-entity.ts b/packages/ui/src/models/visualization/base-visual-entity.ts index 9076b4258..2c145ef6b 100644 --- a/packages/ui/src/models/visualization/base-visual-entity.ts +++ b/packages/ui/src/models/visualization/base-visual-entity.ts @@ -44,6 +44,15 @@ export interface BaseVisualCamelEntity extends BaseCamelEntity { targetProperty?: string; }) => void; + /** Check if the node is draggable */ + isDraggableNode: (path?: string) => boolean; + + /** Switch steps */ + switchGroupSteps: (options: { draggedNodePath: string; droppedNodeData: IVisualizationNodeData }) => void; + + /** Switch steps */ + switchSteps: (options: { draggedNodePath: string; droppedNodePath?: string }) => void; + /** Remove the step at a given path from the underlying Camel entity */ removeStep: (path?: string) => void; @@ -91,6 +100,10 @@ export interface IVisualizationNode implements Bas } } + isDraggableNode(path?: string) { + if (path === 'route.from' || path === 'template.from') { + return false; + } else { + return true; + } + } + + // TBD: check and correct the logic, Currently this only works for Multicast and filter + switchGroupSteps(options: { draggedNodePath: string; droppedNodeData: IVisualizationNodeData }) { + const componentPath = options.draggedNodePath.split('.'); + const componentModel = getValue(this.entityDef, componentPath?.slice(0, -1)); + + const arrayPath = getArrayProperty(this.entityDef, `${options.droppedNodeData.path}.steps`); + arrayPath.unshift(componentModel); + + /** Remove the dragged node */ + this.removeStep(options.draggedNodePath); + } + + switchSteps(options: { draggedNodePath: string; droppedNodePath?: string }) { + if (options.droppedNodePath === undefined) return; + + const pathArray = options.droppedNodePath.split('.'); + const last = pathArray[pathArray.length - 1]; + const penultimate = pathArray[pathArray.length - 2]; + + const componentPath = options.draggedNodePath.split('.'); + const componentModel = getValue(this.entityDef, componentPath?.slice(0, -1)); + + /** Remove the dragged node */ + this.removeStep(options.draggedNodePath); + + let stepsArray: ProcessorDefinition[]; + + if (options.droppedNodePath === 'route.from' || options.droppedNodePath === 'template.from') { + /** Add the draged node just after the from */ + stepsArray = getValue(this.entityDef, `${options.droppedNodePath}.steps`, []); + stepsArray.splice(0, 0, componentModel); + } + + if (!Number.isInteger(Number(last)) && Number.isInteger(Number(penultimate))) { + /** Add the dragged node before the drop target */ + stepsArray = getValue(this.entityDef, pathArray.slice(0, -2), []); + stepsArray.splice(Number(penultimate), 0, componentModel); + } + } + removeStep(path?: string): void { if (!path) return; const pathArray = path.split('.'); diff --git a/packages/ui/src/models/visualization/flows/camel-error-handler-visual-entity.ts b/packages/ui/src/models/visualization/flows/camel-error-handler-visual-entity.ts index c476474e5..8ef4def6d 100644 --- a/packages/ui/src/models/visualization/flows/camel-error-handler-visual-entity.ts +++ b/packages/ui/src/models/visualization/flows/camel-error-handler-visual-entity.ts @@ -86,6 +86,18 @@ export class CamelErrorHandlerVisualEntity implements BaseVisualCamelEntity { return; } + isDraggableNode(_path?: string) { + return false; + } + + switchGroupSteps(_options: { draggedNodePath: string; droppedNodeData: IVisualizationNodeData }) { + return; + } + + switchSteps(_options: { draggedNodePath: string; droppedNodePath?: string | undefined }) { + return; + } + removeStep(): void { return; } diff --git a/packages/ui/src/models/visualization/flows/camel-rest-configuration-visual-entity.ts b/packages/ui/src/models/visualization/flows/camel-rest-configuration-visual-entity.ts index c484791d8..68cf375a3 100644 --- a/packages/ui/src/models/visualization/flows/camel-rest-configuration-visual-entity.ts +++ b/packages/ui/src/models/visualization/flows/camel-rest-configuration-visual-entity.ts @@ -69,6 +69,18 @@ export class CamelRestConfigurationVisualEntity implements BaseVisualCamelEntity return; } + isDraggableNode(_path?: string) { + return false; + } + + switchGroupSteps(_options: { draggedNodePath: string; droppedNodeData: IVisualizationNodeData }) { + return; + } + + switchSteps(_options: { draggedNodePath: string; droppedNodePath?: string | undefined }) { + return; + } + removeStep(): void { return; } diff --git a/packages/ui/src/models/visualization/flows/pipe-visual-entity.ts b/packages/ui/src/models/visualization/flows/pipe-visual-entity.ts index 6d8cf26b8..6a9ce2b4c 100644 --- a/packages/ui/src/models/visualization/flows/pipe-visual-entity.ts +++ b/packages/ui/src/models/visualization/flows/pipe-visual-entity.ts @@ -162,6 +162,42 @@ export class PipeVisualEntity implements BaseVisualCamelEntity { } } + isDraggableNode(path?: string) { + if (path === 'source' || path === 'sink') { + return false; + } else { + return true; + } + } + + switchGroupSteps(_options: { draggedNodePath: string; droppedNodeData: IVisualizationNodeData }) { + return; + } + + switchSteps(options: { draggedNodePath: string; droppedNodePath?: string }) { + if (options.droppedNodePath === undefined) return; + + const step = getValue(this.pipe.spec!, options.draggedNodePath); + /** Remove the dragged node */ + this.removeStep(options.draggedNodePath); + + const kameletArray = getArrayProperty(this.pipe.spec!, 'steps') as PipeStep[]; + let desiredIndex: number; + + if (options.droppedNodePath === 'source') { + /** Add the dragged node just after the source */ + desiredIndex = 0; + } else if (options.droppedNodePath === 'sink') { + /** Add the dragged node just before the sink */ + desiredIndex = kameletArray.length; + } else { + /** Add the dragged node at the target node index */ + desiredIndex = Number(options.droppedNodePath.split('.').pop()); + } + + kameletArray.splice(desiredIndex, 0, step); + } + removeStep(path?: string): void { /** This method needs to be enabled after passing the entire parent to this class*/ if (!path) return; diff --git a/packages/ui/src/models/visualization/visualization-node.ts b/packages/ui/src/models/visualization/visualization-node.ts index d14ffcc7c..36d20235f 100644 --- a/packages/ui/src/models/visualization/visualization-node.ts +++ b/packages/ui/src/models/visualization/visualization-node.ts @@ -64,6 +64,18 @@ class VisualizationNode