diff --git a/apps/gitness/package.json b/apps/gitness/package.json index 64358c24e..69751f99b 100644 --- a/apps/gitness/package.json +++ b/apps/gitness/package.json @@ -23,7 +23,7 @@ "@harnessio/code-service-client": "3.2.1-beta.1", "@harnessio/forms": "workspace:*", "@harnessio/ui": "workspace:*", - "@harnessio/unified-pipeline": "workspace:*", + "@harnessio/pipeline-graph": "workspace:*", "@harnessio/views": "workspace:*", "@harnessio/yaml-editor": "workspace:*", "@hookform/resolvers": "^3.6.0", diff --git a/apps/gitness/src/App.tsx b/apps/gitness/src/App.tsx index 78c59eb7e..952827e7c 100644 --- a/apps/gitness/src/App.tsx +++ b/apps/gitness/src/App.tsx @@ -23,9 +23,11 @@ import { ThemeProvider } from './framework/context/ThemeContext' import { queryClient } from './framework/queryClient' import i18n from './i18n/i18n' import { useTranslationStore } from './i18n/stores/i18n-store' +import PipelineLayout from './layouts/PipelineStudioLayout' import CreateProject from './pages-v2/create-project/create-project-container' import { LandingPage } from './pages-v2/landing-page-container' import { Logout } from './pages-v2/logout' +import PipelineEditPage from './pages-v2/pipeline/pipeline-edit/pipeline-edit' import { SettingsProfileGeneralPage } from './pages-v2/profile-settings/profile-settings-general-container' import { SettingsProfileKeysPage } from './pages-v2/profile-settings/profile-settings-keys-container' import { ProfileSettingsThemePage } from './pages-v2/profile-settings/profile-settings-theme-page' @@ -148,6 +150,26 @@ export default function App() { path: ':spaceId/repos', element: }, + { + path: ':spaceId/repos/:repoId/pipelines/:pipelineId', + element: , + children: [ + { + index: true, + element: + // children: [ + // { + // path: 'edit', + // element: + // } + // ] + }, + { + path: 'edit', + element: + } + ] + }, { path: ':spaceId/repos/:repoId', element: , @@ -275,13 +297,7 @@ export default function App() { }, { path: 'pipelines', - children: [ - { index: true, element: }, - { - path: ':pipelineId', - element: - } - ] + children: [{ index: true, element: }] } ] }, diff --git a/apps/gitness/src/components-v2/file-content-viewer.tsx b/apps/gitness/src/components-v2/file-content-viewer.tsx index 4ad3ba86d..7a73af9cf 100644 --- a/apps/gitness/src/components-v2/file-content-viewer.tsx +++ b/apps/gitness/src/components-v2/file-content-viewer.tsx @@ -11,8 +11,8 @@ import { useThemeStore } from '../framework/context/ThemeContext' import { useDownloadRawFile } from '../framework/hooks/useDownloadRawFile' import { useGetRepoRef } from '../framework/hooks/useGetRepoPath' import useCodePathDetails from '../hooks/useCodePathDetails' +import { themes } from '../pages-v2/pipeline/pipeline-edit/theme/monaco-theme' import { useRepoBranchesStore } from '../pages-v2/repo/stores/repo-branches-store' -import { themes } from '../pages/pipeline-edit/theme/monaco-theme' import { PathParams } from '../RouteDefinitions' import { decodeGitContent, FILE_SEPERATOR, filenameToLanguage, formatBytes, GitCommitAction } from '../utils/git-utils' diff --git a/apps/gitness/src/components-v2/file-editor.tsx b/apps/gitness/src/components-v2/file-editor.tsx index aebf28688..36a726b43 100644 --- a/apps/gitness/src/components-v2/file-editor.tsx +++ b/apps/gitness/src/components-v2/file-editor.tsx @@ -11,8 +11,8 @@ import { useThemeStore } from '../framework/context/ThemeContext' import { useExitConfirm } from '../framework/hooks/useExitConfirm' import useCodePathDetails from '../hooks/useCodePathDetails' import { useTranslationStore } from '../i18n/stores/i18n-store' +import { themes } from '../pages-v2/pipeline/pipeline-edit/theme/monaco-theme' import { useRepoBranchesStore } from '../pages-v2/repo/stores/repo-branches-store' -import { themes } from '../pages/pipeline-edit/theme/monaco-theme' import { PathParams } from '../RouteDefinitions' import { decodeGitContent, FILE_SEPERATOR, filenameToLanguage, GitCommitAction, PLAIN_TEXT } from '../utils/git-utils' import { splitPathWithParents } from '../utils/path-utils' diff --git a/apps/gitness/src/components/FileContentViewer.tsx b/apps/gitness/src/components/FileContentViewer.tsx index dabaf22ad..83b2a6ab3 100644 --- a/apps/gitness/src/components/FileContentViewer.tsx +++ b/apps/gitness/src/components/FileContentViewer.tsx @@ -22,7 +22,7 @@ import { CodeEditor } from '@harnessio/yaml-editor' import { useDownloadRawFile } from '../framework/hooks/useDownloadRawFile' import { useGetRepoRef } from '../framework/hooks/useGetRepoPath' -import { themes } from '../pages/pipeline-edit/theme/monaco-theme' +import { themes } from '../pages-v2/pipeline/pipeline-edit/theme/monaco-theme' import { timeAgoFromISOTime } from '../pages/pipeline-edit/utils/time-utils' import { PathParams } from '../RouteDefinitions' import { decodeGitContent, filenameToLanguage, formatBytes, getTrimmedSha, GitCommitAction } from '../utils/git-utils' diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/canvas/canvas-button.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/canvas/canvas-button.tsx new file mode 100644 index 000000000..25f5e643c --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/canvas/canvas-button.tsx @@ -0,0 +1,14 @@ +export function CanvasButton(props: React.PropsWithChildren<{ onClick: () => void }>) { + const { children, onClick } = props + + return ( +
+ {children} +
+ ) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/canvas/canvas-controls.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/canvas/canvas-controls.tsx new file mode 100644 index 000000000..4094fa5fc --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/canvas/canvas-controls.tsx @@ -0,0 +1,20 @@ +import { useCanvasContext } from '@harnessio/pipeline-graph' + +import { CanvasButton } from './canvas-button' + +export function CanvasControls() { + const { fit } = useCanvasContext() + + return ( +
+ {/* TODO: uncomment increase/decrease once its fixed in pipeline-graph */} + {/* + increase()}>+ + decrease()}>- + */} + fit()}> +
+
+
+ ) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/content-node-types.ts b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/content-node-types.ts new file mode 100644 index 000000000..8a003c43f --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/content-node-types.ts @@ -0,0 +1,11 @@ +export enum ContentNodeTypes { + add = 'add', + start = 'start', + end = 'end', + step = 'step', + approval = 'approval', + parallel = 'parallel', + serial = 'serial', + stage = 'stage', + form = 'form' +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/add-node.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/add-node.tsx new file mode 100644 index 000000000..d2fa3fb85 --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/add-node.tsx @@ -0,0 +1,39 @@ +import { Icon } from '@harnessio/canary' +import { LeafNodeInternalType } from '@harnessio/pipeline-graph' +import { Button } from '@harnessio/ui/components' + +import { useNodeContext } from '../../../context/NodeContextMenuProvider' +import { CommonNodeDataType } from '../types/nodes' + +export interface AddNodeDataType extends CommonNodeDataType {} + +export function AddNode(props: { node: LeafNodeInternalType }) { + const { node } = props + const { data } = node + + const { handleAddIn } = useNodeContext() + + return ( +
+ +
+ ) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/common/CommonContextMenu.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/common/CommonContextMenu.tsx new file mode 100644 index 000000000..f769a34ed --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/common/CommonContextMenu.tsx @@ -0,0 +1,281 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + Icon, + Text +} from '@harnessio/ui/components' + +import { useNodeContext } from '../../../../context/NodeContextMenuProvider' +import { YamlEntityType } from '../../types/nodes' + +export const CommonNodeContextMenu = () => { + const { + contextMenuData, + hideContextMenu, + deleteStep, + addStep, + addStage, + editStep, + addSerialGroup, + addParallelGroup, + revealInYaml + } = useNodeContext() + + const showAddStepBeforeAfter = + (contextMenuData?.nodeData.yamlEntityType === YamlEntityType.Step || + contextMenuData?.nodeData.yamlEntityType === YamlEntityType.StepParallelGroup || + contextMenuData?.nodeData.yamlEntityType === YamlEntityType.StepSerialGroup) && + !contextMenuData.isIn + + const showAddStageBeforeAfter = + (contextMenuData?.nodeData.yamlEntityType === YamlEntityType.Stage || + contextMenuData?.nodeData.yamlEntityType === YamlEntityType.ParallelGroup || + contextMenuData?.nodeData.yamlEntityType === YamlEntityType.SerialGroup) && + !contextMenuData.isIn + + const showAddSerialParallelGroupBeforeAfter = showAddStageBeforeAfter + + const enableEdit = + contextMenuData?.nodeData.yamlEntityType === YamlEntityType.Step || + contextMenuData?.nodeData.yamlEntityType === YamlEntityType.StepParallelGroup || + contextMenuData?.nodeData.yamlEntityType === YamlEntityType.StepSerialGroup + + const enableDelete = true + + const showAddStageIn = + (contextMenuData?.nodeData.yamlEntityType === YamlEntityType.ParallelGroup || + contextMenuData?.nodeData.yamlEntityType === YamlEntityType.SerialGroup) && + contextMenuData.isIn + + const showAddSerialParallelGroupIn = + (contextMenuData?.nodeData.yamlEntityType === YamlEntityType.ParallelGroup || + contextMenuData?.nodeData.yamlEntityType === YamlEntityType.SerialGroup) && + contextMenuData.isIn + + const showRevealInYaml = contextMenuData?.nodeData.yamlEntityType === YamlEntityType.Step + + const getMenu = () => { + const menu: React.ReactNode[][] = [] + + if (!contextMenuData?.isIn) { + menu.push([ + { + contextMenuData && editStep(contextMenuData.nodeData) + }} + > + + Edit + + ]) + } + + if (showAddStepBeforeAfter) { + menu.push([ + { + contextMenuData && addStep(contextMenuData.nodeData, 'before') + }} + > + + Add step/group before + , + { + contextMenuData && addStep(contextMenuData.nodeData, 'after') + }} + > + + Add step/group after + + ]) + } + + if (showAddStageBeforeAfter) { + menu.push([ + { + contextMenuData && addStage(contextMenuData.nodeData, 'before') + }} + > + + Add stage before + , + { + contextMenuData && addStage(contextMenuData.nodeData, 'after') + }} + > + + Add stage after + + ]) + } + + if (showAddSerialParallelGroupBeforeAfter) { + menu.push([ + { + contextMenuData && addSerialGroup(contextMenuData.nodeData, 'before') + }} + > + + Add serial group before + , + { + contextMenuData && addSerialGroup(contextMenuData.nodeData, 'after') + }} + > + + Add serial group after + + ]) + + menu.push([ + { + contextMenuData && addParallelGroup(contextMenuData.nodeData, 'before') + }} + > + + Add parallel group before + , + { + contextMenuData && addParallelGroup(contextMenuData.nodeData, 'after') + }} + > + + Add parallel group after + + ]) + } + + if (showAddStageIn) { + menu.push([ + { + contextMenuData && addStage(contextMenuData.nodeData, 'in') + }} + > + + Add stage + + ]) + } + + if (showAddSerialParallelGroupIn) { + menu.push([ + { + contextMenuData && addSerialGroup(contextMenuData.nodeData, 'in') + }} + > + + Add serial group + + ]) + + menu.push([ + { + contextMenuData && addParallelGroup(contextMenuData.nodeData, 'in') + }} + > + + Add parallel group + + ]) + } + + if (showRevealInYaml) { + menu.push([ + { + contextMenuData && revealInYaml(contextMenuData.nodeData.yamlPath) + }} + > + + Reveal in Yaml + + ]) + } + + if (!contextMenuData?.isIn) { + menu.push([ + { + contextMenuData && deleteStep(contextMenuData.nodeData) + }} + > + + Delete + + ]) + } + + return menu + } + + return ( + { + if (open === false) { + hideContextMenu() + } + }} + > + + {getMenu().map((menuItem, idx) => { + return idx !== 0 ? ( + <> + + {menuItem} + + ) : ( + menuItem + ) + })} + + + ) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/end-node.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/end-node.tsx new file mode 100644 index 000000000..43ad739df --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/end-node.tsx @@ -0,0 +1,19 @@ +import { LeafNodeInternalType } from '@harnessio/pipeline-graph' + +export interface EndNodeDataType {} + +export function EndNode(_props: { node: LeafNodeInternalType }) { + return ( +
+ {/* TODO: replace with icon */} +
+
+ ) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/parallel-group-node.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/parallel-group-node.tsx new file mode 100644 index 000000000..92132dbe2 --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/parallel-group-node.tsx @@ -0,0 +1,69 @@ +import { ParallelNodeInternalType } from '@harnessio/pipeline-graph' +import { Button, Icon } from '@harnessio/ui/components' + +import { useNodeContext } from '../../../context/NodeContextMenuProvider' +import { CommonNodeDataType } from '../types/nodes' + +export interface ParallelGroupContentNodeDataType extends CommonNodeDataType { + icon?: React.ReactElement +} + +export function ParallelGroupContentNode(props: { + node: ParallelNodeInternalType + children?: React.ReactElement + collapsed?: boolean +}) { + const { node, children, collapsed } = props + const data = node.data as ParallelGroupContentNodeDataType + + const { showContextMenu, handleAddIn } = useNodeContext() + + return ( + <> +
+ +
+ {/* //flex h-9 items-center */} +
+ {data.name} +
+
+ + + + {!collapsed && node.children.length === 0 && ( + + )} + + {children} + + ) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/serial-group-node.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/serial-group-node.tsx new file mode 100644 index 000000000..4c90d9124 --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/serial-group-node.tsx @@ -0,0 +1,70 @@ +import { SerialNodeInternalType } from '@harnessio/pipeline-graph' +import { Button, Icon } from '@harnessio/ui/components' + +import { useNodeContext } from '../../../context/NodeContextMenuProvider' +import { CommonNodeDataType } from '../types/nodes' + +export interface SerialGroupContentNodeDataType extends CommonNodeDataType { + icon?: React.ReactElement +} + +export function SerialGroupContentNode(props: { + node: SerialNodeInternalType + children?: React.ReactElement + collapsed?: boolean +}) { + const { node, children, collapsed } = props + const data = node.data as SerialGroupContentNodeDataType + + const { showContextMenu, handleAddIn } = useNodeContext() + + return ( + <> +
+ +
+ {/* //flex h-9 items-center */} +
+ {data.name} +
+
+ + + + {!collapsed && node.children.length === 0 && ( + + )} + + {children} + + ) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/stage-node.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/stage-node.tsx new file mode 100644 index 000000000..d481c7a7e --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/stage-node.tsx @@ -0,0 +1,31 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from '@harnessio/canary' +import { SerialNodeInternalType } from '@harnessio/pipeline-graph' + +import { CommonNodeDataType } from '../types/nodes' + +export interface StageNodeContentType extends CommonNodeDataType { + icon?: React.ReactElement +} + +export function SerialGroupNodeContent(props: { + node: SerialNodeInternalType + children: React.ReactElement +}) { + const { node, children } = props + const data = node.data as StageNodeContentType + + return ( + <> +
+
+ + +
{data.name}
+
+ {data.name} +
+
+ {children} + + ) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/start-node.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/start-node.tsx new file mode 100644 index 000000000..d7f45ed02 --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/start-node.tsx @@ -0,0 +1,19 @@ +import { Icon } from '@harnessio/canary' +import { LeafNodeInternalType } from '@harnessio/pipeline-graph' + +export interface StartNodeDataType {} + +export function StartNode(_props: { node: LeafNodeInternalType }) { + return ( +
+ +
+ ) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/step-node.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/step-node.tsx new file mode 100644 index 000000000..1930d5a18 --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/nodes/step-node.tsx @@ -0,0 +1,51 @@ +import { useMemo } from 'react' + +import { LeafNodeInternalType } from '@harnessio/pipeline-graph' +import { Button, Icon, Text } from '@harnessio/ui/components' + +import { useNodeContext } from '../../../context/NodeContextMenuProvider' +import { CommonNodeDataType } from '../types/nodes' + +export interface StepNodeDataType extends CommonNodeDataType { + icon?: React.ReactElement + state?: 'success' | 'loading' + selected?: boolean +} + +export function StepNode(props: { node: LeafNodeInternalType }) { + const { node } = props + const data = node.data + + const { showContextMenu, selectionPath } = useNodeContext() + + const selected = useMemo(() => selectionPath === data.yamlPath, [selectionPath]) + + return ( +
+ + +
{data.icon}
+ + {data.name} + +
+ ) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/types/nodes.ts b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/types/nodes.ts new file mode 100644 index 000000000..213fed669 --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/types/nodes.ts @@ -0,0 +1,15 @@ +export enum YamlEntityType { + Step = 'Step', + Stage = 'Stage', + ParallelGroup = 'ParallelGroup', + SerialGroup = 'SerialGroup', + StepSerialGroup = 'StepSerialGroup', + StepParallelGroup = 'StepParallelGroup' +} + +export interface CommonNodeDataType { + yamlPath: string + yamlChildrenPath?: string + name: string + yamlEntityType: YamlEntityType +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/common-step-utils.ts b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/common-step-utils.ts new file mode 100644 index 000000000..ba8535555 --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/common-step-utils.ts @@ -0,0 +1,9 @@ +export const getIsRunStep = (step: Record) => Object.hasOwn(step, 'run') + +export const getIsRunTestStep = (step: Record) => Object.hasOwn(step, 'run-test') + +export const getIsBackgroundStep = (step: Record) => Object.hasOwn(step, 'background') + +export const getIsActionStep = (step: Record) => Object.hasOwn(step, 'action') + +export const getIsTemplateStep = (step: Record) => Object.hasOwn(step, 'template') diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/step-icon-utils.ts b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/step-icon-utils.ts new file mode 100644 index 000000000..15564eae1 --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/step-icon-utils.ts @@ -0,0 +1,28 @@ +import { IconProps } from '@harnessio/canary' + +import { + getIsActionStep, + getIsBackgroundStep, + getIsRunStep, + getIsRunTestStep, + getIsTemplateStep +} from './common-step-utils' + +export const getIconBasedOnStep = (step: any): IconProps['name'] => { + if (getIsRunStep(step)) return 'run' + + if (getIsRunTestStep(step)) return 'run-test' + + if (getIsBackgroundStep(step)) return 'cog-6' + + if (getIsActionStep(step)) return 'github-actions' + + if (getIsTemplateStep(step)) return 'harness-plugin' + + /** + * Yet to add Bitrise plugins, + * Request backend to add a property to identify bitrise-plugin + */ + + return 'harness' +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/step-name-utils.ts b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/step-name-utils.ts new file mode 100644 index 000000000..6552efe36 --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/step-name-utils.ts @@ -0,0 +1,43 @@ +import { get } from 'lodash-es' + +import { + getIsActionStep, + getIsBackgroundStep, + getIsRunStep, + getIsRunTestStep, + getIsTemplateStep +} from './common-step-utils' + +const getNameOrScriptText = (stepData: string | Record<'script', string>, defaultString: string): string => { + const isStepHasString = typeof stepData === 'string' + + return isStepHasString ? stepData : get(stepData, 'script', defaultString) +} + +export const getNameBasedOnStep = (step: any, stepIndex: number): string => { + if (step.name) return step.name + + let displayName = `Step ${stepIndex}` + // Run + if (getIsRunStep(step)) { + displayName = getNameOrScriptText(step.run, 'Run') + } + // Run test + else if (getIsRunTestStep(step)) { + displayName = getNameOrScriptText(step?.['run-test'], 'Run Test') + } + // Background + else if (getIsBackgroundStep(step)) { + displayName = getNameOrScriptText(step?.background, 'Background') + } + // Action + else if (getIsActionStep(step)) { + displayName = get(step?.action, 'uses', 'GitHub Action') + } + // Template + else if (getIsTemplateStep(step)) { + displayName = get(step?.template, 'uses', 'Harness Template') + } + + return displayName.split('\n')[0] +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/yaml-to-pipeline-graph.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/yaml-to-pipeline-graph.tsx new file mode 100644 index 000000000..13bb9cb4d --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/graph-implementation/utils/yaml-to-pipeline-graph.tsx @@ -0,0 +1,188 @@ +import { Icon } from '@harnessio/canary' +import { + AnyContainerNodeType, + LeafContainerNodeType, + ParallelContainerNodeType, + SerialContainerNodeType +} from '@harnessio/pipeline-graph' + +import { ContentNodeTypes } from '../content-node-types' +import { ParallelGroupContentNodeDataType } from '../nodes/parallel-group-node' +import { StageNodeContentType } from '../nodes/stage-node' +import { StepNodeDataType } from '../nodes/step-node' +import { YamlEntityType } from '../types/nodes' +import { getIconBasedOnStep } from './step-icon-utils' +import { getNameBasedOnStep } from './step-name-utils' + +export const yaml2Nodes = ( + yamlObject: Record, + options: { selectedPath?: string } = {} +): AnyContainerNodeType[] => { + const nodes: AnyContainerNodeType[] = [] + + const stages = yamlObject?.pipeline?.stages ?? [] + + if (stages) { + const stagesNodes = processStages(stages, 'pipeline.stages', options) + nodes.push(...stagesNodes) + } + + return nodes +} + +const getGroupKey = (stage: Record): 'group' | 'parallel' | undefined => { + if ('group' in stage) return 'group' + else if ('parallel' in stage) return 'parallel' + return undefined +} + +const processStages = ( + stages: any[], + currentPath: string, + options: { selectedPath?: string } +): AnyContainerNodeType[] => { + return stages.map((stage, idx) => { + // parallel stage + const groupKey = getGroupKey(stage) + if (groupKey === 'group') { + const name = stage.name ?? `Serial ${idx + 1}` + const path = `${currentPath}.${idx}` + const childrenPath = `${path}.${groupKey}.stages` + + return { + type: ContentNodeTypes.serial, + config: { + minWidth: 192, + hideDeleteButton: true, + hideBeforeAdd: true, + hideAfterAdd: true + }, + data: { + yamlPath: path, + yamlChildrenPath: childrenPath, + yamlEntityType: YamlEntityType.SerialGroup, + name + } satisfies StageNodeContentType, + children: processStages(stage[groupKey].stages, childrenPath, options) + } satisfies SerialContainerNodeType + } else if (groupKey === 'parallel') { + const name = stage.name ?? `Parallel ${idx + 1}` + const path = `${currentPath}.${idx}` + const childrenPath = `${path}.${groupKey}.stages` + + return { + type: ContentNodeTypes.parallel, + config: { + minWidth: 192, + hideDeleteButton: true, + hideBeforeAdd: true, + hideAfterAdd: true + }, + data: { + yamlPath: path, + yamlChildrenPath: childrenPath, + yamlEntityType: YamlEntityType.ParallelGroup, + name + } satisfies ParallelGroupContentNodeDataType, + children: processStages(stage[groupKey].stages, childrenPath, options) + } satisfies ParallelContainerNodeType + } + // regular stage + else { + const name = stage.name ?? `Stage ${idx + 1}` + const path = `${currentPath}.${idx}` + const childrenPath = `${path}.steps` + + return { + type: ContentNodeTypes.stage, + config: { + minWidth: 192, + hideDeleteButton: true, + hideBeforeAdd: true, + hideAfterAdd: true + }, + data: { + yamlPath: path, + yamlChildrenPath: childrenPath, + yamlEntityType: YamlEntityType.Stage, + name + } satisfies StageNodeContentType, + children: processSteps(stage.steps, childrenPath, options) + } satisfies SerialContainerNodeType + } + }) +} + +const processSteps = ( + steps: any[], + currentPath: string, + options: { selectedPath?: string } +): AnyContainerNodeType[] => { + return steps.map((step, idx) => { + // parallel stage + const groupKey = getGroupKey(step) + if (groupKey === 'group') { + const name = step.name ?? `Serial steps ${idx + 1}` + const path = `${currentPath}.${idx}` + const childrenPath = `${path}.${groupKey}.steps` + + return { + type: ContentNodeTypes.serial, + config: { + minWidth: 192, + hideDeleteButton: true, + hideCollapseButton: false + }, + data: { + yamlPath: path, + yamlChildrenPath: childrenPath, + yamlEntityType: YamlEntityType.StepSerialGroup, + name + } satisfies StageNodeContentType, + + children: processSteps(step[groupKey].steps, childrenPath, options) + } satisfies SerialContainerNodeType + } else if (groupKey === 'parallel') { + const name = step.name ?? `Parallel steps ${idx + 1}` + const path = `${currentPath}.${idx}` + const childrenPath = `${path}.${groupKey}.steps` + + return { + type: ContentNodeTypes.parallel, + config: { + minWidth: 192, + hideDeleteButton: true + }, + data: { + yamlPath: path, + yamlChildrenPath: childrenPath, + yamlEntityType: YamlEntityType.StepParallelGroup, + name + } satisfies ParallelGroupContentNodeDataType, + children: processSteps(step[groupKey].steps, childrenPath, options) + } satisfies ParallelContainerNodeType + } + // regular step + else { + const name = getNameBasedOnStep(step, idx + 1) + const path = `${currentPath}.${idx}` + + return { + type: ContentNodeTypes.step, + config: { + maxWidth: 140, + width: 140, + hideDeleteButton: false, + selectable: true + }, + data: { + yamlPath: path, + yamlEntityType: YamlEntityType.Step, + name, + icon: , + selected: path === options?.selectedPath + } satisfies StepNodeDataType + } satisfies LeafContainerNodeType + } + }) +} diff --git a/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/pipeline-studio-graph-view.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/pipeline-studio-graph-view.tsx new file mode 100644 index 000000000..8b75b01dd --- /dev/null +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/pipeline-studio-graph-view.tsx @@ -0,0 +1,138 @@ +import { useEffect, useState } from 'react' + +import { parse } from 'yaml' + +import { + AnyContainerNodeType, + CanvasProvider, + ContainerNode, + NodeContent, + PipelineGraph +} from '@harnessio/pipeline-graph' + +import { usePipelineDataContext } from '../context/PipelineStudioDataProvider' +import { CanvasControls } from './graph-implementation/canvas/canvas-controls' +import { ContentNodeTypes } from './graph-implementation/content-node-types' +import { EndNode } from './graph-implementation/nodes/end-node' +import { StartNode } from './graph-implementation/nodes/start-node' +import { StepNode } from './graph-implementation/nodes/step-node' +import { yaml2Nodes } from './graph-implementation/utils/yaml-to-pipeline-graph' + +import '@harnessio/pipeline-graph/dist/index.css' + +import { NodeContextProvider } from '../context/NodeContextMenuProvider' +import { AddNode, AddNodeDataType } from './graph-implementation/nodes/add-node' +import { CommonNodeContextMenu } from './graph-implementation/nodes/common/CommonContextMenu' +import { ParallelGroupContentNode } from './graph-implementation/nodes/parallel-group-node' +import { SerialGroupContentNode } from './graph-implementation/nodes/serial-group-node' +import { YamlEntityType } from './graph-implementation/types/nodes' + +const nodes: NodeContent[] = [ + { + type: ContentNodeTypes.add, + component: AddNode, + containerType: ContainerNode.leaf + }, + { + type: ContentNodeTypes.start, + component: StartNode, + containerType: ContainerNode.leaf + }, + { + type: ContentNodeTypes.end, + containerType: ContainerNode.leaf, + component: EndNode + }, + { + type: ContentNodeTypes.step, + containerType: ContainerNode.leaf, + component: StepNode + } as NodeContent, + { + type: ContentNodeTypes.parallel, + containerType: ContainerNode.parallel, + component: ParallelGroupContentNode + } as NodeContent, + { + type: ContentNodeTypes.serial, + containerType: ContainerNode.serial, + component: SerialGroupContentNode + } as NodeContent, + { + type: ContentNodeTypes.stage, + containerType: ContainerNode.serial, + component: SerialGroupContentNode + } as NodeContent +] + +const startNode = { + type: ContentNodeTypes.start, + config: { + width: 40, + height: 40, + hideDeleteButton: true, + hideBeforeAdd: true, + hideLeftPort: true + }, + data: {} +} satisfies AnyContainerNodeType + +const endNode = { + type: ContentNodeTypes.end, + config: { + width: 40, + height: 40, + hideDeleteButton: true, + hideAfterAdd: true, + hideRightPort: true + }, + data: {} +} satisfies AnyContainerNodeType + +export const PipelineStudioGraphView = (): React.ReactElement => { + const { + state: { yamlRevision, editStepIntention } + } = usePipelineDataContext() + + const [data, setData] = useState([]) + + useEffect(() => { + return () => { + setData([]) + } + }, []) + + useEffect(() => { + const yamlJson = parse(yamlRevision.yaml) + const newData = yaml2Nodes(yamlJson, { selectedPath: editStepIntention?.path }) + + if (newData.length === 0) { + newData.push({ + type: ContentNodeTypes.add, + data: { + yamlChildrenPath: 'pipeline.stages', + name: '', + yamlEntityType: YamlEntityType.SerialGroup, + yamlPath: '' + } satisfies AddNodeDataType + }) + } + + newData.unshift(startNode) + newData.push(endNode) + setData(newData) + }, [yamlRevision, editStepIntention]) + + return ( + // TODO: fix style.width +
+ + + + + + + +
+ ) +} diff --git a/apps/gitness/src/pages/pipeline-edit/components/pipeline-studio-header-actions.tsx b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/pipeline-studio-header-actions.tsx similarity index 81% rename from apps/gitness/src/pages/pipeline-edit/components/pipeline-studio-header-actions.tsx rename to apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/pipeline-studio-header-actions.tsx index 69c834ac5..12155351c 100644 --- a/apps/gitness/src/pages/pipeline-edit/components/pipeline-studio-header-actions.tsx +++ b/apps/gitness/src/pages-v2/pipeline/pipeline-edit/components/pipeline-studio-header-actions.tsx @@ -1,10 +1,10 @@ import { useState } from 'react' import { useParams } from 'react-router-dom' -import { Button, DropdownMenuItem, SplitButton } from '@harnessio/canary' +import { Button } from '@harnessio/canary' import { OpenapiCommitFilesRequest, useCommitFilesMutation } from '@harnessio/code-service-client' -import RunPipelineDialog from '../../run-pipeline-dialog/run-pipeline-dialog' +import RunPipelineDialog from '../../../../pages/run-pipeline-dialog/run-pipeline-dialog' import { PipelineParams, usePipelineDataContext } from '../context/PipelineStudioDataProvider' const PipelineStudioHeaderActions = (): JSX.Element => { @@ -69,27 +69,20 @@ const PipelineStudioHeaderActions = (): JSX.Element => { Run ) : ( - handleSave(true)} - dropdown={<>>} - menu={ - <> - handleSave(false)} disabled={disabled}> - Save - - - } - > - Save and Run - + <> + + + ) } return ( <> -
+
{/* + + + + + )} + ) } diff --git a/apps/gitness/src/pages/run-pipeline-dialog/run-pipeline-form.tsx b/apps/gitness/src/pages/run-pipeline-dialog/run-pipeline-form.tsx deleted file mode 100644 index 80c5230f7..000000000 --- a/apps/gitness/src/pages/run-pipeline-dialog/run-pipeline-form.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { useEffect, useState } from 'react' -import { useNavigate, useParams } from 'react-router-dom' - -import { omit } from 'lodash-es' -import { parse } from 'yaml' - -import { Button, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Spacer } from '@harnessio/canary' -import { - findPipeline, - getContent, - useCreateExecutionMutation, - useListBranchesQuery, - UsererrorError -} from '@harnessio/code-service-client' -import { - getTransformers, - IInputDefinition, - outputTransformValues, - RenderForm, - RootForm, - useZodValidationResolver -} from '@harnessio/forms' -import { Alert } from '@harnessio/ui/components' -import { inputComponentFactory, InputType } from '@harnessio/views' - -import { useGetRepoRef } from '../../framework/hooks/useGetRepoPath' -import { Inputs } from '../../types/pipeline-schema' -import { decodeGitContent, normalizeGitRef } from '../../utils/git-utils' -import { PipelineParams } from '../pipeline-edit/context/PipelineStudioDataProvider' -import { createFormFromPipelineInputs } from './utils/utils' - -const ADDITIONAL_INPUTS_PREFIX = '_' - -export interface RunPipelineFormProps { - pipelineId?: string - branch?: string - toExecutions: string -} - -export default function RunPipelineForm({ - pipelineId, - branch, - toExecutions, - requestClose -}: RunPipelineFormProps & { requestClose: () => void }) { - const { pipelineId: pipelineIdFromParams = '' } = useParams() - const navigate = useNavigate() - - const [loading, setLoading] = useState(true) - const [pipeline, setPipeline] = useState>({}) - const [errorMessage, setErrorMessage] = useState(null) - - const repoRef = useGetRepoRef() - const { data: branches, isLoading: listBranchesLoading } = useListBranchesQuery({ - repo_ref: repoRef, - queryParams: {} - }) - - useEffect(() => { - findPipeline({ pipeline_identifier: pipelineId ?? pipelineIdFromParams, repo_ref: repoRef }) - .then( - ({ body: pipelineData }) => { - getContent({ - path: pipelineData?.config_path ?? '', - repo_ref: repoRef, - queryParams: { - git_ref: normalizeGitRef(branch ?? pipelineData?.default_branch) ?? '', - include_commit: true - } - }) - .then( - ({ body: pipelineFileContent }) => { - try { - const pipelineObj = parse(decodeGitContent(pipelineFileContent?.content?.data)) - setPipeline(pipelineObj) - } catch (ex: unknown) { - setErrorMessage((ex as UsererrorError)?.message || null) - } - }, - ex => { - setErrorMessage(ex.message) - } - ) - .finally(() => { - setLoading(false) - }) - }, - ex => { - setErrorMessage(ex.message) - } - ) - .finally(() => { - setLoading(false) - }) - }, [pipelineId, repoRef]) - - const formDefinition = createFormFromPipelineInputs(pipeline) - const autoFocusPath = formDefinition.inputs?.[0]?.path - - const additionalInput: IInputDefinition[] = [ - { - label: 'Branch', - inputType: InputType.select, - path: `${ADDITIONAL_INPUTS_PREFIX}.branch`, - inputConfig: { - options: branches?.body?.map(branchItem => ({ label: branchItem?.name, value: branchItem?.name })) - } - }, - ...(formDefinition.inputs.length > 0 ? [{ inputType: InputType.separator, path: '' }] : []) - ] - formDefinition.inputs = [...additionalInput, ...formDefinition.inputs] - - const resolver = useZodValidationResolver(formDefinition) - - const { mutateAsync: createExecutionAsync, isLoading: isLoadingCreateExecution } = useCreateExecutionMutation({}) - - const runPipeline = ({ branch, inputsValues }: { branch: string; inputsValues: Inputs }) => { - createExecutionAsync({ - pipeline_identifier: pipelineId ?? '', - repo_ref: repoRef, - queryParams: { branch: branch }, - body: inputsValues - }) - .then( - response => { - requestClose() - const executionId = response.body.number - navigate(`${toExecutions}/${executionId}`) - }, - ex => { - setErrorMessage(ex.message) - } - ) - .finally(() => { - setLoading(false) - }) - } - - if (errorMessage) { - return ( - - {errorMessage} - - ) - } - - if (loading || listBranchesLoading) { - // TODO - return 'Loading...' - } - - return ( - { - const transformers = getTransformers(formDefinition) - const transformedValues = outputTransformValues(values, transformers) - - const branch = transformedValues._.branch - const inputsValues = omit(transformedValues, '_') - - runPipeline({ branch, inputsValues }) - }} - validateAfterFirstSubmit={true} - > - {rootForm => ( - <> - - Run Pipeline - - - - - - - - - - - )} - - ) -} diff --git a/apps/gitness/src/routes.tsx b/apps/gitness/src/routes.tsx index 724f9ba6e..fa25c423a 100644 --- a/apps/gitness/src/routes.tsx +++ b/apps/gitness/src/routes.tsx @@ -1,6 +1,5 @@ import { Navigate, Params, RouteObject } from 'react-router-dom' -import { BreadcrumbSeparator } from '@harnessio/canary' import { Text } from '@harnessio/ui/components' import { EmptyPage, RepoSettingsPage, SandboxLayout } from '@harnessio/ui/views' @@ -11,6 +10,7 @@ import { useTranslationStore } from './i18n/stores/i18n-store' import CreateProject from './pages-v2/create-project/create-project-container' import { LandingPage } from './pages-v2/landing-page-container' import { Logout } from './pages-v2/logout' +import PipelineEditPage from './pages-v2/pipeline/pipeline-edit/pipeline-edit' import { SettingsProfileGeneralPage } from './pages-v2/profile-settings/profile-settings-general-container' import { SettingsProfileKeysPage } from './pages-v2/profile-settings/profile-settings-keys-container' import { ProfileSettingsThemePage } from './pages-v2/profile-settings/profile-settings-theme-page' @@ -194,16 +194,25 @@ export const routes: CustomRouteObject[] = [ { index: true, element: }, { path: ':pipelineId', - element: , handle: { - breadcrumb: ({ pipelineId }: { pipelineId: string }) => ( -
- {pipelineId} - / - Executions -
- ) - } + breadcrumb: ({ pipelineId }: { pipelineId: string }) => {pipelineId} + }, + children: [ + { + index: true, + element: , + handle: { + breadcrumb: () => Executions + } + }, + { + path: 'edit', + element: , + handle: { + breadcrumb: () => Edit + } + } + ] } ] }, diff --git a/apps/gitness/tailwind.config.js b/apps/gitness/tailwind.config.js index b7fd7d95a..f9f6f3d0d 100644 --- a/apps/gitness/tailwind.config.js +++ b/apps/gitness/tailwind.config.js @@ -7,7 +7,6 @@ module.exports = { require('@harnessio/ui/tailwind.config') ], content: [ - 'node_modules/@harnessio/unified-pipeline/src/**/*.{ts,tsx}', 'node_modules/@harnessio/views/src/**/*.{ts,tsx}', 'node_modules/@harnessio/ui/src/**/*.{ts,tsx}', './src/**/*.{ts,tsx}' diff --git a/packages/pipeline-graph/.gitignore b/packages/pipeline-graph/.gitignore new file mode 100644 index 000000000..13caefaf1 --- /dev/null +++ b/packages/pipeline-graph/.gitignore @@ -0,0 +1,4 @@ +/node_modules +/coverage +/build +.DS_Store diff --git a/packages/pipeline-graph/README.md b/packages/pipeline-graph/README.md new file mode 100644 index 000000000..00cd5f12c --- /dev/null +++ b/packages/pipeline-graph/README.md @@ -0,0 +1,89 @@ +# Pipeline Graph + +A library for generating and interacting with pipeline graphs. + +![pipeline graph](./public/pipeline-graph.png 'Pipeline graph') + +## Anatomy + +- Model +- Container and Content nodes +- Edges +- Canvas + +### Model + +Type **AnyContainerNodeType** us a union of **LeafContainerNodeType** | **ParallelContainerNodeType** | **SerialContainerNodeType** and represent one node. +**LeafContainerNodeType** node does not have children while other have. + +Example of the model + +```ts +const model: AnyContainerNodeType[] = [ + { + type: 'stage', + children: [{ type: 'step' }, { type: 'step' }] + } +] +``` + +In order to visualize our model we need **container** and **content** nodes. + +### Container and Content nodes + +#### Container node + +Container node is responsible for layout and basic user interaction like adding/deleting nodes. There are three containers: **Leaf**, **Serial** and **Parallel**. **Leaf** render one **Content** node, while **Serial**/**Parallel** renders itself and children. + +Container node anatomy: + +- **Metadata** - At the very root div we have **data-path** and **data-action** attributes which are used for event delegation purpose. +- **Relative positioning** - root div has _position:relative_ so we can use absolute position for styling in the **content** node. +- **Ports** - Port is div. It's position is used for calculating and drawing links between ports. +- **Add/Delete containers** - are used for capturing mouse hover (via css). They have buttons that appears on mouse hover. + +#### Content node + +Content nodes are responsible for the content of the node (like text, icons etc.) and visualizing state (like _loading_ or _selection_) + +#### Pairing Content and Connection nodes + +In order to render our graph we have to pair container and content node. For this we uses **NodeContent** type. + +```ts +const nodes: NodeContent[] = [ + // register "step" content node which will render "StepContentNode" inside "leaf" container + { + type: "step", + component: StepContentNode, + containerType: ContainerNode.leaf + }, + // register "stage" content node which will render "SerialGroupContentNode" inside "serial" container + { + type: "stage", + component: SerialGroupContentNode + containerType: ContainerNode.serial, + } +] +``` + +### Edges + +Connection lines between nodes are drawn using svg paths. Lines are calculated using ports (which are part of container nodes). + +### Canvas + +Library provides pan and pinch-to-zoom out of the box. +For creating manual controls for zoom in/out and fit we can use useCanvasContext that exposes increment, decrement and fit functions. + +Implementing custom controls example + +```tsx +const { increase, decrease, fit } = useCanvasContext() + +return <> + + + + +``` diff --git a/packages/pipeline-graph/examples/index.html b/packages/pipeline-graph/examples/index.html new file mode 100644 index 000000000..4d64ce469 --- /dev/null +++ b/packages/pipeline-graph/examples/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + Harness Open Source + + +
+ + + diff --git a/packages/pipeline-graph/examples/src/assets/harness-step.svg b/packages/pipeline-graph/examples/src/assets/harness-step.svg new file mode 100644 index 000000000..630752f06 --- /dev/null +++ b/packages/pipeline-graph/examples/src/assets/harness-step.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/pipeline-graph/examples/src/assets/run-step.svg b/packages/pipeline-graph/examples/src/assets/run-step.svg new file mode 100644 index 000000000..2ea1c6f83 --- /dev/null +++ b/packages/pipeline-graph/examples/src/assets/run-step.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/pipeline-graph/examples/src/canvas/CanvasButton.tsx b/packages/pipeline-graph/examples/src/canvas/CanvasButton.tsx new file mode 100644 index 000000000..db634a7af --- /dev/null +++ b/packages/pipeline-graph/examples/src/canvas/CanvasButton.tsx @@ -0,0 +1,22 @@ +export function CanvasButton(props: React.PropsWithChildren<{ onClick: () => void }>) { + const { children, onClick } = props + + return ( +
+ {children} +
+ ) +} diff --git a/packages/pipeline-graph/examples/src/canvas/CanvasControls.tsx b/packages/pipeline-graph/examples/src/canvas/CanvasControls.tsx new file mode 100644 index 000000000..ac23c89bb --- /dev/null +++ b/packages/pipeline-graph/examples/src/canvas/CanvasControls.tsx @@ -0,0 +1,23 @@ +import { useCanvasContext } from '../../../src/context/canvas-provider' +import { CanvasButton } from './CanvasButton' + +export function CanvasControls() { + const { increase, decrease, fit } = useCanvasContext() + + return ( +
+ increase()}>+ + decrease()}>- + fit()}>[] +
+ ) +} diff --git a/packages/pipeline-graph/examples/src/css/animations.css b/packages/pipeline-graph/examples/src/css/animations.css new file mode 100644 index 000000000..a3963b1bc --- /dev/null +++ b/packages/pipeline-graph/examples/src/css/animations.css @@ -0,0 +1,26 @@ +/* sample animation example */ +.loading { + --border-angle: 0turn; + --main-bg: conic-gradient(from var(--border-angle), #202020, #1d1d1d 5%, #1d1d1d 60%, #202020 95%); + --gradient-border: conic-gradient(from var(--border-angle), transparent 25%, #acc3f5, #444957 99%, transparent); + + border: solid 1px transparent !important; + background: + var(--main-bg) padding-box, + var(--gradient-border) border-box, + var(--main-bg) border-box !important; + background-position: center center; + animation: bg-spin 3s linear infinite; +} + +@keyframes bg-spin { + to { + --border-angle: 1turn; + } +} + +@property --border-angle { + syntax: ''; + inherits: true; + initial-value: 0turn; +} diff --git a/packages/pipeline-graph/examples/src/css/common.css b/packages/pipeline-graph/examples/src/css/common.css new file mode 100644 index 000000000..331c6311a --- /dev/null +++ b/packages/pipeline-graph/examples/src/css/common.css @@ -0,0 +1,10 @@ +html, +body { + background: #121214; + color: white; + font-size: 13px; + cursor: default; + margin: 0px; + padding: 0px; + font-family: 'Verdana'; +} diff --git a/packages/pipeline-graph/examples/src/css/modifiers.css b/packages/pipeline-graph/examples/src/css/modifiers.css new file mode 100644 index 000000000..7c8ea4edc --- /dev/null +++ b/packages/pipeline-graph/examples/src/css/modifiers.css @@ -0,0 +1,16 @@ +/* skeleton example */ +.skeleton .PipelineGraph-LeafContainerNode .node-text { + color: transparent; + background: #373737; +} + +/* sharp-lines example */ +.sharp-line svg g { + stroke-width: max(0.8px, calc(1px / var(--scale))); +} + +/* sharp-borders example */ +.sharp-border .parallel-node > * > .border, +.sharp-border .serial-node > * > .border { + border-width: max(0.8px, calc(1px / var(--scale))) !important; +} diff --git a/packages/pipeline-graph/examples/src/css/selection.css b/packages/pipeline-graph/examples/src/css/selection.css new file mode 100644 index 000000000..200679548 --- /dev/null +++ b/packages/pipeline-graph/examples/src/css/selection.css @@ -0,0 +1,11 @@ +.leaf-node .selected { + background-color: #333 !important; +} + +.serial-node .selected { + background-color: #333 !important; +} + +.parallel-node .selected { + background-color: #333 !important; +} diff --git a/packages/pipeline-graph/examples/src/example1.tsx b/packages/pipeline-graph/examples/src/example1.tsx new file mode 100644 index 000000000..8bbcca712 --- /dev/null +++ b/packages/pipeline-graph/examples/src/example1.tsx @@ -0,0 +1,202 @@ +import { useEffect, useRef, useState } from 'react' + +import { cloneDeep, get, set } from 'lodash-es' +import { parse } from 'yaml' + +import { PipelineGraph } from '../../src/pipeline-graph' +import { NodeContent } from '../../src/types/node-content' +import { + AnyContainerNodeType, + ContainerNode, + LeafContainerNodeType, + ParallelContainerNodeType, + SerialContainerNodeType +} from '../../src/types/nodes' +import { getPathPeaces } from '../../src/utils/path-utils' +import { ApprovalNode } from './nodes/approval-node' +import { EndNode } from './nodes/end-node' +import { ParallelGroupNodeContent } from './nodes/parallel-group-node' +import { SerialGroupContentNode } from './nodes/stage-node' +import { StartNode } from './nodes/start-node' +import { StepNode, StepNodeDataType } from './nodes/step-node' +import { yaml2Nodes } from './parser/yaml2AnyNodes' +import { pipeline } from './sample-data/pipeline' + +import './sample-data/pipeline-data' + +import React from 'react' + +import { CanvasProvider } from '../../src/context/canvas-provider' +import { AnyNodeInternal } from '../../src/types/nodes-internal' +import { CanvasControls } from './canvas/CanvasControls' +import { getIcon } from './parser/utils' +import { ContentNodeTypes } from './types/content-node-types' + +const nodes: NodeContent[] = [ + { + type: ContentNodeTypes.start, + component: StartNode, + containerType: ContainerNode.leaf + }, + { + type: ContentNodeTypes.end, + containerType: ContainerNode.leaf, + component: EndNode + }, + { + containerType: ContainerNode.leaf, + type: ContentNodeTypes.step, + component: StepNode + }, + { + containerType: ContainerNode.leaf, + type: ContentNodeTypes.approval, + component: ApprovalNode + }, + { + type: ContentNodeTypes.parallel, + containerType: ContainerNode.parallel, + component: ParallelGroupNodeContent + } as NodeContent, + { + type: ContentNodeTypes.serial, + containerType: ContainerNode.serial, + component: SerialGroupContentNode + } as NodeContent +] + +const yamlObject = parse(pipeline) +const plData = yaml2Nodes(yamlObject) + +const startNode = { + type: ContentNodeTypes.start, + config: { + width: 80, + height: 80, + hideDeleteButton: true, + hideBeforeAdd: true, + hideLeftPort: true + }, + data: {} +} satisfies LeafContainerNodeType + +const endNode = { + type: ContentNodeTypes.end, + config: { + width: 80, + height: 80, + hideDeleteButton: true, + hideAfterAdd: true, + hideRightPort: true + }, + data: {} +} satisfies LeafContainerNodeType + +plData.unshift(startNode) +plData.push(endNode) + +function Example1({ addStepType }: { addStepType: ContentNodeTypes }) { + const [data] = useState(plData) + + if (!data) return null + + return ( +
+ + { + // const newData = cloneDeep(data) + // const itemPath = node.path.replace(/^pipeline.children./, '') + // const targetNode = get(newData, itemPath) as { data: { selected: boolean } } + // targetNode.data.selected = !targetNode.data.selected + // setData(newData) + // }} + // onAdd={(node: AnyNodeInternal, position: 'before' | 'after' | 'in') => { + // const newData = cloneDeep(data) + // const itemPath = node.path.replace(/^pipeline.children./, '') + + // if (position === 'in') { + // // add to (empty) array + // const childrenPath = itemPath + '.children' + // const arr = get(newData, childrenPath, []) as unknown[] + // arr.push(getNode(addStepType)) + // set(newData, childrenPath, arr) + // } else { + // // add before or after + // const { arrayPath, index } = getPathPeaces(itemPath) + // const arr = arrayPath ? (get(newData, arrayPath) as unknown[]) : newData + + // arr.splice(position === 'before' ? index : index + 1, 0, getNode(addStepType)) + // } + + // setData(newData) + // }} + // onDelete={(node: AnyNodeInternal) => { + // const newData = cloneDeep(data) + // const { arrayPath, index } = getPathPeaces(node.path.replace(/^pipeline.children./, '')) + // const arr = arrayPath ? (get(newData, arrayPath) as unknown[]) : newData + // arr.splice(index, 1) + // setData(newData) + // }} + /> + + +
+ ) +} + +export default Example1 + +function getNode(stepType: ContentNodeTypes) { + switch (stepType) { + case ContentNodeTypes.step: + return { + type: ContentNodeTypes.step, + config: { + width: 180 + }, + data: { + yamlPath: '', + name: 'step', + icon: getIcon(1), + state: 'loading' + } satisfies StepNodeDataType + } satisfies LeafContainerNodeType + case ContentNodeTypes.parallel: + return { + type: ContentNodeTypes.parallel, + children: [], + config: { + minWidth: 180 + }, + data: { + yamlPath: '', + name: 'Parallel' + } satisfies StepNodeDataType + } satisfies ParallelContainerNodeType + case ContentNodeTypes.serial: + return { + type: ContentNodeTypes.serial, + children: [], + config: { + minWidth: 180 + }, + data: { + yamlPath: '', + name: 'Serial' + } satisfies StepNodeDataType + } satisfies SerialContainerNodeType + } +} diff --git a/packages/pipeline-graph/examples/src/example2-animations.tsx b/packages/pipeline-graph/examples/src/example2-animations.tsx new file mode 100644 index 000000000..f71deeb4c --- /dev/null +++ b/packages/pipeline-graph/examples/src/example2-animations.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useRef, useState } from 'react' + +import { cloneDeep, get, last } from 'lodash-es' +import { parse } from 'yaml' + +import { CanvasProvider } from '../../src/context/canvas-provider' +import { PipelineGraph } from '../../src/pipeline-graph' +import { NodeContent } from '../../src/types/node-content' +import { AnyContainerNodeType, ContainerNode, LeafContainerNodeType } from '../../src/types/nodes' +import { CanvasControls } from './canvas/CanvasControls' +import { ApprovalNode } from './nodes/approval-node' +import { EndNode } from './nodes/end-node' +import { ParallelGroupNodeContent } from './nodes/parallel-group-node' +import { SerialGroupContentNode } from './nodes/stage-node' +import { StartNode } from './nodes/start-node' +import { StepNode, StepNodeDataType } from './nodes/step-node' +import { getIcon } from './parser/utils' +import { yaml2Nodes } from './parser/yaml2AnyNodes' +import { pipeline } from './sample-data/pipeline' +import { ContentNodeTypes } from './types/content-node-types' + +const nodes: NodeContent[] = [ + { + type: ContentNodeTypes.start, + containerType: ContainerNode.leaf, + component: StartNode + }, + { + type: ContentNodeTypes.end, + containerType: ContainerNode.leaf, + component: EndNode + }, + { + type: ContentNodeTypes.step, + containerType: ContainerNode.leaf, + component: StepNode + }, + { + containerType: ContainerNode.leaf, + type: ContentNodeTypes.approval, + component: ApprovalNode + }, + { + type: ContentNodeTypes.parallel, + containerType: ContainerNode.parallel, + component: ParallelGroupNodeContent + } as NodeContent, + { + type: ContentNodeTypes.serial, + containerType: ContainerNode.serial, + component: SerialGroupContentNode + } as NodeContent +] + +const yamlObject = parse(pipeline) +const plData = yaml2Nodes(yamlObject) + +const endNode = { + type: ContentNodeTypes.start, + config: { + width: 80, + height: 80 + }, + data: {} +} + +plData.unshift(endNode) + +function AnimationExample() { + const dataRef = useRef(plData) + const [data, setData] = useState(null) + + useEffect(() => { + const update = () => { + const newData = cloneDeep(dataRef.current) + + const node = { + type: ContentNodeTypes.step, + config: { + width: 180 + }, + data: { + yamlPath: 'qwe-' + Math.random(), + name: 'step', + icon: getIcon(1), + state: 'loading' + } satisfies StepNodeDataType + } satisfies LeafContainerNodeType + + newData.push(cloneDeep(node)) + ;(get(newData, '2.children.0.children.1.children.0.children.2.children.2.children') as unknown as any[]).push( + cloneDeep(node) + ) + + dataRef.current = cloneDeep(newData) + setData(newData) + } + + update() + + setInterval(() => { + update() + }, 3000) + }, []) + + if (!data) return null + + return ( +
+ + + + +
+ ) +} + +export default AnimationExample diff --git a/packages/pipeline-graph/examples/src/example3-performance.tsx b/packages/pipeline-graph/examples/src/example3-performance.tsx new file mode 100644 index 000000000..2a05f221d --- /dev/null +++ b/packages/pipeline-graph/examples/src/example3-performance.tsx @@ -0,0 +1,73 @@ +import React from 'react' + +import { CanvasProvider } from '../../src/context/canvas-provider' +import { PipelineGraph } from '../../src/pipeline-graph' +import { NodeContent } from '../../src/types/node-content' +import { ContainerNode } from '../../src/types/nodes' +import { CanvasControls } from './canvas/CanvasControls' +import { ApprovalNode } from './nodes/approval-node' +import { EndNode } from './nodes/end-node' +import { ParallelGroupNodeContent } from './nodes/parallel-group-node' +import { SerialGroupContentNode } from './nodes/stage-node' +import { StartNode } from './nodes/start-node' +import { StepNode } from './nodes/step-node' +import { getPipeline } from './sample-data/pipeline-data' +import { ContentNodeTypes } from './types/content-node-types' + +const nodes: NodeContent[] = [ + { + type: ContentNodeTypes.start, + containerType: ContainerNode.leaf, + component: StartNode + }, + { + type: ContentNodeTypes.end, + containerType: ContainerNode.leaf, + component: EndNode + }, + { + type: ContentNodeTypes.step, + containerType: ContainerNode.leaf, + component: StepNode + }, + { + containerType: ContainerNode.leaf, + type: ContentNodeTypes.approval, + component: ApprovalNode + }, + { + type: ContentNodeTypes.parallel, + containerType: ContainerNode.parallel, + component: ParallelGroupNodeContent + } as NodeContent, + { + type: ContentNodeTypes.serial, + containerType: ContainerNode.serial, + component: SerialGroupContentNode + } as NodeContent +] + +const data = getPipeline(9, 12, 3, 'success') + +function PerformanceExample() { + return ( +
+ + + + +
+ ) +} + +export default PerformanceExample diff --git a/packages/pipeline-graph/examples/src/example4-size.tsx b/packages/pipeline-graph/examples/src/example4-size.tsx new file mode 100644 index 000000000..f7f610075 --- /dev/null +++ b/packages/pipeline-graph/examples/src/example4-size.tsx @@ -0,0 +1,67 @@ +import { parse } from 'yaml' + +import { PipelineGraph } from '../../src/pipeline-graph' +import { NodeContent } from '../../src/types/node-content' +import { ContainerNode } from '../../src/types/nodes' +import { yaml2Nodes } from './parser/yaml2AnyNodes' +import { pipeline } from './sample-data/pipeline' + +import './sample-data/pipeline-data' + +import React from 'react' + +import { CanvasProvider } from '../../src/context/canvas-provider' +import { CanvasControls } from './canvas/CanvasControls' +import { SimpleParallelGroupNodeContent } from './nodes-simple/simple-parallel-group-node' +import { SimpleSerialGroupContentNode } from './nodes-simple/simple-stage-node' +import { SimpleStepNode } from './nodes-simple/simple-step-node' +import { ContentNodeTypes } from './types/content-node-types' + +const nodes: NodeContent[] = [ + { + type: ContentNodeTypes.step, + containerType: ContainerNode.leaf, + component: SimpleStepNode + }, + { + type: ContentNodeTypes.parallel, + containerType: ContainerNode.parallel, + component: SimpleParallelGroupNodeContent + } as NodeContent, + { + type: ContentNodeTypes.serial, + containerType: ContainerNode.serial, + component: SimpleSerialGroupContentNode + } as NodeContent, + { + type: ContentNodeTypes.stage, + containerType: ContainerNode.serial, + component: SimpleSerialGroupContentNode + } as NodeContent +] + +const yamlObject = parse(pipeline) +const plData = yaml2Nodes(yamlObject, {}) + +function Example4() { + return ( +
+ + + + +
+ ) +} + +export default Example4 diff --git a/packages/pipeline-graph/examples/src/example5-context.tsx b/packages/pipeline-graph/examples/src/example5-context.tsx new file mode 100644 index 000000000..2bbd12478 --- /dev/null +++ b/packages/pipeline-graph/examples/src/example5-context.tsx @@ -0,0 +1,97 @@ +import { parse } from 'yaml' + +import { PipelineGraph } from '../../src/pipeline-graph' +import { NodeContent } from '../../src/types/node-content' +import { ContainerNode } from '../../src/types/nodes' +import { yaml2Nodes } from './parser/yaml2AnyNodes' +import { pipeline } from './sample-data/pipeline' + +import './sample-data/pipeline-data' + +import React from 'react' + +import { CanvasProvider } from '../../src/context/canvas-provider' +import { CanvasControls } from './canvas/CanvasControls' +import { ParallelGroupNodeContent } from './nodes/parallel-group-node' +import { SerialGroupContentNode } from './nodes/stage-node' +import { StepNode } from './nodes/step-node' +import { ContentNodeTypes } from './types/content-node-types' + +const nodes: NodeContent[] = [ + { + type: ContentNodeTypes.step, + containerType: ContainerNode.leaf, + component: StepNode + }, + { + type: ContentNodeTypes.parallel, + containerType: ContainerNode.parallel, + component: ParallelGroupNodeContent + } as NodeContent, + { + type: ContentNodeTypes.serial, + containerType: ContainerNode.serial, + component: SerialGroupContentNode + } as NodeContent, + { + type: ContentNodeTypes.stage, + containerType: ContainerNode.serial, + component: SerialGroupContentNode + } as NodeContent +] + +const yamlObject = parse(pipeline) +const plData = yaml2Nodes(yamlObject, {}) + +function ContextExample() { + return ( +
+ + {}} + // onAdd={() => {}} + // onDelete={() => {}} + // onContext={(node, e) => { + // e.preventDefault() + // console.log(node, e) + // const el = document.getElementsByClassName('context-menu')[0] as HTMLDivElement + // el.style.display = 'block' + // el.style.position = 'absolute' + // el.style.zIndex = '1000' + // el.style.top = (e as MouseEvent).pageY - 40 + 'px' + // el.style.left = (e as MouseEvent).pageX - 40 + 'px' + // }} + /> + + +
+ Add step before + Add step after + Delete step + Preview + Open in Yaml +
+
+ ) +} + +export default ContextExample + +function ContextItem({ children }): React.ReactNode { + return
{children}
+} diff --git a/packages/pipeline-graph/examples/src/examples.css b/packages/pipeline-graph/examples/src/examples.css new file mode 100644 index 000000000..3047a8b95 --- /dev/null +++ b/packages/pipeline-graph/examples/src/examples.css @@ -0,0 +1,60 @@ +@import './css/common.css'; +@import './css/modifiers.css'; +@import './css/animations.css'; +@import './css/selection.css'; + +/* +iframe { + display: none; +} */ + +/* root container */ +.PipelineGraph-RootContainer { + transform-origin: left top; + transition: scale 0.2s; +} + +/* nodes container */ +.PipelineGraph-NodesContainer { + /* z-index: 20; */ + position: absolute; + width: max-content; /* IMPORTANT: do not delete this */ +} + +/* lines container */ +.PipelineGraph-SvgContainer { + top: 0px; + /* z-index: 30; */ + position: absolute; + pointer-events: none; +} + +/* nodes */ +.delete-node-button { + opacity: 0; + display: none; +} + +/* delete button >>>*/ +.leaf-node-header:hover > .delete-node-button { + opacity: 1; + display: flex !important; +} +.parallel-node:hover > * > .delete-node-button { + opacity: 1; + display: flex !important; +} +.serial-node:hover > * > .delete-node-button { + opacity: 1; + display: flex !important; +} + +/* add button */ +.add-node-container .add-node-button { + display: none; + opacity: 0; +} +.add-node-container:hover .add-node-button { + display: flex !important; + opacity: 1; +} diff --git a/packages/pipeline-graph/examples/src/examples.tsx b/packages/pipeline-graph/examples/src/examples.tsx new file mode 100644 index 000000000..559493c53 --- /dev/null +++ b/packages/pipeline-graph/examples/src/examples.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react' + +import Example1 from './example1' +import AnimationExample from './example2-animations' +import PerformanceExample from './example3-performance' +import SizeExample from './example4-size' +import { ContentNodeTypes } from './types/content-node-types' + +import './examples.css' + +import React from 'react' + +import ContextExample from './example5-context' + +type ExperimentalType = 'sharpLine' | 'sharpBorder' | 'skeleton' + +const examplesArr = [ + { + name: 'Demo', + component: Example1 + }, + { + name: 'Animations', + component: AnimationExample + }, + { + name: 'Performance', + component: PerformanceExample + }, + { + name: 'Size', + component: SizeExample + }, + { + name: 'Context', + component: ContextExample + } +] + +const stepTypesArr = [ContentNodeTypes.step, ContentNodeTypes.serial, ContentNodeTypes.parallel] + +const experimentalArr: ExperimentalType[] = ['sharpLine', 'sharpBorder', 'skeleton'] + +function App() { + const [example, setExample] = useState(examplesArr[0].name) + const [addStepType, setAddStepType] = useState(ContentNodeTypes.step) + const [experimental, setExperimental] = useState({ + sharpLine: false, + sharpBorder: false, + skeleton: false + }) + + const render = () => { + switch (example) { + case 'Demo': + return + case 'Animations': + return + case 'Performance': + return + case 'Size': + return + case 'Context': + return + } + } + + useEffect(() => { + const { body } = document + experimental.sharpLine ? body.classList.add('sharp-line') : body.classList.remove('sharp-line') + experimental.sharpBorder ? body.classList.add('sharp-border') : body.classList.remove('sharp-border') + experimental.skeleton ? body.classList.add('skeleton') : body.classList.remove('skeleton') + }, [experimental]) + + return ( + <> +
+
+ {examplesArr.map(exampleItem => ( + + ))} +
+
+ {stepTypesArr.map(stepTypeItem => ( +
+ { + setAddStepType(stepTypeItem) + }} + type="radio" + id={stepTypeItem} + name={stepTypeItem} + value={ContentNodeTypes.step} + checked={addStepType === stepTypeItem} + /> + +
+ ))} +
+
+ {experimentalArr.map(experimentalItem => ( +
+ { + setExperimental({ ...experimental, [experimentalItem]: !experimental[experimentalItem] }) + }} + /> + +
+ ))} +
+
+ {render()} + + ) +} + +export default App diff --git a/packages/pipeline-graph/examples/src/main.tsx b/packages/pipeline-graph/examples/src/main.tsx new file mode 100644 index 000000000..5fedb4406 --- /dev/null +++ b/packages/pipeline-graph/examples/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' + +import Examples from './examples' + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) +root.render( + + + +) diff --git a/packages/pipeline-graph/examples/src/nodes-simple/simple-parallel-group-node.tsx b/packages/pipeline-graph/examples/src/nodes-simple/simple-parallel-group-node.tsx new file mode 100644 index 000000000..64c02009b --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes-simple/simple-parallel-group-node.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import { ParallelNodeInternalType } from '../../../src/types/nodes-internal' + +export interface ParallelGroupContentNodeDataType { + yamlPath: string + name: string +} + +export function SimpleParallelGroupNodeContent(props: { + node: ParallelNodeInternalType + children: React.ReactElement +}) { + const { children } = props + + return ( +
+
+ {children} +
+ ) +} diff --git a/packages/pipeline-graph/examples/src/nodes-simple/simple-serial-group-node.tsx b/packages/pipeline-graph/examples/src/nodes-simple/simple-serial-group-node.tsx new file mode 100644 index 000000000..864909dac --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes-simple/simple-serial-group-node.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import { SerialNodeInternalType } from '../../../src/types/nodes-internal' + +export interface SerialGroupContentNodeDataType { + yamlPath: string + name: string +} + +export function SimpleSerialGroupNodeContent(props: { + node: SerialNodeInternalType + children: React.ReactElement +}) { + const { children } = props + + return ( +
+
+ {children} +
+ ) +} diff --git a/packages/pipeline-graph/examples/src/nodes-simple/simple-stage-node.tsx b/packages/pipeline-graph/examples/src/nodes-simple/simple-stage-node.tsx new file mode 100644 index 000000000..3b5b41d33 --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes-simple/simple-stage-node.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import { SerialNodeInternalType } from '../../../src/types/nodes-internal' + +export interface StageNodeContentType { + yamlPath: string + name: string +} + +export function SimpleSerialGroupContentNode(props: { + node: SerialNodeInternalType + children: React.ReactElement +}) { + const { children } = props + + return ( +
+
+ {children} +
+ ) +} diff --git a/packages/pipeline-graph/examples/src/nodes-simple/simple-step-node.tsx b/packages/pipeline-graph/examples/src/nodes-simple/simple-step-node.tsx new file mode 100644 index 000000000..b1d907699 --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes-simple/simple-step-node.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useRef, useState } from 'react' + +import { useGraphContext } from '../../../src/context/graph-provider' +import { LeafNodeInternalType } from '../../../src/types/nodes-internal' + +export interface StepNodeDataType { + yamlPath: string + name: string + icon?: React.ReactElement +} + +export function SimpleStepNode(props: { node: LeafNodeInternalType }) { + const { rerender } = useGraphContext() + const { node } = props + const data = node.data as StepNodeDataType + + const increment = useRef(Math.random() * 2) + const size = useRef({ x: 150, y: 150 }) + + const [_, setRerender] = useState(1) + + useEffect(() => { + const interval = setInterval(() => { + if (size.current.x > 200) increment.current = -Math.abs(increment.current) + if (size.current.x < 100) increment.current = Math.abs(increment.current) + + size.current = { x: size.current.x + increment.current, y: size.current.y + increment.current } + rerender() + }, 33) + + setRerender(prev => prev + 1) + return () => clearInterval(interval) + }, []) + + const style: React.CSSProperties = { + height: size.current.x + 'px', + width: size.current.y + 'px', + boxSizing: 'border-box', + border: '1px solid #454545', + borderRadius: '6px', + wordBreak: 'break-all', + fontSize: '11px', + fontFamily: 'Verdana', + background: 'linear-gradient(-47deg, rgba(152, 150, 172, 0.05) 0%, rgba(177, 177, 177, 0.15) 100%)', + overflow: 'hidden' + } + + const name = data?.name ?? 'Step' + + return ( +
+
{data?.icon}
+
+ {name} +
+ {props.node.path} +
+
+ ) +} diff --git a/packages/pipeline-graph/examples/src/nodes/approval-node.tsx b/packages/pipeline-graph/examples/src/nodes/approval-node.tsx new file mode 100644 index 000000000..2f9babaaf --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes/approval-node.tsx @@ -0,0 +1,39 @@ +import React from 'react' + +import { LeafNodeInternalType } from '../../../src/types/nodes-internal' + +export interface ApprovalNodeDataType {} + +export function ApprovalNode(_props: { node: LeafNodeInternalType }) { + const style: React.CSSProperties = { + transformOrigin: 'center center', + // zIndex: '-10', + position: 'absolute', + transform: 'rotate(45deg)', + boxSizing: 'border-box', + left: '15%', + top: '15%', + height: '70%', + width: '70%', + border: '1px solid #454545', + background: 'linear-gradient(-47deg, rgba(152, 150, 172, 0.05) 0%, rgba(177, 177, 177, 0.15) 100%)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + } + + return ( +
+
+
APPROVAL
+
+ ) +} diff --git a/packages/pipeline-graph/examples/src/nodes/end-node.tsx b/packages/pipeline-graph/examples/src/nodes/end-node.tsx new file mode 100644 index 000000000..d333328e4 --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes/end-node.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +import { LeafNodeInternalType } from '../../../src/types/nodes-internal' + +export interface EndNodeDataType {} + +export function EndNode(_props: { node: LeafNodeInternalType }) { + const style: React.CSSProperties = { + boxSizing: 'border-box', + height: '100%', + width: '100%', + border: '1px solid #454545', + borderRadius: '50%', + background: 'linear-gradient(-45deg, rgba(152, 150, 172, 0.05) 0%, rgba(177, 177, 177, 0.15) 100%)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + } + + return
END
+} diff --git a/packages/pipeline-graph/examples/src/nodes/form-node.tsx b/packages/pipeline-graph/examples/src/nodes/form-node.tsx new file mode 100644 index 000000000..d2e0fd6e9 --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes/form-node.tsx @@ -0,0 +1,39 @@ +import React from 'react' + +import { LeafNodeInternalType } from '../../../src/types/nodes-internal' + +export interface FormNodeDataType { + yamlPath: string + name: string + icon?: React.ReactElement + state?: 'success' | 'loading' +} + +export function FormNode(props: { node: LeafNodeInternalType }) { + const { node } = props + const { data } = node + + const style: React.CSSProperties = { + height: '100%', + width: '100%', + boxSizing: 'border-box', + border: '1px solid #454545', + borderRadius: '6px', + wordBreak: 'break-all', + fontSize: '11px', + fontFamily: 'Verdana', + background: 'linear-gradient(-47deg, rgba(152, 150, 172, 0.05) 0%, rgba(177, 177, 177, 0.15) 100%)' + } + + return ( +
+
{data.icon}
+
+ {data.name ?? 'Step'} +
+ + +
+
+ ) +} diff --git a/packages/pipeline-graph/examples/src/nodes/parallel-group-node.tsx b/packages/pipeline-graph/examples/src/nodes/parallel-group-node.tsx new file mode 100644 index 000000000..1c2327856 --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes/parallel-group-node.tsx @@ -0,0 +1,65 @@ +import React from 'react' + +import cx from 'classnames' + +import { ParallelNodeInternalType } from '../../../src/types/nodes-internal' + +export interface ParallelGroupContentNodeDataType { + yamlPath: string + name: string + icon?: React.ReactElement + state?: 'success' | 'loading' + selected?: boolean +} + +export function ParallelGroupNodeContent(props: { + node: ParallelNodeInternalType + children: React.ReactElement +}) { + const { node, children } = props + const { data } = node + + const name = `Parallel - ${node.path} (${node.children.length})` + + return ( +
+
+
+ + {name} + +
+ {children} +
+ ) +} diff --git a/packages/pipeline-graph/examples/src/nodes/serial-group-node.tsx b/packages/pipeline-graph/examples/src/nodes/serial-group-node.tsx new file mode 100644 index 000000000..a7c7bf9fe --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes/serial-group-node.tsx @@ -0,0 +1,65 @@ +import React from 'react' + +import cx from 'classnames' + +import { SerialNodeInternalType } from '../../../src/types/nodes-internal' + +export interface SerialGroupContentNodeDataType { + yamlPath: string + name: string + icon?: React.ReactElement + state?: 'success' | 'loading' + selected?: boolean +} + +export function SerialGroupNodeContent(props: { + node: SerialNodeInternalType + children: React.ReactElement +}) { + const { node, children } = props + const { data } = node + + const name = `Serial - ${node.path} (${node.children.length})` + + return ( +
+
+
+ + {name} + +
+ {children} +
+ ) +} diff --git a/packages/pipeline-graph/examples/src/nodes/stage-node.tsx b/packages/pipeline-graph/examples/src/nodes/stage-node.tsx new file mode 100644 index 000000000..ee3349e0b --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes/stage-node.tsx @@ -0,0 +1,65 @@ +import React from 'react' + +import cx from 'classnames' + +import { SerialNodeInternalType } from '../../../src/types/nodes-internal' + +export interface StageNodeContentType { + yamlPath: string + name: string + icon?: React.ReactElement + state?: 'success' | 'loading' + selected?: boolean +} + +export function SerialGroupContentNode(props: { + node: SerialNodeInternalType + children: React.ReactElement +}) { + const { node, children } = props + const { data } = node + + const name = `Stage - ${node.path} (${node.children.length})` + + return ( +
+
+
+ + {name} + +
+ {children} +
+ ) +} diff --git a/packages/pipeline-graph/examples/src/nodes/start-node.tsx b/packages/pipeline-graph/examples/src/nodes/start-node.tsx new file mode 100644 index 000000000..a50a29241 --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes/start-node.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +import { LeafNodeInternalType } from '../../../src/types/nodes-internal' + +export interface StartNodeDataType {} + +export function StartNode(_props: { node: LeafNodeInternalType }) { + const style: React.CSSProperties = { + boxSizing: 'border-box', + height: '100%', + width: '100%', + border: '1px solid #454545', + borderRadius: '50%', + background: 'linear-gradient(-47deg, rgba(152, 150, 172, 0.05) 0%, rgba(177, 177, 177, 0.15) 100%)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + } + + return
START
+} diff --git a/packages/pipeline-graph/examples/src/nodes/step-node.tsx b/packages/pipeline-graph/examples/src/nodes/step-node.tsx new file mode 100644 index 000000000..89ca847c9 --- /dev/null +++ b/packages/pipeline-graph/examples/src/nodes/step-node.tsx @@ -0,0 +1,47 @@ +import React from 'react' + +import cx from 'classnames' + +import { LeafNodeInternalType } from '../../../src/types/nodes-internal' + +export interface StepNodeDataType { + yamlPath: string + name: string + icon?: React.ReactElement + state?: 'success' | 'loading' + selected?: boolean +} + +export function StepNode(props: { node: LeafNodeInternalType }) { + const { node } = props + const { data } = node + + const style: React.CSSProperties = { + height: '100%', + width: '100%', + boxSizing: 'border-box', + border: '1px solid #454545', + borderRadius: '6px', + wordBreak: 'break-all', + fontSize: '11px', + fontFamily: 'Verdana', + background: 'linear-gradient(-47deg, rgba(152, 150, 172, 0.05) 0%, rgba(177, 177, 177, 0.15) 100%)' + } + + const name = data.name ?? 'Step' + + return ( +
+
{data?.icon}
+
+ {name} +
+ {props.node.path} +
+
+ ) +} diff --git a/packages/pipeline-graph/examples/src/parser/utils.tsx b/packages/pipeline-graph/examples/src/parser/utils.tsx new file mode 100644 index 000000000..c591d248f --- /dev/null +++ b/packages/pipeline-graph/examples/src/parser/utils.tsx @@ -0,0 +1,14 @@ +import HarnessStep from '../assets/harness-step.svg' +import RunStep from '../assets/run-step.svg' + +export function getIcon(idx: number) { + return ( +
+ {idx % 2 ? ( + + ) : ( + + )} +
+ ) +} diff --git a/packages/pipeline-graph/examples/src/parser/yaml2AnyNodes.tsx b/packages/pipeline-graph/examples/src/parser/yaml2AnyNodes.tsx new file mode 100644 index 000000000..3af65baad --- /dev/null +++ b/packages/pipeline-graph/examples/src/parser/yaml2AnyNodes.tsx @@ -0,0 +1,156 @@ +import { + AnyContainerNodeType, + LeafContainerNodeType, + ParallelContainerNodeType, + SerialContainerNodeType +} from '../../../src/types/nodes' +import { ParallelGroupContentNodeDataType } from '../nodes/parallel-group-node' +import { StageNodeContentType } from '../nodes/stage-node' +import { StepNodeDataType } from '../nodes/step-node' +import { ContentNodeTypes } from '../types/content-node-types' +import { getIcon } from './utils' + +export const yaml2Nodes = ( + yamlObject: Record, + options: { maxWidth?: number } = { maxWidth: 140 } +): AnyContainerNodeType[] => { + const nodes: AnyContainerNodeType[] = [] + + const stages = yamlObject?.pipeline?.stages + if (stages) { + const stagesNodes = processStages(stages, '', options) + nodes.push(...stagesNodes) + } + + return nodes +} + +const getGroupKey = (stage: Record): 'group' | 'parallel' | undefined => { + if ('group' in stage) return 'group' + else if ('parallel' in stage) return 'parallel' + return undefined +} + +const processStages = (stages: any[], currentPath: string, options: { maxWidth?: number }): AnyContainerNodeType[] => { + return stages.map((stage, idx) => { + // parallel stage + const groupKey = getGroupKey(stage) + if (groupKey === 'group') { + const name = stage.name ?? `Serial group ${idx}` + const path = `${currentPath}.${idx}.${groupKey}.stages` + + return { + type: ContentNodeTypes.serial, + config: { + minWidth: 140, + hideDeleteButton: false, + hideCollapseButton: false + }, + data: { + yamlPath: path, + name + } satisfies StageNodeContentType, + + children: processStages(stage[groupKey].stages, path, options) + } satisfies SerialContainerNodeType + } else if (groupKey === 'parallel') { + const name = stage.name ?? `Parallel group ${idx}` + const path = `${currentPath}.${idx}.${groupKey}.stages` + + return { + type: ContentNodeTypes.parallel, + config: { + minWidth: 140, + hideDeleteButton: false, + hideCollapseButton: false + }, + data: { + yamlPath: path, + name + } satisfies ParallelGroupContentNodeDataType, + children: processStages(stage[groupKey].stages, path, options) + } satisfies ParallelContainerNodeType + } + // regular stage + else { + const name = stage.name ?? `Stage ${idx}` + const path = `${currentPath}.${idx}` + + return { + type: ContentNodeTypes.stage, + config: { + minWidth: 140, + hideDeleteButton: false, + hideCollapseButton: false + }, + data: { + yamlPath: path, + name + } satisfies StageNodeContentType, + children: processSteps(stage.steps, path, options) + } satisfies SerialContainerNodeType + } + }) +} + +const processSteps = (steps: any[], currentPath: string, options: { maxWidth?: number }): AnyContainerNodeType[] => { + return steps.map((step, idx) => { + // parallel stage + const groupKey = getGroupKey(step) + if (groupKey === 'group') { + const name = step.name ?? `Step group ${idx}` + const path = `${currentPath}.${idx}.${groupKey}.steps` + + return { + type: ContentNodeTypes.serial, + config: { + minWidth: 140, + hideDeleteButton: false, + hideCollapseButton: false + }, + data: { + yamlPath: path, + name + } satisfies StageNodeContentType, + + children: processSteps(step[groupKey].steps, path, options) + } satisfies SerialContainerNodeType + } else if (groupKey === 'parallel') { + const name = step.name ?? `Parallel group ${idx}` + const path = `${currentPath}.${idx}.${groupKey}.steps` + + return { + type: ContentNodeTypes.parallel, + config: { + minWidth: 140, // TMP + hideDeleteButton: false, + hideCollapseButton: false + }, + data: { + yamlPath: path, + name + } satisfies ParallelGroupContentNodeDataType, + children: processSteps(step[groupKey].steps, path, options) + } satisfies ParallelContainerNodeType + } + // regular step + else { + const name = step.name ?? `Step ${idx}` + + const path = `${currentPath}.${idx}` + return { + type: ContentNodeTypes.step, + config: { + ...options, + hideDeleteButton: false, + selectable: true + }, + data: { + yamlPath: path, + name, + icon: getIcon(idx) + } satisfies StepNodeDataType + } satisfies LeafContainerNodeType + } + }) +} diff --git a/packages/pipeline-graph/examples/src/sample-data/pipeline-data.tsx b/packages/pipeline-graph/examples/src/sample-data/pipeline-data.tsx new file mode 100644 index 000000000..357e8a325 --- /dev/null +++ b/packages/pipeline-graph/examples/src/sample-data/pipeline-data.tsx @@ -0,0 +1,193 @@ +import { AnyContainerNodeType } from '../../../src/types/nodes' +import { getIcon } from '../parser/utils' + +const config = { width: 140 } + +const getChildren = (count: number, state = 'success'): AnyContainerNodeType[] => + Array(count) + .fill(1) + .map((_, idx) => ({ + type: 'step', + config: { width: 140, maxWidth: 140 }, + data: { icon: getIcon(idx), state } + })) + +const getPipelineInternal = ({ + parallelChildren, + serialChildren, + state +}: { + parallelChildren: number + serialChildren: number + state: 'loading' | 'success' +}): AnyContainerNodeType[] => [ + { + type: 'start', + config: { + width: 60, + height: 60, + hideLeftPort: true, + hideDeleteButton: true + }, + data: {} + }, + { + type: 'step', + config: { width: 250, height: 250 }, + data: { name: 'VERY LARGE STEP NODE', icon: getIcon(1) } + }, + { type: 'approval', config: { width: 100, height: 100 }, data: {} }, + { type: 'approval', config: { width: 200, height: 200 }, data: {} }, + { type: 'step', config, data: { icon: getIcon(3), state } }, + { + type: 'serial', + children: getChildren(serialChildren, state), + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { + type: 'parallel', + children: [ + { + type: 'serial', + children: getChildren(serialChildren), + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { + type: 'serial', + children: getChildren(serialChildren), + config: { minWidth: 140, minHeight: 0 }, + data: {} + } + ], + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { + type: 'serial', + children: [ + { + type: 'parallel', + children: getChildren(parallelChildren), + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { + type: 'parallel', + children: [ + { + type: 'parallel', + children: getChildren(parallelChildren, state), + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { + type: 'step', + config, + data: { icon: getIcon(4) } + } + ], + config: { minWidth: 140, minHeight: 0 }, + data: {} + } + ], + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { + type: 'serial', + children: [ + { + type: 'parallel', + children: [ + { + type: 'parallel', + children: getChildren(parallelChildren), + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { + type: 'parallel', + children: getChildren(parallelChildren), + config: { minWidth: 140, minHeight: 0 }, + data: {} + } + ], + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { + type: 'parallel', + children: [ + { + type: 'parallel', + children: [ + { + type: 'parallel', + children: getChildren(parallelChildren), + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { + type: 'parallel', + children: getChildren(parallelChildren), + config: { minWidth: 140, minHeight: 0 }, + data: {} + } + ], + config: { minWidth: 140, minHeight: 0 }, + data: {} + } + ], + config: { minWidth: 140, minHeight: 0 }, + data: {} + } + ], + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { + type: 'parallel', + children: [ + { type: 'step', config, data: { icon: getIcon(5) } }, + { + type: 'parallel', + children: getChildren(parallelChildren), + config: { minWidth: 140, minHeight: 0 }, + data: {} + } + ], + config: { minWidth: 140, minHeight: 0 }, + data: {} + }, + { type: 'step', config, data: { icon: getIcon(6) } }, + { + type: 'end', + config: { + width: 160, + height: 160, + hideRightPort: true, + hideDeleteButton: true + }, + data: {} + } +] + +/** utility for creating pipelines for testing */ +export const getPipeline = (repeat = 1, parallel = 5, serial = 3, state: 'loading' | 'success' = 'success') => { + let largePipelineInternal: AnyContainerNodeType[] = [] + + for (let i = 0; i < repeat; i++) { + largePipelineInternal = [ + ...largePipelineInternal, + ...getPipelineInternal({ + parallelChildren: parallel, + serialChildren: serial, + state + }) + ] + } + + return largePipelineInternal +} diff --git a/packages/pipeline-graph/examples/src/sample-data/pipeline.ts b/packages/pipeline-graph/examples/src/sample-data/pipeline.ts new file mode 100644 index 000000000..5dc4ca710 --- /dev/null +++ b/packages/pipeline-graph/examples/src/sample-data/pipeline.ts @@ -0,0 +1,42 @@ +export const pipeline = ` +pipeline: + stages: + - group: + stages: + - steps: + - run: go build + - run: go test + - steps: + - run: npm run + - run: npm test + - group: + stages: + - parallel: + stages: + - steps: + - run: go build + - run: go test + - group: + steps: + - run: go build + - run: go test + - steps: + - parallel: + steps: + - run: go build + - run: go build + - group: + steps: + - run: go build + - run: go build + - parallel: + steps: + - run: go build + - run: go build + - group: + steps: + - run: go build + - run: go build + - run: npm run + - run: npm test +`; diff --git a/packages/pipeline-graph/examples/src/sample-data/simple-pipeline.ts b/packages/pipeline-graph/examples/src/sample-data/simple-pipeline.ts new file mode 100644 index 000000000..049904ac0 --- /dev/null +++ b/packages/pipeline-graph/examples/src/sample-data/simple-pipeline.ts @@ -0,0 +1,13 @@ +export const pipeline = ` +pipeline: + stages: + - group: + stages: + - steps: + - run: go build + - run: go test + - steps: + - run: npm run + - run: npm test + +` diff --git a/packages/pipeline-graph/examples/src/types/content-node-types.ts b/packages/pipeline-graph/examples/src/types/content-node-types.ts new file mode 100644 index 000000000..9fb905dad --- /dev/null +++ b/packages/pipeline-graph/examples/src/types/content-node-types.ts @@ -0,0 +1,10 @@ +export enum ContentNodeTypes { + start = "start", + end = "end", + step = "step", + approval = "approval", + parallel = "parallel", + serial = "serial", + stage = "serial", + form = "form", +} diff --git a/packages/pipeline-graph/package.json b/packages/pipeline-graph/package.json new file mode 100644 index 000000000..d048fc7ae --- /dev/null +++ b/packages/pipeline-graph/package.json @@ -0,0 +1,46 @@ +{ + "name": "@harnessio/pipeline-graph", + "version": "1.0.0", + "private": false, + "author": "Harness Inc.", + "license": "Apache-2.0", + "type": "module", + "module": "./dist/index.js", + "main": "./dist/index.js", + "files": [ + "dist" + ], + "types": "./dist/index.d.ts", + "scripts": { + "examples": "vite dev --config vite.config.dev.ts", + "dev": "vite build --watch", + "build": "vite build" + }, + "peerDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/lodash-es": "^4.17.12", + "@types/node": "^22.10.1", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react-swc": "^3.7.2", + "vite": "^6.0.2", + "vite-plugin-dts": "^4.3.0", + "vite-plugin-svgr": "^4.3.0", + "yaml": "^2.6.1" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint ./src --fix", + "prettier ./src --write" + ] + }, + "dependencies": { + "classnames": "^2.5.1", + "lodash-es": "^4.17.21" + } +} diff --git a/packages/pipeline-graph/public/pipeline-graph.png b/packages/pipeline-graph/public/pipeline-graph.png new file mode 100644 index 000000000..bee41419c Binary files /dev/null and b/packages/pipeline-graph/public/pipeline-graph.png differ diff --git a/packages/pipeline-graph/src/components/canvas/canvas-utils.ts b/packages/pipeline-graph/src/components/canvas/canvas-utils.ts new file mode 100644 index 000000000..f8605ad1c --- /dev/null +++ b/packages/pipeline-graph/src/components/canvas/canvas-utils.ts @@ -0,0 +1,42 @@ +export interface MousePoint { + clientX: number + clientY: number +} + +interface CalculateTransformArgs { + originX?: number + originY?: number + panX?: number + panY?: number + scaleDiff?: number + currentTranslateX?: number + currentTranslateY?: number + currentScale?: number +} + +export function calculateTransform(args: CalculateTransformArgs = {}) { + const { + originX = 0, + originY = 0, + panX = 0, + panY = 0, + scaleDiff = 1, + currentTranslateX = 0, + currentTranslateY = 0, + currentScale = 1 + } = args + + const matrix = new DOMMatrix() + .translate(panX, panY) + .translate(originX, originY) + .translate(currentTranslateX, currentTranslateY) + .scale(scaleDiff) + .translate(-originX, -originY) + .scale(currentScale) + + return { + translateX: matrix.e, + translateY: matrix.f, + scale: matrix.a + } +} diff --git a/packages/pipeline-graph/src/components/canvas/canvas.css b/packages/pipeline-graph/src/components/canvas/canvas.css new file mode 100644 index 000000000..815228286 --- /dev/null +++ b/packages/pipeline-graph/src/components/canvas/canvas.css @@ -0,0 +1,12 @@ +.PipelineGraph-Canvas { + display: block; + overflow: hidden; + touch-action: none; + height: 100%; +} + +.PipelineGraph-Canvas > div { + transform: translate(var(--x), var(--y)) scale(var(--scale)); + transform-origin: top left; + will-change: transform; +} diff --git a/packages/pipeline-graph/src/components/canvas/canvas.tsx b/packages/pipeline-graph/src/components/canvas/canvas.tsx new file mode 100644 index 000000000..dbc819846 --- /dev/null +++ b/packages/pipeline-graph/src/components/canvas/canvas.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useRef } from 'react' + +import { useCanvasContext } from '../../context/canvas-provider' +import { calculateTransform, MousePoint } from './canvas-utils' + +import './canvas.css' + +export function Canvas({ children }: React.PropsWithChildren) { + const { setCanvasTransform, canvasTransformRef, config } = useCanvasContext() + + const mainRef = useRef(null) + + // handle zoom-to-pinch (wheel) + useEffect(() => { + if (mainRef.current) { + const handler = (event: WheelEvent) => { + const targetEl = mainRef.current?.children[0] as HTMLDivElement | undefined + + if (!targetEl || !mainRef.current) return + + event.preventDefault() + + if (!event.ctrlKey) { + const newTransform = calculateTransform({ + scaleDiff: 1, + originX: event.deltaX, + originY: event.deltaY, + panX: -event.deltaX, + panY: -event.deltaY, + currentScale: canvasTransformRef.current.scale, + currentTranslateX: canvasTransformRef.current.translateX, + currentTranslateY: canvasTransformRef.current.translateY + }) + + setCanvasTransform(newTransform) + + canvasTransformRef.current = newTransform + + return + } + + const currentRect = targetEl.getBoundingClientRect() + + let { deltaY } = event + const { ctrlKey, deltaMode } = event + + if (deltaMode === 1) { + // 1 = "lines", 0 = "pixels" + deltaY *= 15 + } + + const divisor = ctrlKey ? 100 : 250 + const scaleDiff = 1 - deltaY / divisor + + const newTransform = calculateTransform({ + scaleDiff, + originX: event.clientX - currentRect.left, + originY: event.clientY - currentRect.top, + currentScale: canvasTransformRef.current.scale, + currentTranslateX: canvasTransformRef.current.translateX, + currentTranslateY: canvasTransformRef.current.translateY + }) + + if (newTransform.scale < config.minScale) return + + setCanvasTransform(newTransform) + canvasTransformRef.current = newTransform + } + + mainRef.current.addEventListener('wheel', handler) + + return () => { + mainRef.current?.removeEventListener('wheel', handler) + } + } + }, [mainRef]) + + // handle pan (mousedown/move/up) + const latestPointRef = useRef(null) + + const mouseMoveHandler = (event: MouseEvent) => { + const targetEl = mainRef.current?.children[0] as HTMLDivElement | undefined + + const prevPoint = latestPointRef.current + const currPoint = event + + if (!targetEl || !mainRef.current || !prevPoint || !currPoint) return + + event.preventDefault() + + const currentRect = targetEl.getBoundingClientRect() + + const originX = prevPoint.clientX - currentRect.left + const originY = prevPoint.clientY - currentRect.top + + const newTransform = calculateTransform({ + originX, + originY, + scaleDiff: 1, + panX: currPoint.clientX - prevPoint.clientX, + panY: currPoint.clientY - prevPoint.clientY, + currentScale: canvasTransformRef.current.scale, + currentTranslateX: canvasTransformRef.current.translateX, + currentTranslateY: canvasTransformRef.current.translateY + }) + + setCanvasTransform(newTransform) + canvasTransformRef.current = newTransform + latestPointRef.current = event + } + + const mouseUpHandler = (event: MouseEvent) => { + mainRef.current?.removeEventListener('mousemove', mouseMoveHandler) + document.removeEventListener('mouseup', mouseUpHandler) + latestPointRef.current = null + } + + const mouseDownHandler = (event: MouseEvent | any) => { + if (mainRef.current) { + latestPointRef.current = event + mainRef.current.addEventListener('mousemove', mouseMoveHandler) + document.addEventListener('mouseup', mouseUpHandler) + } + } + + useEffect(() => { + if (mainRef.current) { + return () => { + mainRef.current?.removeEventListener('mousemove', mouseMoveHandler) + document.removeEventListener('mouseup', mouseUpHandler) + } + } + }, [mainRef]) + + return ( +
+ {children} +
+ ) +} diff --git a/packages/pipeline-graph/src/components/components/collapse.tsx b/packages/pipeline-graph/src/components/components/collapse.tsx new file mode 100644 index 000000000..f325bd7e2 --- /dev/null +++ b/packages/pipeline-graph/src/components/components/collapse.tsx @@ -0,0 +1,23 @@ +// TODO: move this component outside of library +export default function CollapseButton(props: { collapsed: boolean; onToggle?: () => void }) { + const { collapsed, onToggle } = props + + return ( + + {collapsed ? '+' : '-'} + + ) +} diff --git a/packages/pipeline-graph/src/components/nodes/leaf-container.tsx b/packages/pipeline-graph/src/components/nodes/leaf-container.tsx new file mode 100644 index 000000000..7ce1ed740 --- /dev/null +++ b/packages/pipeline-graph/src/components/nodes/leaf-container.tsx @@ -0,0 +1,39 @@ +import { useRef } from 'react' + +import { RenderNodeContent } from '../../render/render-node-content' +import { ContainerNodeProps } from '../../types/container-node' +import { LeafNodeInternalType } from '../../types/nodes-internal' +import Port from './port' + +export default function LeafNodeContainer(props: ContainerNodeProps) { + const { node } = props + + const h = node.config?.height ? node.config?.height + 'px' : 'auto' + const w = node.config?.width ? node.config?.width + 'px' : 'auto' + const maxW = node.config?.maxWidth ? node.config?.maxWidth + 'px' : 'auto' + const maxH = node.config?.maxHeight ? node.config?.maxHeight + 'px' : 'auto' + const minW = node.config?.minWidth ? node.config?.minWidth + 'px' : 'auto' + const minH = node.config?.minHeight ? node.config?.minHeight + 'px' : 'auto' + + return ( +
+ {!node.config?.hideLeftPort && } + {!node.config?.hideRightPort && } + + +
+ ) +} diff --git a/packages/pipeline-graph/src/components/nodes/parallel-container.tsx b/packages/pipeline-graph/src/components/nodes/parallel-container.tsx new file mode 100644 index 000000000..b8adf39f7 --- /dev/null +++ b/packages/pipeline-graph/src/components/nodes/parallel-container.tsx @@ -0,0 +1,97 @@ +import { useMemo, useRef } from 'react' + +import { useGraphContext } from '../../context/graph-provider' +import { renderNode } from '../../render/render-node' +import { RenderNodeContent } from '../../render/render-node-content' +import { ContainerNodeProps } from '../../types/container-node' +import { AnyNodeInternal, ParallelNodeInternalType } from '../../types/nodes-internal' +import { findAdjustment } from '../../utils/layout-utils' +import CollapseButton from '../components/collapse' +import Port from './port' + +export const PARALLEL_GROUP_ADJUSTMENT = 10 +export const PARALLEL_PADDING = 42 +export const PADDING_TOP = 30 +export const PADDING_BOTTOM = 25 +export const PARALLEL_NODE_GAP = 36 + +export default function ParallelNodeContainer(props: ContainerNodeProps) { + const { node, level, parentNode } = props + + const myLevel = level + 1 + + const { isCollapsed, collapse } = useGraphContext() + + const collapsed = useMemo(() => isCollapsed(props.node.path!), [isCollapsed]) + + const ADJUSTMENT = findAdjustment(node, parentNode) + PARALLEL_GROUP_ADJUSTMENT + + return ( +
1 ? 0 : -ADJUSTMENT + 'px', + alignItems: 'center', + flexShrink: 0 // IMPORTANT: do not remove this + }} + > + + + +
+ { + collapse(node.path!, !collapsed) + }} + /> +
+ + + {!collapsed && node.children.length > 0 ? ( +
+ {node.children.map((item: AnyNodeInternal, index: number) => + renderNode({ + node: item, + parentNode: node, + level: myLevel, + parentNodeType: 'parallel', + relativeIndex: index, + isFirst: index === 0, + isLast: index === node.children.length - 1 + }) + )} +
+ ) : undefined} +
+
+ ) +} diff --git a/packages/pipeline-graph/src/components/nodes/port.tsx b/packages/pipeline-graph/src/components/nodes/port.tsx new file mode 100644 index 000000000..53bbf222b --- /dev/null +++ b/packages/pipeline-graph/src/components/nodes/port.tsx @@ -0,0 +1,20 @@ +export default function Port(props: { side: 'left' | 'right'; id?: string; adjustment?: number }) { + const { adjustment = 0 } = props + + // TODO: port style + return ( +
+ ) +} diff --git a/packages/pipeline-graph/src/components/nodes/serial-container.tsx b/packages/pipeline-graph/src/components/nodes/serial-container.tsx new file mode 100644 index 000000000..3480a064b --- /dev/null +++ b/packages/pipeline-graph/src/components/nodes/serial-container.tsx @@ -0,0 +1,96 @@ +import { useMemo, useRef } from 'react' + +import { useGraphContext } from '../../context/graph-provider' +import { renderNode } from '../../render/render-node' +import { RenderNodeContent } from '../../render/render-node-content' +import { ContainerNodeProps } from '../../types/container-node' +import { AnyNodeInternal, SerialNodeInternalType } from '../../types/nodes-internal' +import { findAdjustment } from '../../utils/layout-utils' +import CollapseButton from '../components/collapse' +import Port from './port' + +export const SERIAL_GROUP_ADJUSTMENT = 10 +export const PADDING_TOP = 30 +export const PADDING_BOTTOM = 20 +export const SERIAL_PADDING = 26 +export const SERIAL_NODE_GAP = 36 + +export default function SerialNodeContainer(props: ContainerNodeProps) { + const { node, level, parentNode } = props + + const myLevel = level + 1 + + const { isCollapsed, collapse } = useGraphContext() + + const collapsed = useMemo(() => isCollapsed(node.path!), [isCollapsed, node.path]) + + const ADJUSTMENT = findAdjustment(node, parentNode) + SERIAL_GROUP_ADJUSTMENT + + return ( +
1 ? 0 : -ADJUSTMENT + 'px', + flexShrink: 0 + }} + > + + + +
+ { + collapse(node.path!, !collapsed) + }} + /> +
+ + + {!collapsed && node.children.length > 0 ? ( +
+ {node.children.map((item: AnyNodeInternal, index: number) => + renderNode({ + node: item, + parentNode: node, + level: myLevel, + parentNodeType: 'serial', + relativeIndex: index, + isFirst: index === 0, + isLast: index === node.children.length - 1 + }) + )} +
+ ) : undefined} +
+
+ ) +} diff --git a/packages/pipeline-graph/src/context/canvas-provider.tsx b/packages/pipeline-graph/src/context/canvas-provider.tsx new file mode 100644 index 000000000..495dac86d --- /dev/null +++ b/packages/pipeline-graph/src/context/canvas-provider.tsx @@ -0,0 +1,153 @@ +import { createContext, useCallback, useContext, useRef } from 'react' + +import { calculateTransform } from '../components/canvas/canvas-utils' + +interface CanvasConfig { + minScale: number + maxScale: number + scaleFactor: number + paddingForFit: number +} + +interface CanvasTransform { + scale: number + translateX: number + translateY: number +} + +interface CanvasContextProps { + canvasTransformRef: React.MutableRefObject + setTargetEl: (el: HTMLDivElement) => void + setCanvasTransform: (canvasTransform: CanvasTransform) => void + fit: () => void + increase: () => void + decrease: () => void + config: CanvasConfig +} + +const CanvasContext = createContext({ + canvasTransformRef: { current: { scale: 1, translateX: 0, translateY: 0 } }, + setTargetEl: (el: HTMLElement) => undefined, + setCanvasTransform: (_canvasTransform: CanvasTransform) => undefined, + fit: () => undefined, + increase: () => undefined, + decrease: () => undefined, + config: { minScale: 0.1, maxScale: 10, scaleFactor: 0.3, paddingForFit: 30 } +}) + +export interface CanvasProviderProps { + config?: CanvasConfig + children: React.ReactNode +} + +export const CanvasProvider = ({ + children, + config = { minScale: 0.1, maxScale: 10, scaleFactor: 0.3, paddingForFit: 20 } +}: CanvasProviderProps) => { + const canvasTransformRef = useRef({ scale: 1, translateX: 0, translateY: 0 }) + const targetElRef = useRef() + + const setCanvasTransform = useCallback((transform: CanvasTransform) => { + canvasTransformRef.current = transform + targetElRef.current?.style.setProperty('--scale', `${transform.scale}`) + targetElRef.current?.style.setProperty('--x', `${transform.translateX}px`) + targetElRef.current?.style.setProperty('--y', `${transform.translateY}px`) + }, []) + + const setTargetEl = useCallback((targetEl: HTMLElement) => { + targetElRef.current = targetEl + }, []) + + const scaleInc = useCallback((scaleDiff: number) => { + const rootContainerEl = targetElRef?.current + const parentEl = rootContainerEl?.parentElement + + if (!rootContainerEl || !parentEl) return + + let newScale = canvasTransformRef.current.scale + scaleDiff + newScale = Math.max(newScale, config.minScale) + + const rect = parentEl.getBoundingClientRect() + + let originX = rect.width / 2 + let originY = rect.height / 2 + + const currentRect = rootContainerEl.getBoundingClientRect() + originX -= currentRect.left + originY -= currentRect.top + + const newTransform = calculateTransform({ + scaleDiff: newScale / canvasTransformRef.current.scale, + originX: originX, + originY: originY + }) + + setCanvasTransform(newTransform) + }, []) + + const increase = useCallback(() => { + scaleInc(0.2) + }, [scaleInc]) + + const decrease = useCallback(() => { + scaleInc(-0.2) + }, [scaleInc]) + + const fit = useCallback(() => { + const rootContainerEl = targetElRef?.current + const parentEl = rootContainerEl?.parentElement + const nodesContainerEl = rootContainerEl?.getElementsByClassName( + 'PipelineGraph-NodesContainer' + )[0] as HTMLDivElement + const { width: parentWidth, height: parentHeight } = parentEl?.getBoundingClientRect() ?? new DOMRect() + const { width: graphWidth, height: graphHeight } = nodesContainerEl?.getBoundingClientRect() ?? new DOMRect() + + let scaleH = ((parentHeight - config.paddingForFit * 2) / graphHeight) * canvasTransformRef.current.scale + let scaleW = ((parentWidth - config.paddingForFit * 2) / graphWidth) * canvasTransformRef.current.scale + + scaleH = Math.max(scaleH, config.minScale) + scaleW = Math.max(scaleW, config.minScale) + + const translate = { + scale: 1, + translateX: config.paddingForFit, + translateY: config.paddingForFit + } + + if (scaleW < scaleH) { + translate.translateY = + config.paddingForFit + ((scaleH - scaleW) * graphHeight) / canvasTransformRef.current.scale / 2 + translate.scale = scaleW + } else { + translate.translateX = + config.paddingForFit + ((scaleW - scaleH) * graphWidth) / canvasTransformRef.current.scale / 2 + translate.scale = scaleH + } + + setCanvasTransform({ + scale: translate.scale, + translateX: translate.translateX, + translateY: translate.translateY + }) + }, []) + + return ( + + {children} + + ) +} + +export const useCanvasContext = () => { + return useContext(CanvasContext) +} diff --git a/packages/pipeline-graph/src/context/graph-provider.tsx b/packages/pipeline-graph/src/context/graph-provider.tsx new file mode 100644 index 000000000..1c0c51b0e --- /dev/null +++ b/packages/pipeline-graph/src/context/graph-provider.tsx @@ -0,0 +1,133 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' + +import { forOwn } from 'lodash-es' + +import { NodeContent } from '../types/node-content' + +interface GraphContextProps { + initialized: boolean + setInitialized: () => void + nodes: Record + collapse: (path: string, state: boolean) => void + isCollapsed: (path: string) => boolean + setNodeToRemove: (path: string | null) => void + nodeToRemove: string | null + // rerender connections + rerender: () => void + // rerenderConnections increments when in the rerender() + rerenderConnections: number + // shift collapsed on node deletion + shiftCollapsed: (path: string, index: number) => void +} + +const GraphContext = createContext({ + initialized: false, + setInitialized: () => undefined, + nodes: {}, + collapse: (_path: string, _state: boolean) => undefined, + isCollapsed: (_path: string) => false, + rerenderConnections: 0, + setNodeToRemove: (_path: string | null) => undefined, + nodeToRemove: null, + shiftCollapsed: (_path: string, _index: number) => undefined, + rerender: () => undefined +}) + +const GraphProvider = ({ nodes: nodesArr, children }: React.PropsWithChildren<{ nodes: NodeContent[] }>) => { + const [initialized, setInitialized] = useState(false) + + const [collapsed, setCollapsed] = useState>({}) + const [rerenderConnections, setRerenderConnections] = useState(1) + const [nodeToRemove, setNodeToRemove] = useState(null) + + const collapsedRef = useRef(collapsed) + collapsedRef.current = collapsed + + // shift collapsed for 1 when node is deleted + const shiftCollapsed = (path: string, index: number) => { + const newpath = path + '.' + index + const oldpath = path + '.' + (index + 1) + + const newCollapsed: Record = {} + + forOwn(collapsedRef.current, (value, key) => { + let peaces = key.split(path + '.') + + peaces = peaces[1].split('.') + + const collapsedIndex = parseInt(peaces[0]) + + if (collapsedIndex > index) { + const newCollapsedIndex = collapsedIndex - 1 + if (key === path + '.' + collapsedIndex) { + const newKey = key.replace(path + '.' + collapsedIndex, path + '.' + newCollapsedIndex) + newCollapsed[newKey] = value + } else { + const newKey = key.replace(path + '.' + collapsedIndex + '.', path + '.' + newCollapsedIndex + '.') + newCollapsed[newKey] = value + } + } else { + newCollapsed[key] = value + } + }) + + setCollapsed(newCollapsed) + } + + const collapse = useCallback( + (path: string, state: boolean) => { + setCollapsed({ ...collapsed, [path]: state }) + setRerenderConnections(rerenderConnections + 1) + }, + [collapsed, setCollapsed, rerenderConnections, setRerenderConnections] + ) + + const rerender = useCallback(() => { + setRerenderConnections(prev => prev + 1) + }, [setRerenderConnections]) + + const isCollapsed = useCallback( + (path: string) => { + return !!collapsed[path] + }, + [collapsed] + ) + + const nodes = useMemo(() => { + return nodesArr.reduce( + (acc, curr) => { + acc[curr.type] = curr + return acc + }, + {} as Record + ) + }, [nodesArr]) + + return ( + setInitialized(true), + nodes, + collapse, + isCollapsed, + setNodeToRemove, + nodeToRemove, + // force rerender + rerenderConnections, + // shift collapsed on node deletion + shiftCollapsed, + // rerender connections + rerender + }} + > + {children} + + ) +} + +export default GraphProvider + +export const useGraphContext = () => { + return useContext(GraphContext) +} diff --git a/packages/pipeline-graph/src/index.ts b/packages/pipeline-graph/src/index.ts new file mode 100644 index 000000000..f2ede42e3 --- /dev/null +++ b/packages/pipeline-graph/src/index.ts @@ -0,0 +1,6 @@ +export * from './pipeline-graph' +export * from './types/nodes' +export * from './types/nodes-internal' + +export * from './types/node-content' +export * from './context/canvas-provider' diff --git a/packages/pipeline-graph/src/pipeline-graph-internal.tsx b/packages/pipeline-graph/src/pipeline-graph-internal.tsx new file mode 100644 index 000000000..40244d9a1 --- /dev/null +++ b/packages/pipeline-graph/src/pipeline-graph-internal.tsx @@ -0,0 +1,182 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' + +import { SERIAL_NODE_GAP } from './components/nodes/serial-container' +import { useCanvasContext } from './context/canvas-provider' +import { useGraphContext } from './context/graph-provider' +import { renderNode } from './render/render-node' +import { clear, getPortsConnectionPath } from './render/render-svg-lines' +import { AnyContainerNodeType } from './types/nodes' +import { AnyNodeInternal } from './types/nodes-internal' +import { connectPorts } from './utils/connects-utils' +import { addPaths } from './utils/path-utils' + +export interface PipelineGraphInternalProps { + data: AnyContainerNodeType[] +} + +export function PipelineGraphInternal(props: PipelineGraphInternalProps) { + const { initialized, nodes: nodesBank, rerenderConnections, setInitialized } = useGraphContext() + const { setCanvasTransform, canvasTransformRef, config: canvasConfig, setTargetEl } = useCanvasContext() + + const { data } = props + const graphSizeRef = useRef<{ h: number; w: number } | undefined>() + + const svgGroupRef = useRef(null) + + const rootContainerRef = useRef(null) + const nodesContainerRef = useRef(null) + const svgRef = useRef(null) + + // set width and height after initialization + // NOTE: this is required to keep "Start" node at the same vertical position when collapsing or deleting other nodes + const [rootWH, setRootWH] = useState<{ w?: number; h?: number }>({}) + + const [dataInternal, setDataInternal] = useState(addPaths(data, nodesBank, 'pipeline', true)) + const dataInternalRef = useRef(addPaths(data, nodesBank, 'pipeline', true)) + + useEffect(() => { + const newData = addPaths(data, nodesBank, 'pipeline', true) + + setDataInternal(newData) + dataInternalRef.current = newData + }, [data]) + + useLayoutEffect(() => { + if ( + dataInternal && + rootContainerRef.current && + nodesContainerRef.current && + svgRef.current && + svgGroupRef.current + ) { + const rootContainerEl = rootContainerRef.current + const nodesContainerEl = nodesContainerRef.current + const svgEl = svgRef.current + + clear(svgGroupRef.current) + + // create connections + const connections = connectPorts(dataInternal, { left: 'start', right: 'end' }, false) + + // NOTE: required to get ports coordinates from DOM + rootContainerEl.style.transform = 'scale(1)' + + // draw lines + if (svgGroupRef.current) { + let allPaths: string[] = [] + connections.map(portPair => { + const path = getPortsConnectionPath(rootContainerEl, portPair) + allPaths.push(path) + }) + svgGroupRef.current.innerHTML = allPaths.join('') + } + + // reset transform + rootContainerEl.style.transform = '' + + // get nodes container size (to apply size to child containers) + const { width: graphWidth, height: graphHeight } = nodesContainerEl.getBoundingClientRect() + + if (graphHeight > 0) { + setInitialized() + + if (!initialized) { + setRootWH({ w: graphWidth, h: graphHeight }) + + svgEl.setAttribute('width', graphWidth.toString()) + svgEl.setAttribute('height', graphHeight.toString()) + + rootContainerEl.style.width = graphWidth + 'px' + rootContainerEl.style.height = graphHeight + 'px' + + graphSizeRef.current = { + w: graphWidth, + h: graphHeight + } + + // set initial position + const parentEl = rootContainerEl.parentElement + const { height: parentHeight } = parentEl?.getBoundingClientRect() ?? new DOMRect() + + setCanvasTransform({ + scale: 1, + translateX: canvasConfig.paddingForFit, + translateY: parentHeight / 2 - graphHeight / 2 + }) + } else { + if (graphSizeRef.current) { + const { width: graphWidthPx, height: graphHeightPx } = getComputedStyle(nodesContainerRef.current) + + svgEl.setAttribute('width', graphWidthPx) + svgEl.setAttribute('height', graphHeightPx) + + rootContainerEl.style.width = graphWidthPx + rootContainerEl.style.height = graphHeightPx + + const graphWidth = parseInt(graphWidthPx) + const graphHeight = parseInt(graphHeightPx) + + // kep "start node" in place (horizontally) - e.g when delete/add nodes + if (graphHeight !== graphSizeRef.current.h) { + const diffH = (graphSizeRef.current.h - graphHeight) / 2 + + setCanvasTransform({ + scale: canvasTransformRef.current.scale, + translateX: canvasTransformRef.current.translateX, + translateY: canvasTransformRef.current.translateY + diffH * canvasTransformRef.current.scale + }) + + graphSizeRef.current = { h: graphHeight, w: graphWidth } + } + } + } + } + } + }, [dataInternal, rerenderConnections, initialized, graphSizeRef]) + + useEffect(() => { + if (rootContainerRef.current) setTargetEl(rootContainerRef.current) + }, [rootContainerRef]) + + return ( +
+
+ + + +
+
+ {dataInternalRef.current?.map((node, index) => + renderNode({ + node, + level: 0, + parentNodeType: 'serial', + relativeIndex: index, + isFirst: index === 0, + isLast: index === dataInternalRef.current?.length - 1 + }) + )} +
+
+ ) +} + +export default PipelineGraphInternal diff --git a/packages/pipeline-graph/src/pipeline-graph.css b/packages/pipeline-graph/src/pipeline-graph.css new file mode 100644 index 000000000..951a4176f --- /dev/null +++ b/packages/pipeline-graph/src/pipeline-graph.css @@ -0,0 +1,18 @@ +.PipelineGraph-RootContainer { + transform-origin: left top; +} + +.PipelineGraph-NodesContainer { + position: absolute; + width: max-content; /* IMPORTANT: do not delete this */ +} + +.PipelineGraph-SvgContainer { + top: 0px; + position: absolute; + pointer-events: none; +} + +.PipelineGraph-Canvas { + width: 100%; +} diff --git a/packages/pipeline-graph/src/pipeline-graph.tsx b/packages/pipeline-graph/src/pipeline-graph.tsx new file mode 100644 index 000000000..c23563bf0 --- /dev/null +++ b/packages/pipeline-graph/src/pipeline-graph.tsx @@ -0,0 +1,22 @@ +import { Canvas } from './components/canvas/canvas' +import GraphProvider from './context/graph-provider' +import PipelineGraphInternal, { PipelineGraphInternalProps } from './pipeline-graph-internal' +import { NodeContent } from './types/node-content' + +import './pipeline-graph.css' + +export interface PipelineGraphProps extends PipelineGraphInternalProps { + nodes: NodeContent[] +} + +export function PipelineGraph(props: PipelineGraphProps) { + const { data, nodes } = props + + return ( + + + + + + ) +} diff --git a/packages/pipeline-graph/src/render/render-node-content.tsx b/packages/pipeline-graph/src/render/render-node-content.tsx new file mode 100644 index 000000000..59e134ef4 --- /dev/null +++ b/packages/pipeline-graph/src/render/render-node-content.tsx @@ -0,0 +1,40 @@ +import { useGraphContext } from '../context/graph-provider' +import { ContainerNode } from '../types/nodes' +import { + AnyNodeInternal, + LeafNodeInternalType, + ParallelNodeInternalType, + SerialNodeInternalType +} from '../types/nodes-internal' + +export function RenderNodeContent(props: { + node: AnyNodeInternal + children?: React.ReactElement + collapsed?: boolean +}) { + const { node, children, collapsed } = props + const { nodes } = useGraphContext() + + const nodeContent = nodes[node.type] + + switch (nodeContent.containerType) { + case ContainerNode.leaf: + return ( + } collapsed={collapsed}> + {children} + + ) + case ContainerNode.serial: + return ( + } collapsed={collapsed}> + {children} + + ) + case ContainerNode.parallel: + return ( + } collapsed={collapsed}> + {children} + + ) + } +} diff --git a/packages/pipeline-graph/src/render/render-node.tsx b/packages/pipeline-graph/src/render/render-node.tsx new file mode 100644 index 000000000..d34d3766a --- /dev/null +++ b/packages/pipeline-graph/src/render/render-node.tsx @@ -0,0 +1,20 @@ +import LeafNodeContainer from '../components/nodes/leaf-container' +import ParallelNodeContainer from '../components/nodes/parallel-container' +import SerialNodeContainer from '../components/nodes/serial-container' +import { ContainerNodeProps } from '../types/container-node' + +export function renderNode(props: ContainerNodeProps) { + const { node, parentNode, ...rest } = props + + switch (node.containerType) { + case 'serial': { + return + } + case 'parallel': { + return + } + case 'leaf': { + return + } + } +} diff --git a/packages/pipeline-graph/src/render/render-svg-lines.ts b/packages/pipeline-graph/src/render/render-svg-lines.ts new file mode 100644 index 000000000..d190ab54d --- /dev/null +++ b/packages/pipeline-graph/src/render/render-svg-lines.ts @@ -0,0 +1,141 @@ +const RADIUS = 7 +const PARALLEL_LINE_OFFSET = 15 +const SERIAL_LINE_OFFSET = 10 + +export function clear(svgGroup: SVGElement) { + svgGroup.innerHTML = '' +} + +export function getPortsConnectionPath( + pipelineGraphRoot: HTMLDivElement, + connection: { + source: string + target: string + parallel?: { + position: 'left' | 'right' + } + serial?: { + position: 'left' | 'right' + } + } +) { + const { source, target, parallel, serial } = connection + + const fromEl = document.getElementById(source) + const toEl = document.getElementById(target) + + if (!fromEl || !toEl) return '' + + const fromElBB = fromEl.getBoundingClientRect() + const toElBB = toEl.getBoundingClientRect() + + const pipelineGraphRootBB = pipelineGraphRoot?.getBoundingClientRect() ?? new DOMRect(0, 0) + + const pathHtml = getPath( + `${source}-${target}`, + fromElBB.left - pipelineGraphRootBB.left, + fromElBB.top - pipelineGraphRootBB.top, + toElBB.left - pipelineGraphRootBB.left, + toElBB.top - pipelineGraphRootBB.top, + parallel, + serial + ) + + return pathHtml +} + +function getHArcConfig(direction: 'down' | 'up') { + if (direction === 'down') { + return { + arc: `a${RADIUS},${RADIUS} 0 0 1 ${RADIUS},${RADIUS}`, + hCorrection: 7, + vCorrection: 7 + } + } else { + return { + arc: `a${RADIUS},-${RADIUS} 0 0 0 ${RADIUS},-${RADIUS}`, + hCorrection: 7, + vCorrection: -7 + } + } +} + +function getVArcConfig(direction: 'down' | 'up') { + if (direction === 'down') { + return { + arc: `a${RADIUS},${RADIUS} 0 0 0 ${RADIUS},${RADIUS}`, + hCorrection: 7, + vCorrection: 7 + } + } else { + return { + arc: `a${RADIUS},-${RADIUS} 0 0 1 ${RADIUS},-${RADIUS}`, + hCorrection: 7, + vCorrection: -7 + } + } +} + +function getPath( + id: string, + startX: number, + startY: number, + endX: number, + endY: number, + parallel?: { + position: 'left' | 'right' + }, + serial?: { + position: 'left' | 'right' + } +) { + const correction = 3 + + let path = '' + + if (startY === endY) { + path = 'M ' + (startX + correction) + ' ' + (startY + correction) + ' ' + 'H ' + (endX + correction) + } else { + const diff = endX - startX + + let hMiddle = startX + diff / 2 + if (parallel?.position === 'right') { + hMiddle = startX + diff - PARALLEL_LINE_OFFSET * 2 - RADIUS * 2 + } + if (parallel?.position === 'left') { + hMiddle = startX + PARALLEL_LINE_OFFSET + } + if (serial?.position === 'right') { + hMiddle = startX + diff - SERIAL_LINE_OFFSET - RADIUS * 2 + } + if (serial?.position === 'left') { + hMiddle = startX + SERIAL_LINE_OFFSET - RADIUS * 2 + } + + const { arc, hCorrection, vCorrection } = getHArcConfig(endY + correction > startY + correction ? 'down' : 'up') + + const { + arc: arc2, + hCorrection: hCorrection2, + vCorrection: vCorrection2 + } = getVArcConfig(endY + correction > startY + correction ? 'down' : 'up') + + path = + 'M ' + + (startX + correction) + + ' ' + + (startY + correction) + + ' ' + + 'H ' + + (hMiddle + correction + hCorrection) + //- 6 + arc + + 'V ' + + (endY + correction - vCorrection2) + //- 6 + arc2 + + 'H ' + + (endX + correction) + } + + // TODO: line style (color) + return `` +} diff --git a/packages/pipeline-graph/src/types/container-node.ts b/packages/pipeline-graph/src/types/container-node.ts new file mode 100644 index 000000000..f43dd70f2 --- /dev/null +++ b/packages/pipeline-graph/src/types/container-node.ts @@ -0,0 +1,19 @@ +import { AnyNodeInternal, ParallelNodeInternalType, SerialNodeInternalType } from './nodes-internal' + +export type ContainerNodeType = 'leaf' | 'serial' | 'parallel' + +export interface ContainerNodeProps { + /* node itself :) */ + node: CONTAINER_NODE + parentNode?: ParallelNodeInternalType | SerialNodeInternalType + /* what type is a parent node */ + parentNodeType: ContainerNodeType + /* nesting level from root */ + level: number + /* position in array relative to parent */ + relativeIndex: number + /* is first node in array relative to parent */ + isFirst: boolean + /* is last node in array relative to parent */ + isLast: boolean +} diff --git a/packages/pipeline-graph/src/types/node-content.ts b/packages/pipeline-graph/src/types/node-content.ts new file mode 100644 index 000000000..9bed49521 --- /dev/null +++ b/packages/pipeline-graph/src/types/node-content.ts @@ -0,0 +1,34 @@ +import { ContainerNode } from './nodes' +import { LeafNodeInternalType, ParallelNodeInternalType, SerialNodeInternalType } from './nodes-internal' + +export interface LeafNodeContent { + containerType: ContainerNode.leaf + type: string + component: (props: { + node: LeafNodeInternalType + collapsed?: boolean + children?: React.ReactElement + }) => JSX.Element +} + +export interface SerialNodeContent { + containerType: ContainerNode.serial + type: string + component: (props: { + node: SerialNodeInternalType + collapsed?: boolean + children?: React.ReactElement + }) => JSX.Element +} + +export interface ParallelNodeContent { + containerType: ContainerNode.parallel + type: string + component: (props: { + node: ParallelNodeInternalType + collapsed?: boolean + children?: React.ReactElement + }) => JSX.Element +} + +export type NodeContent = LeafNodeContent | SerialNodeContent | ParallelNodeContent diff --git a/packages/pipeline-graph/src/types/nodes-internal.ts b/packages/pipeline-graph/src/types/nodes-internal.ts new file mode 100644 index 000000000..6d7f7c543 --- /dev/null +++ b/packages/pipeline-graph/src/types/nodes-internal.ts @@ -0,0 +1,23 @@ +import { ContainerNode, LeafContainerNodeType, ParallelContainerNodeType, SerialContainerNodeType } from './nodes' + +interface NodeInternal { + path: string +} + +export interface LeafNodeInternalType extends LeafContainerNodeType, NodeInternal { + containerType: ContainerNode.leaf +} + +export interface ParallelNodeInternalType + extends Omit, 'children'>, + NodeInternal { + containerType: ContainerNode.parallel + children: AnyNodeInternal[] +} + +export interface SerialNodeInternalType extends Omit, 'children'>, NodeInternal { + containerType: ContainerNode.serial + children: AnyNodeInternal[] +} + +export type AnyNodeInternal = LeafNodeInternalType | ParallelNodeInternalType | SerialNodeInternalType diff --git a/packages/pipeline-graph/src/types/nodes.ts b/packages/pipeline-graph/src/types/nodes.ts new file mode 100644 index 000000000..30320fd97 --- /dev/null +++ b/packages/pipeline-graph/src/types/nodes.ts @@ -0,0 +1,42 @@ +export enum ContainerNode { + leaf = 'leaf', + parallel = 'parallel', + serial = 'serial' +} + +export interface ContainerNodeConfig { + width?: number + maxWidth?: number + minWidth?: number + height?: number + maxHeight?: number + minHeight?: number + hideLeftPort?: boolean + hideRightPort?: boolean + hideDeleteButton?: boolean + hideBeforeAdd?: boolean + hideAfterAdd?: boolean + selectable?: boolean +} +export interface ContainerNodeCommonType { + data: T + config?: ContainerNodeConfig +} + +export interface LeafContainerNodeType extends ContainerNodeCommonType { + type: string +} + +export interface ParallelContainerNodeType extends ContainerNodeCommonType { + type: string + children: AnyContainerNodeType[] + config?: ContainerNodeCommonType['config'] & { hideCollapseButton?: boolean } +} + +export interface SerialContainerNodeType extends ContainerNodeCommonType { + type: string + children: AnyContainerNodeType[] + config?: ContainerNodeCommonType['config'] & { hideCollapseButton?: boolean } +} + +export type AnyContainerNodeType = LeafContainerNodeType | ParallelContainerNodeType | SerialContainerNodeType diff --git a/packages/pipeline-graph/src/utils/connects-utils.ts b/packages/pipeline-graph/src/utils/connects-utils.ts new file mode 100644 index 000000000..b11805d2e --- /dev/null +++ b/packages/pipeline-graph/src/utils/connects-utils.ts @@ -0,0 +1,72 @@ +import { AnyNodeInternal } from '../types/nodes-internal' + +export function connectPorts( + nodes: AnyNodeInternal[], + parent: { left: string; right: string }, + isParallel: boolean = false +) { + const connections: { + source: string + target: string + parallel?: { position: 'left' | 'right' } + serial?: { + position: 'left' | 'right' + } + }[] = [] + + let prevNode: AnyNodeInternal | null = null + nodes.map((node, idx) => { + const nodeLeftPort = `left-port-${node.path}` + const nodeRightPort = `right-port-${node.path}` + + if (!isParallel) { + // first + if (idx === 0) { + connections.push({ + source: parent.left, + target: `left-port-${node.path}`, + serial: { position: 'left' } + }) + } + // between + if (prevNode) { + connections.push({ + source: `right-port-${prevNode.path}`, + target: nodeLeftPort + }) + } + // last + if (idx === nodes.length - 1) { + connections.push({ + source: `right-port-${node.path}`, + target: parent.right, + serial: { position: 'right' } + }) + } + } else if (isParallel) { + connections.push({ + source: parent.left, + target: nodeLeftPort, + parallel: { position: 'left' } + }) + + connections.push({ + source: nodeRightPort, + target: parent.right, + parallel: { position: 'right' } + }) + } + + if (node.containerType === 'serial' || node.containerType === 'parallel') { + const childrenConnections = connectPorts( + node.children, + { left: nodeLeftPort, right: nodeRightPort }, + node.containerType === 'parallel' + ) + connections.push(...childrenConnections) + } + prevNode = node + }) + + return connections +} diff --git a/packages/pipeline-graph/src/utils/layout-utils.ts b/packages/pipeline-graph/src/utils/layout-utils.ts new file mode 100644 index 000000000..06d352bee --- /dev/null +++ b/packages/pipeline-graph/src/utils/layout-utils.ts @@ -0,0 +1,41 @@ +import { PARALLEL_GROUP_ADJUSTMENT } from '../components/nodes/parallel-container' +import { SERIAL_GROUP_ADJUSTMENT } from '../components/nodes/serial-container' +import { ContainerNode } from '../types/nodes' +import { AnyNodeInternal, ParallelNodeInternalType, SerialNodeInternalType } from '../types/nodes-internal' + +export function getThreeDepth(node: AnyNodeInternal): number { + if (node.containerType === ContainerNode.leaf) { + return 1 + } else { + let maxRet = 1 + node.children.forEach(item => { + maxRet = Math.max(getThreeDepth(item), maxRet) + }) + + return maxRet + 1 + } +} + +export function findAdjustment( + node: AnyNodeInternal, + parent?: SerialNodeInternalType | ParallelNodeInternalType, + adjustment = 0 +): number { + if ('children' in node && node.children[0]) { + if (node.children[0].containerType === ContainerNode.serial) { + return findAdjustment(node.children[0], node, adjustment + SERIAL_GROUP_ADJUSTMENT) + } else if (node.children[0].containerType === ContainerNode.parallel) { + return findAdjustment(node.children[0], node, adjustment + PARALLEL_GROUP_ADJUSTMENT) + } + } + + return adjustment +} + +export function hasNodeGroup(node: SerialNodeInternalType | ParallelNodeInternalType): boolean { + return node.children.some(item => 'children' in item) +} + +export function hasNodeGroupAndDontHaveLeaf(node: SerialNodeInternalType | ParallelNodeInternalType): boolean { + return node.children.some(item => 'children' in item) +} diff --git a/packages/pipeline-graph/src/utils/path-utils.ts b/packages/pipeline-graph/src/utils/path-utils.ts new file mode 100644 index 000000000..7ba20c2da --- /dev/null +++ b/packages/pipeline-graph/src/utils/path-utils.ts @@ -0,0 +1,41 @@ +import { NodeContent } from '../types/node-content' +import { AnyContainerNodeType } from '../types/nodes' +import { AnyNodeInternal } from '../types/nodes-internal' + +/** addPaths function mutates 'nodes' */ +export function addPaths( + nodes: AnyContainerNodeType[], + nodesBank: Record, + parentPath: string, + addUid: boolean +): AnyNodeInternal[] { + const nodesInternal = nodes as AnyNodeInternal[] + + nodesInternal.map((node, idx) => { + const currPath = `${parentPath}.children.${idx}` + + // set path and containerType + node.path = currPath + node.containerType = nodesBank[node.type].containerType + + if ('children' in node) { + addPaths(node.children, nodesBank, currPath, addUid) + } + }) + + return nodesInternal +} + +/** split path of item to 1. path to array and 2. element index */ +export function getPathPeaces(path: string) { + const peaces = path.split('.') + + if (peaces.length === 1) { + return { index: parseInt(path) } + } + + const index = parseInt(peaces.pop() as string) + const arrayPath = peaces.join('.') + + return { arrayPath, index } +} diff --git a/packages/pipeline-graph/tsconfig.json b/packages/pipeline-graph/tsconfig.json new file mode 100644 index 000000000..c0c1d03d6 --- /dev/null +++ b/packages/pipeline-graph/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "outDir": "dist2", + "types": ["node"] + }, + "include": ["./src/**/*"] +} diff --git a/packages/pipeline-graph/vite.config.dev.ts b/packages/pipeline-graph/vite.config.dev.ts new file mode 100644 index 000000000..06d0447aa --- /dev/null +++ b/packages/pipeline-graph/vite.config.dev.ts @@ -0,0 +1,17 @@ +import react from '@vitejs/plugin-react-swc' +import { defineConfig } from 'vite' +import svgr from 'vite-plugin-svgr' + +export default defineConfig({ + server: { + port: 3003 + }, + plugins: [ + react(), + svgr({ + svgrOptions: { exportType: 'default', ref: true, svgo: false, titleProp: true }, + include: '**/*.svg' + }) + ], + root: 'examples' +}) diff --git a/packages/pipeline-graph/vite.config.ts b/packages/pipeline-graph/vite.config.ts new file mode 100644 index 000000000..398091d6e --- /dev/null +++ b/packages/pipeline-graph/vite.config.ts @@ -0,0 +1,33 @@ +import { resolve } from 'path' + +import react from '@vitejs/plugin-react-swc' +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +const pkg = require('./package.json') + +const external = Object.keys(pkg.devDependencies || []) + .concat(Object.keys(pkg.peerDependencies || [])) + .concat(['react/jsx-runtime']) + +export default defineConfig({ + define: { 'process.env.NODE_ENV': '"production"' }, + plugins: [ + dts({ + outDir: 'dist', + tsconfigPath: './tsconfig.json', + rollupTypes: true + }) + ], + build: { + sourcemap: true, + copyPublicDir: false, + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'pipeline-graph', + fileName: 'index', + formats: ['es'] + }, + rollupOptions: { external } + } +}) diff --git a/packages/ui/src/views/pipelines/execution-list/execution-list-page.tsx b/packages/ui/src/views/pipelines/execution-list/execution-list-page.tsx index d42c4c805..753c72d61 100644 --- a/packages/ui/src/views/pipelines/execution-list/execution-list-page.tsx +++ b/packages/ui/src/views/pipelines/execution-list/execution-list-page.tsx @@ -76,7 +76,7 @@ const ExecutionListPage: FC = ({ Run
diff --git a/packages/views/src/components/pipeline-studio/pipeline-studio-footer-bar/pipeline-studio-footer-bar.tsx b/packages/views/src/components/pipeline-studio/pipeline-studio-footer-bar/pipeline-studio-footer-bar.tsx index d9b6a4790..c29cab694 100644 --- a/packages/views/src/components/pipeline-studio/pipeline-studio-footer-bar/pipeline-studio-footer-bar.tsx +++ b/packages/views/src/components/pipeline-studio/pipeline-studio-footer-bar/pipeline-studio-footer-bar.tsx @@ -79,7 +79,7 @@ const PipelineStudioFooterBar: React.FC = (props:
*/} -
+
Branch: