From d82d565743a9e8909d58c84e36f688e4d3e57796 Mon Sep 17 00:00:00 2001 From: GnsP Date: Fri, 28 Mar 2025 09:48:09 +0530 Subject: [PATCH 1/2] Add the main component for the reactflow based DAG editor --- .../PipelineCommentsActionBtn.tsx | 22 + .../components/PluginContextMenu/index.tsx | 27 +- .../components/DAGEditor/DAGOverrides.css | 33 ++ .../StudioV2/components/DAGEditor/index.tsx | 481 ++++++++++++++++++ .../components/DAGEditor/useDAGController.ts | 166 ++++++ app/cdap/components/StudioV2/types.ts | 109 ++++ .../StudioV2/utils/artifactUtils.ts | 21 + .../components/StudioV2/utils/geometry.ts | 19 + .../components/StudioV2/utils/pluginUtils.ts | 196 +++++++ app/common/cask-shared-components.js | 2 + app/directives/react-components/index.js | 3 + 11 files changed, 1075 insertions(+), 4 deletions(-) create mode 100644 app/cdap/components/StudioV2/components/DAGEditor/DAGOverrides.css create mode 100644 app/cdap/components/StudioV2/components/DAGEditor/index.tsx create mode 100644 app/cdap/components/StudioV2/components/DAGEditor/useDAGController.ts create mode 100644 app/cdap/components/StudioV2/types.ts create mode 100644 app/cdap/components/StudioV2/utils/artifactUtils.ts create mode 100644 app/cdap/components/StudioV2/utils/geometry.ts create mode 100644 app/cdap/components/StudioV2/utils/pluginUtils.ts diff --git a/app/cdap/components/PipelineCanvasActions/PipelineCommentsActionBtn.tsx b/app/cdap/components/PipelineCanvasActions/PipelineCommentsActionBtn.tsx index df95462a4b2..83400199d09 100644 --- a/app/cdap/components/PipelineCanvasActions/PipelineCommentsActionBtn.tsx +++ b/app/cdap/components/PipelineCanvasActions/PipelineCommentsActionBtn.tsx @@ -26,6 +26,7 @@ import uuidv4 from 'uuid/v4'; import { PipelineComments } from 'components/PipelineCanvasActions/PipelineComments'; import { IPipelineComment } from 'components/PipelineCanvasActions/PipelineCommentsConstants'; import ClickAwayListener from '@material-ui/core/ClickAwayListener'; +import { ControlButton } from 'reactflow'; const useStyle = makeStyles((theme) => { return { @@ -79,6 +80,7 @@ interface IPipelineCommentsActionBtnProps { onChange: (comments: IPipelineComment[]) => void; comments: IPipelineComment[]; disabled?: boolean; + isV2?: boolean; } function PipelineCommentsActionBtn({ @@ -86,6 +88,7 @@ function PipelineCommentsActionBtn({ onChange, comments = [], disabled, + isV2 = false, }: IPipelineCommentsActionBtnProps) { const [localToggle, setLocalToggle] = React.useState(false); const [showMarker, setShowMarker] = React.useState(comments.length > 0); @@ -122,6 +125,25 @@ function PipelineCommentsActionBtn({ React.useEffect(() => { setShowMarker(Array.isArray(comments) && comments.length > 0); }, [comments]); + + if (isV2) { + return ( + + + {showMarker && } + + + + + ); + } + return ( { onOpen(nodeId); }; diff --git a/app/cdap/components/StudioV2/components/DAGEditor/DAGOverrides.css b/app/cdap/components/StudioV2/components/DAGEditor/DAGOverrides.css new file mode 100644 index 00000000000..8183b010c46 --- /dev/null +++ b/app/cdap/components/StudioV2/components/DAGEditor/DAGOverrides.css @@ -0,0 +1,33 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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. + */ + +.react-flow__handle.target { + height: calc(100% + 10px); + width: 1px; + min-width: 0; + left: 0; + pointer-events: none; + border-radius: 0; + opacity: 0.2; +} + +path.react-flow__edge-path { + stroke-width: 2; +} + +path.react-flow__edge-path:hover { + stroke-width: 4; +} \ No newline at end of file diff --git a/app/cdap/components/StudioV2/components/DAGEditor/index.tsx b/app/cdap/components/StudioV2/components/DAGEditor/index.tsx new file mode 100644 index 00000000000..ad8248cb927 --- /dev/null +++ b/app/cdap/components/StudioV2/components/DAGEditor/index.tsx @@ -0,0 +1,481 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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 React, { createContext, useEffect, useRef } from 'react'; +import { Provider } from 'react-redux'; +import _noop from 'lodash/noop'; + +import { + ReactFlow, + Controls, + Background, + BackgroundVariant, + useReactFlow, + ConnectionLineType, + ReactFlowProvider, + ControlButton, + Viewport, + useOnViewportChange, + Edge, + Connection, + MiniMap, +} from 'reactflow'; +import 'reactflow/dist/style.css'; + +import DragIndicatorIcon from '@material-ui/icons/DragIndicator'; +import UndoIcon from '@material-ui/icons/Undo'; +import RedoIcon from '@material-ui/icons/Redo'; + +import './DAGOverrides.css'; +import { IPluginNode } from '../../types'; +import { useDAGController } from './useDAGController'; +import { NODE_TYPES } from './DAGNodes'; +import { EDGE_TYPES, EdgeInProgress } from './DAGEdges'; +import AvailablePluginsStore from 'services/AvailablePluginsStore'; +import PipelineCommentsActionBtn from 'components/PipelineCanvasActions/PipelineCommentsActionBtn'; +import PipelineContextMenu from 'components/PipelineContextMenu'; +import { addTestIdBySelector } from 'components/StudioV2/utils/domUtils'; +import ZoomIn from '@material-ui/icons/ZoomIn'; +import ZoomOut from '@material-ui/icons/ZoomOut'; +import Fullscreen from '@material-ui/icons/Fullscreen'; + +export interface IDAGEditorContext { + isDisabled?: boolean; +} + +const MIN_ZOOM = 0.4; +const MAX_ZOOM = 2; +const proOptions = { hideAttribution: true }; +export const DAGEditorContext = createContext({}); + +export interface IConnection { + source?: any; + target?: any; + sourceId: string; + targetId: string; +} + +export interface IDAGEditorProps { + isDisabled?: boolean; + pipelineArtifactType?: 'cdap-data-pipeline' | 'cdap-data-streams'; + + metricsData?: any; + disableMetricsClick?: boolean; + onMetricsClick?(node: any, portName?: string): void; + + errorStages?: string[]; + connections?: IConnection[]; + previewMode?: boolean; + + dagNodes?: IPluginNode[]; + updateNode?(nodeid: string, config: any): void; + removeNode?(node: any): void; + onNodeClick?(node: any): void; + onPreviewData?(node: any): void; + shouldShowAlertsPort?(node: any): boolean; + shouldShowErrorsPort?(node: any): boolean; + nodeMenuOpen?: any; + toggleNodeMenu?(): any; + setNodeComments?(comments: any[]): void; + activePluginToComment?: any; + + getPluginConfiguration?(): any; + getSelectedConnections?(): any; + getSelectedNodes?(): any; + onSelectedDelete?(): any; + onPluginMenuOpen?(): any; + onPluginAddComment?(): any; + + cleanupGraph?(): void; + undoActions?(): void; + undoStates?: any[]; + redoActions?(): void; + redoStates?: any[]; + pipelineComments?: any[]; + setPipelineComments?(): void; + onPipelineContextMenuPaste?(): void; + + onViewportChange?(vp: Viewport): void; + addConnection?(conn: any): void; + moveConnection?(oldConn: any, newConn: any): void; + removeConnection?(connToDel: any): void; + prevalidateConnection?(conn?: Connection): boolean; + + uiAutoLayout?: number; +} + +function removeUnits(length?: string | number): number { + if (typeof length === 'undefined') { + return 0; + } + + if (typeof length === 'number') { + return length; + } + return Number(length.replace(/px$/, '')); +} + +export function DagComponent({ + isDisabled, + pipelineArtifactType, + + metricsData, + disableMetricsClick, + onMetricsClick = _noop, + onPreviewData = _noop, + + errorStages, + connections = [], + previewMode, + + dagNodes = [], + removeNode, + updateNode, + onNodeClick, + shouldShowAlertsPort = _noop, + shouldShowErrorsPort = _noop, + + getPluginConfiguration = _noop, + getSelectedConnections = _noop, + getSelectedNodes = _noop, + onSelectedDelete = _noop, + onPluginMenuOpen = _noop, + onPluginAddComment = _noop, + setNodeComments = _noop, + activePluginToComment, + + cleanupGraph, + undoActions, + undoStates = [], + redoActions, + redoStates = [], + pipelineComments, + setPipelineComments, + onViewportChange, + onPipelineContextMenuPaste, + + addConnection, + moveConnection, + removeConnection, + prevalidateConnection, + + uiAutoLayout, + nodeMenuOpen = '', + toggleNodeMenu = _noop, +}: IDAGEditorProps) { + const controlsRef = useRef(); + const reactflow = useReactFlow(); + useOnViewportChange({ + onEnd: onViewportChange, + }); + + const edgeReconnectSuccessful = useRef(true); + const { + nodes, + edges, + onNodesChange, + onConnect, + onEdgesChange, + onReconnect, + onEdgesDelete, + isValidConnection, + } = useDAGController( + dagNodes.map(pluginNodeToDagNode), + connections.map(connectionToDagEdge), + updateNode, + removeNode, + addConnection, + moveConnection, + removeConnection, + prevalidateConnection + ); + + function getNodeByName(nodeName) { + return dagNodes.find((n) => n.name === nodeName) || null; + } + + function pluginNodeToDagNode(node, index) { + return { + id: node.name, + type: 'pipelineNode', + position: { + x: removeUnits(node?._uiPosition?.left), + y: removeUnits(node?._uiPosition?.top), + }, + data: { + label: node.plugin.label, + pluginNode: node, + + onPropertiesClick: onNodeClick || _noop, + disableMetricsClick, + onMetricsClick, + onPreviewData, + shouldShowAlertsPort, + shouldShowErrorsPort, + + getPluginConfiguration, + getSelectedConnections, + getSelectedNodes, + onSelectedDelete, + onPluginMenuOpen, + onPluginAddComment, + nodeMenuOpen, + toggleNodeMenu, + setNodeComments, + activePluginToComment, + + previewMode, + metricsData, + errorStages, + isErrorStage: () => errorStages?.includes(node.name), + index, + }, + draggable: !isDisabled, + }; + } + + function connectionToDagEdge(conn): Edge { + const { from, to, condition } = conn; + const sourceNode = getNodeByName(from); + const targetNode = getNodeByName(to); + + let edgeType = 'standard'; + if ( + sourceNode.type === 'action' || + targetNode.type === 'action' || + sourceNode.type === 'sparkprogram' || + targetNode.type === 'sparkprogram' + ) { + edgeType = 'dashed'; + } + + const edge: Edge = { + id: `edge-${from}-${to}`, + type: edgeType, + source: from, + target: to, + reconnectable: 'target', + data: { + isSourceAtBottom: false, + }, + className: 'jsplumb-connector', // required to keep the same tests work on both old and new UIs + }; + + if (!sourceNode || !targetNode) { + return edge; + } + + const isConditionEdge = condition === 'false'; + const isAlertEdge = targetNode.type === 'alertpublisher'; + const isErrorEdge = targetNode.type === 'errortransform'; + const isSplitterEdge = sourceNode.type === 'splittertransform'; + + let handleType = 'default'; + let sourceHandle = `source-port-${sourceNode.id}-output`; + if (isAlertEdge) { + sourceHandle = `source-port-${sourceNode.id}-alerts`; + handleType = 'alert'; + } else if (isErrorEdge) { + sourceHandle = `source-port-${sourceNode.id}-errors`; + handleType = 'error'; + } else if (isConditionEdge) { + sourceHandle = `source-port-${sourceNode.id}-condition-false`; + handleType = 'condition-false'; + } else if (isSplitterEdge) { + sourceHandle = `source-port-${sourceNode.id}-${conn.port}`; + handleType = 'splitter-port'; + } + + if (sourceNode.type === 'condition') { + if (handleType === 'default') { + edgeType = 'condition-true'; + } else if (handleType === 'condition-false') { + edgeType = 'condition-false'; + } + } + + return { + ...edge, + type: edgeType, + sourceHandle, + data: { + isSourceAtBottom: isAlertEdge || isErrorEdge || isConditionEdge, + }, + }; + } + + function fitToScreen() { + reactflow.fitView({ + padding: 20, + minZoom: MIN_ZOOM, + maxZoom: MAX_ZOOM, + }); + } + + useEffect(() => { + document.body.classList.add('with-new-dag-editor'); + reactflow.zoomTo(0.5); + fitToScreen(); + + return () => { + document.body.classList.remove('with-new-dag-editor'); + }; + }, []); + + useEffect(() => { + if (previewMode) { + document.body.classList.add('preview-mode'); + } + + return () => { + document.body.classList.remove('preview-mode'); + }; + }, [previewMode]); + + useEffect(fitToScreen, [uiAutoLayout]); + + function handleReconnect(oldEdge, newConn) { + edgeReconnectSuccessful.current = true; + onReconnect(oldEdge, newConn); + } + + function handleReconnectStart() { + edgeReconnectSuccessful.current = false; + } + + function handleReconnectEnd(_, edge) { + if (!edgeReconnectSuccessful.current) { + onEdgesDelete([edge]); + } + + edgeReconnectSuccessful.current = true; + } + + return ( + + + + + {!isDisabled && ( + <> + reactflow.zoomOut()} + title="Zoom in" + data-testid="pipeline-zoom-in-control" + > + + + reactflow.zoomOut()} + title="Zoom out" + data-testid="pipeline-zoom-out-control" + > + + + reactflow.fitView()} + title="Fit to screeb" + data-testid="pipeline-fit-to-screen-control" + > + + + + + + + + + + + + + )} + + + + + + {!isDisabled && ( + + )} + + + + ); +} + +export default function DagEditor(props: IDAGEditorProps) { + return ( + + + + ); +} diff --git a/app/cdap/components/StudioV2/components/DAGEditor/useDAGController.ts b/app/cdap/components/StudioV2/components/DAGEditor/useDAGController.ts new file mode 100644 index 00000000000..bb58f480c4a --- /dev/null +++ b/app/cdap/components/StudioV2/components/DAGEditor/useDAGController.ts @@ -0,0 +1,166 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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 React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { + Connection, + Edge, + EdgeChange, + MarkerType, + Node, + NodeChange, + addEdge, + useEdgesState, + useNodesState, + reconnectEdge, +} from 'reactflow'; +import _noop from 'lodash/noop'; +import AvailablePluginsStore from 'services/AvailablePluginsStore'; + +interface IDAGController { + nodes: Node[]; + edges: Edge[]; + onNodesChange: (changes: NodeChange[]) => void; + onEdgesChange: (changes: EdgeChange[]) => void; + onConnect: (conn: Connection) => void; + onReconnect: (oldEdge: Edge, newConn: Connection) => void; + onEdgesDelete: (edges: Edge[]) => void; + isValidConnection: (conn: Connection) => boolean; +} + +function getNodesComparisonKey(nodes) { + return JSON.stringify( + nodes.map((node) => node.data), + (key, value) => (key === '_uiPosition' ? undefined : value === _noop ? '_noop' : value) + ); +} + +export function useDAGController( + uiNodes, + uiEdges, + updateNode, + removeNode, + addConnection, + moveConnection, + removeConnection, + prevalidateConnection +): IDAGController { + const [nodes, setNodes, onNodesChange] = useNodesState(uiNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(uiEdges); + + const pluginsMapRef = useRef(null); + function setUpPluginsListener() { + return AvailablePluginsStore.subscribe(() => { + const { pluginsMap } = AvailablePluginsStore.getState().plugins; + // trigger a re-rendering of nodes when the pluginsMap changes, + // as the nodes views may change depending on changes in plugins + if (pluginsMap !== pluginsMapRef.current) { + setNodes((nodes) => [...nodes]); + pluginsMapRef.current = pluginsMap; + } + }); + } + useLayoutEffect(setUpPluginsListener, []); + + useLayoutEffect(() => { + setNodes(uiNodes); + setEdges(uiEdges); + }, [getNodesComparisonKey(uiNodes), JSON.stringify(uiEdges)]); + + const getNodeById = useCallback( + (nodeid) => { + return nodes.find((n) => n.id === nodeid) || null; + }, + [nodes] + ); + + const handleNodesChange = useCallback( + (changes) => { + onNodesChange(changes); + for (const change of changes) { + if (change.type === 'position' && change.dragging === false) { + const node = getNodeById(change.id); + const { position } = node; + const nodeConfig = { + _uiPosition: { + top: position.y, + left: position.x, + }, + }; + updateNode(node.data.pluginNode.id, nodeConfig); + } else if (change.type === 'remove') { + const node = getNodeById(change.id); + removeNode(node.data.pluginNode); + } + } + }, + [nodes] + ); + + const handleEdgesChange = useCallback((changes) => { + // pass + }, []); + + const populateEdge = useCallback( + (edge) => ({ + ...edge, + source: getNodeById(edge.source), + target: getNodeById(edge.target), + }), + [nodes] + ); + + const onConnect = useCallback( + (conn) => { + setEdges((oldEdges) => addEdge(conn, oldEdges)); + addConnection(populateEdge(conn)); + }, + [edges, nodes] + ); + + const onReconnect = useCallback( + (oldEdge, newConn) => { + setEdges((els) => reconnectEdge(oldEdge, newConn, els)); + moveConnection(populateEdge(oldEdge), populateEdge(newConn)); + }, + [setEdges, getNodeById] + ); + + const onEdgesDelete = useCallback( + (edgesToDel: Edge[]) => { + const edgeIdsToDelSet = new Set(edgesToDel.map((x) => x.id)); + setEdges((eds) => eds.filter((e) => !edgeIdsToDelSet.has(e.id))); + edgesToDel.map(populateEdge).forEach(removeConnection); + }, + [edges] + ); + + const isValidConnection = useCallback( + (conn: Connection) => prevalidateConnection(populateEdge(conn)), + [nodes, prevalidateConnection, populateEdge] + ); + + return { + nodes, + edges, + onNodesChange: handleNodesChange, + onEdgesChange: handleEdgesChange, + onConnect, + onReconnect, + onEdgesDelete, + isValidConnection, + }; +} diff --git a/app/cdap/components/StudioV2/types.ts b/app/cdap/components/StudioV2/types.ts new file mode 100644 index 00000000000..0ff5e7ab7b6 --- /dev/null +++ b/app/cdap/components/StudioV2/types.ts @@ -0,0 +1,109 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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 { + IConfigurationGroup, + IPropertyFilter, + IWidgetProperty, +} from 'components/shared/ConfigurationGroup/types'; + +export type ArtifactScope = 'USER' | 'SYSTEM'; + +export interface IArtifactSummary { + name: string; + version: string; + scope: ArtifactScope; +} + +export interface ILabeledArtifactSummary extends IArtifactSummary { + label: string; +} + +export interface IPlugin { + name?: string; + type?: string; + label?: string; + displayName?: string; + description?: string; + className?: string; + pluginTemplate?: string; + + icon?: string; + showCustomIcon?: boolean; + customIconSrc?: string; + + artifact?: IArtifactSummary; + defaultArtifact?: IArtifactSummary; + allArtifacts?: IPlugin[]; +} + +export interface IPluginTemplate { + artifact?: IArtifactSummary; + description?: string; + lock?: { + [key: string]: any; + }; + nodeClass?: string; + outputSchema?: string; + pluginName?: string; + pluginTemplate?: string; + pluginType?: string; + templateType?: string; + properties?: { + [key: string]: any; + }; +} + +export interface IDagConnection { + from: string; + to: string; +} + +export interface IPluginWithProperties extends IPlugin { + properties?: any; +} + +export interface IPluginNode { + id: string; + name?: string; + description?: string; + type?: string; + + configGroups?: IConfigurationGroup[]; + errorCount?: number; + filters?: IPropertyFilter[]; + icon?: string; + + implicitSchema?: any; // TODO: add proper type + outputSchema?: any; + + outputSchemaProperty?: string; + outputs?: IWidgetProperty[]; + plugin?: IPluginWithProperties; + + isPluginAvailable?: boolean; + selected?: boolean; + visibilityMap?: { + [key: string]: boolean; + }; + warning?: boolean; + + _backendProperties?: any; // TODO: add proper types + _uiPosition?: { + top?: string; + left?: string; + }; +} diff --git a/app/cdap/components/StudioV2/utils/artifactUtils.ts b/app/cdap/components/StudioV2/utils/artifactUtils.ts new file mode 100644 index 00000000000..a6487a9618d --- /dev/null +++ b/app/cdap/components/StudioV2/utils/artifactUtils.ts @@ -0,0 +1,21 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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 { GLOBALS } from 'services/global-constants'; + +export function getArtifactDisaplayName(artifactName: string): string { + return GLOBALS.artifactConvert[artifactName] || artifactName; +} diff --git a/app/cdap/components/StudioV2/utils/geometry.ts b/app/cdap/components/StudioV2/utils/geometry.ts new file mode 100644 index 00000000000..540431c2631 --- /dev/null +++ b/app/cdap/components/StudioV2/utils/geometry.ts @@ -0,0 +1,19 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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. + */ + +export function cartesianDistance(fromX: number, fromY: number, toX: number, toY: number): number { + return Math.sqrt(Math.pow(toX - fromX, 2) + Math.pow(toY - fromY, 2)); +} diff --git a/app/cdap/components/StudioV2/utils/pluginUtils.ts b/app/cdap/components/StudioV2/utils/pluginUtils.ts new file mode 100644 index 00000000000..7b39d2fa6bf --- /dev/null +++ b/app/cdap/components/StudioV2/utils/pluginUtils.ts @@ -0,0 +1,196 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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 _isEqual from 'lodash/isEqual'; +import _get from 'lodash/get'; +import _cloneDeep from 'lodash/cloneDeep'; +import { findHighestVersion } from 'services/VersionRange/VersionUtilities'; +import { IArtifactSummary, IPlugin } from '../types'; + +export function getPluginIcon(pluginName: string): string { + const iconMap = { + script: 'icon-script', + scriptfilter: 'icon-scriptfilter', + twitter: 'icon-twitter', + cube: 'icon-cube', + data: 'fa-database', + database: 'icon-database', + table: 'icon-table', + kafka: 'icon-kafka', + jms: 'icon-jms', + projection: 'icon-projection', + amazonsqs: 'icon-amazonsqs', + datagenerator: 'icon-datagenerator', + validator: 'icon-validator', + corevalidator: 'corevalidator', + logparser: 'icon-logparser', + file: 'icon-file', + kvtable: 'icon-kvtable', + s3: 'icon-s3', + s3avro: 'icon-s3avro', + s3parquet: 'icon-s3parquet', + snapshotavro: 'icon-snapshotavro', + snapshotparquet: 'icon-snapshotparquet', + tpfsavro: 'icon-tpfsavro', + tpfsparquet: 'icon-tpfsparquet', + sink: 'icon-sink', + hive: 'icon-hive', + structuredrecordtogenericrecord: 'icon-structuredrecord', + cassandra: 'icon-cassandra', + teradata: 'icon-teradata', + elasticsearch: 'icon-elasticsearch', + hbase: 'icon-hbase', + mongodb: 'icon-mongodb', + pythonevaluator: 'icon-pythonevaluator', + csvformatter: 'icon-csvformatter', + csvparser: 'icon-csvparser', + clonerecord: 'icon-clonerecord', + compressor: 'icon-compressor', + decompressor: 'icon-decompressor', + encoder: 'icon-encoder', + decoder: 'icon-decoder', + jsonformatter: 'icon-jsonformatter', + jsonparser: 'icon-jsonparser', + hdfs: 'icon-hdfs', + hasher: 'icon-hasher', + javascript: 'icon-javascript', + deduper: 'icon-deduper', + distinct: 'icon-distinct', + naivebayestrainer: 'icon-naivebayestrainer', + groupbyaggregate: 'icon-groupbyaggregate', + naivebayesclassifier: 'icon-naivebayesclassifier', + azureblobstore: 'icon-azureblobstore', + xmlreader: 'icon-XMLreader', + xmlparser: 'icon-XMLparser', + ftp: 'icon-FTP', + joiner: 'icon-joiner', + deduplicate: 'icon-deduplicator', + valuemapper: 'icon-valuemapper', + rowdenormalizer: 'icon-rowdenormalizer', + ssh: 'icon-ssh', + sshaction: 'icon-sshaction', + copybookreader: 'icon-COBOLcopybookreader', + excel: 'icon-excelinputsource', + encryptor: 'icon-Encryptor', + decryptor: 'icon-Decryptor', + hdfsfilemoveaction: 'icon-filemoveaction', + hdfsfilecopyaction: 'icon-filecopyaction', + sqlaction: 'icon-SQLaction', + impalahiveaction: 'icon-impalahiveaction', + email: 'icon-emailaction', + kinesissink: 'icon-Amazon-Kinesis', + bigquerysource: 'icon-Big-Query', + tpfsorc: 'icon-ORC', + groupby: 'icon-groupby', + sparkmachinelearning: 'icon-sparkmachinelearning', + solrsearch: 'icon-solr', + sparkstreaming: 'icon-sparkstreaming', + rename: 'icon-rename', + archive: 'icon-archive', + wrangler: 'icon-DataPreparation', + normalize: 'icon-normalize', + xmlmultiparser: 'icon-XMLmultiparser', + xmltojson: 'icon-XMLtoJSON', + decisiontreepredictor: 'icon-decisiontreeanalytics', + decisiontreetrainer: 'icon-DesicionTree', + hashingtffeaturegenerator: 'icon-HashingTF', + ngramtransform: 'icon-NGram', + tokenizer: 'icon-tokenizeranalytics', + skipgramfeaturegenerator: 'icon-skipgram', + skipgramtrainer: 'icon-skipgramtrainer', + logisticregressionclassifier: 'icon-logisticregressionanalytics', + logisticregressiontrainer: 'icon-LogisticRegressionclassifier', + hdfsdelete: 'icon-hdfsdelete', + hdfsmove: 'icon-hdfsmove', + windowssharecopy: 'icon-windowssharecopy', + httppoller: 'icon-httppoller', + window: 'icon-window', + run: 'icon-Run', + oracleexport: 'icon-OracleDump', + snapshottext: 'icon-SnapshotTextSink', + errorcollector: 'fa-exclamation-triangle', + mainframereader: 'icon-MainframeReader', + fastfilter: 'icon-fastfilter', + trash: 'icon-TrashSink', + staterestore: 'icon-Staterestore', + topn: 'icon-TopN', + wordcount: 'icon-WordCount', + datetransform: 'icon-DateTransform', + sftpcopy: 'icon-FTPcopy', + sftpdelete: 'icon-FTPdelete', + validatingxmlconverter: 'icon-XMLvalidator', + wholefilereader: 'icon-Filereader', + xmlschemaaction: 'icon-XMLschemagenerator', + s3toredshift: 'icon-S3toredshift', + redshifttos3: 'icon-redshifttoS3', + verticabulkexportaction: 'icon-Verticabulkexport', + verticabulkimportaction: 'icon-Verticabulkload', + loadtosnowflake: 'icon-snowflake', + kudu: 'icon-apachekudu', + orientdb: 'icon-OrientDB', + recordsplitter: 'icon-recordsplitter', + scalasparkprogram: 'icon-spark', + scalasparkcompute: 'icon-spark', + cdcdatabase: 'icon-database', + cdchbase: 'icon-hbase', + cdckudu: 'icon-apachekudu', + changetrackingsqlserver: 'icon-database', + conditional: 'fa-question-circle-o', + }; + + const actualPluginName = pluginName ? pluginName.toLowerCase() : ''; + const icon = iconMap[actualPluginName] ? iconMap[actualPluginName] : 'fa-plug'; + return icon; +} + +export function getDefaultVersionForPlugin( + plugin: IPlugin = {}, + defaultVersionMap: { [key: string]: IArtifactSummary } = {} +) { + if (Object.keys(plugin).length === 0) { + return {}; + } + + const defaultVersionsList = Object.keys(defaultVersionMap); + const key = `${plugin.name}-${plugin.type}-${plugin.artifact.name}`; + const isDefaultVersionExists = defaultVersionsList.includes(key); + const isArtifactExistsInBackend = (plugin.allArtifacts || []).filter((plug) => + _isEqual(plug.artifact, defaultVersionMap[key]) + ); + + if (!isDefaultVersionExists || isArtifactExistsInBackend.length === 0) { + const highestVersion = findHighestVersion( + plugin.allArtifacts.map((plugin) => plugin.artifact.version), + true + ); + const latestPluginVersion = plugin.allArtifacts.find( + (plugin) => plugin.artifact.version === highestVersion + ); + return _get(latestPluginVersion, 'artifact'); + } + + return _cloneDeep(defaultVersionMap[key]); +} +export const getTemplatesWithAddedInfo = (templates = [], extension = '') => + templates.map((template) => ({ + ...template, + nodeClass: 'plugin-templates', + name: template.pluginTemplate, + pluginName: template.pluginName, + type: extension, + icon: getPluginIcon(template.pluginName), + allArtifacts: [template.artifact], + })); diff --git a/app/common/cask-shared-components.js b/app/common/cask-shared-components.js index 9a40b3642a6..af60e2b72da 100644 --- a/app/common/cask-shared-components.js +++ b/app/common/cask-shared-components.js @@ -153,6 +153,7 @@ var PreviewErrorClassificationBanner = require('../cdap/components/PipelineDetai .default; var ErrorStageOutline = require('../cdap/components/PipelineDetails/PipelineDetailsTopPanel/PipelineRunErrorDetails/ErrorStageOutline') .default; +var DAGEditor = require('../cdap/components/StudioV2/components/DAGEditor').default; export { TopPanelReact, @@ -264,4 +265,5 @@ export { Connections, PreviewErrorClassificationBanner, ErrorStageOutline, + DAGEditor, }; diff --git a/app/directives/react-components/index.js b/app/directives/react-components/index.js index c483e4a5e72..1edac3b870d 100644 --- a/app/directives/react-components/index.js +++ b/app/directives/react-components/index.js @@ -201,4 +201,7 @@ angular }) .directive('errorStageOutline', function (reactDirective) { return reactDirective(window.CaskCommon.ErrorStageOutline); + }) + .directive('dagEditor', function (reactDirective) { + return reactDirective(window.CaskCommon.DAGEditor); }); From 61275429a9ce4c86542aba1dac4d0023c69e6f12 Mon Sep 17 00:00:00 2001 From: GnsP Date: Fri, 28 Mar 2025 09:54:26 +0530 Subject: [PATCH 2/2] Add components for the DAG Nodes and Edges --- .../components/DAGEditor/DAGEdges.tsx | 178 ++++++ .../components/DAGEditor/DAGNodes.tsx | 523 ++++++++++++++++++ .../components/DAGEditor/NodeHandleStyles.ts | 53 ++ .../components/DAGEditor/NodeMetrics.tsx | 97 ++++ .../components/DAGEditor/SplitterPopover.tsx | 70 +++ 5 files changed, 921 insertions(+) create mode 100644 app/cdap/components/StudioV2/components/DAGEditor/DAGEdges.tsx create mode 100644 app/cdap/components/StudioV2/components/DAGEditor/DAGNodes.tsx create mode 100644 app/cdap/components/StudioV2/components/DAGEditor/NodeHandleStyles.ts create mode 100644 app/cdap/components/StudioV2/components/DAGEditor/NodeMetrics.tsx create mode 100644 app/cdap/components/StudioV2/components/DAGEditor/SplitterPopover.tsx diff --git a/app/cdap/components/StudioV2/components/DAGEditor/DAGEdges.tsx b/app/cdap/components/StudioV2/components/DAGEditor/DAGEdges.tsx new file mode 100644 index 00000000000..968c8f44e6a --- /dev/null +++ b/app/cdap/components/StudioV2/components/DAGEditor/DAGEdges.tsx @@ -0,0 +1,178 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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 React, { useEffect, useMemo, useRef } from 'react'; +import { + BaseEdge, + ConnectionLineComponentProps, + EdgeProps, + Position, + getSimpleBezierPath, + getSmoothStepPath, +} from 'reactflow'; +import { cartesianDistance } from '../../utils/geometry'; + +const END_MARKER_PREFIX = 'cdap-dag-edge-end-marker'; +const EDGE_BORDER_RADIUS = 20; + +const EndMarkers = { + FILLED_TRIANGLE: `${END_MARKER_PREFIX}-traiangular-filled`, + FILLED_TRIANGLE_SELECTED: `${END_MARKER_PREFIX}-traiangular-filled-selected`, +}; + +const endMarkersSvg = ` + + + + + + + + + + + +`; + +function appendMarkersSvg() { + const markers = Array.from(document.querySelectorAll(`marker[id*="${END_MARKER_PREFIX}"]`)); + if (!markers.length) { + const svgWrapperEl = document.createElement('div'); + svgWrapperEl.innerHTML = endMarkersSvg; + document.body.appendChild(svgWrapperEl); + } +} + +function getEdgePath( + sourceX: number, + sourceY: number, + sourcePosition: Position, + targetX: number, + targetY: number, + targetPosition: Position, + inProgress: boolean = false +) { + const distX = Math.abs(targetX - sourceX); + const distY = Math.abs(targetY - sourceY); + if (inProgress && distX < EDGE_BORDER_RADIUS && distY < 2 * EDGE_BORDER_RADIUS) { + return getSimpleBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + } + + return getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: EDGE_BORDER_RADIUS, + }); +} + +export function StandardEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + selected, + data, +}: EdgeProps) { + const [path] = getEdgePath( + sourceX, + sourceY, + data?.isSourceAtBottom ? Position.Bottom : Position.Right, + targetX, + targetY, + Position.Left + ); + + useEffect(() => { + appendMarkersSvg(); + }, []); + + let markerEnd = selected + ? `url(#${EndMarkers.FILLED_TRIANGLE_SELECTED})` + : `url(#${EndMarkers.FILLED_TRIANGLE})`; + if (Math.abs(targetX - sourceX) < EDGE_BORDER_RADIUS) { + markerEnd = ''; + } + + return ; +} + +export function EdgeInProgress({ + fromX, + fromY, + toX, + toY, + fromPosition, + toPosition, +}: ConnectionLineComponentProps) { + const pathRef = useRef(null); + const [path] = useMemo( + () => getEdgePath(fromX, fromY, fromPosition, toX, toY, toPosition, true), + [fromX, fromY, toX, toY, toPosition] + ); + + useEffect(() => { + if (pathRef.current) { + pathRef.current.setAttribute('d', path); + } + }, [path, pathRef.current]); + + useEffect(() => { + appendMarkersSvg(); + }, []); + + return ( + + ); +} + +export const EDGE_TYPES = { + default: StandardEdge, + standard: StandardEdge, +}; diff --git a/app/cdap/components/StudioV2/components/DAGEditor/DAGNodes.tsx b/app/cdap/components/StudioV2/components/DAGEditor/DAGNodes.tsx new file mode 100644 index 00000000000..5c2adc3d162 --- /dev/null +++ b/app/cdap/components/StudioV2/components/DAGEditor/DAGNodes.tsx @@ -0,0 +1,523 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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 React, { useContext, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import styled from 'styled-components'; +import { Handle, NodeProps, Position, useUpdateNodeInternals } from 'reactflow'; +import _noop from 'lodash/noop'; + +import CommentIcon from '@material-ui/icons/Comment'; +import { + getCustomIconSrc, + shouldShowCustomIcon, +} from 'components/hydrator/components/SidePanel/helpers'; +import Comment from 'components/AbstractWidget/Comment'; +import PluginContextMenu, { getPluginMenuOptions } from 'components/PluginContextMenu'; + +import { Button, IconButton, ListItemIcon, Menu, MenuItem } from '@material-ui/core'; +import MenuIcon from '@material-ui/icons/Menu'; +import { + setMetricsTabActive, + setSelectedPlugin, +} from 'services/PipelineMetricsStore/ActionCreator'; +import ErrorStageOutline from 'components/PipelineDetails/PipelineDetailsTopPanel/PipelineRunErrorDetails/ErrorStageOutline'; +import { DAGEditorContext } from '.'; +import { + AlertHandle, + ErrorHandle, + FalseHandle, + disabledSourceHandleStyle, + sourceHandleStyle, + targetHandleStyle, +} from './NodeHandleStyles'; +import SplitterPopover, { PortContainer } from './SplitterPopover'; +import NodeMetrics from './NodeMetrics'; +import { isPluginSink } from 'services/helpers'; + +const NODE_HIGHLIGHT_COLORS = { + batchsource: '#48c038', + transform: '#4586f3', + batchsink: '#8367df', + action: '#988470', + condition: '#4e5568', + alertpublisher: '#ffba01', + errortransform: '#d40001', +}; + +type ConditionHandle = 'CONDITION_TRUE' | 'CONDITION_FALSE'; +type NodeHandle = ConditionHandle | 'GENERIC'; + +const conditionHandleStyles = { + CONDITION_TRUE: sourceHandleStyle, + CONDITION_FALSE: sourceHandleStyle, +}; + +function getNodeHandleStyle({ + nodeType, + isDisabled, + handleType = 'GENERIC', +}: { + nodeType?: string; + isDisabled?: boolean; + handleType?: NodeHandle; +}) { + if (isDisabled) { + return disabledSourceHandleStyle; + } + + if (nodeType === 'condition') { + return conditionHandleStyles[handleType] || sourceHandleStyle; + } + + return sourceHandleStyle; +} + +const DEFAULT_TEXT_COLOR = '#4a4a4a'; +const DEFAULT_HIGHLIGHT_COLOR = 'rgba(0, 0, 0, 0.5)'; +const DEFAULT_SHADOW_COLOR = 'rgba(0, 0, 0, 0.3)'; + +function getNodeBorder(nodeType, selected) { + const color = NODE_HIGHLIGHT_COLORS[nodeType] || DEFAULT_HIGHLIGHT_COLOR; + const width = selected ? '2px' : '1px'; + + return `${width} ${color} solid`; +} + +function getNodeShadow(nodeType, selected) { + const color = selected + ? NODE_HIGHLIGHT_COLORS[nodeType] || DEFAULT_SHADOW_COLOR + : DEFAULT_SHADOW_COLOR; + const spread = selected ? '15px' : '10px'; + + return `0 0 ${spread} ${color}`; +} + +const NodeContainer = styled.div` + position: relative; + margin-left: 2px; + padding: 8px 12px 4px 12px; + + background: white; + width: 200px; + height: 100px; + border-radius: 8px; + box-shadow: ${({ nodeType, selected }) => getNodeShadow(nodeType, selected)}; + border: ${({ nodeType, selected }) => getNodeBorder(nodeType, selected)}; + box-sizing: content-box; +`; + +const CommentsIconContainer = styled.div` + position: absolute; + right: 0; + top: -30px; +`; + +const NodeErrorsAndWarningsCount = styled.div` + position: absolute; + top: 3px; + right: 3px; + + display: inline-block; + padding: 0.5em; + font-weight: 700; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0.25em; + background: ${({ isWarning }) => (isWarning ? '#ffcc00' : 'ff6666')}; + color: white; +`; + +const NodeInnerLayout = styled.div` + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: space-between; + height: 100%; +`; + +const NodeInfoContainer = styled.div` + display: flex; + justify-content: flex-start; +`; + +const PluginIconContainer = styled.div` + width: 25px; + height: 25px; + min-width: 25px; + min-height: 25px; + + margin-right: 10px; + margin-top: 4px; +`; + +const PluginIconImage = styled.img` + width: 25px; + height: 25px; +`; + +const PluginIconDefault = styled.div` + width: 25px; + height: 25px; + font-size: 25px; +`; + +const PluginMetaContainer = styled.div` + overflow: hidden; + text-overflow: ellipsis; +`; + +const PluginName = styled.div` + font-size: 14px; + font-weight: 600; + margin-bottom: 2px; + color: ${DEFAULT_TEXT_COLOR}; +`; + +const PluginVersion = styled.div` + font-size: 11px; + color: ${DEFAULT_TEXT_COLOR}; +`; + +const NodeButtonsContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const SplitterHandlesContainer = styled.div` + position: absolute; + left: calc(100% + 8px); + top: 0; + width: 80px; + + transform: translateY(calc((100px - 100%) / 2)); + display: flex; + flex-direction: column; + align-items: stretch; + background: white; + border-radius: 8px; + box-shadow: ${({ nodeType, selected }) => getNodeShadow(nodeType, selected)}; + border: ${({ nodeType, selected }) => getNodeBorder(nodeType, selected)}; +`; + +const BottomPortsContainer = styled.div` + display: ${({ isVisible }) => (isVisible ? 'flex' : 'none')}; + position: absolute; + top: calc(100% + 8px); + left: 0; + height: 32px; + border-radius: 10px; + align-items: stretch; + background: white; + box-shadow: ${({ nodeType, selected }) => getNodeShadow(nodeType, selected)}; + border: ${({ nodeType, selected }) => getNodeBorder(nodeType, selected)}; + + & > div { + border-right: 1px ${DEFAULT_HIGHLIGHT_COLOR} solid; + + &:last-child { + border-right: none; + } + } +`; + +export function PipelineComments({ + comments = [], + node, + setComments = _noop, + activePluginToComment = '', + setPluginActiveForComment = _noop, + isDisabled, +}) { + if (comments.length < 1 && activePluginToComment !== node.id) { + return null; + } + + return ( + + setPluginActiveForComment()} + disabled={isDisabled} + /> + + ); +} + +export function PipelineNode({ id, data, selected }: NodeProps) { + const updateNodeInternals = useUpdateNodeInternals(); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const pluginsMap = useSelector((state) => state.pluginsMap); + const node = data.pluginNode; + + const hasCustomIcon = shouldShowCustomIcon(node.plugin, pluginsMap); + const { isDisabled } = useContext(DAGEditorContext); + const shouldShowAlertsPort = data.shouldShowAlertsPort(node); + const shouldShowErrorsPort = data.shouldShowErrorsPort(node); + const hasBottomPorts = node.type === 'condition' || shouldShowAlertsPort || shouldShowErrorsPort; + + useEffect(() => updateNodeInternals(id), [ + node?.outputSchema, + node.type, + shouldShowAlertsPort, + shouldShowErrorsPort, + ]); + + function handlePropertiesClick() { + if (typeof data.onPropertiesClick === 'function') { + data.onPropertiesClick(node); + } + } + + function handleMenuClick(event) { + setMenuAnchorEl(event.target); + data.toggleNodeMenu(node); + } + + function handleMenuClose() { + setMenuAnchorEl(null); + data.toggleNodeMenu(node); + } + + const pluginMenuItems = getPluginMenuOptions({ + nodeId: node.id, + getPluginConfiguration: data.getPluginConfiguration, + getSelectedConnections: data.getSelectedConnections, + getSelectedNodes: data.getSelectedNodes, + onDelete: data.onSelectedDelete, + onAddComment: data.onPluginAddComment, + }); + + function handleMenuItemClick(onClickHandler) { + return () => { + handleMenuClose(); + if (typeof onClickHandler === 'function') { + onClickHandler(); + } + }; + } + + return ( + <> + + + {data.isErrorStage() && } + + {!!node.errorCount && ( + + {node.errorCount} + + )} + + + + {hasCustomIcon ? ( + + ) : ( + + )} + + + {data.label} + {node.plugin.artifact.version} + + + {data.previewMode && !['action', 'sparkprogram', 'condition'].includes(node.type) && ( + + + + )} + + + {isDisabled ? ( +
+ ) : ( + + + + )} + + {pluginMenuItems.map((item) => ( + + {item.icon} + {typeof item.label === 'function' ? item.label() : item.label} + + ))} + + + {isDisabled && !['action', 'sparkprogram', 'condition'].includes(node.type) && ( +
+ +
+ )} + + {node.type === 'splittertransform' && ( + + {node?.outputSchema?.length && node.outputSchema[0].name !== 'etlSchemaBody' ? ( + + ) : ( + 0 Splits + )} + + )} + + {node.type === 'condition' && ( + + False + + + )} + {!!shouldShowAlertsPort && ( + + Alert + + + )} + {!!shouldShowErrorsPort && ( + + Error + + + )} + + + {node.type !== 'splittertransform' && !isPluginSink(node.type) && ( + + )} + {!isDisabled && ( + + )} + + ); +} + +export const NODE_TYPES = { + default: PipelineNode, + pipelineNode: PipelineNode, +}; diff --git a/app/cdap/components/StudioV2/components/DAGEditor/NodeHandleStyles.ts b/app/cdap/components/StudioV2/components/DAGEditor/NodeHandleStyles.ts new file mode 100644 index 00000000000..bc0b03afc85 --- /dev/null +++ b/app/cdap/components/StudioV2/components/DAGEditor/NodeHandleStyles.ts @@ -0,0 +1,53 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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 styled from 'styled-components'; + +export const targetHandleStyle = {}; + +export const sourceHandleStyle = { + width: '12px', + height: '12px', + borderRadius: '6px', + right: '-5px', + background: '#b1b1b7', +}; + +export const disabledSourceHandleStyle = { + ...sourceHandleStyle, + background: '#d1d1d7', +}; + +export const AlertHandle = styled.div` + padding: 5px; + font-size: 10px; + color: #efab83; + font-weight: bold; + + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 60px; +`; + +export const ErrorHandle = styled(AlertHandle)` + color: #ef83d3; +`; + +export const FalseHandle = styled(AlertHandle)` + color: #b2b2b2; +`; diff --git a/app/cdap/components/StudioV2/components/DAGEditor/NodeMetrics.tsx b/app/cdap/components/StudioV2/components/DAGEditor/NodeMetrics.tsx new file mode 100644 index 00000000000..25c1a21adf4 --- /dev/null +++ b/app/cdap/components/StudioV2/components/DAGEditor/NodeMetrics.tsx @@ -0,0 +1,97 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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 React from 'react'; + +export interface INodeMetricsProps { + onClick?(event: any, node: any, portName?: string): void; + node?: any; + disabled?: boolean; + metricsData?: any; + portName?: string; +} + +export default function NodeMetrics({ + onClick, + node, + disabled, + metricsData, + portName, +}: INodeMetricsProps) { + if (!metricsData) { + return null; + } + + function handleClick(event) { + return onClick(event, node, portName); + } + + return ( + + ); +} diff --git a/app/cdap/components/StudioV2/components/DAGEditor/SplitterPopover.tsx b/app/cdap/components/StudioV2/components/DAGEditor/SplitterPopover.tsx new file mode 100644 index 00000000000..73a96fd3240 --- /dev/null +++ b/app/cdap/components/StudioV2/components/DAGEditor/SplitterPopover.tsx @@ -0,0 +1,70 @@ +/* + * Copyright © 2025 Cask Data, Inc. + * + * Licensed 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 React from 'react'; +import { Handle, Position } from 'reactflow'; +import styled from 'styled-components'; +import { disabledSourceHandleStyle, sourceHandleStyle } from './NodeHandleStyles'; +import NodeMetrics from './NodeMetrics'; + +export const PortContainer = styled.div` + padding: 5px 10px; + padding-right: 20px; + margin: 0; + border-bottom: 1px #e1e1e1 solid; + position: relative; + + &:last-child { + border-bottom: none; + } +`; + +export default function SplitterPopover({ + ports, + isDisabled, + node, + onMetricsClick, + disableMetricsClick, + metricsData, +}) { + return ( + <> + {ports.map((port) => ( + + {port.name} + + {isDisabled && ( +
+ +
+ )} +
+ ))} + + ); +}