diff --git a/.changeset/node-container-styling.md b/.changeset/node-container-styling.md new file mode 100644 index 0000000..92e35b8 --- /dev/null +++ b/.changeset/node-container-styling.md @@ -0,0 +1,5 @@ +--- +"@serverlessworkflow/diagram-editor": minor +--- + +Update node container styling and other small styling fixes diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css index 242c2cd..a6f11c2 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css @@ -117,132 +117,184 @@ dec:border-[#4324dc] dec:shadow-[0_0_10px_rgba(255,255,255,0.3)]; } -} -/* custom edges */ -/* React flow SVG components do not work with classes defined into layers */ -.dec-root .edge-line { - @apply dec:stroke-[#aea6a6] - dec:stroke-2; -} + /* Hover + selection are shared by every interactive node (leaf + container) */ + .dec-root .dec-leaf-node:hover, + .dec-root .dec-container-node:hover { + @apply dec:shadow-[0_0_10px_rgba(0,65,208,0.3)]; + } -.dec-root .edge-line.error { - @apply dec:stroke-red-500 - dec:[stroke-dasharray:5_5]; -} + .dec-root.dark .dec-leaf-node:hover, + .dec-root.dark .dec-container-node:hover { + @apply dec:shadow-[0_0_10px_rgba(255,255,255,0.3)]; + } -.dec-root .edge-line.condition { - @apply dec:stroke-blue-500; -} + .dec-root .dec-leaf-node.selected, + .dec-root .dec-container-node.selected { + box-shadow: + 0 0 0 2px rgb(59 130 246), + 0 0 12px 2px rgba(59, 130, 246, 0.45); + } -/* custom edge labels */ -@layer custom-edge-labels { - .dec-root .edge-label { - @apply dec:bg-white - dec:border dec:border-[#ccc] - dec:rounded-[3px] - dec:py-0.5 dec:px-1.5 - dec:absolute - dec:text-[10px] - dec:pointer-events-auto; + .dec-root.dark .dec-leaf-node.selected, + .dec-root.dark .dec-container-node.selected { + box-shadow: + 0 0 0 2px rgb(96 165 250), + 0 0 16px 4px rgba(96, 165, 250, 0.5); } - .dec-root.dark .edge-label { - @apply dec:bg-gray-800 - dec:border-gray-600 - dec:text-gray-200; + /* container nodes (do, for, fork, try, try-catch, catch-container) */ + .dec-root .dec-container-node { + @apply dec:relative + dec:rounded-lg + dec:h-full + dec:w-full + dec:transition-[border,box-shadow]; + border: 1px solid var(--task-node-color); + background-color: color-mix(in srgb, var(--task-node-color) 3%, white); } - .dec-root .edge-label.error { - @apply dec:bg-red-50 - dec:border-red-500 - dec:text-red-500; + .dec-root.dark .dec-container-node { + background-color: color-mix(in srgb, var(--task-node-color) 6%, #1a202c); } - .dec-root .edge-label.condition { - @apply dec:bg-blue-50 - dec:border-blue-500 - dec:text-blue-500; + .dec-root .dec-container-node-header { + @apply dec:absolute + dec:top-0 + dec:left-0 + dec:right-0 + dec:flex + dec:items-center + dec:gap-3 + dec:px-3 + dec:py-2 + dec:rounded-t-lg + dec:rounded-b-none; + background-color: color-mix(in srgb, var(--task-node-color) 14%, white); + border-bottom: 1px solid var(--task-node-color); } - .dec-root.dark .edge-label.condition { - @apply dec:bg-gray-800 - dec:border-gray-600 - dec:text-gray-200; + .dec-root.dark .dec-container-node-header { + background-color: color-mix(in srgb, var(--task-node-color) 18%, #1f2937); + } + + .dec-root .dec-container-node-icon { + @apply dec:shrink-0; + color: var(--task-node-color); + } + + .dec-root .dec-container-node-label { + @apply dec:flex + dec:flex-col + dec:gap-0.5 + dec:min-w-0; + } + + .dec-root .dec-container-node-name { + @apply dec:truncate + dec:text-sm + dec:text-black + dec:leading-tight; + } + + .dec-root.dark .dec-container-node-name { + @apply dec:text-white; + } + + .dec-root .dec-container-node-type { + @apply dec:text-[9px] + dec:uppercase + dec:leading-tight + dec:text-gray-600; + } + + .dec-root.dark .dec-container-node-type { + @apply dec:text-gray-300; } /* task leaf nodes */ - .dec-root .dec-task-node-container { - @apply dec:rounded-lg + .dec-root .dec-leaf-node { + @apply dec:flex + dec:flex-col + dec:justify-center + dec:rounded-lg dec:bg-white dec:shadow-sm dec:transition-[border,box-shadow] dec:h-full dec:w-full; - border-top: 4px solid var(--task-node-color); + border-top: 3px solid var(--task-node-color); } - .dec-root.dark .dec-task-node-container { + .dec-root.dark .dec-leaf-node { @apply dec:bg-[#2d3748]; } - .dec-root .dec-task-node-container:hover { - @apply dec:shadow-[0_0_10px_rgba(0,65,208,0.3)]; - } - - .dec-root.dark .dec-task-node-container:hover { - @apply dec:shadow-[0_0_10px_rgba(255,255,255,0.3)]; + .dec-root .dec-leaf-node-content { + @apply dec:flex + dec:items-center + dec:gap-3 + dec:px-3 + dec:py-2; } - .dec-root .dec-task-node-container.selected { - @apply dec:shadow-[0_0_10px_rgba(59,130,246,0.8)]; + .dec-root .dec-leaf-node-icon { + @apply dec:shrink-0; + color: var(--task-node-color); } - .dec-root.dark .dec-task-node-container.selected { - @apply dec:bg-[#3d4a5c] - dec:shadow-[0_0_10px_rgba(255,255,255,0.3)]; + .dec-root .dec-leaf-node-label { + @apply dec:flex + dec:flex-1 + dec:flex-col + dec:gap-0.5 + dec:min-w-0; } - .dec-root .dec-task-node-content { - @apply dec:flex - dec:items-center - dec:gap-3 - dec:px-3 - dec:py-2; + /* Name owns the top row; type label + badge share the metadata row below. + Name wraps to 2 lines max, breaking long camelCase identifiers mid-word, then ellipsing. */ + .dec-root .dec-leaf-node-name { + @apply dec:line-clamp-2 + dec:text-sm + dec:text-black + dec:leading-tight; + overflow-wrap: anywhere; } - .dec-root .dec-task-node-icon { - @apply dec:shrink-0; - color: var(--task-node-color); + .dec-root .dec-leaf-node-meta { + @apply dec:flex + dec:items-center + dec:gap-2; } - .dec-root .dec-task-node-label { - @apply dec:flex - dec:flex-col - dec:gap-0.5 - dec:min-w-0; + .dec-root.dark .dec-leaf-node-name { + @apply dec:text-white; } - .dec-root .dec-task-node-name { - @apply dec:truncate /*TODO: for now truncate text revisit when layout is in and working on styling tweaks */ - dec:text-sm - dec:text-black - dec:leading-tight; + .dec-root .dec-leaf-node-type { + @apply dec:text-[9px] + dec:uppercase + dec:text-gray-600 + dec:leading-tight; } - .dec-root.dark .dec-task-node-name { - @apply dec:text-white; + .dec-root.dark .dec-leaf-node-type { + @apply dec:text-gray-300; } - .dec-root .dec-task-node-type { - @apply dec:text-[9px] - dec:uppercase - dec:text-gray-500 - dec:leading-tight; + /* TaskNodeBadge. A known subtype renders as an uppercased pill; a custom value renders + the raw user string (not uppercased, width-capped + truncated, full value + on hover). */ + .dec-root .dec-task-node-badge, + .dec-root .dec-task-node-badge-custom { + color: color-mix(in srgb, var(--task-node-color) 60%, black); + background-color: color-mix(in srgb, var(--task-node-color) 12%, transparent); } - .dec-root.dark .dec-task-node-type { - @apply dec:text-gray-400; + .dec-root.dark .dec-task-node-badge, + .dec-root.dark .dec-task-node-badge-custom { + color: color-mix(in srgb, var(--task-node-color) 18%, white); + background-color: color-mix(in srgb, var(--task-node-color) 40%, transparent); } .dec-root .dec-task-node-badge { @@ -255,17 +307,72 @@ dec:font-semibold dec:uppercase dec:whitespace-nowrap; - color: var(--task-node-color); - border: 1px solid var(--task-node-color); } - .dec-root .dec-task-node-badge-icon { + .dec-root .dec-task-node-badge-custom { @apply dec:ml-auto - dec:shrink-0 - dec:flex - dec:items-center - dec:justify-center; - color: var(--task-node-color); + dec:block + dec:min-w-0 + dec:max-w-[7.5rem] + dec:truncate + dec:rounded + dec:px-2 + dec:py-0.5 + dec:text-[8px] + dec:font-semibold; } /* end task leaf nodes */ } + +/* custom edges */ +/* React flow SVG components do not work with classes defined into layers */ +.dec-root .edge-line { + @apply dec:stroke-[#aea6a6] + dec:stroke-2; +} + +.dec-root .edge-line.error { + @apply dec:stroke-red-500 + dec:[stroke-dasharray:5_5]; +} + +.dec-root .edge-line.condition { + @apply dec:stroke-blue-500; +} + +/* custom edge labels */ +@layer custom-edge-labels { + .dec-root .edge-label { + @apply dec:bg-white + dec:border dec:border-[#ccc] + dec:rounded-[3px] + dec:py-0.5 dec:px-1.5 + dec:absolute + dec:text-[10px] + dec:pointer-events-auto; + } + + .dec-root.dark .edge-label { + @apply dec:bg-gray-800 + dec:border-gray-600 + dec:text-gray-200; + } + + .dec-root .edge-label.error { + @apply dec:bg-red-50 + dec:border-red-500 + dec:text-red-500; + } + + .dec-root .edge-label.condition { + @apply dec:bg-blue-50 + dec:border-blue-500 + dec:text-blue-500; + } + + .dec-root.dark .edge-label.condition { + @apply dec:bg-gray-800 + dec:border-gray-600 + dec:text-gray-200; + } +} diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx index deeeb21..7ff812a 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx @@ -47,7 +47,8 @@ export type DiagramProps = { export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { const reactFlowInstance: RF.ReactFlowInstance = RF.useReactFlow(); - const { model, nodes, edges, isReadOnly, setNodes, setEdges, setSelectedNodeId } = useDiagramEditorContext(); + const { model, nodes, edges, isReadOnly, setNodes, setEdges, setSelectedNodeId } = + useDiagramEditorContext(); const [minimapVisible, setMinimapVisible] = React.useState(false); diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/autoLayout.ts b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/autoLayout.ts index d238fe3..c1f10bc 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/autoLayout.ts +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/autoLayout.ts @@ -20,8 +20,8 @@ import { ReactFlowGraph } from "./diagramBuilder"; // Defaults export const DEFAULT_NODE_SIZE = { - height: 60, - width: 200, + height: 65, + width: 220, }; export type Point = { diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts index 268d7d3..9267c97 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts @@ -16,10 +16,11 @@ import * as RF from "@xyflow/react"; import { buildFlatGraph } from "../../core"; -import { BaseNodeData, CATCH_CONTAINER_NODE_TYPE, ReactFlowNodeTypes } from "../nodes/Nodes"; +import { BaseNodeData, ReactFlowNodeTypes } from "../nodes/Nodes"; import { BaseEdgeData, EdgeTypes } from "../edges/Edges"; import * as sdk from "@serverlessworkflow/sdk"; import { DEFAULT_NODE_SIZE } from "./autoLayout"; +import { CATCH_CONTAINER_NODE_TYPE } from "../nodes/taskNodeConfig"; export type ReactFlowGraph = { nodes: RF.Node[]; diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx b/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx index 31ae80e..66853cb 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx @@ -17,13 +17,15 @@ import type React from "react"; import { GraphNodeType, type Specification } from "@serverlessworkflow/sdk"; import * as RF from "@xyflow/react"; -import { type LeafNodeType, taskNodeConfigMap } from "./taskNodeConfig"; -import { Info } from "lucide-react"; +import { + CATCH_CONTAINER_NODE_TYPE, + type ContainerNodeType, + type LeafNodeType, + leafNodeConfigMap, + containerNodeConfigMap, +} from "./taskNodeConfig"; import { getCallSubType, getListenSubType, getRunSubType } from "../../core"; -export const CATCH_CONTAINER_NODE_TYPE = "catch-container"; -/* Node types are primarily keyed by the sdk GraphNodeType enum, with custom - React Flow-only node types such as catch-container added where needed. */ export const ReactFlowNodeTypes: RF.NodeTypes = { [GraphNodeType.Start]: StartNode, [GraphNodeType.End]: EndNode, @@ -60,6 +62,8 @@ const KNOWN_BADGES = new Set([ "all", "any", "one", + "compete", + "while", ]); export type BaseNodeData = { @@ -86,8 +90,8 @@ function TaskNodeBadge({ badge, testId }: BadgeProps) { if (isUnknown) { /* TODO: instead of using the browser default to display tool tip like below, replace with tooltip component when we add it */ return ( - - + + {badge} ); } @@ -99,21 +103,46 @@ function TaskNodeBadge({ badge, testId }: BadgeProps) { ); } -function TaskNodeContent({ id, data, selected, type, badge }: NodeContentProps) { - const config = taskNodeConfigMap[type as LeafNodeType]; +function LeafNodeContent({ id, data, selected, type, badge }: NodeContentProps) { + const config = leafNodeConfigMap[type as LeafNodeType]; const Icon = config.icon; return (
-
- -
- {data.label} - {config.typeLabel} +
+ +
+ {data.label} +
+ {config.typeLabel} + {badge && } +
+
+
+ +
+ ); +} + +function ContainerNodeContent({ id, data, selected, type, badge }: NodeContentProps) { + const config = containerNodeConfigMap[type as ContainerNodeType]; + const Icon = config.icon; + return ( +
+ +
+ +
+ {data.label} + {config.typeLabel}
{badge && }
@@ -178,34 +207,33 @@ export function ExitNode({ id, data, selected, type }: RF.NodeProps, typeof GraphNodeType.Call>; export function CallNode({ id, data, selected, type }: RF.NodeProps) { const badge = data.task ? getCallSubType(data.task) : undefined; - return ; + return ; } /* do container node */ export type DoNodeType = RF.Node, typeof GraphNodeType.Do>; export function DoNode({ id, data, selected, type }: RF.NodeProps) { - // TODO: This component is just a placeholder - return ; + return ; } /* emit leaf node */ export type EmitNodeType = RF.Node, typeof GraphNodeType.Emit>; export function EmitNode({ id, data, selected, type }: RF.NodeProps) { - return ; + return ; } /* for container node */ export type ForNodeType = RF.Node, typeof GraphNodeType.For>; export function ForNode({ id, data, selected, type }: RF.NodeProps) { - // TODO: This component is just a placeholder - return ; + const badge = data.task?.while ? "while" : undefined; + return ; } /* fork container node */ export type ForkNodeType = RF.Node, typeof GraphNodeType.Fork>; export function ForkNode({ id, data, selected, type }: RF.NodeProps) { - // TODO: This component is just a placeholder - return ; + const badge = data.task?.fork?.compete ? "compete" : undefined; + return ; } /* listen leaf node */ @@ -215,7 +243,7 @@ export type ListenNodeType = RF.Node< >; export function ListenNode({ id, data, selected, type }: RF.NodeProps) { const badge = data.task ? getListenSubType(data.task) : undefined; - return ; + return ; } /* raise leaf node */ @@ -224,20 +252,20 @@ export type RaiseNodeType = RF.Node< typeof GraphNodeType.Raise >; export function RaiseNode({ id, data, selected, type }: RF.NodeProps) { - return ; + return ; } /* run leaf node */ export type RunNodeType = RF.Node, typeof GraphNodeType.Run>; export function RunNode({ id, data, selected, type }: RF.NodeProps) { const badge = data.task ? getRunSubType(data.task) : undefined; - return ; + return ; } /* set leaf node */ export type SetNodeType = RF.Node, typeof GraphNodeType.Set>; export function SetNode({ id, data, selected, type }: RF.NodeProps) { - return ; + return ; } /* switch leaf node */ @@ -246,27 +274,25 @@ export type SwitchNodeType = RF.Node< typeof GraphNodeType.Switch >; export function SwitchNode({ id, data, selected, type }: RF.NodeProps) { - return ; + return ; } /* try catch container node */ export type TryCatchNodeType = RF.Node; export function TryCatchNode({ id, data, selected, type }: RF.NodeProps) { - // TODO: This component is just a placeholder - return ; + return ; } /* try container node */ export type TryNodeType = RF.Node, typeof GraphNodeType.Try>; export function TryNode({ id, data, selected, type }: RF.NodeProps) { - // TODO: This component is just a placeholder - return ; + return ; } /* catch leaf node */ export type CatchNodeType = RF.Node; export function CatchNode({ id, data, selected, type }: RF.NodeProps) { - return ; + return ; } /* catch container node */ @@ -277,12 +303,11 @@ export function CatchContainerNode({ selected, type, }: RF.NodeProps) { - // TODO: This component is just a placeholder - return ; + return ; } /* wait leaf node */ export type WaitNodeType = RF.Node, typeof GraphNodeType.Wait>; export function WaitNode({ id, data, selected, type }: RF.NodeProps) { - return ; + return ; } diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/taskNodeConfig.ts b/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/taskNodeConfig.ts index 5580651..c377412 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/taskNodeConfig.ts +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/taskNodeConfig.ts @@ -19,7 +19,11 @@ import { AlertTriangle, ArrowRightLeft, AudioLines, + Bug, Clock, + GitFork, + IterationCw, + List, Megaphone, PenLine, Phone, @@ -28,6 +32,23 @@ import { } from "lucide-react"; import type { ComponentType } from "react"; +export interface TaskNodeConfig { + color: string; + icon: ComponentType<{ size?: number; className?: string }>; + typeLabel: string; +} + +/* Custom react-flow only node type for catch nodes that contain child nodes (i.e are containers) (the sdk uses GraphNodeType.Catch for both leaf and container catch nodes) */ +export const CATCH_CONTAINER_NODE_TYPE = "catch-container"; + +export type ContainerNodeType = + | typeof GraphNodeType.Do + | typeof GraphNodeType.For + | typeof GraphNodeType.Fork + | typeof GraphNodeType.Try + | typeof GraphNodeType.TryCatch + | typeof CATCH_CONTAINER_NODE_TYPE; + export type LeafNodeType = | typeof GraphNodeType.Call | typeof GraphNodeType.Catch @@ -39,13 +60,7 @@ export type LeafNodeType = | typeof GraphNodeType.Switch | typeof GraphNodeType.Wait; -export interface TaskNodeConfig { - color: string; - icon: ComponentType<{ size?: number; className?: string }>; - typeLabel: string; -} - -export const taskNodeConfigMap: Record = { +export const leafNodeConfigMap: Record = { [GraphNodeType.Call]: { color: "#2563EB", icon: Phone, @@ -92,3 +107,46 @@ export const taskNodeConfigMap: Record = { typeLabel: "WAIT", }, }; + +export const containerNodeConfigMap: Record = { + [GraphNodeType.Do]: { + color: "#0D9488", + icon: List, + typeLabel: "DO", + }, + [GraphNodeType.For]: { + color: "#A855F7", + icon: IterationCw, + typeLabel: "FOR", + }, + [GraphNodeType.Fork]: { + color: "#FBBF24", + icon: GitFork, + typeLabel: "FORK", + }, + [GraphNodeType.Try]: { + color: "#0891B2", + icon: Bug, + typeLabel: "TRY", + }, + [GraphNodeType.TryCatch]: { + color: "#0891B2", + icon: Bug, + typeLabel: "TRY...CATCH", + }, + [CATCH_CONTAINER_NODE_TYPE]: { + color: "#F97316", + icon: ShieldAlert, + typeLabel: "CATCH", + }, +}; + +const nodeConfigMap: Record = { + ...leafNodeConfigMap, + ...containerNodeConfigMap, +}; + +/* Resolves the visual config for node type, leaf or container */ +export function getNodeVisualConfig(nodeType: string | undefined): TaskNodeConfig | undefined { + return nodeType ? nodeConfigMap[nodeType] : undefined; +} diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx index 5578c07..14a451f 100644 --- a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx @@ -22,7 +22,7 @@ import { Sidebar, SidebarContent, SidebarHeader, useSidebar } from "@/components import { useDiagramEditorContext } from "@/store/DiagramEditorContext"; import { WorkflowInfoView } from "@/side-panel/WorkflowInfoView"; import { NodeDetailsView } from "@/side-panel/NodeDetailsView"; -import { taskNodeConfigMap, type LeafNodeType } from "@/react-flow/nodes/taskNodeConfig"; +import { getNodeVisualConfig } from "@/react-flow/nodes/taskNodeConfig"; import type { BaseNodeData } from "@/react-flow/nodes/Nodes"; import "./SidePanel.css"; @@ -40,9 +40,7 @@ export function SidePanel() { [selectedNodeId, nodes], ); - const nodeConfig = selectedNode - ? taskNodeConfigMap[selectedNode.type as LeafNodeType] - : undefined; + const nodeConfig = getNodeVisualConfig(selectedNode?.type); const HeaderIcon = selectedNode ? (nodeConfig?.icon ?? Box) : Workflow; diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts index 5cc1914..37871cd 100644 --- a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts @@ -1102,8 +1102,8 @@ describe("autoLayout", () => { describe("DEFAULT_NODE_SIZE", () => { it("has correct default dimensions", () => { expect(DEFAULT_NODE_SIZE).toEqual({ - height: 60, - width: 200, + height: 65, + width: 220, }); }); }); diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/diagramBuilder.test.ts b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/diagramBuilder.test.ts index 51f9e7c..d32b27a 100644 --- a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/diagramBuilder.test.ts +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/diagramBuilder.test.ts @@ -24,13 +24,13 @@ import { getCatchContainerNodeIds, } from "../../../src/react-flow/diagram/diagramBuilder"; import { EdgeTypes } from "../../../src/react-flow/edges/Edges"; -import { CATCH_CONTAINER_NODE_TYPE } from "../../../src/react-flow/nodes/Nodes"; import { parseWorkflow } from "../../../src/core"; import { BASIC_VALID_WORKFLOW_JSON, BASIC_VALID_WORKFLOW_JSON_TASKS, } from "../../fixtures/workflows"; import { createFlatGraph } from "../../test-utils/graph-helpers"; +import { CATCH_CONTAINER_NODE_TYPE } from "../../../src/react-flow/nodes/taskNodeConfig"; // Type alias for diagram elements to reduce verbosity type DiagramElements = ReturnType; diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/Nodes.test.tsx b/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/Nodes.test.tsx index 26a93c4..06c2cc6 100644 --- a/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/Nodes.test.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/Nodes.test.tsx @@ -19,7 +19,12 @@ import { vi, it, expect, afterEach, describe } from "vitest"; import * as RF from "@xyflow/react"; import { GraphNodeType } from "@serverlessworkflow/sdk"; import { ReactFlowNodeTypes } from "../../../src/react-flow/nodes/Nodes"; -import { taskNodeConfigMap, type LeafNodeType } from "../../../src/react-flow/nodes/taskNodeConfig"; +import { + containerNodeConfigMap, + leafNodeConfigMap, + type ContainerNodeType, + type LeafNodeType, +} from "../../../src/react-flow/nodes/taskNodeConfig"; import { DEFAULT_NODE_SIZE } from "../../../src/react-flow/diagram/autoLayout"; function testNode( @@ -106,7 +111,7 @@ describe("React Flow custom node types", () => { expect(screen.getByTestId("end-node-end")).toBeInTheDocument(); }); - describe("should render leaf nodes with TaskNodeContent", () => { + describe("should render leaf nodes with LeafNodeContent", () => { const leafNodes: { id: string; type: LeafNodeType; testId: string }[] = [ { id: "n1", type: GraphNodeType.Call, testId: "call" }, { id: "n3", type: GraphNodeType.Switch, testId: "switch" }, @@ -128,11 +133,40 @@ describe("React Flow custom node types", () => { const nodeData = allNodes.find((n) => n.id === id); const node = screen.getByTestId(`${testId}-node-${id}`); - const config = taskNodeConfigMap[type]; + const config = leafNodeConfigMap[type]; - expect(node).toHaveClass("dec-task-node-container"); - expect(node.querySelector(".dec-task-node-type")?.textContent).toBe(config.typeLabel); - expect(node.querySelector(".dec-task-node-name")?.textContent).toBe(nodeData?.data.label); + expect(node).toHaveClass("dec-leaf-node"); + expect(node.querySelector(".dec-leaf-node-type")?.textContent).toBe(config.typeLabel); + expect(node.querySelector(".dec-leaf-node-name")?.textContent).toBe(nodeData?.data.label); + expect(node.style.getPropertyValue("--task-node-color")).toBe(config.color); + }); + }); + + describe("should render container nodes with ContainerNodeContent", () => { + const containerNodes: { id: string; type: ContainerNodeType; testId: string }[] = [ + { id: "n2", type: GraphNodeType.Do, testId: "do" }, + { id: "n5", type: GraphNodeType.For, testId: "for" }, + { id: "n6", type: GraphNodeType.Fork, testId: "fork" }, + { id: "n11", type: GraphNodeType.TryCatch, testId: "try-catch" }, + { id: "n12", type: GraphNodeType.Try, testId: "try" }, + ]; + + it.each(containerNodes)("should render %s node with correct config", ({ id, type, testId }) => { + render( +
+ +
, + ); + + const nodeData = allNodes.find((n) => n.id === id); + const node = screen.getByTestId(`${testId}-node-${id}`); + const config = containerNodeConfigMap[type]; + + expect(node).toHaveClass("dec-container-node"); + expect(node.querySelector(".dec-container-node-type")?.textContent).toBe(config.typeLabel); + expect(node.querySelector(".dec-container-node-name")?.textContent).toBe( + nodeData?.data.label, + ); expect(node.style.getPropertyValue("--task-node-color")).toBe(config.color); }); }); @@ -158,7 +192,7 @@ describe("React Flow custom node types", () => { expect(listenBadge.textContent).toBe("any"); }); - it("should render an icon badge for an unknown subtype", () => { + it("should render the raw value as a custom badge for an unknown subtype", () => { const nodesWithUnknownBadges = [ testNode("n1", GraphNodeType.Call, 100, "CallNode", { call: "customCall" }), ]; @@ -172,8 +206,39 @@ describe("React Flow custom node types", () => {
, ); - const callBadge = screen.getByTestId("call-node-n1-badge-icon"); + const callBadge = screen.getByTestId("call-node-n1-badge-custom"); expect(callBadge).toBeInTheDocument(); + expect(callBadge.textContent).toBe("customCall"); + expect(callBadge).toHaveAttribute("title", "customCall"); + }); + + it("should render while/compete badges on container nodes", () => { + const containerNodesWithBadges = [ + testNode("n5", GraphNodeType.For, 10, "ForNode", { + for: { each: "i", in: "${ .items }" }, + while: "${ true }", + }), + testNode("n6", GraphNodeType.Fork, 100, "ForkNode", { + fork: { branches: [], compete: true }, + }), + ]; + render( +
+ +
, + ); + + const forBadge = screen.getByTestId("for-node-n5-badge"); + expect(forBadge).toBeInTheDocument(); + expect(forBadge.textContent).toBe("while"); + + const forkBadge = screen.getByTestId("fork-node-n6-badge"); + expect(forkBadge).toBeInTheDocument(); + expect(forkBadge.textContent).toBe("compete"); }); it("should not render a badge when task has no subtype", () => { diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/taskNodeConfig.test.ts b/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/taskNodeConfig.test.ts index 6f0b6b9..1c4361d 100644 --- a/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/taskNodeConfig.test.ts +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/taskNodeConfig.test.ts @@ -16,7 +16,14 @@ import { it, expect, describe } from "vitest"; import { GraphNodeType } from "@serverlessworkflow/sdk"; -import { LeafNodeType, taskNodeConfigMap } from "../../../src/react-flow/nodes/taskNodeConfig"; +import { + CATCH_CONTAINER_NODE_TYPE, + type ContainerNodeType, + type LeafNodeType, + containerNodeConfigMap, + getNodeVisualConfig, + leafNodeConfigMap, +} from "../../../src/react-flow/nodes/taskNodeConfig"; const leafNodeTypes: LeafNodeType[] = [ GraphNodeType.Call, @@ -30,18 +37,68 @@ const leafNodeTypes: LeafNodeType[] = [ GraphNodeType.Catch, ]; +const containerNodeTypes: ContainerNodeType[] = [ + GraphNodeType.Do, + GraphNodeType.For, + GraphNodeType.Fork, + GraphNodeType.Try, + GraphNodeType.TryCatch, + CATCH_CONTAINER_NODE_TYPE, +]; + describe("taskNodeConfig", () => { it("should have config for all leaf nodes", () => { for (const leaf of leafNodeTypes) { - expect(taskNodeConfigMap[leaf]).toBeDefined(); + expect(leafNodeConfigMap[leaf]).toBeDefined(); } }); - it.each(leafNodeTypes)("should have config color, icon and typeLabel for %s", (leaf) => { - const config = taskNodeConfigMap[leaf]; + it("should have config for all container nodes", () => { + for (const container of containerNodeTypes) { + expect(containerNodeConfigMap[container]).toBeDefined(); + } + }); + + it.each(leafNodeTypes)("should have config color, icon and typeLabel for leaf %s", (leaf) => { + const config = leafNodeConfigMap[leaf]; expect(config.color).toMatch(/^#([0-9A-Fa-f]{6})$/); expect(config.icon).toBeDefined(); expect(config.typeLabel).toBe(config.typeLabel.toUpperCase()); }); + + it.each(containerNodeTypes)( + "should have config color, icon and typeLabel for container %s", + (container) => { + const config = containerNodeConfigMap[container]; + + expect(config.color).toMatch(/^#([0-9A-Fa-f]{6})$/); + expect(config.icon).toBeDefined(); + expect(config.typeLabel).toBe(config.typeLabel.toUpperCase()); + }, + ); + + describe("getNodeVisualConfig", () => { + it("resolves a leaf node type", () => { + expect(getNodeVisualConfig(GraphNodeType.Call)).toBe(leafNodeConfigMap[GraphNodeType.Call]); + }); + + it("resolves a container node type", () => { + expect(getNodeVisualConfig(GraphNodeType.Do)).toBe(containerNodeConfigMap[GraphNodeType.Do]); + }); + + it("resolves the custom catch-container node type", () => { + expect(getNodeVisualConfig(CATCH_CONTAINER_NODE_TYPE)).toBe( + containerNodeConfigMap[CATCH_CONTAINER_NODE_TYPE], + ); + }); + + it("returns undefined for an unknown type", () => { + expect(getNodeVisualConfig("not-a-node-type")).toBeUndefined(); + }); + + it("returns undefined when type is undefined", () => { + expect(getNodeVisualConfig(undefined)).toBeUndefined(); + }); + }); });